Compare commits
No commits in common. "staging" and "mods-inner" have entirely different histories.
staging
...
mods-inner
18
.env.example
@ -1,20 +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...>
|
||||
|
||||
# A dedicated npub used for site WOT.
|
||||
VITE_SITE_WOT_NPUB= <npub1...>
|
||||
|
||||
# if there's no featured image, or if the image breaks somewhere down the line, then it should default to this image
|
||||
VITE_FALLBACK_MOD_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
|
||||
|
||||
# if there's no image, or if the image breaks somewhere down the line, then it should default to this image
|
||||
VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
|
||||
|
||||
# A comma separated list of npubs, this list is used to fetch just the posts from the admin
|
||||
VITE_BLOG_NPUBS= <A comma separated list of npubs>
|
||||
VITE_ADMIN_NPUBS= <A comma separated list of npubs>
|
@ -1,10 +1,10 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2022: true },
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended'
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
@ -12,7 +12,7 @@ module.exports = {
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true }
|
||||
]
|
||||
}
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
@ -1,42 +0,0 @@
|
||||
name: Release to Production
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build_and_release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Create .env File
|
||||
run: |
|
||||
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
|
||||
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
|
||||
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
|
||||
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
|
||||
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
|
||||
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
|
||||
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
|
||||
cat .env
|
||||
|
||||
- name: Create Build
|
||||
run: npm run build
|
||||
|
||||
- name: Release Build
|
||||
run: |
|
||||
npm -g install cloudron-surfer
|
||||
surfer config --token ${{ secrets.PRODUCTION_CLOUDRON_SURFER_TOKEN }} --server degmods.com
|
||||
surfer put dist/* / --all -d
|
||||
surfer put dist/.well-known / --all
|
@ -1,42 +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_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
|
||||
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
|
||||
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
|
||||
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
|
||||
cat .env
|
||||
|
||||
- name: Create Build
|
||||
run: npm run build
|
||||
|
||||
- name: Release Build
|
||||
run: |
|
||||
npm -g install cloudron-surfer
|
||||
surfer config --token ${{ secrets.STAGING_CLOUDRON_SURFER_TOKEN }} --server dev.degmods.com
|
||||
surfer put dist/* / --all -d
|
||||
surfer put dist/.well-known / --all
|
86
.github/workflows/release-pages-production.yaml
vendored
@ -1,86 +0,0 @@
|
||||
# Simple workflow for deploying static content to GitHub Pages
|
||||
name: Release to DEG-Mods.github.io
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['master']
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Allow one concurrent deployment
|
||||
concurrency:
|
||||
group: 'pages'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
environment:
|
||||
name: github-pages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Create .env File
|
||||
run: |
|
||||
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
|
||||
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
|
||||
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
|
||||
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
|
||||
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
|
||||
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
|
||||
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
|
||||
cat .env
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: './dist'
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ vars.ORGANIZATION_NAME }}/${{ vars.REPOSITORY_NAME }}
|
||||
path: ${{ vars.REPOSITORY_NAME }}
|
||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
|
||||
- name: Clear target repo
|
||||
run: |
|
||||
rm -rf ${{ vars.REPOSITORY_NAME }}/*
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: ./dist
|
||||
|
||||
- name: Prepare files
|
||||
run: |
|
||||
cp -r dist/* ${{ vars.REPOSITORY_NAME }}/
|
||||
echo ${{ vars.CUSTOM_DOMAIN }} > ${{ vars.REPOSITORY_NAME }}/CNAME
|
||||
touch ${{ vars.REPOSITORY_NAME }}/.nojekyll
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
cd ${{ vars.REPOSITORY_NAME }}
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||
git add .
|
||||
git commit -m 'Deploy from source repo'
|
||||
git push origin main
|
21
LICENSE.txt
@ -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.
|
96
README.md
@ -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
|
||||
```
|
||||
|
83
index.html
@ -3,57 +3,50 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Links and Stylesheets -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:image" content="/assets/img/DEGM%20Thumb.png" />
|
||||
<meta name="twitter:title" content="DEG Mods - Liberating Game Mods" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely."
|
||||
/>
|
||||
<meta property="og:image" content="/assets/img/DEGM%20Thumb.png" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/6.4.8/swiper-bundle.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="/assets/fonts/fontawesome-all.min.css" />
|
||||
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="935x934"
|
||||
href="/assets/img/Logo%20with%20circle.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="935x934"
|
||||
href="/assets/img/Logo%20with%20circle.png"
|
||||
/>
|
||||
<link rel="icon" type="image/png" sizes="935x934" href="/index.html" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="935x934"
|
||||
href="/assets/img/Logo%20with%20circle.png"
|
||||
/>
|
||||
<title>DEG Mods - Liberating Game Mods</title>
|
||||
|
||||
<!-- Start Hash Router Backwards Compatibility -->
|
||||
<script type="text/javascript">
|
||||
;(function (l) {
|
||||
if (l.hash.startsWith('#/')) {
|
||||
l.href = l.href.replace('#/', '')
|
||||
}
|
||||
})(window.location)
|
||||
</script>
|
||||
<!-- End Hash Router Backwards Compatibility -->
|
||||
|
||||
<!-- Start Single Page Apps for GitHub Pages -->
|
||||
<script type="text/javascript">
|
||||
// Single Page Apps for GitHub Pages
|
||||
// MIT License
|
||||
// https://github.com/rafgraph/spa-github-pages
|
||||
// This script checks to see if a redirect is present in the query string,
|
||||
// converts it back into the correct url and adds it to the
|
||||
// browser's history using window.history.replaceState(...),
|
||||
// which won't cause the browser to attempt to load the new url.
|
||||
// When the single page app is loaded further down in this file,
|
||||
// the correct url will be waiting in the browser's history for
|
||||
// the single page app to route accordingly.
|
||||
;(function (l) {
|
||||
if (l.search[1] === '/') {
|
||||
var decoded = l.search
|
||||
.slice(1)
|
||||
.split('&')
|
||||
.map(function (s) {
|
||||
return s.replace(/~and~/g, '&')
|
||||
})
|
||||
.join('?')
|
||||
window.history.replaceState(
|
||||
null,
|
||||
null,
|
||||
l.pathname.slice(0, -1) + decoded + l.hash
|
||||
)
|
||||
}
|
||||
})(window.location)
|
||||
</script>
|
||||
<!-- End Single Page Apps for GitHub Pages -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script src="/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>
|
||||
|
4816
package-lock.json
generated
29
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "degmods.com",
|
||||
"private": true,
|
||||
"version": "0.0.0-alpha-1",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -10,50 +10,32 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getalby/lightning-tools": "5.0.3",
|
||||
"@mdxeditor/editor": "^3.20.0",
|
||||
"@nostr-dev-kit/ndk": "2.11.0",
|
||||
"@nostr-dev-kit/ndk-cache-dexie": "2.5.9",
|
||||
"@nostr-dev-kit/ndk": "2.8.2",
|
||||
"@reduxjs/toolkit": "2.2.6",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"axios": "^1.7.9",
|
||||
"bech32": "2.0.0",
|
||||
"buffer": "6.0.3",
|
||||
"date-fns": "3.6.0",
|
||||
"dexie": "4.0.8",
|
||||
"dompurify": "3.1.6",
|
||||
"file-saver": "2.0.5",
|
||||
"fslightbox-react": "1.7.6",
|
||||
"lodash": "4.17.21",
|
||||
"marked": "^14.1.3",
|
||||
"marked-directive": "^1.0.7",
|
||||
"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-dropzone": "^14.3.5",
|
||||
"react-helmet": "^6.1.0",
|
||||
"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",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-window": "1.8.8",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
@ -63,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"
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +0,0 @@
|
||||
{
|
||||
"names": {
|
||||
"degmods": "f4bf1fb5ba8be839f70c7331733e309f780822b311f63e01f9dc8abbb428f8d5",
|
||||
"degmodsreposter": "7382a4cc21742ac3e3581f1c653a41f912e985e6a941439377803b866042e53f",
|
||||
"degmodsreport": "ca2734bb5e59427dd5d66f59dde3b4a045110b7a12eb99a4a862bf012b7850d9",
|
||||
"freakoverse": "3cea4806b1e1a9829d30d5cb8a78011d4271c6474eb31531ec91f28110fe3f40",
|
||||
"nostrdev": "27487c9600b16b24a1bfb0519cfe4a5d1ad84959e3cce5d6d7a99d48660a1f78",
|
||||
"Merlin": "76dd32f31619b8e35e9f32e015224b633a0df8be8d5613c25b8838a370407698",
|
||||
"makano": "fd5989ddfadd9e2af6ceb8b63942a9e31b37367e89917931ede3b2ea76823f10",
|
||||
"reya": "126103bfddc8df256b6e0abfd7f3797c80dcc4ea88f7c2f87dd4104220b4d65f",
|
||||
"podcast_at_melonmancy.net": "4f66998fc435425257e5672a58b5c6fefda86a8b33514780e52d024a54f50ede"
|
||||
}
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Single Page Apps for GitHub Pages</title>
|
||||
<script type="text/javascript">
|
||||
// Single Page Apps for GitHub Pages
|
||||
// MIT License
|
||||
// https://github.com/rafgraph/spa-github-pages
|
||||
// This script takes the current url and converts the path and query
|
||||
// string into just a query string, and then redirects the browser
|
||||
// to the new url with only a query string and hash fragment,
|
||||
// e.g. https://www.foo.tld/one/two?a=b&c=d#qwe, becomes
|
||||
// https://www.foo.tld/?/one/two&a=b~and~c=d#qwe
|
||||
// Note: this 404.html file must be at least 512 bytes for it to work
|
||||
// with Internet Explorer (it is currently > 512 bytes)
|
||||
|
||||
// If you're creating a Project Pages site and NOT using a custom domain,
|
||||
// then set pathSegmentsToKeep to 1 (enterprise users may need to set it to > 1).
|
||||
// This way the code will only replace the route part of the path, and not
|
||||
// the real directory in which the app resides, for example:
|
||||
// https://username.github.io/repo-name/one/two?a=b&c=d#qwe becomes
|
||||
// https://username.github.io/repo-name/?/one/two&a=b~and~c=d#qwe
|
||||
// Otherwise, leave pathSegmentsToKeep as 0.
|
||||
var pathSegmentsToKeep = 0
|
||||
|
||||
var l = window.location
|
||||
l.replace(
|
||||
l.protocol +
|
||||
'//' +
|
||||
l.hostname +
|
||||
(l.port ? ':' + l.port : '') +
|
||||
l.pathname
|
||||
.split('/')
|
||||
.slice(0, 1 + pathSegmentsToKeep)
|
||||
.join('/') +
|
||||
'/?/' +
|
||||
l.pathname
|
||||
.slice(1)
|
||||
.split('/')
|
||||
.slice(pathSegmentsToKeep)
|
||||
.join('/')
|
||||
.replace(/&/g, '~and~') +
|
||||
(l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
|
||||
l.hash
|
||||
)
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
203171
public/assets/games.csv
Normal file
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
@ -12,7 +12,7 @@
|
||||
<g>
|
||||
<path class="cls-1" d="M379.5,237.42V3.38h75.39c20.68,0,39.27,4.69,55.78,14.06,16.5,9.38,29.39,22.61,38.66,39.7,9.27,17.09,13.96,36.25,14.06,57.47v10.77c0,21.43-4.53,40.64-13.58,57.63-9.06,16.99-21.81,30.27-38.26,39.86-16.45,9.59-34.8,14.44-55.05,14.55h-77Zm56.42-190.48V194.02h19.61c16.18,0,28.61-5.76,37.29-17.28,8.68-11.52,13.02-28.64,13.02-51.36v-10.13c0-22.61-4.34-39.65-13.02-51.12-8.68-11.47-21.33-17.2-37.94-17.2h-18.97Z"/>
|
||||
<path class="cls-1" d="M737.47,138.08h-88.73v55.94h104.8v43.4h-161.22V3.38h161.55V46.94h-105.12v49.35h88.73v41.79Z"/>
|
||||
<path class="cls-1" d="M968.14,208.48c-8.68,9.64-21.38,17.42-38.1,23.31-16.72,5.89-35.04,8.84-54.97,8.84-30.65,0-55.13-9.38-73.46-28.13-18.32-18.75-28.13-44.85-29.42-78.28l-.16-20.25c0-23.04,4.07-43.16,12.22-60.36,8.14-17.2,19.8-30.43,34.96-39.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 |
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
BIN
public/assets/img/DEGM Thumb.png
Normal file
After Width: | Height: | Size: 277 KiB |
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
BIN
public/assets/img/Logo with circle.png
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
public/assets/img/ProfileLinkQR.png
Normal file
After Width: | Height: | Size: 100 KiB |
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 687 B After Width: | Height: | Size: 687 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
BIN
public/assets/img/media-cache (1).jpg
Normal file
After Width: | Height: | Size: 86 KiB |
BIN
public/assets/img/media-cache (4).png
Normal file
After Width: | Height: | Size: 382 KiB |
11
public/assets/img/uBlog Logo.svg
Normal 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 |
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 284 KiB |
42
src/App.tsx
@ -1,31 +1,21 @@
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { routerWithNdkContext as routerWithState } from 'routes'
|
||||
import { useNDKContext } from 'hooks'
|
||||
import './styles/styles.css'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { Layout } from './layout'
|
||||
import { routes } from './routes'
|
||||
|
||||
function App() {
|
||||
const ndkContext = useNDKContext()
|
||||
const router = useMemo(() => routerWithState(ndkContext), [ndkContext])
|
||||
|
||||
useEffect(() => {
|
||||
// Find the element with id 'root'
|
||||
const rootElement = document.getElementById('root')
|
||||
|
||||
if (rootElement) {
|
||||
// Add the class to the element
|
||||
rootElement.classList.add('bodyMain')
|
||||
}
|
||||
|
||||
// Cleanup function (optional): Remove the class when the component unmounts
|
||||
return () => {
|
||||
if (rootElement) {
|
||||
rootElement.classList.remove('bodyMain')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <RouterProvider router={router} />
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
{routes.map((route, index) => (
|
||||
<Route
|
||||
key={route.path + index}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
@ -1,170 +0,0 @@
|
||||
import { NDKFilter } from '@nostr-dev-kit/ndk'
|
||||
import { NDKContextType } from 'contexts/NDKContext'
|
||||
import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||
import { ActionFunctionArgs } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { store } from 'store'
|
||||
import { UserRelaysType } from 'types'
|
||||
import {
|
||||
log,
|
||||
LogType,
|
||||
now,
|
||||
npubToHex,
|
||||
parseFormData,
|
||||
sendDMUsingRandomKey,
|
||||
signAndPublish
|
||||
} from 'utils'
|
||||
|
||||
export const reportRouteAction =
|
||||
(ndkContext: NDKContextType) =>
|
||||
async ({ params, request }: ActionFunctionArgs) => {
|
||||
// Check which post type is reported
|
||||
const url = new URL(request.url)
|
||||
const isModReport = url.pathname.startsWith('/mod/')
|
||||
const isBlogReport = url.pathname.startsWith('/blog/')
|
||||
const title = isModReport ? 'Mod' : isBlogReport ? 'Blog' : 'Post'
|
||||
const requestData = await request.formData()
|
||||
const { naddr } = params
|
||||
if (!naddr) {
|
||||
log(true, LogType.Error, 'Required naddr.')
|
||||
return false
|
||||
}
|
||||
|
||||
// Decode author from naddr
|
||||
let aTag: string | undefined
|
||||
try {
|
||||
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||
const { identifier, kind, pubkey } = decoded.data
|
||||
|
||||
aTag = `${kind}:${pubkey}:${identifier}`
|
||||
|
||||
if (isModReport) {
|
||||
aTag = identifier
|
||||
}
|
||||
} catch (error) {
|
||||
log(true, LogType.Error, 'Failed to decode naddr')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!aTag) {
|
||||
log(true, LogType.Error, 'Missing #a Tag')
|
||||
return false
|
||||
}
|
||||
|
||||
const userState = store.getState().user
|
||||
let hexPubkey: string | undefined
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
hexPubkey = userState.user.pubkey as string
|
||||
}
|
||||
|
||||
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
|
||||
const reportingPubkey = npubToHex(reportingNpub)
|
||||
|
||||
// Parse the the data
|
||||
const formSubmit = parseFormData(requestData)
|
||||
|
||||
const selectedOptionsCount = Object.values(formSubmit).filter(
|
||||
(checked) => checked === 'on'
|
||||
).length
|
||||
if (selectedOptionsCount === 0) {
|
||||
toast.error('At least one option should be checked!')
|
||||
return false
|
||||
}
|
||||
|
||||
if (reportingPubkey === hexPubkey) {
|
||||
// Define the event filter to search for the user's mute list events.
|
||||
// We look for events of a specific kind (Mutelist) authored by the given hexPubkey.
|
||||
const filter: NDKFilter = {
|
||||
kinds: [kinds.Mutelist],
|
||||
authors: [hexPubkey]
|
||||
}
|
||||
|
||||
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
|
||||
const muteListEvent = await ndkContext.fetchEventFromUserRelays(
|
||||
filter,
|
||||
hexPubkey,
|
||||
UserRelaysType.Write
|
||||
)
|
||||
|
||||
let unsignedEvent: UnsignedEvent
|
||||
if (muteListEvent) {
|
||||
const tags = muteListEvent.tags
|
||||
const alreadyExists =
|
||||
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
|
||||
if (alreadyExists) {
|
||||
toast.warn(`${title} reference is already in user's mute list`)
|
||||
return false
|
||||
}
|
||||
tags.push(['a', aTag])
|
||||
unsignedEvent = {
|
||||
pubkey: muteListEvent.pubkey,
|
||||
kind: kinds.Mutelist,
|
||||
content: muteListEvent.content,
|
||||
created_at: now(),
|
||||
tags: [...tags]
|
||||
}
|
||||
} else {
|
||||
unsignedEvent = {
|
||||
pubkey: hexPubkey,
|
||||
kind: kinds.Mutelist,
|
||||
content: '',
|
||||
created_at: now(),
|
||||
tags: [['a', aTag]]
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
hexPubkey = await window.nostr?.getPublicKey()
|
||||
} catch (error) {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
`Could not get pubkey for reporting ${title.toLowerCase()}!`,
|
||||
error
|
||||
)
|
||||
toast.error(
|
||||
`Could not get pubkey for reporting ${title.toLowerCase()}!`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const isUpdated = await signAndPublish(
|
||||
unsignedEvent,
|
||||
ndkContext.ndk,
|
||||
ndkContext.publish
|
||||
)
|
||||
return { isSent: isUpdated }
|
||||
} else if (reportingPubkey) {
|
||||
const href = window.location.href
|
||||
let message = `I'd like to report ${href} due to following reasons:\n`
|
||||
Object.entries(formSubmit).forEach(([key, value]) => {
|
||||
if (value === 'on') {
|
||||
message += `* ${key}\n`
|
||||
}
|
||||
})
|
||||
try {
|
||||
const isSent = await sendDMUsingRandomKey(
|
||||
message,
|
||||
reportingPubkey,
|
||||
ndkContext.ndk,
|
||||
ndkContext.publish
|
||||
)
|
||||
return { isSent: isSent }
|
||||
} catch (error) {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
`Failed to send a ${title.toLowerCase()} report`,
|
||||
error
|
||||
)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
`Failed to send a ${title.toLowerCase()} report: VITE_REPORTING_NPUB missing`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
[
|
||||
{ "name": "gameplay ", "sub": ["difficulty"] },
|
||||
{ "name": "input", "sub": ["key mapping", "macro"] },
|
||||
{
|
||||
"name": "visual",
|
||||
"sub": ["textures", "lighting", "character models", "environment models"]
|
||||
},
|
||||
{ "name": "audio", "sub": ["sfx", "music", "voice"] },
|
||||
{ "name": "user interface", "sub": ["hud", "menu"] },
|
||||
{
|
||||
"name": "quality of life",
|
||||
"sub": ["bug fixes", "performance", "accessibility"]
|
||||
},
|
||||
"total conversions",
|
||||
"translation",
|
||||
"multiplayer",
|
||||
"clothing",
|
||||
"mod manager"
|
||||
]
|
@ -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,2 +0,0 @@
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
Fire Emblem Engage,,https://image.nostr.build/f9f883f88c7d1abc38b98b0aa2394684e52e10171b621011f348034ab9973476.jpg
|
|
@ -1,14 +0,0 @@
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
(Unlisted Game),,
|
||||
Minecraft,,https://image.nostr.build/b75b2d3a7855370230f2976567e2d5f913a567c57ac61adfb60c7e1102f05117.jpg
|
||||
Vintage Story,,https://image.nostr.build/9efe683d339cc864032a99047ce26b2b5c19fab1ec4dcc6d4db96e2785c44eda.png
|
||||
Yandere Simulator,,https://image.nostr.build/54ba56b752bb9d411cbdc1d249fa0cb74c6062a305bcd0a70ecacb61b8d50030.png
|
||||
Genshin Impact,,https://image.nostr.build/999fccf93cf16a2e0dd8e6f00595b0ab3b5cc6beff9fe4a52f64f427cce9aedd.jpg
|
||||
Zenless Zone Zero,,https://image.nostr.build/4a9b9c2cbef619552d0c123f8794286f35710dc7ca1ca0010380a630883eb2ca.jpg
|
||||
Ananta,,https://image.nostr.build/09fb30fe2c22784079e4c0a848410e709ff359af09b3f96b651c7dc249a35cdd.jpg
|
||||
Bloodborne,,https://image.nostr.build/9d20c246b539e43f1bebcf602f996fa6eb45cf585f05cc19a1d9f86a53201485.jpg
|
||||
The Elder Scrolls: Skyblivion,,https://cdn.nostrcheck.me/3cea4806b1e1a9829d30d5cb8a78011d4271c6474eb31531ec91f28110fe3f40/87868c8134d0ed30e74b99750e292fc85ad708d0add24c7c9113b086cd0784c3.webp
|
||||
Stellar Blade,,https://image.nostr.build/4dd76ef8ff985d2afc8b3eba323f18de7165659c4e925b2f06ae8b130372d5ae.jpg
|
||||
Bayonetta 2,,https://image.nostr.build/916f0b1fd4938114867654d7625f8e817b7f710d7729c81c911e1011fa74afad.jpg
|
||||
Grand Theft Auto: Vice City,,https://image.nostr.build/6f364ebb635cc878b284a06e8131dcec5eb4b574eece7ab206df8a9af639ddf6.jpg
|
||||
Alan Wake 2,,https://image.nostr.build/9c185ddb6f3c3b1f56835be8d36200eda7de0f36888b02523f4d39ade235ffab.jpg
|
|
@ -1,10 +0,0 @@
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
Marvel's Spider-Man 2,,https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg
|
||||
S.T.A.L.K.E.R. 2: Heart of Chornobyl,,https://image.nostr.build/f5b61071bebcc8deccfd71362696fb708649b9c528bec1c6964262ded4157843.jpg
|
||||
FINAL FANTASY VII REBIRTH,,https://image.nostr.build/e16228ccfad19669f17f83517ac621142ad7129c82d0e5f346a3523643f98a28.jpg
|
||||
NINJA GAIDEN 2 Black,,https://image.nostr.build/867b5d4ae7580f138b9d7e3bc41c0184e59fc22a758d2dcd9e941f8adf6d6e6e.jpg
|
||||
Rise of the Ronin,,https://image.nostr.build/5eba5c6efed335e1e6c74e7af29790a9cc519dbccdd81122e84336848a7e7866.jpg
|
||||
NINJA GAIDEN 4,,https://image.nostr.build/2b49b1571ba90450f95a9eb306d2ef9f3ad632dc6282125cc1651d17da17a439.jpg
|
||||
Batman Arkham Asylum,,https://image.nostr.build/ba5c07be4747957380213ad86ab83b8a4cb6b8ef0123ebb9863318ed1de6e43e.jpg
|
||||
Kingdom Hearts,,https://image.nostr.build/883b71c52b5b498aac20218b52af471ba89afb5cbb7072dc403da7446ca04e39.jpg
|
||||
Kingdom Hearts II,,https://image.nostr.build/24b6002029e91e4ad99b56aca9f20b076feb594ae48b320ba9122254add6b57e.jpg
|
|
Before Width: | Height: | Size: 326 KiB |
Before Width: | Height: | Size: 43 KiB |
@ -1,72 +0,0 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AlertPopupProps } from 'types'
|
||||
|
||||
export const AlertPopup = ({
|
||||
header,
|
||||
label,
|
||||
handleConfirm,
|
||||
handleClose
|
||||
}: AlertPopupProps) => {
|
||||
return createPortal(
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>{header}</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
gap: '10px'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={() => handleConfirm(true)}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={() => handleConfirm(false)}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
@ -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 alpha (<a href="https://geyser.fund/project/degmods/posts/view/3411" target="_blank">Learn more</a>).
|
||||
Check out its funding campaign (<a href="https://geyser.fund/project/degmods" target="_blank">Learn more</a>). (Soon on Kickstarter)
|
||||
</p>
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
@ -1,33 +1,41 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { BlogCardDetails } from 'types'
|
||||
import { getBlogPageRoute } from 'routes'
|
||||
import '../styles/cardBlogs.css'
|
||||
import placeholder from '../assets/img/DEGMods Placeholder Img.png'
|
||||
|
||||
type BlogCardProps = Partial<BlogCardDetails>
|
||||
|
||||
export const BlogCard = ({ title, image, nsfw, naddr }: BlogCardProps) => {
|
||||
if (!naddr) return null
|
||||
type BlogCardProps = {
|
||||
backgroundLink: string
|
||||
}
|
||||
|
||||
export const BlogCard = ({ backgroundLink }: BlogCardProps) => {
|
||||
return (
|
||||
<Link to={getBlogPageRoute(naddr)} className='cardBlogMainWrapperLink'>
|
||||
<a className='cardBlogMainWrapperLink' href='blog-inner.html'>
|
||||
<div
|
||||
className='cardBlogMain'
|
||||
style={{
|
||||
background: `url("${
|
||||
image ? image : placeholder
|
||||
}") center / cover no-repeat`
|
||||
background: `url("${backgroundLink}") center / cover no-repeat`
|
||||
}}
|
||||
>
|
||||
<div className='cardBlogMainInside'>
|
||||
<h3 className='cardBlogMainInsideTitle'>{title}</h3>
|
||||
{nsfw && (
|
||||
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW IBMSMSMBSSTagsTagNSFWCard IBMSMSMBSSTagsTagNSFWCardAlt'>
|
||||
<p>NSFW</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className='cardBlogMainInside'
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient( rgba(255, 255, 255, 0) 0%, #232323 100%)'
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
WebkitLineClamp: 2,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1.5,
|
||||
color: 'rgba(255, 255, 255, 0.75)',
|
||||
textShadow: '0 0 8px rgba(0, 0, 0, 0.25)'
|
||||
}}
|
||||
>
|
||||
This is a blog title, the best blog title in the world!
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>{' '}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
@ -1,333 +0,0 @@
|
||||
import { useLocalStorage } from 'hooks'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getGamePageRoute } from 'routes'
|
||||
import { ModFormState, Categories, Category } from 'types'
|
||||
import {
|
||||
getCategories,
|
||||
flattenCategories,
|
||||
addToUserCategories,
|
||||
capitalizeEachWord
|
||||
} from 'utils'
|
||||
|
||||
interface CategoryAutocompleteProps {
|
||||
game: string
|
||||
LTags: string[]
|
||||
setFormState: (value: React.SetStateAction<ModFormState>) => void
|
||||
}
|
||||
|
||||
export const CategoryAutocomplete = ({
|
||||
game,
|
||||
LTags,
|
||||
setFormState
|
||||
}: CategoryAutocompleteProps) => {
|
||||
// Fetch the hardcoded categories from assets
|
||||
const flattenedCategories = useMemo(() => getCategories(), [])
|
||||
|
||||
// Fetch the user categories from local storage
|
||||
const [userHierarchies, setUserHierarchies] = useLocalStorage<
|
||||
(string | Category)[]
|
||||
>('user-hierarchies', [])
|
||||
const flattenedUserCategories = useMemo(
|
||||
() => flattenCategories(userHierarchies, []),
|
||||
[userHierarchies]
|
||||
)
|
||||
|
||||
// Create options and select categories from the mod LTags (hierarchies)
|
||||
const { selectedCategories, combinedOptions } = useMemo(() => {
|
||||
const combinedCategories = [
|
||||
...flattenedCategories,
|
||||
...flattenedUserCategories
|
||||
]
|
||||
const hierarchies = LTags.map((hierarchy) => {
|
||||
const existingCategory = combinedCategories.find(
|
||||
(cat) => cat.hierarchy === hierarchy.replace(/:/g, ' > ')
|
||||
)
|
||||
if (existingCategory) {
|
||||
return existingCategory
|
||||
} else {
|
||||
const segments = hierarchy.split(':')
|
||||
const lastSegment = segments[segments.length - 1]
|
||||
return { name: lastSegment, hierarchy: hierarchy, l: [lastSegment] }
|
||||
}
|
||||
})
|
||||
|
||||
// Selected categorires (based on the LTags)
|
||||
const selectedCategories = Array.from(new Set([...hierarchies]))
|
||||
|
||||
// Combine user, predefined category hierarchies and selected values (LTags in case some are missing)
|
||||
const combinedOptions = Array.from(
|
||||
new Set([...combinedCategories, ...selectedCategories])
|
||||
)
|
||||
|
||||
return { selectedCategories, combinedOptions }
|
||||
}, [LTags, flattenedCategories, flattenedUserCategories])
|
||||
|
||||
const [inputValue, setInputValue] = useState<string>('')
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
combinedOptions.filter((option) =>
|
||||
option.hierarchy.toLowerCase().includes(inputValue.toLowerCase())
|
||||
),
|
||||
[combinedOptions, inputValue]
|
||||
)
|
||||
|
||||
const getSelectedCategories = (cats: Categories[]) => {
|
||||
const uniqueValues = new Set(
|
||||
cats.reduce<string[]>((prev, cat) => [...prev, ...cat.l], [])
|
||||
)
|
||||
const concatenatedValue = Array.from(uniqueValues)
|
||||
return concatenatedValue
|
||||
}
|
||||
const getSelectedHierarchy = (cats: Categories[]) => {
|
||||
const hierarchies = cats.reduce<string[]>(
|
||||
(prev, cat) => [...prev, cat.hierarchy.replace(/ > /g, ':')],
|
||||
[]
|
||||
)
|
||||
const concatenatedValue = Array.from(hierarchies)
|
||||
return concatenatedValue
|
||||
}
|
||||
const handleReset = () => {
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
['lTags']: [],
|
||||
['LTags']: []
|
||||
}))
|
||||
setInputValue('')
|
||||
}
|
||||
const handleRemove = (option: Categories) => {
|
||||
const updatedCategories = selectedCategories.filter(
|
||||
(cat) => cat.hierarchy !== option.hierarchy
|
||||
)
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
['lTags']: getSelectedCategories(updatedCategories),
|
||||
['LTags']: getSelectedHierarchy(updatedCategories)
|
||||
}))
|
||||
}
|
||||
const handleSelect = (option: Categories) => {
|
||||
if (!selectedCategories.some((cat) => cat.hierarchy === option.hierarchy)) {
|
||||
const updatedCategories = [...selectedCategories, option]
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
['lTags']: getSelectedCategories(updatedCategories),
|
||||
['LTags']: getSelectedHierarchy(updatedCategories)
|
||||
}))
|
||||
}
|
||||
setInputValue('')
|
||||
}
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
}
|
||||
const handleAddNew = () => {
|
||||
if (inputValue) {
|
||||
const value = inputValue.trim().toLowerCase()
|
||||
const values = value.split('>').map((s) => s.trim())
|
||||
const newOption: Categories = {
|
||||
name: value,
|
||||
hierarchy: value,
|
||||
l: values
|
||||
}
|
||||
setUserHierarchies((prev) => {
|
||||
addToUserCategories(prev, value)
|
||||
return [...prev]
|
||||
})
|
||||
const updatedCategories = [...selectedCategories, newOption]
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
['lTags']: getSelectedCategories(updatedCategories),
|
||||
['LTags']: getSelectedHierarchy(updatedCategories)
|
||||
}))
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
const handleAddNewCustom = (option: Categories) => {
|
||||
setUserHierarchies((prev) => {
|
||||
addToUserCategories(prev, option.hierarchy)
|
||||
return [...prev]
|
||||
})
|
||||
}
|
||||
|
||||
const Row = ({ index }: { index: number }) => {
|
||||
return (
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
|
||||
onClick={() => handleSelect(filteredOptions[index])}
|
||||
>
|
||||
{capitalizeEachWord(filteredOptions[index].hierarchy)}
|
||||
|
||||
{/* Show "Remove" button when the category is selected */}
|
||||
{selectedCategories.some(
|
||||
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
|
||||
) && (
|
||||
<button
|
||||
type='button'
|
||||
className='btn btnMain btnMainInsideField btnMainRemove'
|
||||
onClick={() => handleRemove(filteredOptions[index])}
|
||||
title='Remove'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M323.3 32.01H188.7C172.3 32.01 160 44.31 160 60.73V96.01H32C14.33 96.01 0 110.3 0 128S14.33 160 32 160H480c17.67 0 32-14.33 32-32.01S497.7 96.01 480 96.01H352v-35.28C352 44.31 339.7 32.01 323.3 32.01zM64.9 477.5C66.5 492.3 79.31 504 94.72 504H417.3c15.41 0 28.22-11.72 29.81-26.5L480 192.2H32L64.9 477.5z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show "Add" button when the category is not included in the predefined or userdefined lists */}
|
||||
{!flattenedCategories.some(
|
||||
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
|
||||
) &&
|
||||
!flattenedUserCategories.some(
|
||||
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
|
||||
) && (
|
||||
<button
|
||||
type='button'
|
||||
className='btn btnMain btnMainInsideField btnMainAdd'
|
||||
onClick={() => handleAddNewCustom(filteredOptions[index])}
|
||||
title='Add'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>Categories</label>
|
||||
<p className='labelDescriptionMain'>You can select multiple categories</p>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<div className='inputWrapperMain inputWrapperMainAlt'>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain inputMainWithBtn dropdown-toggle'
|
||||
placeholder='Select some categories...'
|
||||
data-bs-toggle='dropdown'
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<button
|
||||
className='btn btnMain btnMainInsideField btnMainRemove'
|
||||
title='Remove'
|
||||
type='button'
|
||||
onClick={handleReset}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt category'
|
||||
style={{
|
||||
maxHeight: '500px'
|
||||
}}
|
||||
>
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((c, i) => <Row key={c.hierarchy} index={i} />)
|
||||
) : (
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
|
||||
onClick={handleAddNew}
|
||||
>
|
||||
{inputValue &&
|
||||
!filteredOptions?.find(
|
||||
(option) =>
|
||||
option.hierarchy.toLowerCase() === inputValue.toLowerCase()
|
||||
) ? (
|
||||
<>
|
||||
Add "{inputValue}"
|
||||
<button
|
||||
type='button'
|
||||
className='btn btnMain btnMainInsideField btnMainAdd'
|
||||
title='Add'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>No matches</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{LTags.length > 0 && (
|
||||
<div className='IBMSMSMBSSCategories'>
|
||||
{LTags.map((hierarchy) => {
|
||||
const hierarchicalCategories = hierarchy.split(`:`)
|
||||
const categories = hierarchicalCategories
|
||||
.map<React.ReactNode>((c, i) => {
|
||||
const partialHierarchy = hierarchicalCategories
|
||||
.slice(0, i + 1)
|
||||
.join(':')
|
||||
|
||||
return game ? (
|
||||
<Link
|
||||
key={`category-${i}`}
|
||||
target='_blank'
|
||||
to={{
|
||||
pathname: getGamePageRoute(game),
|
||||
search: `h=${partialHierarchy}`
|
||||
}}
|
||||
className='IBMSMSMBSSCategoriesBoxItem'
|
||||
>
|
||||
<p>{capitalizeEachWord(c)}</p>
|
||||
</Link>
|
||||
) : (
|
||||
<p className='IBMSMSMBSSCategoriesBoxItem'>
|
||||
{capitalizeEachWord(c)}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
.reduce((prev, curr, i) => [
|
||||
prev,
|
||||
<div
|
||||
key={`separator-${i}`}
|
||||
className='IBMSMSMBSSCategoriesBoxSeparator'
|
||||
>
|
||||
<p>></p>
|
||||
</div>,
|
||||
curr
|
||||
])
|
||||
|
||||
return (
|
||||
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
|
||||
{categories}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,113 +0,0 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { DownloadUrl } from '../types'
|
||||
|
||||
export const DownloadDetailsPopup = ({
|
||||
title,
|
||||
url,
|
||||
hash,
|
||||
signatureKey,
|
||||
malwareScanLink,
|
||||
modVersion,
|
||||
customNote,
|
||||
mediaUrl,
|
||||
handleClose
|
||||
}: DownloadUrl & {
|
||||
handleClose: () => void
|
||||
}) => {
|
||||
return createPortal(
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>{title || 'Authentication Details'}</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTable'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>Download URL</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<p>{url}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>SHA-256 hash</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<p>{hash}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>Signature from</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<p>{signatureKey}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>Scan</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<p>{malwareScanLink}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>Mod Version</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<p>{modVersion}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>Note</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<p>{customNote}</p>
|
||||
</div>
|
||||
</div>
|
||||
{typeof mediaUrl !== 'undefined' && mediaUrl !== '' && (
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>Media</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<img
|
||||
src={mediaUrl}
|
||||
className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol_Img'
|
||||
alt=''
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -1,148 +0,0 @@
|
||||
import { useAppSelector, useLocalStorage } from 'hooks'
|
||||
import React from 'react'
|
||||
import { FilterOptions, ModeratedFilter, SortBy, WOTFilterOptions } from 'types'
|
||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||
import { Dropdown } from './Dropdown'
|
||||
import { Option } from './Option'
|
||||
import { Filter } from '.'
|
||||
import { NsfwFilterOptions } from './NsfwFilterOptions'
|
||||
|
||||
type Props = {
|
||||
author?: string | undefined
|
||||
filterKey?: string | undefined
|
||||
}
|
||||
|
||||
export const BlogsFilter = React.memo(
|
||||
({ author, filterKey = 'filter-blog' }: Props) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||
filterKey,
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
|
||||
return (
|
||||
<Filter>
|
||||
{/* sort filter options */}
|
||||
<Dropdown label={filterOptions.sort}>
|
||||
{Object.values(SortBy).map((item, index) => (
|
||||
<div
|
||||
key={`sortByItem-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
sort: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</Dropdown>
|
||||
|
||||
{/* moderation filter options */}
|
||||
<Dropdown label={filterOptions.moderated}>
|
||||
{Object.values(ModeratedFilter).map((item) => {
|
||||
if (item === ModeratedFilter.Unmoderated_Fully) {
|
||||
const isAdmin =
|
||||
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||
|
||||
const isOwnProfile =
|
||||
author && userState.auth && userState.user?.pubkey === author
|
||||
|
||||
if (!(isAdmin || isOwnProfile)) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Option
|
||||
key={`sort-${item}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
moderated: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
)
|
||||
})}
|
||||
</Dropdown>
|
||||
|
||||
{/* wot filter options */}
|
||||
<Dropdown label={<>Trust: {filterOptions.wot}</>}>
|
||||
{Object.values(WOTFilterOptions).map((item, index) => {
|
||||
// when user is not logged in
|
||||
if (item === WOTFilterOptions.Site_And_Mine && !userState.auth) {
|
||||
return null
|
||||
}
|
||||
|
||||
// when logged in user not admin
|
||||
if (
|
||||
item === WOTFilterOptions.None ||
|
||||
item === WOTFilterOptions.Mine_Only ||
|
||||
item === WOTFilterOptions.Exclude
|
||||
) {
|
||||
const isWoTNpub =
|
||||
userState.user?.npub === import.meta.env.VITE_SITE_WOT_NPUB
|
||||
|
||||
const isOwnProfile =
|
||||
author && userState.auth && userState.user?.pubkey === author
|
||||
|
||||
if (!(isWoTNpub || isOwnProfile)) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Option
|
||||
key={`wotFilterOption-${index}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
wot: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
)
|
||||
})}
|
||||
</Dropdown>
|
||||
|
||||
{/* nsfw filter options */}
|
||||
<Dropdown label={filterOptions.nsfw}>
|
||||
<NsfwFilterOptions filterKey={filterKey} />
|
||||
</Dropdown>
|
||||
|
||||
{/* source filter options */}
|
||||
<Dropdown
|
||||
label={
|
||||
filterOptions.source === window.location.host
|
||||
? `Show From: ${filterOptions.source}`
|
||||
: 'Show All'
|
||||
}
|
||||
>
|
||||
<Option
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
source: window.location.host
|
||||
}))
|
||||
}
|
||||
>
|
||||
Show From: {window.location.host}
|
||||
</Option>
|
||||
<Option
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
source: 'Show All'
|
||||
}))
|
||||
}
|
||||
>
|
||||
Show All
|
||||
</Option>
|
||||
</Dropdown>
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
)
|
@ -1,3 +0,0 @@
|
||||
.noResult:not(:only-child) {
|
||||
display: none;
|
||||
}
|
@ -1,550 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Category } from 'types'
|
||||
import {
|
||||
addToUserCategories,
|
||||
capitalizeEachWord,
|
||||
deleteFromUserCategories,
|
||||
flattenCategories
|
||||
} from 'utils'
|
||||
import { useLocalStorage } from 'hooks'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import styles from './CategoryFilterPopup.module.scss'
|
||||
import categoriesData from './../../assets/categories/categories.json'
|
||||
|
||||
interface CategoryFilterPopupProps {
|
||||
categories: string[]
|
||||
setCategories: React.Dispatch<React.SetStateAction<string[]>>
|
||||
hierarchies: string[]
|
||||
setHierarchies: React.Dispatch<React.SetStateAction<string[]>>
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
export const CategoryFilterPopup = ({
|
||||
categories,
|
||||
setCategories,
|
||||
hierarchies,
|
||||
setHierarchies,
|
||||
handleClose
|
||||
}: CategoryFilterPopupProps) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const linkedHierarchy = searchParams.get('h')
|
||||
|
||||
const [userHierarchies, setUserHierarchies] = useLocalStorage<
|
||||
(string | Category)[]
|
||||
>('user-hierarchies', [])
|
||||
const [filterCategories, setFilterCategories] = useState(categories)
|
||||
const [filterHierarchies, setFilterHierarchies] = useState(hierarchies)
|
||||
const handleApply = () => {
|
||||
// Update selection with linked category if it exists
|
||||
if (linkedHierarchy !== null && linkedHierarchy !== '') {
|
||||
// Combine existing selection with the linked
|
||||
setFilterHierarchies((prev) => {
|
||||
prev.push(linkedHierarchy)
|
||||
const newFilterHierarchies = Array.from(new Set([...prev]))
|
||||
setHierarchies(newFilterHierarchies)
|
||||
return newFilterHierarchies
|
||||
})
|
||||
// Clear hierarchy link in search params
|
||||
searchParams.delete('h')
|
||||
setSearchParams(searchParams)
|
||||
} else {
|
||||
setHierarchies(filterHierarchies)
|
||||
}
|
||||
setCategories(filterCategories)
|
||||
}
|
||||
const [inputValue, setInputValue] = useState<string>('')
|
||||
const userHierarchiesMatching = useMemo(
|
||||
() =>
|
||||
flattenCategories(userHierarchies, []).some((h) =>
|
||||
h.hierarchy.includes(inputValue.toLowerCase())
|
||||
),
|
||||
[inputValue, userHierarchies]
|
||||
)
|
||||
// const hierarchiesMatching = useMemo(
|
||||
// () =>
|
||||
// flattenCategories(categoriesData, []).some((h) =>
|
||||
// h.hierarchy.includes(inputValue.toLowerCase())
|
||||
// ),
|
||||
// [inputValue]
|
||||
// )
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
}
|
||||
const handleSingleSelection = (category: string, isSelected: boolean) => {
|
||||
let updatedCategories = [...filterCategories]
|
||||
if (isSelected) {
|
||||
updatedCategories.push(category)
|
||||
} else {
|
||||
updatedCategories = updatedCategories.filter((item) => item !== category)
|
||||
}
|
||||
setFilterCategories(updatedCategories)
|
||||
}
|
||||
const handleCombinationSelection = (path: string[], isSelected: boolean) => {
|
||||
const pathString = path.join(':')
|
||||
let updatedHierarchies = [...filterHierarchies]
|
||||
if (isSelected) {
|
||||
updatedHierarchies.push(pathString)
|
||||
} else {
|
||||
updatedHierarchies = updatedHierarchies.filter(
|
||||
(item) => item !== pathString
|
||||
)
|
||||
}
|
||||
setFilterHierarchies(updatedHierarchies)
|
||||
}
|
||||
const handleAddNew = () => {
|
||||
if (inputValue) {
|
||||
const value = inputValue.toLowerCase()
|
||||
const values = value
|
||||
.trim()
|
||||
.split('>')
|
||||
.map((s) => s.trim())
|
||||
|
||||
setUserHierarchies((prev) => {
|
||||
addToUserCategories(prev, value)
|
||||
return [...prev]
|
||||
})
|
||||
|
||||
const path = values.join(':')
|
||||
|
||||
// Add new hierarchy to current selection and active selection
|
||||
// Convert through set to remove duplicates
|
||||
setFilterHierarchies((prev) => {
|
||||
prev.push(path)
|
||||
return Array.from(new Set([...prev]))
|
||||
})
|
||||
setHierarchies((prev) => {
|
||||
prev.push(path)
|
||||
return Array.from(new Set([...prev]))
|
||||
})
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Categories filter</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Choose categories...
|
||||
</label>
|
||||
<p className='labelDescriptionMain'>
|
||||
Choose one or more pre-definied or custom categories to filter out mods with.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain inputMainWithBtn'
|
||||
placeholder='Select some categories...'
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
{userHierarchies.length > 0 && (
|
||||
<>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Custom categories
|
||||
</label>
|
||||
<p className='labelDescriptionMain'>Here's where your custom categories appear (You can add them in the above field. Example > banana > seed)</p>
|
||||
</div>
|
||||
<div
|
||||
className='inputMain'
|
||||
style={{
|
||||
minHeight: '40px',
|
||||
maxHeight: '500px',
|
||||
height: '100%',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
{!userHierarchiesMatching && <div>No results.</div>}
|
||||
{userHierarchies
|
||||
.filter((c) => typeof c !== 'string')
|
||||
.map((c, i) => (
|
||||
<CategoryCheckbox
|
||||
key={`${c}_${i}`}
|
||||
inputValue={inputValue}
|
||||
category={c}
|
||||
path={[c.name]}
|
||||
handleSingleSelection={handleSingleSelection}
|
||||
handleCombinationSelection={
|
||||
handleCombinationSelection
|
||||
}
|
||||
selectedSingles={filterCategories}
|
||||
selectedCombinations={filterHierarchies}
|
||||
handleRemove={(path) => {
|
||||
setUserHierarchies((prev) => {
|
||||
deleteFromUserCategories(prev, path.join('>'))
|
||||
return [...prev]
|
||||
})
|
||||
|
||||
// Remove the deleted hierarchies from current filter selection and active selection
|
||||
setFilterHierarchies((prev) =>
|
||||
prev.filter(
|
||||
(h) => !h.startsWith(path.join(':'))
|
||||
)
|
||||
)
|
||||
setHierarchies((prev) =>
|
||||
prev.filter(
|
||||
(h) => !h.startsWith(path.join(':'))
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Categories
|
||||
</label>
|
||||
<p className='labelDescriptionMain'>Here's where you select any of the pre-defined categories</p>
|
||||
</div>
|
||||
<div
|
||||
className='inputMain'
|
||||
style={{
|
||||
minHeight: '40px',
|
||||
maxHeight: '500px',
|
||||
height: '100%',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<div className={`${styles.noResult}`}>
|
||||
<div>No results.</div>
|
||||
<br />
|
||||
{userHierarchiesMatching ? (
|
||||
<div>Already defined in your categories</div>
|
||||
) : (
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
|
||||
onClick={handleAddNew}
|
||||
>
|
||||
Add and search for "{inputValue}" category
|
||||
<button
|
||||
type='button'
|
||||
className='btn btnMain btnMainInsideField btnMainAdd'
|
||||
title='Add'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(categoriesData as Category[]).map((category) => {
|
||||
const name =
|
||||
typeof category === 'string' ? category : category.name
|
||||
return (
|
||||
<CategoryCheckbox
|
||||
key={name}
|
||||
inputValue={inputValue}
|
||||
category={category}
|
||||
path={[name]}
|
||||
handleSingleSelection={handleSingleSelection}
|
||||
handleCombinationSelection={
|
||||
handleCombinationSelection
|
||||
}
|
||||
selectedSingles={filterCategories}
|
||||
selectedCombinations={filterHierarchies}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
gap: '10px'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={() => {
|
||||
// Clear the linked hierarchy
|
||||
searchParams.delete('h')
|
||||
setSearchParams(searchParams)
|
||||
|
||||
// Clear current filters
|
||||
setFilterCategories([])
|
||||
setFilterHierarchies([])
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={() => {
|
||||
handleApply()
|
||||
handleClose()
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
interface CategoryCheckboxProps {
|
||||
inputValue: string
|
||||
category: Category | string
|
||||
path: string[]
|
||||
handleSingleSelection: (category: string, isSelected: boolean) => void
|
||||
handleCombinationSelection: (path: string[], isSelected: boolean) => void
|
||||
selectedSingles: string[]
|
||||
selectedCombinations: string[]
|
||||
indentLevel?: number
|
||||
handleRemove?: (path: string[]) => void
|
||||
}
|
||||
|
||||
const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
|
||||
inputValue,
|
||||
category,
|
||||
path,
|
||||
handleSingleSelection,
|
||||
handleCombinationSelection,
|
||||
selectedSingles,
|
||||
selectedCombinations,
|
||||
indentLevel = 0,
|
||||
handleRemove
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const linkedHierarchy = searchParams.get('h')
|
||||
const name = typeof category === 'string' ? category : category.name
|
||||
const hierarchy = path.join(' > ').toLowerCase()
|
||||
const isMatching = hierarchy.includes(inputValue.toLowerCase())
|
||||
const isLinked =
|
||||
linkedHierarchy !== null &&
|
||||
hierarchy === linkedHierarchy.replace(/:/g, ' > ')
|
||||
const [isSingleChecked, setIsSingleChecked] = useState<boolean>(false)
|
||||
const [isCombinationChecked, setIsCombinationChecked] =
|
||||
useState<boolean>(false)
|
||||
const [isIndeterminate, setIsIndeterminate] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const pathString = path.join(':')
|
||||
setIsSingleChecked(selectedSingles.includes(name))
|
||||
setIsCombinationChecked(selectedCombinations.includes(pathString))
|
||||
// Recursive function to gather all descendant paths
|
||||
const collectChildPaths = (
|
||||
category: string | Category,
|
||||
basePath: string[]
|
||||
) => {
|
||||
if (!category.sub || !Array.isArray(category.sub)) {
|
||||
return []
|
||||
}
|
||||
let paths: string[] = []
|
||||
for (const sub of category.sub) {
|
||||
const subPath =
|
||||
typeof sub === 'string'
|
||||
? [...basePath, sub].join(':')
|
||||
: [...basePath, sub.name].join(':')
|
||||
paths.push(subPath)
|
||||
if (typeof sub === 'object') {
|
||||
paths = paths.concat(collectChildPaths(sub, [...basePath, sub.name]))
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
const childPaths = collectChildPaths(category, path)
|
||||
const anyChildCombinationSelected = childPaths.some((childPath) =>
|
||||
selectedCombinations.includes(childPath)
|
||||
)
|
||||
const anyChildCombinationLinked = childPaths.some(
|
||||
(childPath) =>
|
||||
linkedHierarchy !== null && linkedHierarchy.includes(childPath)
|
||||
)
|
||||
setIsIndeterminate(
|
||||
(anyChildCombinationSelected || anyChildCombinationLinked) &&
|
||||
!selectedCombinations.includes(pathString)
|
||||
)
|
||||
}, [
|
||||
category,
|
||||
linkedHierarchy,
|
||||
name,
|
||||
path,
|
||||
selectedCombinations,
|
||||
selectedSingles
|
||||
])
|
||||
|
||||
const handleSingleChange = () => {
|
||||
setIsSingleChecked(!isSingleChecked)
|
||||
handleSingleSelection(name, !isSingleChecked)
|
||||
}
|
||||
|
||||
const handleCombinationChange = () => {
|
||||
// If combination is linked, clicking it again we will delete it
|
||||
if (isLinked) {
|
||||
searchParams.delete('h')
|
||||
setSearchParams(searchParams)
|
||||
} else {
|
||||
setIsCombinationChecked(!isCombinationChecked)
|
||||
handleCombinationSelection(path, !isCombinationChecked)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMatching && (
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory dropdownMainMenuItemCategoryAlt'
|
||||
style={{
|
||||
marginLeft: `${indentLevel * 20}px`,
|
||||
width: `calc(100% - ${indentLevel * 20}px)`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`inputLabelWrapperMain inputLabelWrapperMainAlt stylized`}
|
||||
style={{
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={name}
|
||||
type='checkbox'
|
||||
ref={(input) => {
|
||||
if (input) {
|
||||
input.indeterminate = isIndeterminate
|
||||
}
|
||||
}}
|
||||
className={`CheckboxMain ${
|
||||
isIndeterminate ? 'CheckboxIndeterminate' : ''
|
||||
}`}
|
||||
checked={isCombinationChecked || isLinked}
|
||||
onChange={handleCombinationChange}
|
||||
/>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className='form-label labelMain labelMainCategory'
|
||||
>
|
||||
{capitalizeEachWord(name)}
|
||||
</label>
|
||||
<input
|
||||
style={{
|
||||
display: 'none'
|
||||
}}
|
||||
id={name}
|
||||
type='checkbox'
|
||||
className='CheckboxMain'
|
||||
name={name}
|
||||
checked={isSingleChecked}
|
||||
onChange={handleSingleChange}
|
||||
/>
|
||||
{typeof handleRemove === 'function' && (
|
||||
<button
|
||||
className='btn btnMain btnMainInsideField btnMainRemove'
|
||||
title='Remove'
|
||||
type='button'
|
||||
onClick={() => handleRemove(path)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M323.3 32.01H188.7C172.3 32.01 160 44.31 160 60.73V96.01H32C14.33 96.01 0 110.3 0 128S14.33 160 32 160H480c17.67 0 32-14.33 32-32.01S497.7 96.01 480 96.01H352v-35.28C352 44.31 339.7 32.01 323.3 32.01zM64.9 477.5C66.5 492.3 79.31 504 94.72 504H417.3c15.41 0 28.22-11.72 29.81-26.5L480 192.2H32L64.9 477.5z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{typeof category !== 'string' &&
|
||||
category.sub &&
|
||||
Array.isArray(category.sub) && (
|
||||
<>
|
||||
{category.sub.map((subCategory) => {
|
||||
if (typeof subCategory === 'string') {
|
||||
return (
|
||||
<CategoryCheckbox
|
||||
inputValue={inputValue}
|
||||
key={`${category.name}-${subCategory}`}
|
||||
category={{ name: subCategory }}
|
||||
path={[...path, subCategory]}
|
||||
handleSingleSelection={handleSingleSelection}
|
||||
handleCombinationSelection={handleCombinationSelection}
|
||||
selectedSingles={selectedSingles}
|
||||
selectedCombinations={selectedCombinations}
|
||||
indentLevel={indentLevel + 1}
|
||||
handleRemove={handleRemove}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<CategoryCheckbox
|
||||
inputValue={inputValue}
|
||||
key={subCategory.name}
|
||||
category={subCategory}
|
||||
path={[...path, subCategory.name]}
|
||||
handleSingleSelection={handleSingleSelection}
|
||||
handleCombinationSelection={handleCombinationSelection}
|
||||
selectedSingles={selectedSingles}
|
||||
selectedCombinations={selectedCombinations}
|
||||
indentLevel={indentLevel + 1}
|
||||
handleRemove={handleRemove}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
interface DropdownProps {
|
||||
label: React.ReactNode
|
||||
}
|
||||
export const Dropdown = ({
|
||||
label,
|
||||
children
|
||||
}: PropsWithChildren<DropdownProps>) => {
|
||||
return (
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,176 +0,0 @@
|
||||
import { useAppSelector, useLocalStorage } from 'hooks'
|
||||
import React, { PropsWithChildren } from 'react'
|
||||
import {
|
||||
FilterOptions,
|
||||
SortBy,
|
||||
ModeratedFilter,
|
||||
WOTFilterOptions,
|
||||
RepostFilter
|
||||
} from 'types'
|
||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||
import { Filter } from '.'
|
||||
import { Dropdown } from './Dropdown'
|
||||
import { Option } from './Option'
|
||||
import { NsfwFilterOptions } from './NsfwFilterOptions'
|
||||
|
||||
type Props = {
|
||||
author?: string | undefined
|
||||
filterKey?: string | undefined
|
||||
}
|
||||
|
||||
export const ModFilter = React.memo(
|
||||
({ author, filterKey = 'filter', children }: PropsWithChildren<Props>) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||
filterKey,
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
|
||||
return (
|
||||
<Filter>
|
||||
{/* sort filter options */}
|
||||
<Dropdown label={filterOptions.sort}>
|
||||
{Object.values(SortBy).map((item, index) => (
|
||||
<Option
|
||||
key={`sortByItem-${index}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
sort: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
))}
|
||||
</Dropdown>
|
||||
|
||||
{/* moderation filter options */}
|
||||
<Dropdown label={filterOptions.moderated}>
|
||||
{Object.values(ModeratedFilter).map((item, index) => {
|
||||
const isAdmin =
|
||||
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||
|
||||
if (item === ModeratedFilter.Only_Blocked && !isAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (item === ModeratedFilter.Unmoderated_Fully) {
|
||||
const isOwnProfile =
|
||||
author && userState.auth && userState.user?.pubkey === author
|
||||
|
||||
if (!(isAdmin || isOwnProfile)) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Option
|
||||
key={`moderatedFilterItem-${index}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
moderated: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
)
|
||||
})}
|
||||
</Dropdown>
|
||||
|
||||
{/* wot filter options */}
|
||||
<Dropdown label={<>Trust: {filterOptions.wot}</>}>
|
||||
{Object.values(WOTFilterOptions).map((item, index) => {
|
||||
// when user is not logged in
|
||||
if (item === WOTFilterOptions.Site_And_Mine && !userState.auth) {
|
||||
return null
|
||||
}
|
||||
|
||||
// when logged in user not admin
|
||||
if (
|
||||
item === WOTFilterOptions.None ||
|
||||
item === WOTFilterOptions.Mine_Only ||
|
||||
item === WOTFilterOptions.Exclude
|
||||
) {
|
||||
const isWoTNpub =
|
||||
userState.user?.npub === import.meta.env.VITE_SITE_WOT_NPUB
|
||||
|
||||
const isOwnProfile =
|
||||
author && userState.auth && userState.user?.pubkey === author
|
||||
|
||||
if (!(isWoTNpub || isOwnProfile)) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Option
|
||||
key={`wotFilterOption-${index}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
wot: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
)
|
||||
})}
|
||||
</Dropdown>
|
||||
|
||||
{/* nsfw filter options */}
|
||||
<Dropdown label={filterOptions.nsfw}>
|
||||
<NsfwFilterOptions filterKey={filterKey} />
|
||||
</Dropdown>
|
||||
|
||||
{/* repost filter options */}
|
||||
<Dropdown label={filterOptions.repost}>
|
||||
{Object.values(RepostFilter).map((item, index) => (
|
||||
<Option
|
||||
key={`repostFilterItem-${index}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
repost: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
))}
|
||||
</Dropdown>
|
||||
|
||||
{/* source filter options */}
|
||||
<Dropdown
|
||||
label={
|
||||
filterOptions.source === window.location.host
|
||||
? `Show From: ${filterOptions.source}`
|
||||
: 'Show All'
|
||||
}
|
||||
>
|
||||
<Option
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
source: window.location.host
|
||||
}))
|
||||
}
|
||||
>
|
||||
Show From: {window.location.host}
|
||||
</Option>
|
||||
<Option
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
source: 'Show All'
|
||||
}))
|
||||
}
|
||||
>
|
||||
Show All
|
||||
</Option>
|
||||
</Dropdown>
|
||||
|
||||
{children}
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
)
|
@ -1,64 +0,0 @@
|
||||
import { FilterOptions, NSFWFilter } from 'types'
|
||||
import { Option } from './Option'
|
||||
import { NsfwAlertPopup } from 'components/NsfwAlertPopup'
|
||||
import { useState } from 'react'
|
||||
import { useLocalStorage } from 'hooks'
|
||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||
|
||||
interface NsfwFilterOptionsProps {
|
||||
filterKey: string
|
||||
}
|
||||
|
||||
export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => {
|
||||
const [, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||
filterKey,
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(false)
|
||||
const [selectedNsfwOption, setSelectedNsfwOption] = useState<
|
||||
NSFWFilter | undefined
|
||||
>()
|
||||
const [confirmNsfw] = useLocalStorage<boolean>('confirm-nsfw', false)
|
||||
const handleConfirm = (confirm: boolean) => {
|
||||
if (confirm && selectedNsfwOption) {
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
nsfw: selectedNsfwOption
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.values(NSFWFilter).map((item, index) => (
|
||||
<Option
|
||||
key={`nsfwFilterItem-${index}`}
|
||||
onClick={() => {
|
||||
// Trigger NSFW popup
|
||||
if (
|
||||
(item === NSFWFilter.Only_NSFW ||
|
||||
item === NSFWFilter.Show_NSFW) &&
|
||||
!confirmNsfw
|
||||
) {
|
||||
setSelectedNsfwOption(item)
|
||||
setShowNsfwPopup(true)
|
||||
} else {
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
nsfw: item
|
||||
}))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
))}
|
||||
{showNsfwPopup && (
|
||||
<NsfwAlertPopup
|
||||
handleConfirm={handleConfirm}
|
||||
handleClose={() => setShowNsfwPopup(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
interface OptionProps {
|
||||
onClick: React.MouseEventHandler<HTMLDivElement>
|
||||
}
|
||||
|
||||
export const Option = ({
|
||||
onClick,
|
||||
children
|
||||
}: PropsWithChildren<OptionProps>) => {
|
||||
return (
|
||||
<div className='dropdown-item dropdownMainMenuItem' onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
export const Filter = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
<div className='FiltersMain'>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,28 +1,21 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getGamePageRoute } from 'routes'
|
||||
import '../styles/cardGames.css'
|
||||
import { handleGameImageError } from '../utils'
|
||||
|
||||
type GameCardProps = {
|
||||
title: string
|
||||
imageUrl: string
|
||||
backgroundLink: string
|
||||
}
|
||||
|
||||
export const GameCard = ({ title, imageUrl }: GameCardProps) => {
|
||||
const route = getGamePageRoute(title)
|
||||
|
||||
export const GameCard = ({ backgroundLink }: GameCardProps) => {
|
||||
return (
|
||||
<Link className='cardGameMainWrapperLink' to={route}>
|
||||
<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>
|
||||
</Link>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
140
src/components/Inputs.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import React from 'react'
|
||||
import ReactQuill from 'react-quill'
|
||||
import 'react-quill/dist/quill.snow.css'
|
||||
import '../styles/customQuillStyles.css'
|
||||
import '../styles/styles.css'
|
||||
|
||||
const editorFormats = [
|
||||
'header',
|
||||
'font',
|
||||
'size',
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'strike',
|
||||
'blockquote',
|
||||
'list',
|
||||
'bullet',
|
||||
'indent',
|
||||
'link'
|
||||
]
|
||||
|
||||
const editorModules = {
|
||||
toolbar: [
|
||||
[{ header: '1' }, { header: '2' }, { font: [] }],
|
||||
[{ size: [] }],
|
||||
['bold', 'italic', 'underline', 'strike', 'blockquote'],
|
||||
[
|
||||
{ list: 'ordered' },
|
||||
{ list: 'bullet' },
|
||||
{ indent: '-1' },
|
||||
{ indent: '+1' }
|
||||
],
|
||||
['link']
|
||||
]
|
||||
}
|
||||
|
||||
interface InputFieldProps {
|
||||
label: string
|
||||
description?: string
|
||||
type?: 'text' | 'textarea' | 'richtext'
|
||||
placeholder: string
|
||||
name: string
|
||||
inputMode?: 'url'
|
||||
value: string
|
||||
error?: string
|
||||
onChange: (name: string, value: string) => void
|
||||
}
|
||||
|
||||
export const InputField = React.memo(
|
||||
({
|
||||
label,
|
||||
description,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
name,
|
||||
inputMode,
|
||||
value,
|
||||
error,
|
||||
onChange
|
||||
}: InputFieldProps) => {
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
onChange(name, e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>{label}</label>
|
||||
{description && <p className='labelDescriptionMain'>{description}</p>}
|
||||
{type === 'textarea' ? (
|
||||
<textarea
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
></textarea>
|
||||
) : type === 'richtext' ? (
|
||||
<ReactQuill
|
||||
className='inputMain'
|
||||
formats={editorFormats}
|
||||
modules={editorModules}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(content) => onChange(name, content)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
inputMode={inputMode}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
{error && <InputError message={error} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
type InputErrorProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const InputError = ({ message }: InputErrorProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className='errorMain'>
|
||||
<div className='errorMainColor'></div>
|
||||
<p className='errorMainText'>{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CheckboxFieldProps {
|
||||
label: string
|
||||
name: string
|
||||
isChecked: boolean
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
}
|
||||
|
||||
export const CheckboxField = React.memo(
|
||||
({ label, name, isChecked, handleChange }: CheckboxFieldProps) => (
|
||||
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
|
||||
<label className='form-label labelMain'>{label}</label>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='CheckboxMain'
|
||||
name={name}
|
||||
checked={isChecked}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)
|
@ -1,14 +0,0 @@
|
||||
type InputErrorProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const InputError = ({ message }: InputErrorProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className='errorMain'>
|
||||
<div className='errorMainColor'></div>
|
||||
<p className='errorMainText'>{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
.spinner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
-webkit-backdrop-filter: blur(1px);
|
||||
backdrop-filter: blur(1px);
|
||||
pointer-events: none;
|
||||
}
|
@ -1,126 +0,0 @@
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
MediaOption,
|
||||
MEDIA_OPTIONS,
|
||||
ImageController,
|
||||
MEDIA_DROPZONE_OPTIONS
|
||||
} from '../../controllers'
|
||||
import { errorFeedback } from '../../types'
|
||||
import { MediaInputPopover } from './MediaInputPopover'
|
||||
import { Spinner } from 'components/Spinner'
|
||||
import styles from './ImageUpload.module.scss'
|
||||
|
||||
export interface ImageUploadProps {
|
||||
multiple?: boolean | undefined
|
||||
onChange: (values: string[]) => void
|
||||
}
|
||||
export const ImageUpload = React.memo(
|
||||
({ multiple = false, onChange }: ImageUploadProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [mediaOption, setMediaOption] = useState<MediaOption>(
|
||||
MEDIA_OPTIONS[0]
|
||||
)
|
||||
const handleOptionChange = useCallback(
|
||||
(mo: MediaOption) => () => {
|
||||
setMediaOption(mo)
|
||||
},
|
||||
[]
|
||||
)
|
||||
const handleUpload = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length) {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const imageController = new ImageController(mediaOption)
|
||||
const urls: string[] = []
|
||||
for (let i = 0; i < acceptedFiles.length; i++) {
|
||||
const file = acceptedFiles[i]
|
||||
urls.push(await imageController.post(file))
|
||||
}
|
||||
onChange(urls)
|
||||
} catch (error) {
|
||||
errorFeedback(error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[mediaOption, onChange]
|
||||
)
|
||||
const {
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
acceptedFiles,
|
||||
isFileDialogActive,
|
||||
isDragAccept,
|
||||
isDragReject,
|
||||
fileRejections
|
||||
} = useDropzone({
|
||||
...MEDIA_DROPZONE_OPTIONS,
|
||||
onDrop: handleUpload,
|
||||
multiple: multiple
|
||||
})
|
||||
|
||||
const dropzoneLabel = useMemo(
|
||||
() =>
|
||||
isFileDialogActive
|
||||
? 'Select files in dialog'
|
||||
: isDragActive
|
||||
? isDragAccept
|
||||
? 'Drop the files here...'
|
||||
: isDragReject
|
||||
? 'Drop the files here (one more more unsupported types)...'
|
||||
: 'Drop the files here...'
|
||||
: 'Click or drag files here',
|
||||
[isDragAccept, isDragActive, isDragReject, isFileDialogActive]
|
||||
)
|
||||
|
||||
return (
|
||||
<div aria-label='upload featuredImageUrl' className='uploadBoxMain'>
|
||||
<MediaInputPopover
|
||||
acceptedFiles={acceptedFiles}
|
||||
fileRejections={fileRejections}
|
||||
/>
|
||||
<div className='uploadBoxMainInside' {...getRootProps()} tabIndex={-1}>
|
||||
<input id='featuredImageUrl-upload' {...getInputProps()} />
|
||||
<span>{dropzoneLabel}</span>
|
||||
<div
|
||||
className='FiltersMainElement'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
Image Host: {mediaOption.name}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{MEDIA_OPTIONS.map((mo) => {
|
||||
return (
|
||||
<div
|
||||
key={mo.host}
|
||||
onClick={handleOptionChange(mo)}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
>
|
||||
{mo.name}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className={styles.spinner}>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
@ -1,14 +0,0 @@
|
||||
.accordion-button::after {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5) !important;
|
||||
|
||||
top: unset !important;
|
||||
bottom: unset !important;
|
||||
}
|
||||
.accordion-body > * {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.accordion-item + .accordion-item {
|
||||
margin-top: 10px;
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
import { FileError } from 'react-dropzone'
|
||||
import styles from './MediaInputError.module.scss'
|
||||
|
||||
type MediaInputErrorProps = {
|
||||
rootId: string
|
||||
index: number
|
||||
message: string
|
||||
errors?: readonly FileError[] | undefined
|
||||
}
|
||||
|
||||
export const MediaInputError = ({
|
||||
rootId,
|
||||
index,
|
||||
message,
|
||||
errors
|
||||
}: MediaInputErrorProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className={['accordion-item', styles['accordion-item']].join(' ')}>
|
||||
<h2 className='accordion-header' role='tab'>
|
||||
<button
|
||||
className={[
|
||||
'accordion-button collapsed',
|
||||
styles['accordion-button']
|
||||
].join(' ')}
|
||||
type='button'
|
||||
data-bs-toggle='collapse'
|
||||
data-bs-target={`#${rootId} .item-${index}`}
|
||||
aria-expanded='false'
|
||||
aria-controls={`${rootId} .item-${index}`}
|
||||
>
|
||||
<div className='errorMain'>
|
||||
<div className='errorMainColor'></div>
|
||||
<p className='errorMainText'>{message}</p>
|
||||
</div>
|
||||
</button>
|
||||
</h2>
|
||||
{errors && (
|
||||
<div
|
||||
className={`accordion-collapse collapse item-${index}`}
|
||||
role='tabpanel'
|
||||
data-bs-parent={`#${rootId}`}
|
||||
>
|
||||
<div
|
||||
className={['accordion-body', styles['accordion-body']].join(' ')}
|
||||
>
|
||||
{errors.map((e) => {
|
||||
return typeof e === 'string' ? (
|
||||
<div className='errorMain' key={e}>
|
||||
{e}
|
||||
</div>
|
||||
) : (
|
||||
<div className='errorMain' key={e.code}>
|
||||
{e.message}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
.popover {
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 0 16px 0px rgb(0 0 0 / 15%);
|
||||
background: #232323;
|
||||
z-index: 2;
|
||||
}
|
||||
.content {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding: 25px;
|
||||
> *:not(:first-child) {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
.trigger {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
right: 25px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.mediaInputError {
|
||||
--bs-accordion-color: unset;
|
||||
--bs-accordion-bg: unset;
|
||||
--bs-accordion-transition: unset;
|
||||
--bs-accordion-border-color: unset;
|
||||
--bs-accordion-border-width: unset;
|
||||
--bs-accordion-border-radius: unset;
|
||||
--bs-accordion-inner-border-radius: unset;
|
||||
--bs-accordion-btn-padding-x: unset;
|
||||
--bs-accordion-btn-padding-y: unset;
|
||||
--bs-accordion-btn-color: unset;
|
||||
--bs-accordion-btn-bg: unset;
|
||||
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='gray'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||
--bs-accordion-btn-icon-width: 1.25rem;
|
||||
--bs-accordion-btn-icon-transform: rotate(-180deg);
|
||||
--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;
|
||||
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='gray'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||
--bs-accordion-btn-focus-border-color: unset;
|
||||
--bs-accordion-btn-focus-box-shadow: unset;
|
||||
--bs-accordion-body-padding-x: unset;
|
||||
--bs-accordion-body-padding-y: unset;
|
||||
--bs-accordion-active-color: unset;
|
||||
--bs-accordion-active-bg: unset;
|
||||
}
|
@ -1,108 +0,0 @@
|
||||
import * as Popover from '@radix-ui/react-popover'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useMemo } from 'react'
|
||||
import { FileRejection, FileWithPath } from 'react-dropzone'
|
||||
import { MediaInputError } from './MediaInputError'
|
||||
import { InputSuccess } from './Success'
|
||||
import styles from './MediaInputPopover.module.scss'
|
||||
|
||||
interface MediaInputPopoverProps {
|
||||
acceptedFiles: readonly FileWithPath[]
|
||||
fileRejections: readonly FileRejection[]
|
||||
}
|
||||
|
||||
export const MediaInputPopover = ({
|
||||
acceptedFiles,
|
||||
fileRejections
|
||||
}: MediaInputPopoverProps) => {
|
||||
const uuid = useMemo(() => uuidv4(), [])
|
||||
const acceptedFileItems = useMemo(
|
||||
() =>
|
||||
acceptedFiles.map((file) => (
|
||||
<InputSuccess
|
||||
key={file.path}
|
||||
message={`${file.path} - ${file.size} bytes`}
|
||||
/>
|
||||
)),
|
||||
[acceptedFiles]
|
||||
)
|
||||
const fileRejectionItems = useMemo(() => {
|
||||
const id = `errors-${uuid}`
|
||||
return (
|
||||
<div
|
||||
className={`accordion accordion-flush ${styles.mediaInputError}`}
|
||||
role='tablist'
|
||||
id={id}
|
||||
>
|
||||
{fileRejections.map(({ file, errors }, index) => (
|
||||
<MediaInputError
|
||||
rootId={id}
|
||||
index={index}
|
||||
key={file.path}
|
||||
message={`${file.path} - ${file.size} bytes`}
|
||||
errors={errors}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}, [fileRejections, uuid])
|
||||
|
||||
if (acceptedFiles.length === 0 && fileRejections.length === 0) return null
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<div className={styles.trigger}>
|
||||
{acceptedFiles.length > 0 ? (
|
||||
<svg
|
||||
width='1.5em'
|
||||
height='1.5em'
|
||||
fill='currentColor'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 576 512'
|
||||
>
|
||||
<path d='M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 38.6C310.1 219.5 256 287.4 256 368c0 59.1 29.1 111.3 73.7 143.3c-3.2 .5-6.4 .7-9.7 .7L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128zM288 368a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm211.3-43.3c-6.2-6.2-16.4-6.2-22.6 0L416 385.4l-28.7-28.7c-6.2-6.2-16.4-6.2-22.6 0s-6.2 16.4 0 22.6l40 40c6.2 6.2 16.4 6.2 22.6 0l72-72c6.2-6.2 6.2-16.4 0-22.6z' />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width='1.5em'
|
||||
height='1.5em'
|
||||
fill='tomato'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 576 512'
|
||||
>
|
||||
<path d='M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 38.6C310.1 219.5 256 287.4 256 368c0 59.1 29.1 111.3 73.7 143.3c-3.2 .5-6.4 .7-9.7 .7L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128zm48 96a144 144 0 1 1 0 288 144 144 0 1 1 0-288zm0 240a24 24 0 1 0 0-48 24 24 0 1 0 0 48zm0-192c-8.8 0-16 7.2-16 16l0 80c0 8.8 7.2 16 16 16s16-7.2 16-16l0-80c0-8.8-7.2-16-16-16z' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content className={styles.popover} sideOffset={5}>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Selected files</h3>
|
||||
</div>
|
||||
<Popover.Close asChild aria-label='Close'>
|
||||
<div className='popUpMainCardTopClose'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</Popover.Close>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{acceptedFileItems}
|
||||
{fileRejectionItems}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
type InputSuccessProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const InputSuccess = ({ message }: InputSuccessProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className='successMain'>
|
||||
<div className='successMainColor'></div>
|
||||
<p className='successMainText'>{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,206 +0,0 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { InputError } from './Error'
|
||||
import { ImageUpload } from './ImageUpload'
|
||||
import '../../styles/styles.css'
|
||||
|
||||
interface InputFieldProps {
|
||||
label: string | React.ReactElement
|
||||
description?: string
|
||||
type?: 'text' | 'textarea'
|
||||
placeholder: string
|
||||
name: string
|
||||
inputMode?: 'url'
|
||||
value: string
|
||||
error?: string
|
||||
onChange: (name: string, value: string) => void
|
||||
}
|
||||
|
||||
export const InputField = React.memo(
|
||||
({
|
||||
label,
|
||||
description,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
name,
|
||||
inputMode,
|
||||
value,
|
||||
error,
|
||||
onChange
|
||||
}: InputFieldProps) => {
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
onChange(name, e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>{label}</label>
|
||||
{description && <p className='labelDescriptionMain'>{description}</p>}
|
||||
{type === 'textarea' ? (
|
||||
<textarea
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
></textarea>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
inputMode={inputMode}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
{error && <InputError message={error} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
interface CheckboxFieldProps {
|
||||
label: string
|
||||
name: string
|
||||
isChecked: boolean
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
type?: 'default' | 'stylized'
|
||||
}
|
||||
|
||||
export const CheckboxField = React.memo(
|
||||
({
|
||||
label,
|
||||
name,
|
||||
isChecked,
|
||||
handleChange,
|
||||
type = 'default'
|
||||
}: CheckboxFieldProps) => (
|
||||
<div
|
||||
className={`inputLabelWrapperMain inputLabelWrapperMainAlt${
|
||||
type === 'stylized' ? ` inputLabelWrapperMainAltStylized` : ''
|
||||
}`}
|
||||
>
|
||||
<label htmlFor={name} className='form-label labelMain'>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={name}
|
||||
type='checkbox'
|
||||
className='CheckboxMain'
|
||||
name={name}
|
||||
checked={isChecked}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
||||
label: string | React.ReactElement
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
/**
|
||||
* Uncontrolled input component with design classes, label, description and error support
|
||||
*
|
||||
* Extends {@link React.ComponentProps<'input'> React.ComponentProps<'input'>}
|
||||
* @param label
|
||||
* @param description
|
||||
* @param error
|
||||
*
|
||||
* @see {@link React.ComponentProps<'input'>}
|
||||
*/
|
||||
export const InputFieldUncontrolled = ({
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
...rest
|
||||
}: InputFieldUncontrolledProps) => (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label htmlFor={rest.id} className='form-label labelMain'>
|
||||
{label}
|
||||
</label>
|
||||
{description && <p className='labelDescriptionMain'>{description}</p>}
|
||||
<input className='inputMain' {...rest} />
|
||||
{error && <InputError message={error} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
||||
label: string
|
||||
}
|
||||
|
||||
export const CheckboxFieldUncontrolled = ({
|
||||
label,
|
||||
...rest
|
||||
}: CheckboxFieldUncontrolledProps) => (
|
||||
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
|
||||
<label htmlFor={rest.id} className='form-label labelMain'>
|
||||
{label}
|
||||
</label>
|
||||
<input type='checkbox' className='CheckboxMain' {...rest} />
|
||||
</div>
|
||||
)
|
||||
|
||||
interface InputFieldWithImageUploadProps {
|
||||
label: string | React.ReactElement
|
||||
description?: string
|
||||
placeholder: string
|
||||
name: string
|
||||
inputMode?: 'url'
|
||||
value: string
|
||||
error?: string
|
||||
onInputChange: (name: string, value: string) => void
|
||||
}
|
||||
|
||||
export const InputFieldWithImageUpload = React.memo(
|
||||
({
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
name,
|
||||
inputMode,
|
||||
value,
|
||||
error,
|
||||
onInputChange
|
||||
}: InputFieldWithImageUploadProps) => {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
onInputChange(name, e.currentTarget.value)
|
||||
},
|
||||
[name, onInputChange]
|
||||
)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(values: string[]) => {
|
||||
onInputChange(name, values[0])
|
||||
},
|
||||
[name, onInputChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>{label}</label>
|
||||
{typeof description !== 'undefined' && (
|
||||
<p className='labelDescriptionMain'>{description}</p>
|
||||
)}
|
||||
|
||||
<ImageUpload onChange={handleFileChange} />
|
||||
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
inputMode={inputMode}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{error && <InputError message={error} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
@ -1,42 +0,0 @@
|
||||
import { Addressable } from 'types'
|
||||
import { abbreviateNumber } from 'utils'
|
||||
import { Zap } from './Zap'
|
||||
import { Reactions } from './Reactions'
|
||||
|
||||
type InteractionsProps = {
|
||||
addressable: Addressable
|
||||
commentCount: number
|
||||
}
|
||||
|
||||
export const Interactions = ({
|
||||
addressable,
|
||||
commentCount
|
||||
}: InteractionsProps) => {
|
||||
return (
|
||||
<div className='IBMSMSplitMainBigSideSec'>
|
||||
<div className='IBMSMSMBSS_Details'>
|
||||
<a style={{ textDecoration: 'unset', color: 'unset' }}>
|
||||
<div className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CComments'>
|
||||
<div className='IBMSMSMBSS_Details_CardVisual'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSS_Details_CardVisualIcon'
|
||||
>
|
||||
<path d='M256 31.1c-141.4 0-255.1 93.12-255.1 208c0 49.62 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734c1.249 3 4.021 4.766 7.271 4.766c66.25 0 115.1-31.76 140.6-51.39c32.63 12.25 69.02 19.39 107.4 19.39c141.4 0 255.1-93.13 255.1-207.1S397.4 31.1 256 31.1zM127.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S145.7 271.1 127.1 271.1zM256 271.1c-17.75 0-31.1-14.25-31.1-31.1s14.25-32 31.1-32s31.1 14.25 31.1 32S273.8 271.1 256 271.1zM383.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S401.7 271.1 383.1 271.1z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='IBMSMSMBSS_Details_CardText'>
|
||||
{abbreviateNumber(commentCount)}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<Zap addressable={addressable} />
|
||||
<Reactions addressable={addressable} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
import { formatDate } from 'date-fns'
|
||||
|
||||
type PublishDetailsProps = {
|
||||
published_at: number
|
||||
edited_at: number
|
||||
site: string
|
||||
}
|
||||
|
||||
export const PublishDetails = ({
|
||||
published_at,
|
||||
edited_at,
|
||||
site
|
||||
}: PublishDetailsProps) => {
|
||||
return (
|
||||
<div className='IBMSMSplitMainBigSideSec'>
|
||||
<div className='IBMSMSMBSSPost_PostDetails'>
|
||||
<div
|
||||
data-bs-toggle='tooltip'
|
||||
data-bs-placement='left'
|
||||
className='IBMSMSMBSSPost_PDElement'
|
||||
title='Publish date'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSPost_PDElementIcon'
|
||||
data-bs-toggle='tooltip'
|
||||
data-bss-tooltip
|
||||
aria-label='Publish date'
|
||||
>
|
||||
<path d='M480 32H128C110.3 32 96 46.33 96 64v336C96 408.8 88.84 416 80 416S64 408.8 64 400V96H32C14.33 96 0 110.3 0 128v288c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V64C512 46.33 497.7 32 480 32zM272 416h-96C167.2 416 160 408.8 160 400C160 391.2 167.2 384 176 384h96c8.836 0 16 7.162 16 16C288 408.8 280.8 416 272 416zM272 320h-96C167.2 320 160 312.8 160 304C160 295.2 167.2 288 176 288h96C280.8 288 288 295.2 288 304C288 312.8 280.8 320 272 320zM432 416h-96c-8.836 0-16-7.164-16-16c0-8.838 7.164-16 16-16h96c8.836 0 16 7.162 16 16C448 408.8 440.8 416 432 416zM432 320h-96C327.2 320 320 312.8 320 304C320 295.2 327.2 288 336 288h96C440.8 288 448 295.2 448 304C448 312.8 440.8 320 432 320zM448 208C448 216.8 440.8 224 432 224h-256C167.2 224 160 216.8 160 208v-96C160 103.2 167.2 96 176 96h256C440.8 96 448 103.2 448 112V208z' />
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSPost_PDElementText'>
|
||||
{formatDate(
|
||||
(published_at !== -1 ? published_at : edited_at) * 1000,
|
||||
'dd/MM/yyyy hh:mm:ss aa'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
data-bs-toggle='tooltip'
|
||||
data-bs-placement='left'
|
||||
className='IBMSMSMBSSPost_PDElement'
|
||||
title='Last modified'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSPost_PDElementIcon'
|
||||
>
|
||||
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z' />
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSPost_PDElementText'>
|
||||
{formatDate(edited_at * 1000, 'dd/MM/yyyy hh:mm:ss aa')}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
data-bs-toggle='tooltip'
|
||||
data-bs-placement='left'
|
||||
className='IBMSMSMBSSPost_PDElement IBMSMSMBSSPost_PDElementLink'
|
||||
href='#'
|
||||
title='Published on'
|
||||
target='_blank'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -64 640 640'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSPost_PDElementIcon'
|
||||
>
|
||||
<path d='M172.5 131.1C228.1 75.51 320.5 75.51 376.1 131.1C426.1 181.1 433.5 260.8 392.4 318.3L391.3 319.9C381 334.2 361 337.6 346.7 327.3C332.3 317 328.9 297 339.2 282.7L340.3 281.1C363.2 249 359.6 205.1 331.7 177.2C300.3 145.8 249.2 145.8 217.7 177.2L105.5 289.5C73.99 320.1 73.99 372 105.5 403.5C133.3 431.4 177.3 435 209.3 412.1L210.9 410.1C225.3 400.7 245.3 404 255.5 418.4C265.8 432.8 262.5 452.8 248.1 463.1L246.5 464.2C188.1 505.3 110.2 498.7 60.21 448.8C3.741 392.3 3.741 300.7 60.21 244.3L172.5 131.1zM467.5 380C411 436.5 319.5 436.5 263 380C213 330 206.5 251.2 247.6 193.7L248.7 192.1C258.1 177.8 278.1 174.4 293.3 184.7C307.7 194.1 311.1 214.1 300.8 229.3L299.7 230.9C276.8 262.1 280.4 306.9 308.3 334.8C339.7 366.2 390.8 366.2 422.3 334.8L534.5 222.5C566 191 566 139.1 534.5 108.5C506.7 80.63 462.7 76.99 430.7 99.9L429.1 101C414.7 111.3 394.7 107.1 384.5 93.58C374.2 79.2 377.5 59.21 391.9 48.94L393.5 47.82C451 6.731 529.8 13.25 579.8 63.24C636.3 119.7 636.3 211.3 579.8 267.7L467.5 380z' />
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSPost_PDElementText'>{site}</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,77 +0,0 @@
|
||||
import { Dots } from 'components/Spinner'
|
||||
import { useReactions } from 'hooks'
|
||||
import { Addressable } from 'types'
|
||||
|
||||
type ReactionsProps = {
|
||||
addressable: Addressable
|
||||
}
|
||||
|
||||
export const Reactions = ({ addressable }: ReactionsProps) => {
|
||||
const {
|
||||
isDataLoaded,
|
||||
likesCount,
|
||||
disLikesCount,
|
||||
handleReaction,
|
||||
hasReactedPositively,
|
||||
hasReactedNegatively
|
||||
} = useReactions({
|
||||
pubkey: addressable.author,
|
||||
eTag: addressable.id,
|
||||
aTag: addressable.aTag
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp ${
|
||||
hasReactedPositively ? 'IBMSMSMBSS_D_CRUActive' : ''
|
||||
}`}
|
||||
onClick={isDataLoaded ? () => handleReaction(true) : undefined}
|
||||
>
|
||||
<div className='IBMSMSMBSS_Details_CardVisual'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSS_Details_CardVisualIcon'
|
||||
>
|
||||
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='IBMSMSMBSS_Details_CardText'>
|
||||
{isDataLoaded ? likesCount : <Dots />}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown ${
|
||||
hasReactedNegatively ? 'IBMSMSMBSS_D_CRDActive' : ''
|
||||
}`}
|
||||
onClick={isDataLoaded ? () => handleReaction() : undefined}
|
||||
>
|
||||
<div className='IBMSMSMBSS_Details_CardVisual'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSS_Details_CardVisualIcon'
|
||||
>
|
||||
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='IBMSMSMBSS_Details_CardText'>
|
||||
{isDataLoaded ? disLikesCount : <Dots />}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,100 +0,0 @@
|
||||
import { Dots } from 'components/Spinner'
|
||||
import { ZapSplit } from 'components/Zap'
|
||||
import {
|
||||
useAppSelector,
|
||||
useBodyScrollDisable,
|
||||
useDidMount,
|
||||
useNDKContext
|
||||
} from 'hooks'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { Addressable } from 'types'
|
||||
import { abbreviateNumber, log, LogType } from 'utils'
|
||||
|
||||
type ZapProps = {
|
||||
addressable: Addressable
|
||||
}
|
||||
|
||||
export const Zap = ({ addressable }: ZapProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isAvailable, setIsAvailable] = useState(false)
|
||||
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
|
||||
const [hasZapped, setHasZapped] = useState(false)
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const { getTotalZapAmount, findMetadata } = useNDKContext()
|
||||
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
useDidMount(() => {
|
||||
findMetadata(addressable.author)
|
||||
.then((res) => {
|
||||
setIsAvailable(typeof res?.lud16 !== 'undefined' && res.lud16 !== '')
|
||||
})
|
||||
.catch((err) => {
|
||||
log(true, LogType.Error, err.message || err)
|
||||
})
|
||||
|
||||
getTotalZapAmount(
|
||||
addressable.author,
|
||||
addressable.id,
|
||||
addressable.aTag,
|
||||
userState.user?.pubkey as string
|
||||
)
|
||||
.then((res) => {
|
||||
setTotalZappedAmount(res.accumulatedZapAmount)
|
||||
setHasZapped(res.hasZapped)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Hide button if the author hasn't set lud16
|
||||
if (!isAvailable) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
id='reactBolt'
|
||||
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CBolt ${
|
||||
hasZapped ? 'IBMSMSMBSS_D_CBActive' : ''
|
||||
}`}
|
||||
onClick={isLoading ? undefined : () => 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'>
|
||||
{isLoading ? <Dots /> : abbreviateNumber(totalZappedAmount)}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ZapSplit
|
||||
pubkey={addressable.author}
|
||||
eventId={addressable.id}
|
||||
aTag={addressable.aTag}
|
||||
setTotalZapAmount={setTotalZappedAmount}
|
||||
setHasZapped={setHasZapped}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,12 +1,12 @@
|
||||
import { PropsWithChildren, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigation } from 'react-router-dom'
|
||||
import styles from '../../styles/loadingSpinner.module.scss'
|
||||
|
||||
interface Props {
|
||||
desc: string
|
||||
}
|
||||
|
||||
export const LoadingSpinner = ({ desc }: Props) => {
|
||||
export const LoadingSpinner = (props: Props) => {
|
||||
const { desc } = props
|
||||
|
||||
return (
|
||||
<div className={styles.loadingSpinnerOverlay}>
|
||||
<div className={styles.loadingSpinnerContainer}>
|
||||
@ -16,62 +16,3 @@ export const LoadingSpinner = ({ desc }: Props) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RouterLoadingSpinner = () => {
|
||||
const navigation = useNavigation()
|
||||
|
||||
if (navigation.state === 'idle') return null
|
||||
|
||||
const desc =
|
||||
navigation.state.charAt(0).toUpperCase() + navigation.state.slice(1)
|
||||
|
||||
return <LoadingSpinner desc={`${desc}...`} />
|
||||
}
|
||||
|
||||
interface TimerLoadingSpinner {
|
||||
timeoutMs?: number
|
||||
countdownMs?: number
|
||||
}
|
||||
|
||||
export const TimerLoadingSpinner = ({
|
||||
timeoutMs = 10000,
|
||||
countdownMs = 30000,
|
||||
children
|
||||
}: PropsWithChildren<TimerLoadingSpinner>) => {
|
||||
const [show, setShow] = useState(false)
|
||||
const [timer, setTimer] = useState(
|
||||
Math.floor((countdownMs - timeoutMs) / 1000)
|
||||
)
|
||||
const startTime = useMemo(() => Date.now(), [])
|
||||
|
||||
useEffect(() => {
|
||||
let interval: number
|
||||
const timeout = window.setTimeout(() => {
|
||||
setShow(true)
|
||||
interval = window.setInterval(() => {
|
||||
const time = Date.now() - startTime
|
||||
const diff = Math.max(0, countdownMs - time)
|
||||
setTimer(Math.floor(diff / 1000))
|
||||
}, 1000)
|
||||
}, timeoutMs)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [countdownMs, startTime, timeoutMs])
|
||||
|
||||
return (
|
||||
<div className={styles.loadingSpinnerOverlay}>
|
||||
<div className={styles.loadingSpinnerContainer}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
{children}
|
||||
{show && (
|
||||
<>
|
||||
<div>You can try again in {timer}s...</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,10 +0,0 @@
|
||||
.formAction {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border-radius: 0;
|
||||
}
|
@ -1,148 +0,0 @@
|
||||
import {
|
||||
BlockTypeSelect,
|
||||
BoldItalicUnderlineToggles,
|
||||
codeBlockPlugin,
|
||||
CodeToggle,
|
||||
CreateLink,
|
||||
diffSourcePlugin,
|
||||
DiffSourceToggleWrapper,
|
||||
directivesPlugin,
|
||||
headingsPlugin,
|
||||
imagePlugin,
|
||||
InsertCodeBlock,
|
||||
InsertImage,
|
||||
InsertTable,
|
||||
InsertThematicBreak,
|
||||
linkDialogPlugin,
|
||||
linkPlugin,
|
||||
listsPlugin,
|
||||
ListsToggle,
|
||||
markdownShortcutPlugin,
|
||||
MDXEditor,
|
||||
MDXEditorMethods,
|
||||
MDXEditorProps,
|
||||
quotePlugin,
|
||||
Separator,
|
||||
StrikeThroughSupSubToggles,
|
||||
tablePlugin,
|
||||
thematicBreakPlugin,
|
||||
toolbarPlugin,
|
||||
UndoRedo
|
||||
} from '@mdxeditor/editor'
|
||||
import { PlainTextCodeEditorDescriptor } from './PlainTextCodeEditorDescriptor'
|
||||
import { YoutubeDirectiveDescriptor } from './YoutubeDirectiveDescriptor'
|
||||
import { YouTubeButton } from './YoutubeButton'
|
||||
import '@mdxeditor/editor/style.css'
|
||||
import '../../styles/mdxEditor.scss'
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react'
|
||||
import { ImageDialog } from './ImageDialog'
|
||||
import { LinkDialog } from './LinkDialog'
|
||||
|
||||
export interface EditorRef {
|
||||
setMarkdown: (md: string) => void
|
||||
}
|
||||
interface EditorProps extends MDXEditorProps {}
|
||||
/**
|
||||
* The editor component is small wrapper (`forwardRef`) around {@link MDXEditor MDXEditor} that sets up the toolbars and plugins, and requires `markdown` and `onChange`.
|
||||
* To reset editor markdown it's required to pass the {@link EditorRef EditorRef}.
|
||||
*
|
||||
* Extends {@link MDXEditorProps MDXEditorProps}
|
||||
*
|
||||
* **Important**: the markdown is not a state, but an _initialState_ and is not "controlled".
|
||||
* All updates are handled with onChange and will not be reflected on markdown prop.
|
||||
* This component should never re-render if used correctly.
|
||||
* @see https://mdxeditor.dev/editor/docs/getting-started#basic-usage
|
||||
*/
|
||||
export const Editor = React.memo(
|
||||
forwardRef<EditorRef, EditorProps>(({ markdown, onChange, ...rest }, ref) => {
|
||||
const editorRef = useRef<MDXEditorMethods>(null)
|
||||
const setMarkdown = useCallback((md: string) => {
|
||||
editorRef.current?.setMarkdown(md)
|
||||
}, [])
|
||||
useImperativeHandle(ref, () => ({ setMarkdown }))
|
||||
const plugins = useMemo(
|
||||
() => [
|
||||
toolbarPlugin({
|
||||
toolbarContents: () => (
|
||||
<DiffSourceToggleWrapper
|
||||
children={
|
||||
<>
|
||||
<UndoRedo />
|
||||
<Separator />
|
||||
<BoldItalicUnderlineToggles />
|
||||
<CodeToggle />
|
||||
<Separator />
|
||||
<StrikeThroughSupSubToggles />
|
||||
<Separator />
|
||||
<ListsToggle />
|
||||
<Separator />
|
||||
<BlockTypeSelect />
|
||||
<Separator />
|
||||
|
||||
<CreateLink />
|
||||
<InsertImage />
|
||||
<YouTubeButton />
|
||||
|
||||
<Separator />
|
||||
|
||||
<InsertTable />
|
||||
<InsertThematicBreak />
|
||||
|
||||
<Separator />
|
||||
<InsertCodeBlock />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
headingsPlugin(),
|
||||
diffSourcePlugin({
|
||||
viewMode: 'rich-text',
|
||||
diffMarkdown: markdown
|
||||
}),
|
||||
quotePlugin(),
|
||||
imagePlugin({
|
||||
ImageDialog: ImageDialog
|
||||
}),
|
||||
tablePlugin(),
|
||||
linkPlugin(),
|
||||
linkDialogPlugin({
|
||||
LinkDialog: LinkDialog
|
||||
}),
|
||||
listsPlugin(),
|
||||
thematicBreakPlugin(),
|
||||
directivesPlugin({
|
||||
directiveDescriptors: [YoutubeDirectiveDescriptor]
|
||||
}),
|
||||
markdownShortcutPlugin(),
|
||||
// HACK: due to a bug with shortcut interaction shortcut for code block is disabled
|
||||
// Editor freezes if you type in ```word and put a space in between ``` word
|
||||
codeBlockPlugin({
|
||||
defaultCodeBlockLanguage: '',
|
||||
codeBlockEditorDescriptors: [PlainTextCodeEditorDescriptor]
|
||||
})
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<MDXEditor
|
||||
ref={editorRef}
|
||||
contentEditableClassName='editor'
|
||||
className='dark-theme dark-editor'
|
||||
markdown={markdown}
|
||||
plugins={plugins}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
() => true
|
||||
)
|
@ -1,166 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useCellValues, usePublisher } from '@mdxeditor/gurx'
|
||||
import {
|
||||
closeImageDialog$,
|
||||
editorRootElementRef$,
|
||||
imageDialogState$,
|
||||
imageUploadHandler$,
|
||||
saveImage$
|
||||
} from '@mdxeditor/editor'
|
||||
import styles from './Dialog.module.scss'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
interface ImageFormFields {
|
||||
src: string
|
||||
title: string
|
||||
altText: string
|
||||
file: FileList
|
||||
}
|
||||
|
||||
export const ImageDialog: React.FC = () => {
|
||||
const [state, editorRootElementRef, imageUploadHandler] = useCellValues(
|
||||
imageDialogState$,
|
||||
editorRootElementRef$,
|
||||
imageUploadHandler$
|
||||
)
|
||||
const saveImage = usePublisher(saveImage$)
|
||||
const closeImageDialog = usePublisher(closeImageDialog$)
|
||||
const { register, handleSubmit, setValue, reset } = useForm<ImageFormFields>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
|
||||
values: state.type === 'editing' ? (state.initialValues as any) : {}
|
||||
})
|
||||
const [open, setOpen] = useState(state.type !== 'inactive')
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(state.type !== 'inactive')
|
||||
}, [state.type])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
closeImageDialog()
|
||||
reset({ src: '', title: '', altText: '' })
|
||||
}
|
||||
}, [closeImageDialog, open, reset])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
if (!open) return null
|
||||
if (!editorRootElementRef?.current) return null
|
||||
|
||||
return createPortal(
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Add an image</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<form
|
||||
className='pUMCB_ZapsInside'
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(saveImage)(e)
|
||||
reset({ src: '', title: '', altText: '' })
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{imageUploadHandler === null ? (
|
||||
<input type='hidden' accept='image/*' {...register('file')} />
|
||||
) : (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain' htmlFor='file'>
|
||||
Upload an image from your device:
|
||||
</label>
|
||||
<input type='file' accept='image/*' {...register('file')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain' htmlFor='src'>
|
||||
{imageUploadHandler !== null
|
||||
? 'Or add an image from an URL:'
|
||||
: 'Add an image from an URL:'}
|
||||
</label>
|
||||
<input
|
||||
defaultValue={
|
||||
state.type === 'editing'
|
||||
? state.initialValues.src ?? ''
|
||||
: ''
|
||||
}
|
||||
className='inputMain'
|
||||
size={40}
|
||||
autoFocus
|
||||
{...register('src')}
|
||||
onChange={(e) => setValue('src', e.currentTarget.value)}
|
||||
placeholder={'Paste an image src'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain' htmlFor='alt'>
|
||||
Alt:
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
{...register('altText')}
|
||||
className='inputMain'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain' htmlFor='title'>
|
||||
Title:
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
{...register('title')}
|
||||
className='inputMain'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formAction}>
|
||||
<button
|
||||
type='submit'
|
||||
title={'Save'}
|
||||
aria-label={'Save'}
|
||||
className='btn btnMain btnMainPopup'
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type='reset'
|
||||
title={'Cancel'}
|
||||
aria-label={'Cancel'}
|
||||
className='btn btnMain btnMainPopup'
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
editorRootElementRef?.current
|
||||
)
|
||||
}
|
@ -1,306 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import * as Popover from '@radix-ui/react-popover'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import React from 'react'
|
||||
|
||||
import {
|
||||
activeEditor$,
|
||||
editorRootElementRef$,
|
||||
iconComponentFor$,
|
||||
cancelLinkEdit$,
|
||||
linkDialogState$,
|
||||
onWindowChange$,
|
||||
removeLink$,
|
||||
switchFromPreviewToLinkEdit$,
|
||||
updateLink$,
|
||||
ClickLinkCallback
|
||||
} from '@mdxeditor/editor'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Cell, useCellValues, usePublisher } from '@mdxeditor/gurx'
|
||||
import styles from './Dialog.module.scss'
|
||||
|
||||
interface LinkEditFormProps {
|
||||
url: string
|
||||
title: string
|
||||
onSubmit: (link: { url: string; title: string }) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
interface LinkFormFields {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export function LinkEditForm({
|
||||
url,
|
||||
title,
|
||||
onSubmit,
|
||||
onCancel
|
||||
}: LinkEditFormProps) {
|
||||
const { register, handleSubmit, setValue } = useForm<LinkFormFields>({
|
||||
values: {
|
||||
url,
|
||||
title
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='pUMCB_Zaps'>
|
||||
<form
|
||||
className='pUMCB_ZapsInside'
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(onSubmit)(e)
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
onReset={(e) => {
|
||||
e.stopPropagation()
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain' htmlFor='file'>
|
||||
URL:
|
||||
</label>
|
||||
<input
|
||||
defaultValue={url}
|
||||
className='inputMain'
|
||||
size={40}
|
||||
autoFocus
|
||||
{...register('url')}
|
||||
onChange={(e) => setValue('url', e.currentTarget.value)}
|
||||
placeholder={'Paste an URL'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain' htmlFor='link-title'>
|
||||
Title:
|
||||
</label>
|
||||
<input
|
||||
id='link-title'
|
||||
className='inputMain'
|
||||
size={40}
|
||||
{...register('title')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formAction}>
|
||||
<button
|
||||
type='submit'
|
||||
title={'Set URL'}
|
||||
aria-label={'Set URL'}
|
||||
className='btn btnMain btnMainPopup'
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type='reset'
|
||||
title={'Cancel change'}
|
||||
aria-label={'Cancel change'}
|
||||
className='btn btnMain btnMainPopup'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const onClickLinkCallback$ = Cell<ClickLinkCallback | null>(null)
|
||||
|
||||
/** @internal */
|
||||
export const LinkDialog = () => {
|
||||
const [
|
||||
editorRootElementRef,
|
||||
activeEditor,
|
||||
iconComponentFor,
|
||||
linkDialogState,
|
||||
onClickLinkCallback
|
||||
] = useCellValues(
|
||||
editorRootElementRef$,
|
||||
activeEditor$,
|
||||
iconComponentFor$,
|
||||
linkDialogState$,
|
||||
onClickLinkCallback$
|
||||
)
|
||||
const publishWindowChange = usePublisher(onWindowChange$)
|
||||
const updateLink = usePublisher(updateLink$)
|
||||
const cancelLinkEdit = usePublisher(cancelLinkEdit$)
|
||||
const switchFromPreviewToLinkEdit = usePublisher(switchFromPreviewToLinkEdit$)
|
||||
const removeLink = usePublisher(removeLink$)
|
||||
|
||||
React.useEffect(() => {
|
||||
const update = () => {
|
||||
activeEditor?.getEditorState().read(() => {
|
||||
publishWindowChange(true)
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', update)
|
||||
window.addEventListener('scroll', update)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update)
|
||||
window.removeEventListener('scroll', update)
|
||||
}
|
||||
}, [activeEditor, publishWindowChange])
|
||||
|
||||
const [copyUrlTooltipOpen, setCopyUrlTooltipOpen] = React.useState(false)
|
||||
|
||||
const theRect = linkDialogState.rectangle
|
||||
|
||||
const urlIsExternal =
|
||||
linkDialogState.type === 'preview' && linkDialogState.url.startsWith('http')
|
||||
|
||||
return (
|
||||
<Popover.Root open={linkDialogState.type !== 'inactive'}>
|
||||
<Popover.Anchor
|
||||
data-visible={linkDialogState.type === 'edit'}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${theRect?.top ?? 0}px`,
|
||||
left: `${theRect?.left ?? 0}px`,
|
||||
width: `${theRect?.width ?? 0}px`,
|
||||
height: `${theRect?.height ?? 0}px`
|
||||
}}
|
||||
/>
|
||||
|
||||
<Popover.Portal container={editorRootElementRef?.current}>
|
||||
<Popover.Content
|
||||
sideOffset={5}
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
key={linkDialogState.linkNodeKey}
|
||||
className={[
|
||||
'popUpMainCard',
|
||||
...(linkDialogState.type === 'edit' ? [styles.wrapper] : [])
|
||||
].join(' ')}
|
||||
>
|
||||
{linkDialogState.type === 'edit' && (
|
||||
<LinkEditForm
|
||||
url={linkDialogState.url}
|
||||
title={linkDialogState.title}
|
||||
onSubmit={updateLink}
|
||||
onCancel={cancelLinkEdit.bind(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{linkDialogState.type === 'preview' && (
|
||||
<>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapper'>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapped'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Address'>
|
||||
<a
|
||||
className={styles.linkDialogPreviewAnchor}
|
||||
href={linkDialogState.url}
|
||||
{...(urlIsExternal
|
||||
? { target: '_blank', rel: 'noreferrer' }
|
||||
: {})}
|
||||
onClick={(e) => {
|
||||
if (
|
||||
onClickLinkCallback !== null &&
|
||||
typeof onClickLinkCallback === 'function'
|
||||
) {
|
||||
e.preventDefault()
|
||||
onClickLinkCallback(linkDialogState.url)
|
||||
}
|
||||
}}
|
||||
title={
|
||||
urlIsExternal
|
||||
? `Open ${linkDialogState.url} in new window`
|
||||
: linkDialogState.url
|
||||
}
|
||||
>
|
||||
<span>{linkDialogState.url}</span>
|
||||
{urlIsExternal && iconComponentFor('open_in_new')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
onClick={() => {
|
||||
switchFromPreviewToLinkEdit()
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root open={copyUrlTooltipOpen}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
onClick={() => {
|
||||
void window.navigator.clipboard
|
||||
.writeText(linkDialogState.url)
|
||||
.then(() => {
|
||||
setCopyUrlTooltipOpen(true)
|
||||
setTimeout(() => {
|
||||
setCopyUrlTooltipOpen(false)
|
||||
}, 1000)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal container={editorRootElementRef?.current}>
|
||||
<Tooltip.Content sideOffset={5}>
|
||||
{'Copied!'}
|
||||
<Tooltip.Arrow />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
onClick={() => {
|
||||
removeLink()
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 640 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5L373 389.9z' />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Popover.Arrow />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
import {
|
||||
CodeBlockEditorDescriptor,
|
||||
useCodeBlockEditorContext
|
||||
} from '@mdxeditor/editor'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
export const PlainTextCodeEditorDescriptor: CodeBlockEditorDescriptor = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
match: (_language, _meta) => true,
|
||||
priority: 0,
|
||||
Editor: ({ code, focusEmitter }) => {
|
||||
const { parentEditor, lexicalNode, setCode } = useCodeBlockEditorContext()
|
||||
const defaultValue = useRef(code)
|
||||
const codeRef = useRef<HTMLElement>(null)
|
||||
|
||||
const handleInput = useCallback(
|
||||
(e: React.FormEvent<HTMLElement>) => {
|
||||
setCode(e.currentTarget.innerHTML)
|
||||
},
|
||||
[setCode]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
if (codeRef.current) {
|
||||
codeRef.current.focus()
|
||||
}
|
||||
}
|
||||
focusEmitter.subscribe(handleFocus)
|
||||
}, [focusEmitter])
|
||||
|
||||
useEffect(() => {
|
||||
const currentRef = codeRef.current
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Backspace' || event.key === 'Delete') {
|
||||
if (codeRef.current?.textContent === '') {
|
||||
parentEditor.update(() => {
|
||||
lexicalNode.selectNext()
|
||||
lexicalNode.remove()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentRef) {
|
||||
currentRef.addEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
return () => {
|
||||
if (currentRef) {
|
||||
currentRef.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}
|
||||
}, [lexicalNode, parentEditor])
|
||||
|
||||
return (
|
||||
<pre>
|
||||
<code
|
||||
ref={codeRef}
|
||||
contentEditable={true}
|
||||
onInput={handleInput}
|
||||
dangerouslySetInnerHTML={{ __html: defaultValue.current }}
|
||||
/>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
import { createDirectives, presetDirectiveConfigs } from 'marked-directive'
|
||||
import { youtubeDirective } from './YoutubeDirective'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
interface ViewerProps {
|
||||
markdown: string
|
||||
}
|
||||
|
||||
export const Viewer = ({ markdown }: ViewerProps) => {
|
||||
const html = useMemo(() => {
|
||||
DOMPurify.addHook('beforeSanitizeAttributes', function (node) {
|
||||
if (node.nodeName && node.nodeName === 'IFRAME') {
|
||||
const src = node.attributes.getNamedItem('src')
|
||||
if (!(src && src.value.startsWith('https://www.youtube.com/embed/'))) {
|
||||
node.remove()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return DOMPurify.sanitize(
|
||||
marked
|
||||
.use(createDirectives([...presetDirectiveConfigs, youtubeDirective]))
|
||||
.parse(`${markdown}`, {
|
||||
async: false
|
||||
}),
|
||||
{
|
||||
ADD_TAGS: ['iframe']
|
||||
}
|
||||
)
|
||||
}, [markdown])
|
||||
|
||||
return (
|
||||
<div className='viewer' dangerouslySetInnerHTML={{ __html: html }}></div>
|
||||
)
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
import { LeafDirective } from 'mdast-util-directive'
|
||||
import { usePublisher, insertDirective$, DialogButton } from '@mdxeditor/editor'
|
||||
|
||||
function getId(url: string) {
|
||||
const regExp =
|
||||
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/
|
||||
const match = url.match(regExp)
|
||||
return match && match[7].length == 11 ? match[7] : false
|
||||
}
|
||||
|
||||
export const YouTubeButton = () => {
|
||||
const insertDirective = usePublisher(insertDirective$)
|
||||
|
||||
return (
|
||||
<DialogButton
|
||||
tooltipTitle='Insert Youtube video'
|
||||
submitButtonTitle='Insert video'
|
||||
dialogInputPlaceholder='Paste the youtube video URL'
|
||||
buttonContent='YT'
|
||||
onSubmit={(url) => {
|
||||
const videoId = getId(url)
|
||||
if (videoId) {
|
||||
insertDirective({
|
||||
name: 'youtube',
|
||||
type: 'leafDirective',
|
||||
|
||||
attributes: { id: videoId },
|
||||
children: []
|
||||
} as LeafDirective)
|
||||
} else {
|
||||
alert('Invalid YouTube URL')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
import { type DirectiveConfig } from 'marked-directive'
|
||||
|
||||
// defines `:youtube` directive
|
||||
export const youtubeDirective: DirectiveConfig = {
|
||||
level: 'block',
|
||||
marker: '::',
|
||||
renderer(token) {
|
||||
//https://www.youtube.com/embed/<VIDEO_ID>
|
||||
//::youtube{#<VIDEO_ID>}
|
||||
let vid: string = ''
|
||||
if (token.attrs && token.meta.name === 'youtube') {
|
||||
if (token.attrs.id) {
|
||||
vid = token.attrs.id as string // Get the video `id` attribute (common id style)
|
||||
} else if (token.attrs.vid) {
|
||||
vid = token.attrs.vid as string // Check for the `vid` attribute (youtube directive attribute style)
|
||||
} else {
|
||||
// Fallback for id
|
||||
// In case that video starts with the number it will not be recongizned as an id
|
||||
// We have to manually fetch it
|
||||
for (const attr in token.attrs) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(token.attrs, attr) &&
|
||||
attr.startsWith('#')
|
||||
) {
|
||||
vid = attr.replace('#', '')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (vid) {
|
||||
return `<iframe title="Video embed" width="560" height="315" src="https://www.youtube.com/embed/${vid}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
import { LeafDirective } from 'mdast-util-directive'
|
||||
import { DirectiveDescriptor } from '@mdxeditor/editor'
|
||||
|
||||
interface YoutubeDirectiveNode extends LeafDirective {
|
||||
name: 'youtube'
|
||||
attributes: { id: string }
|
||||
}
|
||||
|
||||
export const YoutubeDirectiveDescriptor: DirectiveDescriptor<YoutubeDirectiveNode> =
|
||||
{
|
||||
name: 'youtube',
|
||||
type: 'leafDirective',
|
||||
testNode(node) {
|
||||
return node.name === 'youtube'
|
||||
},
|
||||
attributes: ['id'],
|
||||
hasChildren: false,
|
||||
Editor: ({ mdastNode, lexicalNode, parentEditor }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
title='delete'
|
||||
className='btnMain'
|
||||
onClick={() => {
|
||||
parentEditor.update(() => {
|
||||
lexicalNode.selectNext()
|
||||
lexicalNode.remove()
|
||||
})
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 448 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M135.2 17.7L128 32 32 32C14.3 32 0 46.3 0 64S14.3 96 32 96l384 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-96 0-7.2-14.3C307.4 6.8 296.3 0 284.2 0L163.8 0c-12.1 0-23.2 6.8-28.6 17.7zM416 128L32 128 53.2 467c1.6 25.3 22.6 45 47.9 45l245.8 0c25.3 0 46.3-19.7 47.9-45L416 128z' />
|
||||
</svg>
|
||||
</button>
|
||||
<iframe
|
||||
width='560'
|
||||
height='315'
|
||||
src={`https://www.youtube.com/embed/${mdastNode.attributes.id}`}
|
||||
title='YouTube video player'
|
||||
frameBorder='0'
|
||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,74 +1,30 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import '../styles/cardMod.css'
|
||||
import { handleModImageError } from '../utils'
|
||||
import { ModDetails } from 'types'
|
||||
import { getModPageRoute } from 'routes'
|
||||
import { kinds, nip19 } from 'nostr-tools'
|
||||
import { useDidMount, useNDKContext, useReactions } from 'hooks'
|
||||
import { toast } from 'react-toastify'
|
||||
import { useComments } from 'hooks/useComments'
|
||||
|
||||
export const ModCard = React.memo((props: ModDetails) => {
|
||||
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
|
||||
const [commentCount, setCommentCount] = useState(0)
|
||||
const { commentEvents } = useComments(props.author, props.aTag)
|
||||
const { likesCount, disLikesCount } = useReactions({
|
||||
pubkey: props.author,
|
||||
eTag: props.id,
|
||||
aTag: props.aTag
|
||||
})
|
||||
const { getTotalZapAmount } = useNDKContext()
|
||||
|
||||
useDidMount(() => {
|
||||
getTotalZapAmount(props.author, props.id, props.aTag)
|
||||
.then((res) => {
|
||||
setTotalZappedAmount(res.accumulatedZapAmount)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setCommentCount(commentEvents.length)
|
||||
}, [commentEvents])
|
||||
|
||||
const route = getModPageRoute(
|
||||
nip19.naddrEncode({
|
||||
identifier: props.aTag,
|
||||
pubkey: props.author,
|
||||
kind: kinds.ClassifiedListing
|
||||
})
|
||||
)
|
||||
type ModCardProps = {
|
||||
title: string
|
||||
summary: string
|
||||
backgroundLink: string
|
||||
handleClick: () => void
|
||||
}
|
||||
|
||||
export const ModCard = ({
|
||||
title,
|
||||
summary,
|
||||
backgroundLink,
|
||||
handleClick
|
||||
}: ModCardProps) => {
|
||||
return (
|
||||
<Link className='cardModMainWrapperLink' to={route}>
|
||||
<a className='cardModMainWrapperLink' onClick={handleClick}>
|
||||
<div className='cardModMain'>
|
||||
<div className='cMMPictureWrapper'>
|
||||
<img
|
||||
src={props.featuredImageUrl}
|
||||
onError={handleModImageError}
|
||||
className='cMMPicture'
|
||||
alt={`featured image for mod ${props.title}`}
|
||||
/>
|
||||
{props.nsfw && (
|
||||
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW IBMSMSMBSSTagsTagNSFWCard'>
|
||||
<p>NSFW</p>
|
||||
</div>
|
||||
)}
|
||||
{props.repost && (
|
||||
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagRepost IBMSMSMBSSTagsTagRepostCard'>
|
||||
<p>REPOST</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className='cMMPicture'
|
||||
style={{
|
||||
background: `url("${backgroundLink}") center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
<div className='cMMBody'>
|
||||
<h3 className='cMMBodyTitle'>{props.title}</h3>
|
||||
<p className='cMMBodyText'>{props.summary}</p>
|
||||
<div className='cMMBodyGame'>
|
||||
<p>{props.game}</p>
|
||||
</div>
|
||||
<h3 className='cMMBodyTitle'>{title}</h3>
|
||||
<p className='cMMBodyText'>{summary}</p>
|
||||
</div>
|
||||
<div className='cMMFoot'>
|
||||
<div className='cMMFootReactions'>
|
||||
@ -82,7 +38,7 @@ export const ModCard = React.memo((props: ModDetails) => {
|
||||
>
|
||||
<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>{likesCount}</p>
|
||||
<p>420</p>
|
||||
</div>
|
||||
<div className='cMMFootReactionsElement'>
|
||||
<svg
|
||||
@ -94,7 +50,7 @@ export const ModCard = React.memo((props: ModDetails) => {
|
||||
>
|
||||
<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>{disLikesCount}</p>
|
||||
<p>420</p>
|
||||
</div>
|
||||
<div className='cMMFootReactionsElement'>
|
||||
<svg
|
||||
@ -106,23 +62,11 @@ export const ModCard = React.memo((props: ModDetails) => {
|
||||
>
|
||||
<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>{commentCount}</p>
|
||||
</div>
|
||||
<div className='cMMFootReactionsElement'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-64 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
|
||||
</svg>
|
||||
<p>{totalZappedAmount}</p>
|
||||
<p>420</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</a>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
@ -1,39 +0,0 @@
|
||||
import { AlertPopupProps } from 'types'
|
||||
import { AlertPopup } from './AlertPopup'
|
||||
import { useLocalStorage } from 'hooks'
|
||||
|
||||
type NsfwAlertPopup = Omit<AlertPopupProps, 'header' | 'label'>
|
||||
|
||||
/**
|
||||
* Triggers when the user wants to switch the filter to see any of the NSFW options
|
||||
* (including preferences)
|
||||
*
|
||||
* Option will be remembered for the session only and will not show the popup again
|
||||
*/
|
||||
export const NsfwAlertPopup = ({
|
||||
handleConfirm,
|
||||
handleClose
|
||||
}: NsfwAlertPopup) => {
|
||||
const [confirmNsfw, setConfirmNsfw] = useLocalStorage<boolean>(
|
||||
'confirm-nsfw',
|
||||
false
|
||||
)
|
||||
|
||||
return (
|
||||
!confirmNsfw && (
|
||||
<AlertPopup
|
||||
header='Confirm'
|
||||
label='Are you above 18 years of age?'
|
||||
handleClose={() => {
|
||||
handleConfirm(false)
|
||||
handleClose()
|
||||
}}
|
||||
handleConfirm={(confirm: boolean) => {
|
||||
setConfirmNsfw(confirm)
|
||||
handleConfirm(confirm)
|
||||
handleClose()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { appRoutes, getProfilePageRoute } from 'routes'
|
||||
import { npubToHex } from 'utils'
|
||||
import { ProfileLink } from './ProfileLink'
|
||||
|
||||
interface OriginalAuthorProps {
|
||||
value: string
|
||||
fallback?: boolean
|
||||
}
|
||||
|
||||
export const OriginalAuthor = ({
|
||||
value,
|
||||
fallback = false
|
||||
}: OriginalAuthorProps) => {
|
||||
let profilePubkey
|
||||
let displayName = '[name not set up]'
|
||||
|
||||
// Try to decode/encode depending on what we send to link
|
||||
let profileRoute = appRoutes.home
|
||||
try {
|
||||
if (value.startsWith('nprofile1')) {
|
||||
const decoded = nip19.decode(value as `nprofile1${string}`)
|
||||
profileRoute = getProfilePageRoute(value)
|
||||
profilePubkey = decoded?.data.pubkey
|
||||
} else if (value.startsWith('npub1')) {
|
||||
profilePubkey = npubToHex(value)
|
||||
const nprofile = profilePubkey
|
||||
? nip19.nprofileEncode({
|
||||
pubkey: profilePubkey
|
||||
})
|
||||
: undefined
|
||||
|
||||
if (nprofile) {
|
||||
profileRoute = getProfilePageRoute(nprofile)
|
||||
}
|
||||
} else {
|
||||
displayName = value
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create profile link:', error)
|
||||
displayName = value
|
||||
}
|
||||
|
||||
if (profileRoute && profilePubkey)
|
||||
return <ProfileLink pubkey={profilePubkey} profileRoute={profileRoute} />
|
||||
|
||||
return fallback ? displayName : null
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
interface PostWarningsProps {
|
||||
type: 'user' | 'admin'
|
||||
}
|
||||
|
||||
export const PostWarnings = ({ type }: PostWarningsProps) => (
|
||||
<div className='IBMSMSMBSSWarning'>
|
||||
<p>
|
||||
{type === 'admin' ? (
|
||||
<>
|
||||
Warning: This post has been blocked/hidden by the site for one of the
|
||||
following reasons:
|
||||
<br />
|
||||
Malware, Not a Mod, Illegal, Spam, Verified Report of Unauthorized
|
||||
Repost.
|
||||
<br />
|
||||
</>
|
||||
) : (
|
||||
<>Notice: You have blocked this post</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
@ -1,15 +0,0 @@
|
||||
import { useProfile } from 'hooks/useProfile'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface ProfileLinkProps {
|
||||
pubkey: string
|
||||
profileRoute: string
|
||||
}
|
||||
|
||||
export const ProfileLink = ({ pubkey, profileRoute }: ProfileLinkProps) => {
|
||||
const profile = useProfile(pubkey)
|
||||
const displayName =
|
||||
profile?.displayName || profile?.name || '[name not set up]'
|
||||
|
||||
return <Link to={profileRoute}>{displayName}</Link>
|
||||
}
|
@ -1,216 +1,174 @@
|
||||
import { FALLBACK_PROFILE_IMAGE } from 'constants.ts'
|
||||
import { Event, Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import {
|
||||
useAppSelector,
|
||||
useBodyScrollDisable,
|
||||
useDidMount,
|
||||
useNDKContext
|
||||
} from '../hooks'
|
||||
import { appRoutes, getProfilePageRoute } from '../routes'
|
||||
import '../styles/author.css'
|
||||
import '../styles/innerPage.css'
|
||||
import '../styles/socialPosts.css'
|
||||
import { UserRelaysType } from '../types'
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
hexToNpub,
|
||||
log,
|
||||
LogType,
|
||||
now,
|
||||
npubToHex
|
||||
} from '../utils'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
import { ZapPopUp } from './Zap'
|
||||
import placeholder from '../assets/img/DEGMods Placeholder Img.png'
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||
import { useProfile } from 'hooks/useProfile'
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export const ProfileSection = ({ pubkey }: Props) => {
|
||||
export const ProfileSection = () => {
|
||||
return (
|
||||
<div className='IBMSMSplitMainSmallSide'>
|
||||
<div className='IBMSMSplitMainSmallSideSecWrapper'>
|
||||
<div className='IBMSMSplitMainSmallSideSec'>
|
||||
<Profile pubkey={pubkey} />
|
||||
</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 = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export const Profile = ({ pubkey }: ProfileProps) => {
|
||||
const profile = useProfile(pubkey)
|
||||
|
||||
const displayName =
|
||||
profile?.displayName || profile?.name || '[name not set up]'
|
||||
const about = profile?.bio || profile?.about || '[bio not set up]'
|
||||
const image = profile?.image || FALLBACK_PROFILE_IMAGE
|
||||
const nip05 = profile?.nip05
|
||||
const lud16 = profile?.lud16
|
||||
|
||||
const npub = hexToNpub(pubkey)
|
||||
|
||||
const handleCopy = async () => {
|
||||
copyTextToClipboard(npub).then((isCopied) => {
|
||||
if (isCopied) {
|
||||
toast.success('Npub copied to clipboard!')
|
||||
} else {
|
||||
toast.error(
|
||||
'Failed to copy, look into console for more details on error!'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Try to encode
|
||||
let profileRoute = appRoutes.home
|
||||
let nprofile: string | undefined
|
||||
try {
|
||||
const hexPubkey = npubToHex(pubkey)
|
||||
nprofile = hexPubkey
|
||||
? nip19.nprofileEncode({
|
||||
pubkey: hexPubkey
|
||||
})
|
||||
: undefined
|
||||
profileRoute = nprofile ? getProfilePageRoute(nprofile) : appRoutes.home
|
||||
} catch (error) {
|
||||
// Silently ignore and redirect to home
|
||||
log(true, LogType.Error, 'Failed to encode profile.', error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='IBMSMSMSSS_Author'>
|
||||
<div className='IBMSMSMSSS_Author_Top'>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left'>
|
||||
<Link
|
||||
className='IBMSMSMSSS_Author_Top_Left_InsideLinkWrapper'
|
||||
to={profileRoute}
|
||||
>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_Inside'>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_InsidePic'>
|
||||
<div className='IBMSMSMSSS_Author_Top_PPWrapper'>
|
||||
<div className='IBMSMSMSSS_Author_Top_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('${image}') center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
id='copySiteOwnerAddress'
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_IconWrapped IBMSMSMSSS_Author_Top_IconWrappedQR'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<a
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
href='https://primal.net/p/npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M256 64C256 46.33 270.3 32 288 32H415.1C415.1 32 415.1 32 415.1 32C420.3 32 424.5 32.86 428.2 34.43C431.1 35.98 435.5 38.27 438.6 41.3C438.6 41.35 438.6 41.4 438.7 41.44C444.9 47.66 447.1 55.78 448 63.9C448 63.94 448 63.97 448 64V192C448 209.7 433.7 224 416 224C398.3 224 384 209.7 384 192V141.3L214.6 310.6C202.1 323.1 181.9 323.1 169.4 310.6C156.9 298.1 156.9 277.9 169.4 265.4L338.7 96H288C270.3 96 256 81.67 256 64V64zM0 128C0 92.65 28.65 64 64 64H160C177.7 64 192 78.33 192 96C192 113.7 177.7 128 160 128H64V416H352V320C352 302.3 366.3 288 384 288C401.7 288 416 302.3 416 320V416C416 451.3 387.3 480 352 480H64C28.65 480 0 451.3 0 416V128z'></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
|
||||
<div className='IBMSMSMSSS_Author_TopWrapper'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Name'>{displayName}</p>
|
||||
{/* Nip05 can sometimes be an empty object '{}' which causes the error */}
|
||||
{typeof nip05 === 'string' && nip05 !== '' && (
|
||||
<p className='IBMSMSMSSS_Author_Top_Handle'>{nip05}</p>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_Details'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Bio'>{author.bio}</p>
|
||||
<div
|
||||
id='OwnerFollowLogin'
|
||||
className='IBMSMSMSSS_Author_Top_NostrLinks'
|
||||
style={{ display: 'flex' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button className='btn btnMain' type='button'>
|
||||
Follow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSplitMainSmallSideSec'>
|
||||
<div className='IBMSMSMSSS_ShortPosts'>
|
||||
{posts.map((post, index) => (
|
||||
<a
|
||||
key={'post' + index}
|
||||
className='IBMSMSMSSS_ShortPostsPostLink'
|
||||
href={post.link}
|
||||
>
|
||||
<div className='IBMSMSMSSS_ShortPostsPost'>
|
||||
<div className='IBMSMSMSSS_ShortPostsPost_Top'>
|
||||
<p className='IBMSMSMSSS_ShortPostsPost_TopName'>
|
||||
{post.name}
|
||||
</p>
|
||||
<div className='IBMSMSMSSS_ShortPostsPost_TopLink'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_ShortPostsPost_TopLinkIcon'
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<path d='M256 64C256 46.33 270.3 32 288 32H415.1C415.1 32 415.1 32 415.1 32C420.3 32 424.5 32.86 428.2 34.43C431.1 35.98 435.5 38.27 438.6 41.3C438.6 41.35 438.6 41.4 438.7 41.44C444.9 47.66 447.1 55.78 448 63.9C448 63.94 448 63.97 448 64V192C448 209.7 433.7 224 416 224C398.3 224 384 209.7 384 192V141.3L214.6 310.6C202.1 323.1 181.9 323.1 169.4 310.6C156.9 298.1 156.9 277.9 169.4 265.4L338.7 96H288C270.3 96 256 81.67 256 64V64zM0 128C0 92.65 28.65 64 64 64H160C177.7 64 192 78.33 192 96C192 113.7 177.7 128 160 128H64V416H352V320C352 302.3 366.3 288 384 288C401.7 288 416 302.3 416 320V416C416 451.3 387.3 480 352 480H64C28.65 480 0 451.3 0 416V128z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_ShortPostsPost_Bottom'>
|
||||
<p>{post.content}</p>
|
||||
{post.imageUrl && (
|
||||
<div
|
||||
className='IBMSMSMSSS_ShortPostsPost_BottomImg'
|
||||
style={{
|
||||
background: `linear-gradient(0deg, #232323 5%, rgba(255, 255, 255, 0)), url("${post.imageUrl}") top / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapper'>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapped'>
|
||||
<p
|
||||
id='SiteOwnerAddress'
|
||||
className='IBMSMSMSSS_Author_Top_Address'
|
||||
>
|
||||
{npub}
|
||||
</p>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
|
||||
<div
|
||||
id='copySiteOwnerAddress'
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
{typeof nprofile !== 'undefined' && (
|
||||
<ProfileQRButtonWithPopUp nprofile={nprofile} />
|
||||
)}
|
||||
{typeof lud16 !== 'undefined' && lud16 !== '' && (
|
||||
<ZapButtonWithPopUp pubkey={pubkey} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_Details'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Bio'>{about}</p>
|
||||
<div
|
||||
id='OwnerFollowLogin'
|
||||
className='IBMSMSMSSS_Author_Top_NostrLinks'
|
||||
style={{ display: 'flex' }}
|
||||
></div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</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
|
||||
@ -220,335 +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: placeholder
|
||||
content: `This is good.`,
|
||||
imageUrl: '/assets/img/media-cache%20(1).jpg'
|
||||
}
|
||||
]
|
||||
|
||||
type QRButtonWithPopUpProps = {
|
||||
nprofile: string
|
||||
}
|
||||
|
||||
export const ProfileQRButtonWithPopUp = ({
|
||||
nprofile
|
||||
}: QRButtonWithPopUpProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
const onQrCodeClicked = async () => {
|
||||
const href = `https://njump.me/${nprofile}`
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
a.target = '_blank' // Open in a new tab
|
||||
a.rel = 'noopener noreferrer' // Recommended for security reasons
|
||||
a.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped IBMSMSMSSS_Author_Top_IconWrappedQR'
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Nostr Address</h3>
|
||||
</div>
|
||||
<div
|
||||
className='popUpMainCardTopClose'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='popUpMainCardBottom'>
|
||||
<QRCodeSVG
|
||||
className='popUpMainCardBottomQR'
|
||||
onClick={onQrCodeClicked}
|
||||
value={nprofile}
|
||||
height={235}
|
||||
width={235}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ZapButtonWithPopUpProps = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
const ZapButtonWithPopUp = ({ pubkey }: ZapButtonWithPopUpProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ZapPopUp
|
||||
title='Tip/Zap'
|
||||
receiver={pubkey}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type FollowButtonProps = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
const FollowButton = ({ pubkey }: FollowButtonProps) => {
|
||||
const { ndk, fetchEventFromUserRelays, publish } = useNDKContext()
|
||||
const [isFollowing, setIsFollowing] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useDidMount(async () => {
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
const userHexKey = userState.user.pubkey as string
|
||||
const { isFollowing: isAlreadyFollowing } = await checkIfFollowing(
|
||||
userHexKey,
|
||||
pubkey
|
||||
)
|
||||
setIsFollowing(isAlreadyFollowing)
|
||||
}
|
||||
})
|
||||
|
||||
const getUserPubKey = async (): Promise<string | null> => {
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
return userState.user.pubkey as string
|
||||
} else {
|
||||
try {
|
||||
return (await window.nostr?.getPublicKey()) as string
|
||||
} catch (error) {
|
||||
log(true, LogType.Error, `Could not get pubkey`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkIfFollowing = async (
|
||||
userHexKey: string,
|
||||
pubkey: string
|
||||
): Promise<{
|
||||
isFollowing: boolean
|
||||
tags: string[][]
|
||||
}> => {
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.Contacts],
|
||||
authors: [userHexKey]
|
||||
}
|
||||
|
||||
const contactListEvent = await fetchEventFromUserRelays(
|
||||
filter,
|
||||
userHexKey,
|
||||
UserRelaysType.Both
|
||||
)
|
||||
|
||||
if (!contactListEvent)
|
||||
return {
|
||||
isFollowing: false,
|
||||
tags: []
|
||||
}
|
||||
|
||||
return {
|
||||
isFollowing: contactListEvent.tags.some(
|
||||
(t) => t[0] === 'p' && t[1] === pubkey
|
||||
),
|
||||
tags: contactListEvent.tags
|
||||
}
|
||||
}
|
||||
|
||||
const signAndPublishEvent = async (
|
||||
unsignedEvent: UnsignedEvent
|
||||
): Promise<boolean> => {
|
||||
const signedEvent = await window.nostr
|
||||
?.signEvent(unsignedEvent)
|
||||
.then((event) => event as Event)
|
||||
.catch((err) => {
|
||||
toast.error('Failed to sign the event!')
|
||||
log(true, LogType.Error, 'Failed to sign the event!', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!signedEvent) return false
|
||||
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
if (publishedOnRelays.length === 0) {
|
||||
toast.error('Failed to publish event on any relay')
|
||||
return false
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
|
||||
'\n'
|
||||
)}`
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
const handleFollow = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Processing follow request')
|
||||
|
||||
const userHexKey = await getUserPubKey()
|
||||
if (!userHexKey) {
|
||||
setIsLoading(false)
|
||||
toast.error('Could not get pubkey')
|
||||
return
|
||||
}
|
||||
|
||||
const { isFollowing: isAlreadyFollowing, tags } = await checkIfFollowing(
|
||||
userHexKey,
|
||||
pubkey
|
||||
)
|
||||
if (isAlreadyFollowing) {
|
||||
toast.info('Already following!')
|
||||
setIsFollowing(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
content: '',
|
||||
created_at: now(),
|
||||
kind: kinds.Contacts,
|
||||
pubkey: userHexKey,
|
||||
tags: [...tags, ['p', pubkey]]
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Signing and publishing follow event')
|
||||
const success = await signAndPublishEvent(unsignedEvent)
|
||||
setIsFollowing(success)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleUnFollow = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Processing unfollow request')
|
||||
|
||||
const userHexKey = await getUserPubKey()
|
||||
if (!userHexKey) {
|
||||
setIsLoading(false)
|
||||
toast.error('Could not get pubkey')
|
||||
return
|
||||
}
|
||||
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.Contacts],
|
||||
authors: [userHexKey]
|
||||
}
|
||||
|
||||
const contactListEvent = await fetchEventFromUserRelays(
|
||||
filter,
|
||||
userHexKey,
|
||||
UserRelaysType.Both
|
||||
)
|
||||
|
||||
if (
|
||||
!contactListEvent ||
|
||||
!contactListEvent.tags.some((t) => t[0] === 'p' && t[1] === pubkey)
|
||||
) {
|
||||
// could not found target pubkey in user's follow list
|
||||
// so, just update the status and return
|
||||
setIsFollowing(false)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
content: '',
|
||||
created_at: now(),
|
||||
kind: kinds.Contacts,
|
||||
pubkey: userHexKey,
|
||||
tags: contactListEvent.tags.filter(
|
||||
(t) => !(t[0] === 'p' && t[1] === pubkey)
|
||||
)
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Signing and publishing unfollow event')
|
||||
const success = await signAndPublishEvent(unsignedEvent)
|
||||
setIsFollowing(!success)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className='btn btnMain'
|
||||
type='button'
|
||||
onClick={isFollowing ? handleUnFollow : handleFollow}
|
||||
>
|
||||
{isFollowing ? 'Un-Follow' : 'Follow'}
|
||||
</button>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,93 +0,0 @@
|
||||
import { useFetcher } from 'react-router-dom'
|
||||
import { CheckboxFieldUncontrolled } from 'components/Inputs'
|
||||
import { useEffect } from 'react'
|
||||
import { ReportReason } from 'types/report'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
import { PopupProps } from 'types'
|
||||
|
||||
type ReportPopupProps = {
|
||||
openedAt: number
|
||||
reasons: ReportReason[]
|
||||
} & PopupProps
|
||||
|
||||
export const ReportPopup = ({
|
||||
openedAt,
|
||||
reasons,
|
||||
handleClose
|
||||
}: ReportPopupProps) => {
|
||||
// Use openedAt to allow for multiple reports
|
||||
// by default, fetcher will remember the data
|
||||
const fetcher = useFetcher({ key: openedAt.toString() })
|
||||
|
||||
// Close automatically if action succeeds
|
||||
useEffect(() => {
|
||||
if (fetcher.data) {
|
||||
const { isSent } = fetcher.data
|
||||
if (isSent) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
}, [fetcher, handleClose])
|
||||
|
||||
return (
|
||||
<>
|
||||
{fetcher.state !== 'idle' && <LoadingSpinner desc={''} />}
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Report Post</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<fetcher.Form
|
||||
className='pUMCB_ZapsInside'
|
||||
method='post'
|
||||
action='report'
|
||||
>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Why are you reporting this?
|
||||
</label>
|
||||
{reasons.map((r) => (
|
||||
<CheckboxFieldUncontrolled
|
||||
key={r.key}
|
||||
label={r.label}
|
||||
name={r.key}
|
||||
defaultChecked={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className='btn btnMain pUMCB_Report'
|
||||
type='submit'
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Submit Report
|
||||
</button>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
export const ProfileSVG = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
{...props}
|
||||
>
|
||||
<path d='M224 256c70.7 0 128-57.31 128-128s-57.3-128-128-128C153.3 0 96 57.31 96 128S153.3 256 224 256zM274.7 304H173.3C77.61 304 0 381.6 0 477.3c0 19.14 15.52 34.67 34.66 34.67h378.7C432.5 512 448 496.5 448 477.3C448 381.6 370.4 304 274.7 304z'></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const RelaySVG = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
{...props}
|
||||
>
|
||||
<path d='M480 288H32c-17.62 0-32 14.38-32 32v128c0 17.62 14.38 32 32 32h448c17.62 0 32-14.38 32-32v-128C512 302.4 497.6 288 480 288zM352 408c-13.25 0-24-10.75-24-24s10.75-24 24-24s24 10.75 24 24S365.3 408 352 408zM416 408c-13.25 0-24-10.75-24-24s10.75-24 24-24s24 10.75 24 24S429.3 408 416 408zM480 32H32C14.38 32 0 46.38 0 64v128c0 17.62 14.38 32 32 32h448c17.62 0 32-14.38 32-32V64C512 46.38 497.6 32 480 32zM352 152c-13.25 0-24-10.75-24-24S338.8 104 352 104S376 114.8 376 128S365.3 152 352 152zM416 152c-13.25 0-24-10.75-24-24S402.8 104 416 104S440 114.8 440 128S429.3 152 416 152z'></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const PreferenceSVG = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
{...props}
|
||||
>
|
||||
<path d='M0 416C0 398.3 14.33 384 32 384H86.66C99 355.7 127.2 336 160 336C192.8 336 220.1 355.7 233.3 384H480C497.7 384 512 398.3 512 416C512 433.7 497.7 448 480 448H233.3C220.1 476.3 192.8 496 160 496C127.2 496 99 476.3 86.66 448H32C14.33 448 0 433.7 0 416V416zM192 416C192 398.3 177.7 384 160 384C142.3 384 128 398.3 128 416C128 433.7 142.3 448 160 448C177.7 448 192 433.7 192 416zM352 176C384.8 176 412.1 195.7 425.3 224H480C497.7 224 512 238.3 512 256C512 273.7 497.7 288 480 288H425.3C412.1 316.3 384.8 336 352 336C319.2 336 291 316.3 278.7 288H32C14.33 288 0 273.7 0 256C0 238.3 14.33 224 32 224H278.7C291 195.7 319.2 176 352 176zM384 256C384 238.3 369.7 224 352 224C334.3 224 320 238.3 320 256C320 273.7 334.3 288 352 288C369.7 288 384 273.7 384 256zM480 64C497.7 64 512 78.33 512 96C512 113.7 497.7 128 480 128H265.3C252.1 156.3 224.8 176 192 176C159.2 176 131 156.3 118.7 128H32C14.33 128 0 113.7 0 96C0 78.33 14.33 64 32 64H118.7C131 35.75 159.2 16 192 16C224.8 16 252.1 35.75 265.3 64H480zM160 96C160 113.7 174.3 128 192 128C209.7 128 224 113.7 224 96C224 78.33 209.7 64 192 64C174.3 64 160 78.33 160 96z'></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const AdminSVG = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -32 576 576'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
{...props}
|
||||
>
|
||||
<path d='M560 448H512V113.5c0-27.25-21.5-49.5-48-49.5L352 64.01V128h96V512h112c8.875 0 16-7.125 16-15.1v-31.1C576 455.1 568.9 448 560 448zM280.3 1.007l-192 49.75C73.1 54.51 64 67.76 64 82.88V448H16c-8.875 0-16 7.125-16 15.1v31.1C0 504.9 7.125 512 16 512H320V33.13C320 11.63 300.5-4.243 280.3 1.007zM232 288c-13.25 0-24-14.37-24-31.1c0-17.62 10.75-31.1 24-31.1S256 238.4 256 256C256 273.6 245.3 288 232 288z'></path>
|
||||
</svg>
|
||||
)
|
@ -1,39 +0,0 @@
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
interface SearchInputProps {
|
||||
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
handleSearch: () => void
|
||||
}
|
||||
|
||||
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
({ handleKeyDown, handleSearch }, ref) => (
|
||||
<div className='SearchMain'>
|
||||
<div className='SearchMainInside'>
|
||||
<div className='SearchMainInsideWrapper'>
|
||||
<input
|
||||
type='text'
|
||||
className='SMIWInput'
|
||||
ref={ref}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Enter search term'
|
||||
/>
|
||||
<button
|
||||
className='btn btnMain SMIWButton'
|
||||
type='button'
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
@ -1,9 +0,0 @@
|
||||
import styles from '../styles/dotsSpinner.module.scss'
|
||||
|
||||
export const Spinner = () => (
|
||||
<div className='spinner'>
|
||||
<div className='spinnerCircle'></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Dots = () => <span className={styles.loading}></span>
|
@ -1,26 +0,0 @@
|
||||
interface TabsProps {
|
||||
tabs: string[]
|
||||
tab: number
|
||||
setTab: React.Dispatch<React.SetStateAction<number>>
|
||||
}
|
||||
|
||||
export const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
|
||||
return (
|
||||
<div className='IBMSMSplitMainFullSideSec IBMSMSMFSSNav'>
|
||||
{tabs.map((t, i) => {
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
className={`btn btnMain IBMSMSMFSSNavBtn${
|
||||
tab === i ? ' IBMSMSMFSSNavBtnActive' : ''
|
||||
}`}
|
||||
type='button'
|
||||
onClick={() => setTab(i)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,157 +0,0 @@
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk'
|
||||
import { formatDate } from 'date-fns'
|
||||
import { useDidMount, useNDKContext } from 'hooks'
|
||||
import { useState } from 'react'
|
||||
import { useParams, useLocation, Link } from 'react-router-dom'
|
||||
import { getModPageRoute, getBlogPageRoute, getProfilePageRoute } from 'routes'
|
||||
import { CommentEvent, UserProfile } from 'types'
|
||||
import { hexToNpub } from 'utils'
|
||||
import { Reactions } from './Reactions'
|
||||
import { Zap } from './Zap'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { CommentContent } from './CommentContent'
|
||||
|
||||
interface CommentProps {
|
||||
comment: CommentEvent
|
||||
}
|
||||
export const Comment = ({ comment }: CommentProps) => {
|
||||
const { naddr } = useParams()
|
||||
const location = useLocation()
|
||||
const { ndk } = useNDKContext()
|
||||
const isMod = location.pathname.includes('/mod/')
|
||||
const isBlog = location.pathname.includes('/blog/')
|
||||
const baseUrl = naddr
|
||||
? isMod
|
||||
? getModPageRoute(naddr)
|
||||
: isBlog
|
||||
? getBlogPageRoute(naddr)
|
||||
: undefined
|
||||
: undefined
|
||||
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
|
||||
const [profile, setProfile] = useState<UserProfile>()
|
||||
|
||||
useDidMount(() => {
|
||||
comment.event.author.fetchProfile().then((res) => setProfile(res))
|
||||
ndk
|
||||
.fetchEvents({
|
||||
kinds: [NDKKind.Text, NDKKind.GenericReply],
|
||||
'#e': [comment.event.id]
|
||||
})
|
||||
.then((ndkEventsSet) => {
|
||||
setCommentEvents(
|
||||
Array.from(ndkEventsSet).map((ndkEvent) => ({
|
||||
event: ndkEvent
|
||||
}))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const profileRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: comment.event.pubkey
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='IBMSMSMBSSCL_Comment'>
|
||||
<div className='IBMSMSMBSSCL_CommentTop'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CommentTopPP'
|
||||
to={profileRoute}
|
||||
style={{
|
||||
background: `url('${
|
||||
profile?.image || ''
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetails'>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
|
||||
{profile?.displayName || profile?.name || ''}{' '}
|
||||
</Link>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
|
||||
{hexToNpub(comment.event.pubkey)}
|
||||
</Link>
|
||||
</div>
|
||||
{comment.event.created_at && (
|
||||
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
|
||||
<a className='IBMSMSMBSSCL_CADTime'>
|
||||
{formatDate(comment.event.created_at * 1000, 'hh:mm aa')}{' '}
|
||||
</a>
|
||||
<a className='IBMSMSMBSSCL_CADDate'>
|
||||
{formatDate(comment.event.created_at * 1000, 'dd/MM/yyyy')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentBottom'>
|
||||
{comment.status && (
|
||||
<p className='IBMSMSMBSSCL_CBTextStatus'>
|
||||
<span className='IBMSMSMBSSCL_CBTextStatusSpan'>Status:</span>
|
||||
{comment.status}
|
||||
</p>
|
||||
)}
|
||||
<CommentContent content={comment.event.content} />
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentActions'>
|
||||
<div className='IBMSMSMBSSCL_CommentActionsInside'>
|
||||
<Reactions {...comment.event.rawEvent()} />
|
||||
{/* <div
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost'
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -64 640 640'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>0</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div> */}
|
||||
{typeof profile?.lud16 !== 'undefined' && profile.lud16 !== '' && (
|
||||
<Zap {...comment.event.rawEvent()} />
|
||||
)}
|
||||
{comment.event.kind === NDKKind.GenericReply && (
|
||||
<>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'
|
||||
to={baseUrl + comment.event.encode()}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||
{commentEvents.length}
|
||||
</p>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>Replies</p>
|
||||
</Link>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReply'
|
||||
to={baseUrl + comment.event.encode()}
|
||||
>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>Reply</p>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import { useTextLimit } from 'hooks/useTextLimit'
|
||||
interface CommentContentProps {
|
||||
content: string
|
||||
}
|
||||
export const CommentContent = ({ content }: CommentContentProps) => {
|
||||
const { text, isTextOverflowing, isExpanded, toggle } = useTextLimit(content)
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className='IBMSMSMBSSCL_CBText'>{text}</p>
|
||||
{isTextOverflowing && (
|
||||
<div className='IBMSMSMBSSCL_CBExpand' onClick={toggle}>
|
||||
<p>{isExpanded ? 'Hide' : 'View'} full post</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|