Compare commits
137 Commits
Author | SHA1 | Date | |
---|---|---|---|
59efa91677 | |||
1960589fc3 | |||
0250f6dc11 | |||
dc91a6a186 | |||
|
1d02bf0d6f | ||
|
a1dd002d28 | ||
|
72cbd325b0 | ||
|
05414013ce | ||
|
c62c1a29b9 | ||
|
381028614a | ||
876f986ea5 | |||
b9d5bb8211 | |||
|
4dc65b92f7 | ||
|
9e8aa16297 | ||
|
a90e932ed6 | ||
|
9730fec14f | ||
|
06f0282cad | ||
|
5b641ff4cc | ||
49ed027b5c | |||
aa8d18ea53 | |||
f708dd6530 | |||
e3f49832f2 | |||
d76676c089 | |||
6b1d4e7322 | |||
05adb00072 | |||
4175ebc010 | |||
3a71a4a297 | |||
|
d3a93eab3e | ||
|
22fc2b4ba3 | ||
d70e302a69 | |||
8b93d0506d | |||
a56d26387e | |||
d13c7ca6c3 | |||
|
34b096b121 | ||
|
53d47fcb80 | ||
|
7a1d0bbfb0 | ||
|
d15c5a21d9 | ||
|
990460d7cf | ||
96bf84a0c4 | |||
aa0b9cf3c3 | |||
|
b808157352 | ||
511a67b793 | |||
5ed6a51e76 | |||
4d5f132bab | |||
50540f0e3f | |||
e31e7d85ac | |||
|
a3a022c436 | ||
|
458ad744e4 | ||
d77d9f7bcc | |||
23ad13fa85 | |||
|
87359a914e | ||
3d20163b08 | |||
|
0ac31675f9 | ||
|
98f4666f96 | ||
|
2dd2992810 | ||
|
a85314f0a7 | ||
b12887cdf5 | |||
3e3f5fe82b | |||
a661b3f781 | |||
1fde36bc5c | |||
77d849e3ab | |||
|
822d5110a8 | ||
|
03f9269eb6 | ||
|
4b51fa55f5 | ||
|
018536e11d | ||
|
c44a28f755 | ||
|
8fea6fa27f | ||
|
fad1ff98b3 | ||
4a7899cfde | |||
0c32999df8 | |||
b4465ee1c6 | |||
b492f97795 | |||
f13d800d85 | |||
8726d042f2 | |||
17ef110f6f | |||
764c936ff8 | |||
1b2926ae77 | |||
9a192451f6 | |||
16b3c7684b | |||
24ea309dd1 | |||
41240ee3fb | |||
098068acef | |||
|
56ec37e57b | ||
|
c6831f3fb2 | ||
|
0733849b25 | ||
faf30a89b0 | |||
|
0b2e5c29d5 | ||
|
e1da323c2f | ||
9893373f75 | |||
|
203e27b19d | ||
|
47cc4a19ea | ||
|
ab27a1f9e1 | ||
|
8a232c7d91 | ||
|
1e98b16c14 | ||
d9f0972961 | |||
aa9884b9fa | |||
5cb20794d0 | |||
4d64c33597 | |||
|
d9347014ec | ||
|
1259144228 | ||
a5018d9a1f | |||
e0440e1638 | |||
74e38eac50 | |||
eb450839d5 | |||
733c155447 | |||
9bdc8678c4 | |||
7c2dd9fe7a | |||
9782256483 | |||
1cd898eae7 | |||
926d29a36e | |||
29947757af | |||
b9b1e1457c | |||
a88ef61eb7 | |||
|
4de54f7688 | ||
|
c429dfa322 | ||
26dcd5463d | |||
|
1927887992 | ||
dc19e614df | |||
848af66a75 | |||
aca6908ce9 | |||
be7e506457 | |||
d9129ab4da | |||
3f873c410c | |||
a89a9582fd | |||
de21af1b4a | |||
1a52bfd30b | |||
23088cb3ec | |||
d8d04b8ae0 | |||
f8ca32c143 | |||
60317c2e8c | |||
46b0384bc6 | |||
99463e3fd2 | |||
00e73aaad8 | |||
16b8107731 | |||
d1f84ddd90 | |||
17e28ede53 | |||
24203884fb |
12
.env.example
12
.env.example
@ -1,4 +1,14 @@
|
||||
# This relay will be used to publish/retrieve events along with other relays (user's relays, admin relays)
|
||||
VITE_APP_RELAY=wss://relay.degmods.com
|
||||
|
||||
# A comma separated list of npubs, Relay list will be extracted for these npubs and this relay list will be used to publish event
|
||||
VITE_ADMIN_NPUBS= <A comma separated list of npubs>
|
||||
VITE_ADMIN_NPUBS= <A comma separated list of npubs>
|
||||
|
||||
# A dedicated npub used for reporting mods, blogs, profile and etc.
|
||||
VITE_REPORTING_NPUB= <npub1...>
|
||||
|
||||
# if there's no featured image, or if the image breaks somewhere down the line, then it should default to this image
|
||||
VITE_FALLBACK_MOD_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
|
||||
|
||||
# if there's no image, or if the image breaks somewhere down the line, then it should default to this image
|
||||
VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
|
@ -24,6 +24,9 @@ jobs:
|
||||
run: |
|
||||
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
|
||||
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
|
||||
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
|
||||
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
|
||||
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
|
||||
cat .env
|
||||
|
||||
- name: Create Build
|
||||
@ -34,4 +37,4 @@ jobs:
|
||||
npm -g install cloudron-surfer
|
||||
surfer config --token ${{ secrets.PRODUCTION_CLOUDRON_SURFER_TOKEN }} --server degmods.com
|
||||
surfer put dist/* / --all -d
|
||||
surfer put dist/.well-known / --all
|
||||
surfer put dist/.well-known / --all
|
||||
|
@ -24,6 +24,9 @@ jobs:
|
||||
run: |
|
||||
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
|
||||
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
|
||||
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
|
||||
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
|
||||
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
|
||||
cat .env
|
||||
|
||||
- name: Create Build
|
||||
|
26
index.html
26
index.html
@ -6,7 +6,10 @@
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="DEG Mods - Liberating Game Mods" />
|
||||
<meta property="og:description" content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely." />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely."
|
||||
/>
|
||||
<meta property="og:image" content="/assets/img/DEGM%20Thumb.png" />
|
||||
<meta property="og:url" content="https://degmods.com" />
|
||||
<meta property="og:type" content="website" />
|
||||
@ -14,24 +17,33 @@
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="DEG Mods - Liberating Game Mods" />
|
||||
<meta name="twitter:description" content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely." />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely."
|
||||
/>
|
||||
<meta name="twitter:image" content="/assets/img/DEGM%20Thumb.png" />
|
||||
|
||||
<!-- Other Meta Tags -->
|
||||
<meta name="description" content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely." />
|
||||
<meta
|
||||
name="description"
|
||||
content="Never get your game mods censored, get banned, lose your history, nor lose the connection between game mod creators and fans. Download your mods freely."
|
||||
/>
|
||||
|
||||
<!-- Links and Stylesheets -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/6.4.8/swiper-bundle.min.css" />
|
||||
<link rel="stylesheet" href="/assets/bootstrap/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="/assets/fonts/fontawesome-all.min.css" />
|
||||
<link rel="icon" type="image/png" sizes="935x934" href="/assets/img/Logo%20with%20circle.png" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="935x934"
|
||||
href="/assets/img/Logo%20with%20circle.png"
|
||||
/>
|
||||
|
||||
<title>DEG Mods - Liberating Game Mods</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/6.4.8/swiper-bundle.min.js"></script>
|
||||
<script src="assets/bootstrap/js/bootstrap.min.js"></script>
|
||||
<script src="/assets/bootstrap/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
1125
package-lock.json
generated
1125
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -13,12 +13,17 @@
|
||||
"@getalby/lightning-tools": "5.0.3",
|
||||
"@nostr-dev-kit/ndk": "2.10.0",
|
||||
"@reduxjs/toolkit": "2.2.6",
|
||||
"@tiptap/core": "2.6.6",
|
||||
"@tiptap/extension-link": "2.6.6",
|
||||
"@tiptap/react": "2.6.6",
|
||||
"@tiptap/starter-kit": "2.6.6",
|
||||
"axios": "1.7.3",
|
||||
"bech32": "2.0.0",
|
||||
"buffer": "6.0.3",
|
||||
"date-fns": "3.6.0",
|
||||
"dompurify": "3.1.6",
|
||||
"file-saver": "2.0.5",
|
||||
"fslightbox-react": "1.7.6",
|
||||
"lodash": "4.17.21",
|
||||
"nostr-login": "1.5.2",
|
||||
"nostr-tools": "2.7.1",
|
||||
@ -27,17 +32,18 @@
|
||||
"react": "^18.3.1",
|
||||
"react-countdown": "2.3.5",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-quill": "2.0.0",
|
||||
"react-redux": "9.1.2",
|
||||
"react-router-dom": "^6.24.1",
|
||||
"react-toastify": "10.0.5",
|
||||
"react-window": "1.8.10",
|
||||
"swiper": "11.1.11",
|
||||
"uuid": "10.0.0",
|
||||
"webln": "0.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "3.0.5",
|
||||
"@types/file-saver": "2.0.7",
|
||||
"@types/fslightbox-react": "1.7.7",
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/papaparse": "5.3.14",
|
||||
"@types/react": "^18.3.3",
|
||||
@ -52,6 +58,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"ts-css-modules-vite-plugin": "1.0.20",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.3.1"
|
||||
"vite": "^5.3.1",
|
||||
"vite-tsconfig-paths": "5.0.1"
|
||||
}
|
||||
}
|
||||
|
3
public/assets/games/Games_Itch.csv
Normal file
3
public/assets/games/Games_Itch.csv
Normal file
@ -0,0 +1,3 @@
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
Voices of the Void,,https://image.nostr.build/472949882d0756c84d3effd9f641b10c88abd48265f0f01f360937b189d50b54.jpg
|
||||
Shroom and Gloom,,
|
|
4
public/assets/games/Games_Other.csv
Normal file
4
public/assets/games/Games_Other.csv
Normal file
@ -0,0 +1,4 @@
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
Minecraft,,https://image.nostr.build/b75b2d3a7855370230f2976567e2d5f913a567c57ac61adfb60c7e1102f05117.jpg
|
||||
Vintage Story,,
|
||||
Yandere Simulator,,
|
|
Can't render this file because it is too large.
|
@ -6,7 +6,7 @@ export const Banner = () => {
|
||||
<p
|
||||
className={navStyles.FundingCampaignLink}
|
||||
>
|
||||
DEG Mods is currently in pre-alpha (<a href="https://geyser.fund/entry/3335" target="_blank">Learn more</a>).
|
||||
DEG Mods is currently in pre-alpha (<a href="https://geyser.fund/project/degmods/posts/view/3411" target="_blank">Learn more</a>).
|
||||
Check out its funding campaign (<a href="https://geyser.fund/project/degmods" target="_blank">Learn more</a>).
|
||||
</p>
|
||||
</div>
|
||||
|
@ -15,10 +15,6 @@ export const BlogCard = ({ backgroundLink }: BlogCardProps) => {
|
||||
>
|
||||
<div
|
||||
className='cardBlogMainInside'
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient( rgba(255, 255, 255, 0) 0%, #232323 100%)'
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
|
48
src/components/ErrorBoundary.tsx
Normal file
48
src/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
// Define the state interface for error boundary
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
// Define the props interface (if you want to pass any props)
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
// Update state so the next render will show the fallback UI.
|
||||
static getDerivedStateFromError(_: Error): ErrorBoundaryState {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
// Log the error and error info (optional)
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo)
|
||||
// You could also send the error to a logging service here
|
||||
console.error('props', this.props)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// You can render any fallback UI here
|
||||
return (
|
||||
<div>
|
||||
<h1>Oops! Something went wrong.</h1>
|
||||
<p>Please check console.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// If no error, render children
|
||||
return this.props.children
|
||||
}
|
||||
}
|
@ -1,21 +1,31 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import '../styles/cardGames.css'
|
||||
import { handleGameImageError } from '../utils'
|
||||
import { getGamePageRoute } from 'routes'
|
||||
|
||||
type GameCardProps = {
|
||||
backgroundLink: string
|
||||
title: string
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
export const GameCard = ({ backgroundLink }: GameCardProps) => {
|
||||
export const GameCard = ({ title, imageUrl }: GameCardProps) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<a className='cardGameMainWrapperLink' href='search.html'>
|
||||
<div
|
||||
className='cardGameMain'
|
||||
style={{
|
||||
background: `url("${backgroundLink}") center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
<div className='cardGameMainTitle'>
|
||||
<p>This is a game title, the best game title</p>
|
||||
<div
|
||||
className='cardGameMainWrapperLink'
|
||||
onClick={() => navigate(getGamePageRoute(title))}
|
||||
>
|
||||
<div className='cardGameMainWrapper'>
|
||||
<img
|
||||
src={imageUrl}
|
||||
onError={handleGameImageError}
|
||||
className='cardGameMain'
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<div className='cardGameMainTitle'>
|
||||
<p>{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,38 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactQuill from 'react-quill'
|
||||
import 'react-quill/dist/quill.snow.css'
|
||||
import '../styles/customQuillStyles.css'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import { Editor, EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import React, { useEffect } from 'react'
|
||||
import '../styles/styles.css'
|
||||
|
||||
const editorFormats = [
|
||||
'header',
|
||||
'font',
|
||||
'size',
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'strike',
|
||||
'blockquote',
|
||||
'list',
|
||||
'bullet',
|
||||
'indent',
|
||||
'link'
|
||||
]
|
||||
|
||||
const editorModules = {
|
||||
toolbar: [
|
||||
[{ header: '1' }, { header: '2' }, { font: [] }],
|
||||
[{ size: [] }],
|
||||
['bold', 'italic', 'underline', 'strike', 'blockquote'],
|
||||
[
|
||||
{ list: 'ordered' },
|
||||
{ list: 'bullet' },
|
||||
{ indent: '-1' },
|
||||
{ indent: '+1' }
|
||||
],
|
||||
['link']
|
||||
]
|
||||
}
|
||||
import '../styles/tiptap.scss'
|
||||
|
||||
interface InputFieldProps {
|
||||
label: string
|
||||
@ -77,13 +48,9 @@ export const InputField = React.memo(
|
||||
onChange={handleChange}
|
||||
></textarea>
|
||||
) : type === 'richtext' ? (
|
||||
<ReactQuill
|
||||
className='inputMain'
|
||||
formats={editorFormats}
|
||||
modules={editorModules}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(content) => onChange(name, content)}
|
||||
<RichTextEditor
|
||||
content={value}
|
||||
updateContent={(content) => onChange(name, content)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@ -138,3 +105,186 @@ export const CheckboxField = React.memo(
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
type RichTextEditorProps = {
|
||||
content: string
|
||||
updateContent: (updatedContent: string) => void
|
||||
}
|
||||
|
||||
const RichTextEditor = ({ content, updateContent }: RichTextEditorProps) => {
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit, Link],
|
||||
onUpdate: ({ editor }) => {
|
||||
// Update the state when the editor content changes
|
||||
updateContent(editor.getHTML())
|
||||
},
|
||||
content
|
||||
})
|
||||
|
||||
// Update editor content when the `content` prop changes
|
||||
useEffect(() => {
|
||||
if (editor && editor.getHTML() !== content) {
|
||||
editor.commands.setContent(content, false)
|
||||
}
|
||||
}, [content, editor])
|
||||
|
||||
return (
|
||||
<div className='inputMain'>
|
||||
{editor && (
|
||||
<>
|
||||
<MenuBar editor={editor} />
|
||||
<EditorContent editor={editor} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MenuBarProps = {
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
const MenuBar = ({ editor }: MenuBarProps) => {
|
||||
const setLink = () => {
|
||||
// Prompt the user to enter a URL
|
||||
let url = prompt('URL')
|
||||
|
||||
// Check if the user provided a URL
|
||||
if (url) {
|
||||
// If the URL doesn't start with 'http://' or 'https://',
|
||||
// prepend 'https://' to the URL
|
||||
if (!/^(http|https):\/\//i.test(url)) {
|
||||
url = `https://${url}`
|
||||
}
|
||||
|
||||
return editor.chain().focus().setLink({ href: url }).run()
|
||||
}
|
||||
|
||||
// If no URL was provided (e.g., the user cancels the prompt),
|
||||
// return false, indicating that the link was not set.
|
||||
return false
|
||||
}
|
||||
|
||||
const unsetLink = () => editor.chain().focus().unsetLink().run()
|
||||
|
||||
const buttons: MenuBarButtonProps[] = [
|
||||
{
|
||||
label: 'Bold',
|
||||
disabled: !editor.can().chain().focus().toggleBold().run(),
|
||||
isActive: editor.isActive('bold'),
|
||||
onClick: () => editor.chain().focus().toggleBold().run()
|
||||
},
|
||||
{
|
||||
label: 'Italic',
|
||||
disabled: !editor.can().chain().focus().toggleItalic().run(),
|
||||
isActive: editor.isActive('italic'),
|
||||
onClick: () => editor.chain().focus().toggleItalic().run()
|
||||
},
|
||||
{
|
||||
label: 'Strike',
|
||||
disabled: !editor.can().chain().focus().toggleStrike().run(),
|
||||
isActive: editor.isActive('strike'),
|
||||
onClick: () => editor.chain().focus().toggleStrike().run()
|
||||
},
|
||||
{
|
||||
label: 'Clear marks',
|
||||
onClick: () => editor.chain().focus().unsetAllMarks().run()
|
||||
},
|
||||
{
|
||||
label: 'Clear nodes',
|
||||
onClick: () => editor.chain().focus().clearNodes().run()
|
||||
},
|
||||
{
|
||||
label: 'Paragraph',
|
||||
isActive: editor.isActive('paragraph'),
|
||||
onClick: () => editor.chain().focus().toggleStrike().run()
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...[1, 2, 3, 4, 5, 6].map((level: any) => ({
|
||||
label: `H${level}`,
|
||||
isActive: editor.isActive('heading', { level }),
|
||||
onClick: () => editor.chain().focus().toggleHeading({ level }).run()
|
||||
})),
|
||||
{
|
||||
label: 'Bullet list',
|
||||
isActive: editor.isActive('bulletList'),
|
||||
onClick: () => editor.chain().focus().toggleBulletList().run()
|
||||
},
|
||||
{
|
||||
label: 'Ordered list',
|
||||
isActive: editor.isActive('orderedList'),
|
||||
onClick: () => editor.chain().focus().toggleOrderedList().run()
|
||||
},
|
||||
{
|
||||
label: 'Code block',
|
||||
isActive: editor.isActive('codeBlock'),
|
||||
onClick: () => editor.chain().focus().toggleCodeBlock().run()
|
||||
},
|
||||
{
|
||||
label: 'Blockquote',
|
||||
isActive: editor.isActive('blockquote'),
|
||||
onClick: () => editor.chain().focus().toggleBlockquote().run()
|
||||
},
|
||||
{
|
||||
label: 'Link',
|
||||
isActive: editor.isActive('link'),
|
||||
onClick: editor.isActive('link') ? unsetLink : setLink
|
||||
},
|
||||
{
|
||||
label: 'Horizontal rule',
|
||||
onClick: () => editor.chain().focus().setHorizontalRule().run()
|
||||
},
|
||||
{
|
||||
label: 'Hard break',
|
||||
onClick: () => editor.chain().focus().setHardBreak().run()
|
||||
},
|
||||
{
|
||||
label: 'Undo',
|
||||
disabled: !editor.can().chain().focus().undo().run(),
|
||||
onClick: () => editor.chain().focus().undo().run()
|
||||
},
|
||||
{
|
||||
label: 'Redo',
|
||||
disabled: !editor.can().chain().focus().redo().run(),
|
||||
onClick: () => editor.chain().focus().redo().run()
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='control-group'>
|
||||
<div className='button-group'>
|
||||
{buttons.map(({ label, disabled, isActive, onClick }) => (
|
||||
<MenuBarButton
|
||||
key={label}
|
||||
label={label}
|
||||
disabled={disabled}
|
||||
isActive={isActive}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MenuBarButtonProps {
|
||||
label: string
|
||||
isActive?: boolean
|
||||
disabled?: boolean
|
||||
onClick: () => boolean
|
||||
}
|
||||
|
||||
const MenuBarButton = ({
|
||||
label,
|
||||
isActive = false,
|
||||
disabled = false,
|
||||
onClick
|
||||
}: MenuBarButtonProps) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`btn btnMain btnMainTipTap ${isActive ? 'is-active' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
@ -1,72 +1,89 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import '../styles/cardMod.css'
|
||||
import { handleModImageError } from '../utils'
|
||||
|
||||
type ModCardProps = {
|
||||
title: string
|
||||
gameName: string
|
||||
summary: string
|
||||
backgroundLink: string
|
||||
handleClick: () => void
|
||||
imageUrl: string
|
||||
route: string
|
||||
}
|
||||
|
||||
export const ModCard = ({
|
||||
title,
|
||||
summary,
|
||||
backgroundLink,
|
||||
handleClick
|
||||
}: ModCardProps) => {
|
||||
return (
|
||||
<a className='cardModMainWrapperLink' onClick={handleClick}>
|
||||
<div className='cardModMain'>
|
||||
<div
|
||||
className='cMMPicture'
|
||||
style={{
|
||||
background: `url("${backgroundLink}") center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
<div className='cMMBody'>
|
||||
<h3 className='cMMBodyTitle'>{title}</h3>
|
||||
<p className='cMMBodyText'>{summary}</p>
|
||||
</div>
|
||||
<div className='cMMFoot'>
|
||||
<div className='cMMFootReactions'>
|
||||
<div className='cMMFootReactionsElement'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
|
||||
</svg>
|
||||
<p>420</p>
|
||||
export const ModCard = React.memo(
|
||||
({ title, gameName, summary, imageUrl, route }: ModCardProps) => {
|
||||
return (
|
||||
<Link className='cardModMainWrapperLink' to={route}>
|
||||
<div className='cardModMain'>
|
||||
<div className='cMMPictureWrapper'>
|
||||
<img
|
||||
src={imageUrl}
|
||||
onError={handleModImageError}
|
||||
className='cMMPicture'
|
||||
/>
|
||||
</div>
|
||||
<div className='cMMBody'>
|
||||
<h3 className='cMMBodyTitle'>{title}</h3>
|
||||
<p className='cMMBodyText'>{summary}</p>
|
||||
<div className='cMMBodyGame'>
|
||||
<p>{gameName}</p>
|
||||
</div>
|
||||
<div className='cMMFootReactionsElement'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
|
||||
</svg>
|
||||
<p>420</p>
|
||||
</div>
|
||||
<div className='cMMFootReactionsElement'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
|
||||
</svg>
|
||||
<p>420</p>
|
||||
</div>
|
||||
<div className='cMMFoot'>
|
||||
<div className='cMMFootReactions'>
|
||||
<div className='cMMFootReactionsElement'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
|
||||
</svg>
|
||||
<p>420</p>
|
||||
</div>
|
||||
<div className='cMMFootReactionsElement'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
|
||||
</svg>
|
||||
<p>420</p>
|
||||
</div>
|
||||
<div className='cMMFootReactionsElement'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
|
||||
</svg>
|
||||
<p>420</p>
|
||||
</div>
|
||||
<div className='cMMFootReactionsElement'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-64 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
|
||||
</svg>
|
||||
<p>420</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
@ -1,6 +1,5 @@
|
||||
import _ from 'lodash'
|
||||
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||
import Papa from 'papaparse'
|
||||
import React, {
|
||||
Fragment,
|
||||
useCallback,
|
||||
@ -9,11 +8,16 @@ import React, {
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { FixedSizeList as List } from 'react-window'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useAppSelector } from '../hooks'
|
||||
import { T_TAG_VALUE } from '../constants'
|
||||
import { RelayController } from '../controllers'
|
||||
import { useAppSelector, useGames } from '../hooks'
|
||||
import { appRoutes, getModPageRoute } from '../routes'
|
||||
import '../styles/styles.css'
|
||||
import { DownloadUrl, ModDetails, ModFormState } from '../types'
|
||||
import {
|
||||
initializeFormState,
|
||||
isReachable,
|
||||
@ -24,12 +28,7 @@ import {
|
||||
now
|
||||
} from '../utils'
|
||||
import { CheckboxField, InputError, InputField } from './Inputs'
|
||||
import { RelayController } from '../controllers'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getModsInnerPageRoute } from '../routes'
|
||||
import { DownloadUrl, ModFormState, ModDetails } from '../types'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
import { T_TAG_VALUE } from '../constants'
|
||||
|
||||
interface FormErrors {
|
||||
game?: string
|
||||
@ -48,14 +47,14 @@ interface GameOption {
|
||||
label: string
|
||||
}
|
||||
|
||||
let processedCSV = false
|
||||
|
||||
type ModFormProps = {
|
||||
existingModData?: ModDetails
|
||||
}
|
||||
|
||||
export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const games = useGames()
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const [isPublishing, setIsPublishing] = useState(false)
|
||||
@ -63,36 +62,21 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
const [formState, setFormState] = useState<ModFormState>(
|
||||
initializeFormState(existingModData)
|
||||
)
|
||||
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (processedCSV) return
|
||||
processedCSV = true
|
||||
if (location.pathname === appRoutes.submitMod) {
|
||||
setFormState(initializeFormState())
|
||||
}
|
||||
}, [location.pathname]) // Only trigger when the pathname changes to submit-mod
|
||||
|
||||
// Fetch the CSV file from the public folder
|
||||
fetch('/assets/games.csv')
|
||||
.then((response) => response.text())
|
||||
.then((csvText) => {
|
||||
// Parse the CSV text using PapaParse
|
||||
Papa.parse<{
|
||||
'Game Name': string
|
||||
'16 by 9 image': string
|
||||
'Boxart image': string
|
||||
}>(csvText, {
|
||||
worker: true,
|
||||
header: true,
|
||||
complete: (results) => {
|
||||
const options = results.data.map((row) => ({
|
||||
label: row['Game Name'],
|
||||
value: row['Game Name']
|
||||
}))
|
||||
setGameOptions(options)
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((error) => console.error('Error fetching CSV file:', error))
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const options = games.map((game) => ({
|
||||
label: game['Game Name'],
|
||||
value: game['Game Name']
|
||||
}))
|
||||
setGameOptions(options)
|
||||
}, [games])
|
||||
|
||||
const handleInputChange = useCallback((name: string, value: string) => {
|
||||
setFormState((prevState) => ({
|
||||
@ -274,7 +258,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
relays: publishedOnRelays
|
||||
})
|
||||
|
||||
navigate(getModsInnerPageRoute(naddr))
|
||||
navigate(getModPageRoute(naddr))
|
||||
}
|
||||
|
||||
setIsPublishing(false)
|
||||
|
138
src/components/Pagination.tsx
Normal file
138
src/components/Pagination.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import React from 'react'
|
||||
|
||||
type PaginationProps = {
|
||||
page: number
|
||||
disabledNext: boolean
|
||||
handlePrev: () => void
|
||||
handleNext: () => void
|
||||
}
|
||||
|
||||
export const Pagination = React.memo(
|
||||
({ page, disabledNext, handlePrev, handleNext }: PaginationProps) => {
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
<div className='PaginationMain'>
|
||||
<div className='PaginationMainInside'>
|
||||
<button
|
||||
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
|
||||
onClick={handlePrev}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<i className='fas fa-chevron-left'></i>
|
||||
</button>
|
||||
<div className='PaginationMainInsideBoxGroup'>
|
||||
<button className='PaginationMainInsideBox PMIBActive'>
|
||||
<p>{page}</p>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
|
||||
onClick={handleNext}
|
||||
disabled={disabledNext}
|
||||
>
|
||||
<i className='fas fa-chevron-right'></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
type PaginationWithPageNumbersProps = {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
handlePageChange: (page: number) => void
|
||||
}
|
||||
|
||||
export const PaginationWithPageNumbers = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
handlePageChange
|
||||
}: PaginationWithPageNumbersProps) => {
|
||||
// Function to render the pagination controls with page numbers
|
||||
const renderPagination = () => {
|
||||
const pagesToShow = 5 // Number of page numbers to show around the current page
|
||||
const pageNumbers: (number | string)[] = [] // Array to store page numbers and ellipses
|
||||
|
||||
// Case when the total number of pages is less than or equal to the limit
|
||||
if (totalPages <= pagesToShow + 2) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pageNumbers.push(i) // Add all pages to the pagination
|
||||
}
|
||||
} else {
|
||||
// Add the first page (always visible)
|
||||
pageNumbers.push(1)
|
||||
|
||||
// Calculate the range of pages to show around the current page
|
||||
const startPage = Math.max(2, currentPage - Math.floor(pagesToShow / 2))
|
||||
const endPage = Math.min(
|
||||
totalPages - 1,
|
||||
currentPage + Math.floor(pagesToShow / 2)
|
||||
)
|
||||
|
||||
// Add ellipsis if there are pages between the first page and the startPage
|
||||
if (startPage > 2) pageNumbers.push('...')
|
||||
|
||||
// Add the pages around the current page
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pageNumbers.push(i)
|
||||
}
|
||||
|
||||
// Add ellipsis if there are pages between the endPage and the last page
|
||||
if (endPage < totalPages - 1) pageNumbers.push('...')
|
||||
|
||||
// Add the last page (always visible)
|
||||
pageNumbers.push(totalPages)
|
||||
}
|
||||
|
||||
// Map over the array and render each page number or ellipsis
|
||||
return pageNumbers.map((page, index) => {
|
||||
if (typeof page === 'number') {
|
||||
// For actual page numbers, render clickable boxes
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`PaginationMainInsideBox ${
|
||||
currentPage === page ? 'PMIBActive' : '' // Highlight the current page
|
||||
}`}
|
||||
onClick={() => handlePageChange(page)} // Navigate to the selected page
|
||||
>
|
||||
<p>{page}</p>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
// For ellipses, render non-clickable dots
|
||||
return (
|
||||
<p key={index} className='PaginationMainInsideBox PMIBDots'>
|
||||
...
|
||||
</p>
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
<div className='PaginationMain'>
|
||||
<div className='PaginationMainInside'>
|
||||
<div
|
||||
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
>
|
||||
<i className='fas fa-chevron-left'></i>
|
||||
</div>
|
||||
<div className='PaginationMainInsideBoxGroup'>
|
||||
{renderPagination()}
|
||||
</div>
|
||||
<div
|
||||
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
>
|
||||
<i className='fas fa-chevron-right'></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,110 +1,46 @@
|
||||
import { Event, Filter, kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import {
|
||||
MetadataController,
|
||||
RelayController,
|
||||
UserRelaysType
|
||||
} from '../controllers'
|
||||
import { useAppSelector, useDidMount } from '../hooks'
|
||||
import { getProfilePageRoute } from '../routes'
|
||||
import '../styles/author.css'
|
||||
import '../styles/innerPage.css'
|
||||
import '../styles/socialPosts.css'
|
||||
import { UserProfile } from '../types'
|
||||
import { copyTextToClipboard, log, LogType, now, npubToHex } from '../utils'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
import { ZapPopUp } from './Zap'
|
||||
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
|
||||
import _ from 'lodash'
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export const ProfileSection = ({ pubkey }: Props) => {
|
||||
const [profile, setProfile] = useState<UserProfile>()
|
||||
|
||||
useDidMount(async () => {
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
metadataController.findMetadata(pubkey).then((res) => {
|
||||
setProfile(res)
|
||||
})
|
||||
})
|
||||
|
||||
if (!profile) return null
|
||||
|
||||
export const ProfileSection = () => {
|
||||
return (
|
||||
<div className='IBMSMSplitMainSmallSide'>
|
||||
<div className='IBMSMSplitMainSmallSideSecWrapper'>
|
||||
<div className='IBMSMSplitMainSmallSideSec'>
|
||||
<div className='IBMSMSMSSS_Author'>
|
||||
<div className='IBMSMSMSSS_Author_Top'>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left'>
|
||||
<a
|
||||
className='IBMSMSMSSS_Author_Top_Left_InsideLinkWrapper'
|
||||
href='profile.html'
|
||||
>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_Inside'>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_InsidePic'>
|
||||
<div className='IBMSMSMSSS_Author_Top_PPWrapper'>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_PP'
|
||||
style={{
|
||||
background:
|
||||
"url('assets/img/DEG%20Mods%20Default%20PP.png') center / cover no-repeat"
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
|
||||
<div className='IBMSMSMSSS_Author_TopWrapper'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Name'>
|
||||
{author.name}
|
||||
</p>
|
||||
<p className='IBMSMSMSSS_Author_Top_Handle'>
|
||||
{author.handle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapper'>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapped'>
|
||||
<p
|
||||
id='SiteOwnerAddress'
|
||||
className='IBMSMSMSSS_Author_Top_Address'
|
||||
>
|
||||
{author.address}
|
||||
</p>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
|
||||
<div
|
||||
id='copySiteOwnerAddress'
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d="M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_IconWrapped IBMSMSMSSS_Author_Top_IconWrappedQR'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d="M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_Details'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Bio'>{author.bio}</p>
|
||||
<div
|
||||
id='OwnerFollowLogin'
|
||||
className='IBMSMSMSSS_Author_Top_NostrLinks'
|
||||
style={{ display: 'flex' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button className='btn btnMain' type='button'>
|
||||
Follow
|
||||
</button>
|
||||
</div>
|
||||
<Profile profile={profile} />
|
||||
</div>
|
||||
<div className='IBMSMSplitMainSmallSideSec'>
|
||||
<div className='IBMSMSMSSS_ShortPosts'>
|
||||
@ -154,18 +90,115 @@ export const ProfileSection = () => {
|
||||
)
|
||||
}
|
||||
|
||||
interface Author {
|
||||
name: string
|
||||
handle: string
|
||||
address: string
|
||||
bio: string
|
||||
type ProfileProps = {
|
||||
profile: NDKUserProfile
|
||||
}
|
||||
|
||||
const author: Author = {
|
||||
name: 'User name',
|
||||
handle: 'nip5handle@domain.com',
|
||||
address: 'npub1address',
|
||||
bio: `user bio, this is a long string of temporary text that would be replaced with the user bio from their metada address`
|
||||
export const Profile = ({ profile }: ProfileProps) => {
|
||||
const handleCopy = async () => {
|
||||
copyTextToClipboard(profile.npub as string).then((isCopied) => {
|
||||
if (isCopied) {
|
||||
toast.success('Npub copied to clipboard!')
|
||||
} else {
|
||||
toast.error(
|
||||
'Failed to copy, look into console for more details on error!'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const hexPubkey = npubToHex(profile.pubkey as string)
|
||||
|
||||
if (!hexPubkey) return null
|
||||
|
||||
const profileRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: hexPubkey
|
||||
})
|
||||
)
|
||||
|
||||
const npub = (profile.npub as string) || ''
|
||||
const displayName =
|
||||
profile.displayName ||
|
||||
profile.name ||
|
||||
_.truncate(npub, {
|
||||
length: 16
|
||||
})
|
||||
const nip05 = profile.nip05 || ''
|
||||
const about = profile.bio || profile.about || ''
|
||||
|
||||
return (
|
||||
<div className='IBMSMSMSSS_Author'>
|
||||
<div className='IBMSMSMSSS_Author_Top'>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left'>
|
||||
<Link
|
||||
className='IBMSMSMSSS_Author_Top_Left_InsideLinkWrapper'
|
||||
to={profileRoute}
|
||||
>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_Inside'>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_InsidePic'>
|
||||
<div className='IBMSMSMSSS_Author_Top_PPWrapper'>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_PP'
|
||||
style={{
|
||||
background: `url('${
|
||||
profile.image || ''
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
|
||||
<div className='IBMSMSMSSS_Author_TopWrapper'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Name'>{displayName}</p>
|
||||
<p className='IBMSMSMSSS_Author_Top_Handle'>{nip05}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapper'>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapped'>
|
||||
<p
|
||||
id='SiteOwnerAddress'
|
||||
className='IBMSMSMSSS_Author_Top_Address'
|
||||
>
|
||||
{npub}
|
||||
</p>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
|
||||
<div
|
||||
id='copySiteOwnerAddress'
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<QRButtonWithPopUp pubkey={hexPubkey} />
|
||||
<ZapButtonWithPopUp pubkey={hexPubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_Details'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Bio'>{about}</p>
|
||||
<div
|
||||
id='OwnerFollowLogin'
|
||||
className='IBMSMSMSSS_Author_Top_NostrLinks'
|
||||
style={{ display: 'flex' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<FollowButton pubkey={hexPubkey} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Post {
|
||||
@ -193,3 +226,314 @@ const posts: Post[] = [
|
||||
imageUrl: '/assets/img/DEGMods%20Placeholder%20Img.png'
|
||||
}
|
||||
]
|
||||
|
||||
type QRButtonWithPopUpProps = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
const QRButtonWithPopUp = ({ pubkey }: QRButtonWithPopUpProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const nprofile = nip19.nprofileEncode({
|
||||
pubkey
|
||||
})
|
||||
|
||||
const onQrCodeClicked = async () => {
|
||||
const href = `https://njump.me/${nprofile}`
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
a.target = '_blank' // Open in a new tab
|
||||
a.rel = 'noopener noreferrer' // Recommended for security reasons
|
||||
a.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped IBMSMSMSSS_Author_Top_IconWrappedQR'
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Nostr Address</h3>
|
||||
</div>
|
||||
<div
|
||||
className='popUpMainCardTopClose'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='popUpMainCardBottom'>
|
||||
<QRCodeSVG
|
||||
className='popUpMainCardBottomQR'
|
||||
onClick={onQrCodeClicked}
|
||||
value={nprofile}
|
||||
height={235}
|
||||
width={235}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ZapButtonWithPopUpProps = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
const ZapButtonWithPopUp = ({ pubkey }: ZapButtonWithPopUpProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ZapPopUp
|
||||
title='Tip/Zap'
|
||||
receiver={pubkey}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type FollowButtonProps = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
const FollowButton = ({ pubkey }: FollowButtonProps) => {
|
||||
const [isFollowing, setIsFollowing] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useDidMount(async () => {
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
const userHexKey = userState.user.pubkey as string
|
||||
const { isFollowing: isAlreadyFollowing } = await checkIfFollowing(
|
||||
userHexKey,
|
||||
pubkey
|
||||
)
|
||||
setIsFollowing(isAlreadyFollowing)
|
||||
}
|
||||
})
|
||||
|
||||
const getUserPubKey = async (): Promise<string | null> => {
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
return userState.user.pubkey as string
|
||||
} else {
|
||||
return (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
}
|
||||
|
||||
const checkIfFollowing = async (
|
||||
userHexKey: string,
|
||||
pubkey: string
|
||||
): Promise<{
|
||||
isFollowing: boolean
|
||||
tags: string[][]
|
||||
}> => {
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.Contacts],
|
||||
authors: [userHexKey]
|
||||
}
|
||||
|
||||
const contactListEvent =
|
||||
await RelayController.getInstance().fetchEventFromUserRelays(
|
||||
filter,
|
||||
userHexKey,
|
||||
UserRelaysType.Both
|
||||
)
|
||||
|
||||
if (!contactListEvent)
|
||||
return {
|
||||
isFollowing: false,
|
||||
tags: []
|
||||
}
|
||||
|
||||
return {
|
||||
isFollowing: contactListEvent.tags.some(
|
||||
(t) => t[0] === 'p' && t[1] === pubkey
|
||||
),
|
||||
tags: contactListEvent.tags
|
||||
}
|
||||
}
|
||||
|
||||
const signAndPublishEvent = async (
|
||||
unsignedEvent: UnsignedEvent
|
||||
): Promise<boolean> => {
|
||||
const signedEvent = await window.nostr
|
||||
?.signEvent(unsignedEvent)
|
||||
.then((event) => event as Event)
|
||||
.catch((err) => {
|
||||
toast.error('Failed to sign the event!')
|
||||
log(true, LogType.Error, 'Failed to sign the event!', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!signedEvent) return false
|
||||
|
||||
const publishedOnRelays = await RelayController.getInstance().publish(
|
||||
signedEvent as Event
|
||||
)
|
||||
|
||||
if (publishedOnRelays.length === 0) {
|
||||
toast.error('Failed to publish event on any relay')
|
||||
return false
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
|
||||
'\n'
|
||||
)}`
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
const handleFollow = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Processing follow request')
|
||||
|
||||
const userHexKey = await getUserPubKey()
|
||||
if (!userHexKey) {
|
||||
setIsLoading(false)
|
||||
toast.error('Could not get pubkey')
|
||||
return
|
||||
}
|
||||
|
||||
const { isFollowing: isAlreadyFollowing, tags } = await checkIfFollowing(
|
||||
userHexKey,
|
||||
pubkey
|
||||
)
|
||||
if (isAlreadyFollowing) {
|
||||
toast.info('Already following!')
|
||||
setIsFollowing(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
content: '',
|
||||
created_at: now(),
|
||||
kind: kinds.Contacts,
|
||||
pubkey: userHexKey,
|
||||
tags: [...tags, ['p', pubkey]]
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Signing and publishing follow event')
|
||||
const success = await signAndPublishEvent(unsignedEvent)
|
||||
setIsFollowing(success)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleUnFollow = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Processing unfollow request')
|
||||
|
||||
const userHexKey = await getUserPubKey()
|
||||
if (!userHexKey) {
|
||||
setIsLoading(false)
|
||||
toast.error('Could not get pubkey')
|
||||
return
|
||||
}
|
||||
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.Contacts],
|
||||
authors: [userHexKey]
|
||||
}
|
||||
|
||||
const contactListEvent =
|
||||
await RelayController.getInstance().fetchEventFromUserRelays(
|
||||
filter,
|
||||
userHexKey,
|
||||
UserRelaysType.Both
|
||||
)
|
||||
|
||||
if (
|
||||
!contactListEvent ||
|
||||
!contactListEvent.tags.some((t) => t[0] === 'p' && t[1] === pubkey)
|
||||
) {
|
||||
// could not found target pubkey in user's follow list
|
||||
// so, just update the status and return
|
||||
setIsFollowing(false)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
content: '',
|
||||
created_at: now(),
|
||||
kind: kinds.Contacts,
|
||||
pubkey: userHexKey,
|
||||
tags: contactListEvent.tags.filter(
|
||||
(t) => !(t[0] === 'p' && t[1] === pubkey)
|
||||
)
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Signing and publishing unfollow event')
|
||||
const success = await signAndPublishEvent(unsignedEvent)
|
||||
setIsFollowing(!success)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className='btn btnMain'
|
||||
type='button'
|
||||
onClick={isFollowing ? handleUnFollow : handleFollow}
|
||||
>
|
||||
{isFollowing ? 'Un-Follow' : 'Follow'}
|
||||
</button>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,12 +1,25 @@
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import React, { Dispatch, SetStateAction, useMemo } from 'react'
|
||||
import React, {
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react'
|
||||
import Countdown, { CountdownRenderProps } from 'react-countdown'
|
||||
import { toast } from 'react-toastify'
|
||||
import { ZapController } from '../controllers'
|
||||
import { useDidMount } from '../hooks'
|
||||
import { MetadataController, ZapController } from '../controllers'
|
||||
import { useAppSelector, useDidMount } from '../hooks'
|
||||
import '../styles/popup.css'
|
||||
import { PaymentRequest } from '../types'
|
||||
import { copyTextToClipboard } from '../utils'
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
formatNumber,
|
||||
getZapAmount,
|
||||
unformatNumber
|
||||
} from '../utils'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
|
||||
type PresetAmountProps = {
|
||||
label: string
|
||||
@ -106,15 +119,28 @@ type ZapQRProps = {
|
||||
paymentRequest: PaymentRequest
|
||||
handleClose: () => void
|
||||
handleQRExpiry: () => void
|
||||
setTotalZapAmount?: Dispatch<SetStateAction<number>>
|
||||
setHasZapped?: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const ZapQR = React.memo(
|
||||
({ paymentRequest, handleClose, handleQRExpiry }: ZapQRProps) => {
|
||||
({
|
||||
paymentRequest,
|
||||
handleClose,
|
||||
handleQRExpiry,
|
||||
setTotalZapAmount,
|
||||
setHasZapped
|
||||
}: ZapQRProps) => {
|
||||
useDidMount(() => {
|
||||
ZapController.getInstance()
|
||||
.pollZapReceipt(paymentRequest)
|
||||
.then(() => {
|
||||
.then((zapReceipt) => {
|
||||
toast.success(`Successfully sent sats!`)
|
||||
if (setTotalZapAmount) {
|
||||
const amount = getZapAmount(zapReceipt)
|
||||
setTotalZapAmount((prev) => prev + amount)
|
||||
if (setHasZapped) setHasZapped(true)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
@ -194,3 +220,243 @@ const Timer = React.memo(({ onTimerExpired }: TimerProps) => {
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
type ZapPopUpProps = {
|
||||
title: string
|
||||
labelDescriptionMain?: ReactNode
|
||||
receiver: string
|
||||
eventId?: string
|
||||
aTag?: string
|
||||
notCloseAfterZap?: boolean
|
||||
lastNode?: ReactNode
|
||||
setTotalZapAmount?: Dispatch<SetStateAction<number>>
|
||||
setHasZapped?: Dispatch<SetStateAction<boolean>>
|
||||
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
export const ZapPopUp = ({
|
||||
title,
|
||||
labelDescriptionMain,
|
||||
receiver,
|
||||
eventId,
|
||||
aTag,
|
||||
lastNode,
|
||||
notCloseAfterZap,
|
||||
setTotalZapAmount,
|
||||
setHasZapped,
|
||||
handleClose
|
||||
}: ZapPopUpProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
const [amount, setAmount] = useState<number>(0)
|
||||
const [message, setMessage] = useState('')
|
||||
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest>()
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const unformattedValue = unformatNumber(event.target.value)
|
||||
setAmount(unformattedValue)
|
||||
}
|
||||
|
||||
const generatePaymentRequest =
|
||||
useCallback(async (): Promise<PaymentRequest | null> => {
|
||||
let userHexKey: string
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Getting user pubkey')
|
||||
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
userHexKey = userState.user.pubkey as string
|
||||
} else {
|
||||
userHexKey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
|
||||
if (!userHexKey) {
|
||||
setIsLoading(false)
|
||||
toast.error('Could not get pubkey')
|
||||
return null
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('finding receiver metadata')
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
|
||||
const receiverMetadata = await metadataController.findMetadata(receiver)
|
||||
|
||||
if (!receiverMetadata?.lud16) {
|
||||
setIsLoading(false)
|
||||
toast.error('Lighting address (lud16) is missing in receiver metadata!')
|
||||
return null
|
||||
}
|
||||
|
||||
if (!receiverMetadata?.pubkey) {
|
||||
setIsLoading(false)
|
||||
toast.error('pubkey is missing in receiver metadata!')
|
||||
return null
|
||||
}
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
setLoadingSpinnerDesc('Creating zap request')
|
||||
return await zapController
|
||||
.getLightningPaymentRequest(
|
||||
receiverMetadata.lud16,
|
||||
amount,
|
||||
receiverMetadata.pubkey as string,
|
||||
userHexKey,
|
||||
message,
|
||||
eventId,
|
||||
aTag
|
||||
)
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [amount, message, userState, receiver, eventId, aTag])
|
||||
|
||||
const handleGenerateQRCode = async () => {
|
||||
const pr = await generatePaymentRequest()
|
||||
|
||||
if (!pr) return
|
||||
|
||||
setPaymentRequest(pr)
|
||||
}
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const pr = await generatePaymentRequest()
|
||||
|
||||
if (!pr) return
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Sending payment!')
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
if (await zapController.isWeblnProviderExists()) {
|
||||
await zapController
|
||||
.sendPayment(pr.pr)
|
||||
.then(() => {
|
||||
toast.success(`Successfully sent ${amount} sats!`)
|
||||
if (setTotalZapAmount) {
|
||||
setTotalZapAmount((prev) => prev + amount)
|
||||
|
||||
if (setHasZapped) setHasZapped(true)
|
||||
}
|
||||
|
||||
if (!notCloseAfterZap) {
|
||||
handleClose()
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
} else {
|
||||
toast.warn('Webln is not present. Use QR code to send zap.')
|
||||
setPaymentRequest(pr)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, [
|
||||
amount,
|
||||
notCloseAfterZap,
|
||||
handleClose,
|
||||
generatePaymentRequest,
|
||||
setTotalZapAmount,
|
||||
setHasZapped
|
||||
])
|
||||
|
||||
const handleQRExpiry = useCallback(() => {
|
||||
setPaymentRequest(undefined)
|
||||
}, [])
|
||||
|
||||
const handleQRClose = useCallback(() => {
|
||||
setPaymentRequest(undefined)
|
||||
setIsLoading(false)
|
||||
if (!notCloseAfterZap) {
|
||||
handleClose()
|
||||
}
|
||||
}, [notCloseAfterZap, handleClose])
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>{title}</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z' />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='pUMCB_ZapsInsideAmount'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
{labelDescriptionMain}
|
||||
<label className='form-label labelMain'>
|
||||
Amount (Satoshis)
|
||||
</label>
|
||||
<input
|
||||
className='inputMain'
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
value={amount ? formatNumber(amount) : ''}
|
||||
onChange={handleAmountChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='pUMCB_ZapsInsideAmountOptions'>
|
||||
<ZapPresets setAmount={setAmount} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>
|
||||
Message (optional)
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ZapButtons
|
||||
disabled={!amount}
|
||||
handleGenerateQRCode={handleGenerateQRCode}
|
||||
handleSend={handleSend}
|
||||
/>
|
||||
{paymentRequest && (
|
||||
<ZapQR
|
||||
paymentRequest={paymentRequest}
|
||||
handleClose={handleQRClose}
|
||||
handleQRExpiry={handleQRExpiry}
|
||||
setTotalZapAmount={setTotalZapAmount}
|
||||
setHasZapped={setHasZapped}
|
||||
/>
|
||||
)}
|
||||
{lastNode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
133
src/constants.ts
133
src/constants.ts
@ -1,2 +1,135 @@
|
||||
export const T_TAG_VALUE = 'GameMod'
|
||||
export const MOD_FILTER_LIMIT = 20
|
||||
export const LANDING_PAGE_DATA = {
|
||||
featuredSlider: [
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5cek8pnrwc34xgknyv33xqkngc34xyknscfjxsknzvp38quxgc33vejnqvqhqecq8',
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5vpcxs6nwwp3x5knyd3evckngetxxcknjdfkx5kngdfhvgukvwfjxsunseqnend73',
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5d3excenzvf5xgkkvdny8qkngveex5knjcnxxqkn2efnx3jrxvpcxgukxdggsmal6',
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5dp4xsex2e3cxuknsdryvvkngc3sxcknjef4vcknvvmyvcukyd3kvd3rxdgnuver5',
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5vf5x9nrxcekxvknjvmzxvkngcfsx5kkzcf3xqknsvmrvgenwe3j8p3nzwgka59vj'
|
||||
],
|
||||
awesomeMods: [
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5d3excenzvf5xgkkvdny8qkngveex5knjcnxxqkn2efnx3jrxvpcxgukxdggsmal6',
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5df5xccngvtrxqkkydpexukngvp4xgknsvp4vskkgdrxvgmkxdmp8quxycgx78rpf',
|
||||
'naddr1qvzqqqrkcgpzquuz5nxzzap2c034s8cuv5ayr7gjaxz7d22pgwfh0qpmsesy9eflqp4nxvp5xqer5den8qexzdrrvverzde5xfskxvm9xv6nsvtxx93nvdfnvy6rze3exyex2wfcx4jnvcfexscngveexvmnwwpsxd3rsd3kxq6ryef4xdnr5vrrvgmnjc33xuknwde4vskngvekxgknsenyxvkk2ctxvscrvenpvsmnxeqydygjx'
|
||||
],
|
||||
featuredGames: [
|
||||
{
|
||||
title: 'SUPERHOT',
|
||||
imageUrl: ''
|
||||
},
|
||||
{
|
||||
title: 'The Bounce House',
|
||||
imageUrl: ''
|
||||
},
|
||||
{
|
||||
title: 'Immortal Guns',
|
||||
imageUrl: ''
|
||||
},
|
||||
{
|
||||
title: 'Magenta Horizon Act 1',
|
||||
imageUrl: ''
|
||||
},
|
||||
{
|
||||
title: 'DEAD LETTER DEPT. Demo',
|
||||
imageUrl: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
// we use this object to check if a user has reacted positively or negatively to a post
|
||||
// reactions are kind 7 events and their content is either emoji icon or emoji shortcode
|
||||
// Extend the following object as per need to include more emojis and shortcodes
|
||||
// NOTE: In following object emojis and shortcode array are not interlinked.
|
||||
// Both of these arrays can have separate items
|
||||
export const REACTIONS = {
|
||||
positive: {
|
||||
emojis: [
|
||||
'+',
|
||||
'❤️',
|
||||
'💙',
|
||||
'💖',
|
||||
'💚',
|
||||
'⭐',
|
||||
'🚀',
|
||||
'🫂',
|
||||
'🎉',
|
||||
'🥳',
|
||||
'🎊',
|
||||
'👍',
|
||||
'💪',
|
||||
'😎'
|
||||
],
|
||||
shortCodes: [
|
||||
':red_heart:',
|
||||
':blue_heart:',
|
||||
':sparkling_heart:',
|
||||
':green_heart:',
|
||||
':star:',
|
||||
':rocket:',
|
||||
':people_hugging:',
|
||||
':party_popper:',
|
||||
':tada:',
|
||||
':partying_face:',
|
||||
':confetti_ball:',
|
||||
':thumbs_up:',
|
||||
':+1:',
|
||||
':thumbsup:',
|
||||
':thumbup:',
|
||||
':flexed_biceps:',
|
||||
':muscle:'
|
||||
]
|
||||
},
|
||||
negative: {
|
||||
emojis: [
|
||||
'-',
|
||||
'💩',
|
||||
'💔',
|
||||
'👎',
|
||||
'😠',
|
||||
'😞',
|
||||
'🤬',
|
||||
'🤢',
|
||||
'🤮',
|
||||
'🖕',
|
||||
'😡',
|
||||
'💢',
|
||||
'😠',
|
||||
'💀'
|
||||
],
|
||||
shortCodes: [
|
||||
':poop:',
|
||||
':shit:',
|
||||
':poo:',
|
||||
':hankey:',
|
||||
':pile_of_poo:',
|
||||
':broken_heart:',
|
||||
':thumbsdown:',
|
||||
':thumbdown:',
|
||||
':nauseated_face:',
|
||||
':sick:',
|
||||
':face_vomiting:',
|
||||
':vomiting_face:',
|
||||
':face_with_open_mouth_vomiting:',
|
||||
':middle_finger:',
|
||||
':rage:',
|
||||
':anger:',
|
||||
':anger_symbol:',
|
||||
':angry_face:',
|
||||
':angry:',
|
||||
':smiling_face_with_sunglasses:',
|
||||
':sunglasses:',
|
||||
':skull:',
|
||||
':skeleton:'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: there should be a corresponding CSV file in public/assets/games folder for each entry in the array
|
||||
export const GAME_FILES = [
|
||||
'Games_Itch.csv',
|
||||
'Games_Other.csv',
|
||||
'Games_Steam.csv'
|
||||
]
|
||||
|
||||
export const MAX_MODS_PER_PAGE = 10
|
||||
export const MAX_GAMES_PER_PAGE = 10
|
||||
|
@ -2,7 +2,7 @@ import NDK, { getRelayListForUser, NDKList, NDKUser } from '@nostr-dev-kit/ndk'
|
||||
import { kinds } from 'nostr-tools'
|
||||
import { MuteLists } from '../types'
|
||||
import { UserProfile } from '../types/user'
|
||||
import { hexToNpub, log, LogType, npubToHex } from '../utils'
|
||||
import { hexToNpub, log, LogType, npubToHex, timeout } from '../utils'
|
||||
|
||||
export enum UserRelaysType {
|
||||
Read = 'readRelayUrls',
|
||||
@ -19,6 +19,7 @@ export class MetadataController {
|
||||
private usersMetadata = new Map<string, UserProfile>()
|
||||
public adminNpubs: string[]
|
||||
public adminRelays = new Set<string>()
|
||||
public reportingNpub: string
|
||||
|
||||
private constructor() {
|
||||
this.ndk = new NDK({
|
||||
@ -40,6 +41,7 @@ export class MetadataController {
|
||||
})
|
||||
|
||||
this.adminNpubs = import.meta.env.VITE_ADMIN_NPUBS.split(',')
|
||||
this.reportingNpub = import.meta.env.VITE_REPORTING_NPUB
|
||||
}
|
||||
|
||||
private setAdminRelays = async () => {
|
||||
@ -119,48 +121,130 @@ export class MetadataController {
|
||||
public findUserRelays = async (
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType = UserRelaysType.Both
|
||||
) => {
|
||||
const ndkRelayList = await getRelayListForUser(hexKey, this.ndk)
|
||||
): Promise<string[]> => {
|
||||
log(true, LogType.Info, `ℹ Finding user's relays`, hexKey, userRelaysType)
|
||||
|
||||
if (!ndkRelayList) {
|
||||
throw new Error(`Couldn't found user's relay list`)
|
||||
}
|
||||
const ndkRelayListPromise = getRelayListForUser(hexKey, this.ndk)
|
||||
|
||||
return ndkRelayList[userRelaysType]
|
||||
// Use Promise.race to either get the NDKRelayList instance or handle the timeout
|
||||
return await Promise.race([
|
||||
ndkRelayListPromise,
|
||||
timeout() // Custom timeout function that rejects after a specified time
|
||||
])
|
||||
.then((ndkRelayList) => {
|
||||
if (ndkRelayList) return ndkRelayList[userRelaysType]
|
||||
return [] // Return an empty array if ndkRelayList is undefined
|
||||
})
|
||||
.catch((err) => {
|
||||
log(true, LogType.Error, err)
|
||||
return [] // Return an empty array if an error occurs
|
||||
})
|
||||
}
|
||||
|
||||
public getAdminsMuteLists = async (): Promise<MuteLists> => {
|
||||
// Create a Set to collect all unique muted authors
|
||||
public getMuteLists = async (
|
||||
pubkey?: string
|
||||
): Promise<{
|
||||
admin: MuteLists
|
||||
user: MuteLists
|
||||
}> => {
|
||||
const adminMutedAuthors = new Set<string>()
|
||||
const adminMutedPosts = new Set<string>()
|
||||
|
||||
const mutedAuthors = new Set<string>()
|
||||
|
||||
// Create an array of promises to fetch mute lists for each npub
|
||||
const promises = this.adminNpubs.map(async (npub) => {
|
||||
const hexKey = npubToHex(npub)
|
||||
if (!hexKey) return
|
||||
const adminHexKey = npubToHex(this.reportingNpub)
|
||||
|
||||
if (adminHexKey) {
|
||||
const muteListEvent = await this.ndk.fetchEvent({
|
||||
kinds: [kinds.Mutelist],
|
||||
authors: [hexKey]
|
||||
authors: [adminHexKey]
|
||||
})
|
||||
|
||||
if (muteListEvent) {
|
||||
const list = NDKList.from(muteListEvent)
|
||||
|
||||
list.items.forEach((item) => {
|
||||
// Add muted authors to the Set directly
|
||||
if (item[0] === 'p') {
|
||||
mutedAuthors.add(item[1])
|
||||
adminMutedAuthors.add(item[1])
|
||||
} else if (item[0] === 'a') {
|
||||
adminMutedPosts.add(item[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
const userMutedAuthors = new Set<string>()
|
||||
const userMutedPosts = new Set<string>()
|
||||
|
||||
if (pubkey) {
|
||||
const userHexKey = npubToHex(pubkey)
|
||||
|
||||
if (userHexKey) {
|
||||
const muteListEvent = await this.ndk.fetchEvent({
|
||||
kinds: [kinds.Mutelist],
|
||||
authors: [userHexKey]
|
||||
})
|
||||
|
||||
if (muteListEvent) {
|
||||
const list = NDKList.from(muteListEvent)
|
||||
|
||||
list.items.forEach((item) => {
|
||||
if (item[0] === 'p') {
|
||||
userMutedAuthors.add(item[1])
|
||||
} else if (item[0] === 'a') {
|
||||
userMutedPosts.add(item[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authors: Array.from(mutedAuthors),
|
||||
eventIds: []
|
||||
admin: {
|
||||
authors: Array.from(adminMutedAuthors),
|
||||
replaceableEvents: Array.from(adminMutedPosts)
|
||||
},
|
||||
user: {
|
||||
authors: Array.from(userMutedAuthors),
|
||||
replaceableEvents: Array.from(userMutedPosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of NSFW (Not Safe For Work) posts that were not specified as NSFW by post author but marked as NSFW by admin.
|
||||
*
|
||||
* @returns {Promise<string[]>} - A promise that resolves to an array of NSFW post identifiers (e.g., URLs or IDs).
|
||||
*/
|
||||
public getNSFWList = async (): Promise<string[]> => {
|
||||
// Initialize an array to store the NSFW post identifiers
|
||||
const nsfwPosts: string[] = []
|
||||
|
||||
// Convert the public key (npub) to a hexadecimal format
|
||||
const hexKey = npubToHex(this.reportingNpub)
|
||||
|
||||
// If the conversion is successful and we have a hexKey
|
||||
if (hexKey) {
|
||||
// Fetch the event that contains the NSFW list
|
||||
const nsfwListEvent = await this.ndk.fetchEvent({
|
||||
kinds: [kinds.Curationsets],
|
||||
authors: [hexKey],
|
||||
'#d': ['nsfw']
|
||||
})
|
||||
|
||||
if (nsfwListEvent) {
|
||||
// Convert the event data to an NDKList, which is a structured list format
|
||||
const list = NDKList.from(nsfwListEvent)
|
||||
|
||||
// Iterate through the items in the list
|
||||
list.items.forEach((item) => {
|
||||
if (item[0] === 'a') {
|
||||
// Add the identifier of the NSFW post to the nsfwPosts array
|
||||
nsfwPosts.push(item[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return the array of NSFW post identifiers
|
||||
return nsfwPosts
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Event, Filter, kinds, Relay } from 'nostr-tools'
|
||||
import { Event, Filter, kinds, nip57, Relay } from 'nostr-tools'
|
||||
import {
|
||||
extractZapAmount,
|
||||
log,
|
||||
@ -7,13 +7,13 @@ import {
|
||||
timeout
|
||||
} from '../utils'
|
||||
import { MetadataController, UserRelaysType } from './metadata'
|
||||
import { ModDetails } from '../types'
|
||||
|
||||
/**
|
||||
* Singleton class to manage relay operations.
|
||||
*/
|
||||
export class RelayController {
|
||||
private static instance: RelayController
|
||||
private events = new Map<string, Event>()
|
||||
private debug = true
|
||||
public connectedRelays: Relay[] = []
|
||||
|
||||
@ -61,7 +61,116 @@ export class RelayController {
|
||||
/**
|
||||
* Publishes an event to multiple relays.
|
||||
*
|
||||
* This method connects to the application relay and a set of write relays
|
||||
* This method establishes a connection to the application relay specified by
|
||||
* an environment variable and a set of relays obtained from the
|
||||
* `MetadataController`. It attempts to publish the event to all connected
|
||||
* relays and returns a list of URLs of relays where the event was successfully
|
||||
* published.
|
||||
*
|
||||
* If the process of finding relays or publishing the event takes too long,
|
||||
* it handles the timeout to prevent blocking the operation.
|
||||
*
|
||||
* @param event - The event to be published.
|
||||
* @param userHexKey - The user's hexadecimal public key, used to retrieve their relays.
|
||||
* If not provided, the event's public key will be used.
|
||||
* @param userRelaysType - The type of relays to be retrieved (e.g., write relays).
|
||||
* Defaults to `UserRelaysType.Write`.
|
||||
* @returns A promise that resolves to an array of URLs of relays where the event
|
||||
* was published, or an empty array if no relays were connected or the
|
||||
* event could not be published.
|
||||
*/
|
||||
publish = async (
|
||||
event: Event,
|
||||
userHexKey?: string,
|
||||
userRelaysType?: UserRelaysType
|
||||
): Promise<string[]> => {
|
||||
// Connect to the application relay specified by an environment variable
|
||||
const appRelayPromise = this.connectRelay(import.meta.env.VITE_APP_RELAY)
|
||||
|
||||
// TODO: Implement logic to retrieve relays using `window.nostr.getRelays()` once it becomes available in nostr-login.
|
||||
|
||||
// Retrieve an instance of MetadataController to find user relays
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
|
||||
// Retrieve the list of relays for the specified user's public key
|
||||
const relayUrls = await metadataController.findUserRelays(
|
||||
userHexKey || event.pubkey,
|
||||
userRelaysType || UserRelaysType.Write
|
||||
)
|
||||
|
||||
// Add admin relay URLs from the metadata controller to the list of relay URLs
|
||||
metadataController.adminRelays.forEach((url) => {
|
||||
relayUrls.push(url)
|
||||
})
|
||||
|
||||
// Attempt to connect to all write relays obtained from MetadataController
|
||||
const relayPromises = relayUrls.map((relayUrl) =>
|
||||
this.connectRelay(relayUrl)
|
||||
)
|
||||
|
||||
// Wait for all relay connection attempts to settle (either fulfilled or rejected)
|
||||
await Promise.allSettled([appRelayPromise, ...relayPromises])
|
||||
|
||||
// If no relays are connected, log an error and return an empty array
|
||||
if (this.connectedRelays.length === 0) {
|
||||
log(this.debug, LogType.Error, 'No relay is connected!')
|
||||
return []
|
||||
}
|
||||
|
||||
const publishedOnRelays: string[] = [] // Track relays where the event was successfully published
|
||||
|
||||
// Create promises to publish the event to each connected relay
|
||||
const publishPromises = this.connectedRelays.map((relay) => {
|
||||
log(
|
||||
this.debug,
|
||||
LogType.Info,
|
||||
`⬆️ nostr (${relay.url}): Sending event:`,
|
||||
event
|
||||
)
|
||||
|
||||
return Promise.race([
|
||||
relay.publish(event), // Publish the event to the relay
|
||||
timeout(30000) // Set a timeout to handle slow publishing operations
|
||||
])
|
||||
.then((res) => {
|
||||
log(
|
||||
this.debug,
|
||||
LogType.Info,
|
||||
`⬆️ nostr (${relay.url}): Publish result:`,
|
||||
res
|
||||
)
|
||||
publishedOnRelays.push(relay.url) // Add successful relay URL to the list
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
this.debug,
|
||||
LogType.Error,
|
||||
`❌ nostr (${relay.url}): Publish error!`,
|
||||
err
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Wait for all publish operations to complete (either fulfilled or rejected)
|
||||
await Promise.allSettled(publishPromises)
|
||||
|
||||
if (publishedOnRelays.length > 0) {
|
||||
// If the event was successfully published to any relays, check if it contains an `aTag`
|
||||
// If the `aTag` is present, cache the event locally
|
||||
const aTag = event.tags.find((item) => item[0] === 'a')
|
||||
if (aTag && aTag[1]) {
|
||||
this.events.set(aTag[1], event)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the list of relay URLs where the event was successfully published
|
||||
return publishedOnRelays
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes an encrypted DM to receiver's read relays.
|
||||
*
|
||||
* This method connects to the application relay and a set of receiver's read relays
|
||||
* obtained from the `MetadataController`. It then publishes the event to
|
||||
* all connected relays and returns a list of relays where the event was successfully published.
|
||||
*
|
||||
@ -69,7 +178,7 @@ export class RelayController {
|
||||
* @returns A promise that resolves to an array of URLs of relays where the event was published,
|
||||
* or an empty array if no relays were connected or the event could not be published.
|
||||
*/
|
||||
publish = async (event: Event): Promise<string[]> => {
|
||||
publishDM = async (event: Event, receiver: string): Promise<string[]> => {
|
||||
// Connect to the application relay specified by environment variable
|
||||
const appRelayPromise = this.connectRelay(import.meta.env.VITE_APP_RELAY)
|
||||
|
||||
@ -77,31 +186,19 @@ export class RelayController {
|
||||
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
|
||||
// Retrieve the list of write relays for the event's public key
|
||||
// Use a timeout to handle cases where retrieving write relays takes too long
|
||||
const writeRelaysPromise = metadataController.findUserRelays(
|
||||
event.pubkey,
|
||||
UserRelaysType.Write
|
||||
// Retrieve the list of read relays for the receiver
|
||||
const readRelayUrls = await metadataController.findUserRelays(
|
||||
receiver,
|
||||
UserRelaysType.Read
|
||||
)
|
||||
|
||||
log(this.debug, LogType.Info, `ℹ Finding user's write relays`)
|
||||
|
||||
// Use Promise.race to either get the write relay URLs or timeout
|
||||
const writeRelayUrls = await Promise.race([
|
||||
writeRelaysPromise,
|
||||
timeout() // This is a custom timeout function that rejects the promise after a specified time
|
||||
]).catch((err) => {
|
||||
log(this.debug, LogType.Error, err)
|
||||
return [] as string[] // Return an empty array if an error occurs
|
||||
})
|
||||
|
||||
// push admin relay urls obtained from metadata controller to writeRelayUrls list
|
||||
// push admin relay urls obtained from metadata controller to readRelayUrls list
|
||||
metadataController.adminRelays.forEach((url) => {
|
||||
writeRelayUrls.push(url)
|
||||
readRelayUrls.push(url)
|
||||
})
|
||||
|
||||
// Connect to all write relays obtained from MetadataController
|
||||
const relayPromises = writeRelayUrls.map((relayUrl) =>
|
||||
const relayPromises = readRelayUrls.map((relayUrl) =>
|
||||
this.connectRelay(relayUrl)
|
||||
)
|
||||
|
||||
@ -155,6 +252,112 @@ export class RelayController {
|
||||
return publishedOnRelays
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes an event to multiple relays.
|
||||
*
|
||||
* This method establishes a connection to the application relay specified by
|
||||
* an environment variable and a set of relays provided as argument.
|
||||
* It attempts to publish the event to all connected relays
|
||||
* and returns a list of URLs of relays where the event was successfully published.
|
||||
*
|
||||
* If the process of publishing the event takes too long,
|
||||
* it handles the timeout to prevent blocking the operation.
|
||||
*
|
||||
* @param event - The event to be published.
|
||||
* @param relayUrls - The array of relayUrl where event should be published
|
||||
* @returns A promise that resolves to an array of URLs of relays where the event
|
||||
* was published, or an empty array if no relays were connected or the
|
||||
* event could not be published.
|
||||
*/
|
||||
publishOnRelays = async (
|
||||
event: Event,
|
||||
relayUrls: string[]
|
||||
): Promise<string[]> => {
|
||||
const appRelay = import.meta.env.VITE_APP_RELAY
|
||||
|
||||
if (!relayUrls.includes(appRelay)) {
|
||||
/**
|
||||
* NOTE: To avoid side-effects on external relayUrls array passed as argument
|
||||
* re-assigned relayUrls with added sigit relay instead of just appending to same array
|
||||
*/
|
||||
relayUrls = [...relayUrls, appRelay] // Add app relay to relays array if not exists already
|
||||
}
|
||||
|
||||
// connect to all specified relays
|
||||
const relayPromises = relayUrls.map((relayUrl) =>
|
||||
this.connectRelay(relayUrl)
|
||||
)
|
||||
|
||||
// Use Promise.allSettled to wait for all promises to settle
|
||||
const results = await Promise.allSettled(relayPromises)
|
||||
|
||||
// Extract non-null values from fulfilled promises in a single pass
|
||||
const relays = results.reduce<Relay[]>((acc, result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
const value = result.value
|
||||
if (value) {
|
||||
acc.push(value)
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
// Check if any relays are connected
|
||||
if (relays.length === 0) {
|
||||
log(this.debug, LogType.Error, 'No relay is connected!')
|
||||
return []
|
||||
}
|
||||
|
||||
const publishedOnRelays: string[] = [] // Track relays where the event was successfully published
|
||||
|
||||
// Create promises to publish the event to each connected relay
|
||||
const publishPromises = this.connectedRelays.map((relay) => {
|
||||
log(
|
||||
this.debug,
|
||||
LogType.Info,
|
||||
`⬆️ nostr (${relay.url}): Sending event:`,
|
||||
event
|
||||
)
|
||||
|
||||
return Promise.race([
|
||||
relay.publish(event), // Publish the event to the relay
|
||||
timeout(30000) // Set a timeout to handle slow publishing operations
|
||||
])
|
||||
.then((res) => {
|
||||
log(
|
||||
this.debug,
|
||||
LogType.Info,
|
||||
`⬆️ nostr (${relay.url}): Publish result:`,
|
||||
res
|
||||
)
|
||||
publishedOnRelays.push(relay.url) // Add successful relay URL to the list
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
this.debug,
|
||||
LogType.Error,
|
||||
`❌ nostr (${relay.url}): Publish error!`,
|
||||
err
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Wait for all publish operations to complete (either fulfilled or rejected)
|
||||
await Promise.allSettled(publishPromises)
|
||||
|
||||
if (publishedOnRelays.length > 0) {
|
||||
// If the event was successfully published to any relays, check if it contains an `aTag`
|
||||
// If the `aTag` is present, cache the event locally
|
||||
const aTag = event.tags.find((item) => item[0] === 'a')
|
||||
if (aTag && aTag[1]) {
|
||||
this.events.set(aTag[1], event)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the list of relay URLs where the event was successfully published
|
||||
return publishedOnRelays
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves multiple event from a set of relays based on a provided filter.
|
||||
* If no relays are specified, it defaults to using connected relays.
|
||||
@ -165,24 +368,46 @@ export class RelayController {
|
||||
*/
|
||||
fetchEvents = async (
|
||||
filter: Filter,
|
||||
relays: string[] = []
|
||||
relayUrls: string[] = []
|
||||
): Promise<Event[]> => {
|
||||
// add app relay to relays array
|
||||
relays.push(import.meta.env.VITE_APP_RELAY)
|
||||
const relaySet = new Set<string>()
|
||||
|
||||
// add all the relays passed to relay set
|
||||
relayUrls.forEach((relayUrl) => {
|
||||
relaySet.add(relayUrl)
|
||||
})
|
||||
|
||||
relaySet.add(import.meta.env.VITE_APP_RELAY)
|
||||
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
// add admin relays to relays array
|
||||
metadataController.adminRelays.forEach((url) => {
|
||||
relays.push(url)
|
||||
metadataController.adminRelays.forEach((relayUrl) => {
|
||||
relaySet.add(relayUrl)
|
||||
})
|
||||
|
||||
relayUrls = Array.from(relaySet)
|
||||
|
||||
// Connect to all specified relays
|
||||
const relayPromises = relays.map((relayUrl) => this.connectRelay(relayUrl))
|
||||
await Promise.allSettled(relayPromises)
|
||||
const relayPromises = relayUrls.map((relayUrl) =>
|
||||
this.connectRelay(relayUrl)
|
||||
)
|
||||
|
||||
// Use Promise.allSettled to wait for all promises to settle
|
||||
const results = await Promise.allSettled(relayPromises)
|
||||
|
||||
// Extract non-null values from fulfilled promises in a single pass
|
||||
const relays = results.reduce<Relay[]>((acc, result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
const value = result.value
|
||||
if (value) {
|
||||
acc.push(value)
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
// Check if any relays are connected
|
||||
if (this.connectedRelays.length === 0) {
|
||||
log(this.debug, LogType.Error, 'No relay is connected to fetch events!')
|
||||
if (relays.length === 0) {
|
||||
throw new Error('No relay is connected to fetch events!')
|
||||
}
|
||||
|
||||
@ -190,7 +415,7 @@ export class RelayController {
|
||||
const eventIds = new Set<string>() // To keep track of event IDs and avoid duplicates
|
||||
|
||||
// Create a promise for each relay subscription
|
||||
const subPromises = this.connectedRelays.map((relay) => {
|
||||
const subPromises = relays.map((relay) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
// Subscribe to the relay with the specified filter
|
||||
const sub = relay.subscribe([filter], {
|
||||
@ -238,92 +463,287 @@ export class RelayController {
|
||||
filter: Filter,
|
||||
relays: string[] = []
|
||||
): Promise<Event | null> => {
|
||||
// first check if event is present in cached map then return that
|
||||
// otherwise query relays
|
||||
if (filter['#a']) {
|
||||
const aTag = filter['#a'][0]
|
||||
const cachedEvent = this.events.get(aTag)
|
||||
|
||||
if (cachedEvent) return cachedEvent
|
||||
}
|
||||
|
||||
const events = await this.fetchEvents(filter, relays)
|
||||
|
||||
// Sort events by creation date in descending order
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
// Return the most recent event, or null if no events were received
|
||||
return events[0] || null
|
||||
if (events.length > 0) {
|
||||
const event = events[0]
|
||||
|
||||
// if the aTag was specified in filter then cache the fetched event before returning
|
||||
if (filter['#a']) {
|
||||
const aTag = filter['#a'][0]
|
||||
this.events.set(aTag, event)
|
||||
}
|
||||
|
||||
// return the event
|
||||
return event
|
||||
}
|
||||
|
||||
// return null if event array is empty
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves multiple events from the user's relays based on a specified filter.
|
||||
* The function first retrieves the user's relays, and then fetches the events using the provided filter.
|
||||
*
|
||||
* @param filter - The event filter to use when fetching the event (e.g., kinds, authors).
|
||||
* @param hexKey - The hexadecimal representation of the user's public key.
|
||||
* @param userRelaysType - The type of relays to search (e.g., write, read).
|
||||
* @returns A promise that resolves with an array of events.
|
||||
*/
|
||||
fetchEventsFromUserRelays = async (
|
||||
filter: Filter,
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType
|
||||
): Promise<Event[]> => {
|
||||
// Get an instance of the MetadataController, which manages user metadata and relays
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
|
||||
// Find the user's relays using the MetadataController.
|
||||
const relayUrls = await metadataController.findUserRelays(
|
||||
hexKey,
|
||||
userRelaysType
|
||||
)
|
||||
|
||||
// Fetch the event from the user's relays using the provided filter and relay URLs
|
||||
return this.fetchEvents(filter, relayUrls)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an event from the user's relays based on a specified filter.
|
||||
* The function first retrieves the user's relays, and then fetches the event using the provided filter.
|
||||
*
|
||||
* @param filter - The event filter to use when fetching the event (e.g., kinds, authors).
|
||||
* @param hexKey - The hexadecimal representation of the user's public key.
|
||||
* @param userRelaysType - The type of relays to search (e.g., write, read).
|
||||
* @returns A promise that resolves to the fetched event or null if the operation fails.
|
||||
*/
|
||||
fetchEventFromUserRelays = async (
|
||||
filter: Filter,
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType
|
||||
): Promise<Event | null> => {
|
||||
// first check if event is present in cached map then return that
|
||||
// otherwise query relays
|
||||
if (filter['#a']) {
|
||||
const aTag = filter['#a'][0]
|
||||
const cachedEvent = this.events.get(aTag)
|
||||
|
||||
if (cachedEvent) return cachedEvent
|
||||
}
|
||||
|
||||
const events = await this.fetchEventsFromUserRelays(
|
||||
filter,
|
||||
hexKey,
|
||||
userRelaysType
|
||||
)
|
||||
// Sort events by creation date in descending order
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
if (events.length > 0) {
|
||||
const event = events[0]
|
||||
|
||||
// if the aTag was specified in filter then cache the fetched event before returning
|
||||
if (filter['#a']) {
|
||||
const aTag = filter['#a'][0]
|
||||
this.events.set(aTag, event)
|
||||
}
|
||||
|
||||
// return the event
|
||||
return event
|
||||
}
|
||||
|
||||
// return null if event array is empty
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to events from multiple relays.
|
||||
*
|
||||
* This method connects to the specified relay URLs and subscribes to events
|
||||
* using the provided filter. It handles incoming events through the given
|
||||
* `eventHandler` callback and manages the subscription lifecycle.
|
||||
*
|
||||
* @param filter - The filter criteria to apply when subscribing to events.
|
||||
* @param relayUrls - An optional array of relay URLs to connect to. The default relay URL (`APP_RELAY`) is added automatically.
|
||||
* @param eventHandler - A callback function to handle incoming events. It receives an `Event` object.
|
||||
*
|
||||
*/
|
||||
subscribeForEvents = async (
|
||||
filter: Filter,
|
||||
relayUrls: string[] = [],
|
||||
eventHandler: (event: Event) => void
|
||||
) => {
|
||||
const appRelay = import.meta.env.VITE_APP_RELAY
|
||||
if (!relayUrls.includes(appRelay)) {
|
||||
/**
|
||||
* NOTE: To avoid side-effects on external relayUrls array passed as argument
|
||||
* re-assigned relayUrls with added sigit relay instead of just appending to same array
|
||||
*/
|
||||
relayUrls = [...relayUrls, appRelay] // Add app relay to relays array if not exists already
|
||||
}
|
||||
|
||||
// connect to all specified relays
|
||||
const relayPromises = relayUrls.map((relayUrl) =>
|
||||
this.connectRelay(relayUrl)
|
||||
)
|
||||
|
||||
// Use Promise.allSettled to wait for all promises to settle
|
||||
const results = await Promise.allSettled(relayPromises)
|
||||
|
||||
// Extract non-null values from fulfilled promises in a single pass
|
||||
const relays = results.reduce<Relay[]>((acc, result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
const value = result.value
|
||||
if (value) {
|
||||
acc.push(value)
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
// Check if any relays are connected
|
||||
if (relays.length === 0) {
|
||||
throw new Error('No relay is connected to fetch events!')
|
||||
}
|
||||
|
||||
const processedEvents: string[] = [] // To keep track of processed events
|
||||
|
||||
// Create a promise for each relay subscription
|
||||
const subscriptions = relays.map((relay) =>
|
||||
relay.subscribe([filter], {
|
||||
// Handle incoming events
|
||||
onevent: (e) => {
|
||||
// Process event only if it hasn't been processed before
|
||||
if (!processedEvents.includes(e.id)) {
|
||||
processedEvents.push(e.id)
|
||||
eventHandler(e) // Call the event handler with the event
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return subscriptions
|
||||
}
|
||||
|
||||
getTotalZapAmount = async (
|
||||
modDetails: ModDetails,
|
||||
user: string,
|
||||
eTag: string,
|
||||
aTag?: string,
|
||||
currentLoggedInUser?: string
|
||||
) => {
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
|
||||
const authorReadRelaysPromise = metadataController.findUserRelays(
|
||||
modDetails.author,
|
||||
const relayUrls = await metadataController.findUserRelays(
|
||||
user,
|
||||
UserRelaysType.Read
|
||||
)
|
||||
|
||||
log(this.debug, LogType.Info, `ℹ Finding user's read relays`)
|
||||
|
||||
// Use Promise.race to either get the write relay URLs or timeout
|
||||
const relayUrls = await Promise.race([
|
||||
authorReadRelaysPromise,
|
||||
timeout() // This is a custom timeout function that rejects the promise after a specified time
|
||||
]).catch((err) => {
|
||||
log(this.debug, LogType.Error, err)
|
||||
return [] as string[] // Return an empty array if an error occurs
|
||||
})
|
||||
|
||||
// add app relay to relays array
|
||||
relayUrls.push(import.meta.env.VITE_APP_RELAY)
|
||||
|
||||
// add admin relays to relays array
|
||||
metadataController.adminRelays.forEach((url) => {
|
||||
relayUrls.push(url)
|
||||
})
|
||||
const appRelay = import.meta.env.VITE_APP_RELAY
|
||||
if (!relayUrls.includes(appRelay)) {
|
||||
relayUrls.push(appRelay)
|
||||
}
|
||||
|
||||
// Connect to all specified relays
|
||||
const relayPromises = relayUrls.map((relayUrl) =>
|
||||
this.connectRelay(relayUrl)
|
||||
)
|
||||
await Promise.allSettled(relayPromises)
|
||||
|
||||
// Use Promise.allSettled to wait for all promises to settle
|
||||
const results = await Promise.allSettled(relayPromises)
|
||||
|
||||
// Extract non-null values from fulfilled promises in a single pass
|
||||
const relays = results.reduce<Relay[]>((acc, result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
const value = result.value
|
||||
if (value) {
|
||||
acc.push(value)
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
let accumulatedZapAmount = 0
|
||||
let hasZapped = false
|
||||
|
||||
const eventIds = new Set<string>() // To keep track of event IDs and avoid duplicates
|
||||
|
||||
const filters: Filter[] = [
|
||||
{
|
||||
kinds: [kinds.Zap],
|
||||
'#e': [eTag]
|
||||
}
|
||||
]
|
||||
|
||||
if (aTag) {
|
||||
filters.push({
|
||||
kinds: [kinds.Zap],
|
||||
'#a': [aTag]
|
||||
})
|
||||
}
|
||||
|
||||
// Create a promise for each relay subscription
|
||||
const subPromises = this.connectedRelays.map((relay) => {
|
||||
const subPromises = relays.map((relay) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
// Subscribe to the relay with the specified filter
|
||||
const sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [kinds.Zap],
|
||||
'#a': [modDetails.aTag]
|
||||
}
|
||||
],
|
||||
{
|
||||
// Handle incoming events
|
||||
onevent: (e) => {
|
||||
// Add the event to the array if it's not a duplicate
|
||||
if (!eventIds.has(e.id)) {
|
||||
console.log('e :>> ', e)
|
||||
eventIds.add(e.id) // Record the event ID
|
||||
const amount = extractZapAmount(e)
|
||||
accumulatedZapAmount += amount
|
||||
const sub = relay.subscribe(filters, {
|
||||
// Handle incoming events
|
||||
onevent: (e) => {
|
||||
// Add the event to the array if it's not a duplicate
|
||||
if (!eventIds.has(e.id)) {
|
||||
eventIds.add(e.id) // Record the event ID
|
||||
|
||||
const zapRequestStr = e.tags.find(
|
||||
(t) => t[0] === 'description'
|
||||
)?.[1]
|
||||
if (!zapRequestStr) return
|
||||
|
||||
const error = nip57.validateZapRequest(zapRequestStr)
|
||||
if (error) return
|
||||
|
||||
let zapRequest: Event | null = null
|
||||
|
||||
try {
|
||||
zapRequest = JSON.parse(zapRequestStr)
|
||||
} catch (error) {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
'Error occurred in parsing zap request',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
if (!zapRequest) return
|
||||
|
||||
const amount = extractZapAmount(zapRequest)
|
||||
accumulatedZapAmount += amount
|
||||
|
||||
if (amount > 0) {
|
||||
if (!hasZapped) {
|
||||
hasZapped =
|
||||
e.tags.findIndex(
|
||||
(tag) => tag[0] === 'P' && tag[1] === currentLoggedInUser
|
||||
) > -1
|
||||
hasZapped = zapRequest.pubkey === currentLoggedInUser
|
||||
}
|
||||
}
|
||||
},
|
||||
// Handle the End-Of-Stream (EOSE) message
|
||||
oneose: () => {
|
||||
sub.close() // Close the subscription
|
||||
resolve() // Resolve the promise when EOSE is received
|
||||
}
|
||||
},
|
||||
// Handle the End-Of-Stream (EOSE) message
|
||||
oneose: () => {
|
||||
sub.close() // Close the subscription
|
||||
resolve() // Resolve the promise when EOSE is received
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
} from '../types'
|
||||
import { log, LogType, npubToHex } from '../utils'
|
||||
import { RelayController } from './relay'
|
||||
import { MetadataController, UserRelaysType } from './metadata'
|
||||
|
||||
/**
|
||||
* Singleton class to manage zap related operations.
|
||||
@ -147,7 +148,7 @@ export class ZapController {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
|
||||
sub.close()
|
||||
subscriptions.forEach((subscription) => subscription.close())
|
||||
}
|
||||
|
||||
// Polling timeout
|
||||
@ -160,13 +161,11 @@ export class ZapController {
|
||||
pollingTimeout || 6 * 60 * 1000 // 6 minutes
|
||||
)
|
||||
|
||||
const relay = await RelayController.getInstance().connectRelay(
|
||||
this.appRelay
|
||||
)
|
||||
const relaysTag = zapRequest.tags.find((t) => t[0] === 'relays')
|
||||
if (!relaysTag)
|
||||
throw new Error('Zap request does not contain relays tag.')
|
||||
|
||||
if (!relay) {
|
||||
return reject('Polling Zap Receipt: Could not connect to app relay!')
|
||||
}
|
||||
const relayUrls = relaysTag.slice(1)
|
||||
|
||||
// filter relay for event of kind 9735
|
||||
const filter: Filter = {
|
||||
@ -174,25 +173,27 @@ export class ZapController {
|
||||
since: created_at
|
||||
}
|
||||
|
||||
const sub = relay.subscribe([filter], {
|
||||
// Handle incoming events
|
||||
onevent: async (event) => {
|
||||
// get description tag of the event
|
||||
const description = event.tags.filter(
|
||||
(tag) => tag[0] === 'description'
|
||||
)[0]
|
||||
const subscriptions =
|
||||
await RelayController.getInstance().subscribeForEvents(
|
||||
filter,
|
||||
relayUrls,
|
||||
async (event) => {
|
||||
// get description tag of the event
|
||||
const description = event.tags.filter(
|
||||
(tag) => tag[0] === 'description'
|
||||
)[0]
|
||||
|
||||
// compare description tag of the event with stringified zap request
|
||||
if (description[1] === zapRequestStringified) {
|
||||
// validate zap receipt
|
||||
if (await this.validateZapReceipt(pr, event as ZapReceipt)) {
|
||||
cleanup()
|
||||
// compare description tag of the event with stringified zap request
|
||||
if (description[1] === zapRequestStringified) {
|
||||
// validate zap receipt
|
||||
if (await this.validateZapReceipt(pr, event as ZapReceipt)) {
|
||||
cleanup()
|
||||
|
||||
resolve(event as ZapReceipt)
|
||||
resolve(event as ZapReceipt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -280,11 +281,21 @@ export class ZapController {
|
||||
|
||||
if (!recipientHexKey) throw 'Invalid recipient pubKey.'
|
||||
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
const receiverReadRelays = await metadataController.findUserRelays(
|
||||
recipientHexKey,
|
||||
UserRelaysType.Read
|
||||
)
|
||||
|
||||
if (!receiverReadRelays.includes(this.appRelay)) {
|
||||
receiverReadRelays.push(this.appRelay)
|
||||
}
|
||||
|
||||
const zapRequest: ZapRequest = {
|
||||
kind: kinds.ZapRequest,
|
||||
content,
|
||||
tags: [
|
||||
['relays', `${this.appRelay}`],
|
||||
['relays', ...receiverReadRelays],
|
||||
['amount', `${amount}`],
|
||||
['p', recipientHexKey]
|
||||
],
|
||||
|
@ -1,2 +1,5 @@
|
||||
export * from './redux'
|
||||
export * from './useDidMount'
|
||||
export * from './useGames'
|
||||
export * from './useMuteLists'
|
||||
export * from './useReactions'
|
||||
|
81
src/hooks/useGames.ts
Normal file
81
src/hooks/useGames.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { GAME_FILES } from 'constants.ts'
|
||||
import Papa from 'papaparse'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { Game } from 'types'
|
||||
import { log, LogType } from 'utils'
|
||||
|
||||
export const useGames = () => {
|
||||
const hasProcessedFiles = useRef(false)
|
||||
const [games, setGames] = useState<Game[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasProcessedFiles.current) return
|
||||
|
||||
hasProcessedFiles.current = true
|
||||
|
||||
const readGamesCSVs = async () => {
|
||||
const uniqueGames: Game[] = []
|
||||
const gameNames = new Set<string>()
|
||||
|
||||
// Function to promisify PapaParse
|
||||
const parseCSV = (csvText: string) =>
|
||||
new Promise<Game[]>((resolve, reject) => {
|
||||
Papa.parse<Game>(csvText, {
|
||||
worker: true,
|
||||
header: true,
|
||||
complete: (results) => {
|
||||
if (results.errors.length) {
|
||||
reject(results.errors)
|
||||
}
|
||||
|
||||
resolve(results.data)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
// Fetch and parse each file
|
||||
const promises = GAME_FILES.map(async (filename) => {
|
||||
const response = await fetch(`/assets/games/${filename}`)
|
||||
const csvText = await response.text()
|
||||
const parsedGames = await parseCSV(csvText)
|
||||
|
||||
// Remove duplicate games based on 'Game Name'
|
||||
parsedGames.forEach((game) => {
|
||||
if (!gameNames.has(game['Game Name'])) {
|
||||
gameNames.add(game['Game Name'])
|
||||
uniqueGames.push(game)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
setGames(uniqueGames)
|
||||
} catch (err) {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
'An error occurred in reading and parsing games CSVs',
|
||||
err
|
||||
)
|
||||
|
||||
// Handle the unknown error type
|
||||
if (err instanceof Error) {
|
||||
toast.error(err.message)
|
||||
} else if (Array.isArray(err) && err.length > 0 && err[0]?.message) {
|
||||
// Handle the case when it's an array of PapaParse errors
|
||||
toast.error(err[0].message)
|
||||
} else {
|
||||
toast.error(
|
||||
'An unknown error occurred in reading and parsing csv files'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readGamesCSVs()
|
||||
}, [])
|
||||
|
||||
return games
|
||||
}
|
37
src/hooks/useMuteLists.ts
Normal file
37
src/hooks/useMuteLists.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MuteLists } from 'types'
|
||||
import { useAppSelector } from './redux'
|
||||
import { MetadataController } from 'controllers'
|
||||
|
||||
export const useMuteLists = () => {
|
||||
const [muteLists, setMuteLists] = useState<{
|
||||
admin: MuteLists
|
||||
user: MuteLists
|
||||
}>({
|
||||
admin: {
|
||||
authors: [],
|
||||
replaceableEvents: []
|
||||
},
|
||||
user: {
|
||||
authors: [],
|
||||
replaceableEvents: []
|
||||
}
|
||||
})
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useEffect(() => {
|
||||
const getMuteLists = async () => {
|
||||
const pubkey = userState.user?.pubkey as string | undefined
|
||||
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
metadataController.getMuteLists(pubkey).then((lists) => {
|
||||
setMuteLists(lists)
|
||||
})
|
||||
}
|
||||
|
||||
getMuteLists()
|
||||
}, [userState])
|
||||
|
||||
return muteLists
|
||||
}
|
174
src/hooks/useReactions.ts
Normal file
174
src/hooks/useReactions.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { REACTIONS } from 'constants.ts'
|
||||
import { RelayController, UserRelaysType } from 'controllers'
|
||||
import { useAppSelector, useDidMount } from 'hooks'
|
||||
import { Event, Filter, UnsignedEvent, kinds } from 'nostr-tools'
|
||||
import { abbreviateNumber, log, LogType, now } from 'utils'
|
||||
|
||||
type UseReactionsParams = {
|
||||
pubkey: string
|
||||
eTag: string
|
||||
aTag?: string
|
||||
}
|
||||
|
||||
export const useReactions = (params: UseReactionsParams) => {
|
||||
const [isReactionInProgress, setIsReactionInProgress] = useState(false)
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false)
|
||||
const [reactionEvents, setReactionEvents] = useState<Event[]>([])
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useDidMount(() => {
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.Reaction]
|
||||
}
|
||||
|
||||
if (params.aTag) {
|
||||
filter['#a'] = [params.aTag]
|
||||
} else {
|
||||
filter['#e'] = [params.eTag]
|
||||
}
|
||||
|
||||
RelayController.getInstance()
|
||||
.fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read)
|
||||
.then((events) => {
|
||||
setReactionEvents(events)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDataLoaded(true)
|
||||
})
|
||||
})
|
||||
|
||||
const hasReactedPositively = useMemo(() => {
|
||||
return (
|
||||
!!userState.auth &&
|
||||
reactionEvents.some(
|
||||
(event) =>
|
||||
event.pubkey === userState.user?.pubkey &&
|
||||
(REACTIONS.positive.emojis.includes(event.content) ||
|
||||
REACTIONS.positive.shortCodes.includes(event.content))
|
||||
)
|
||||
)
|
||||
}, [reactionEvents, userState])
|
||||
|
||||
const hasReactedNegatively = useMemo(() => {
|
||||
return (
|
||||
!!userState.auth &&
|
||||
reactionEvents.some(
|
||||
(event) =>
|
||||
event.pubkey === userState.user?.pubkey &&
|
||||
(REACTIONS.negative.emojis.includes(event.content) ||
|
||||
REACTIONS.negative.shortCodes.includes(event.content))
|
||||
)
|
||||
)
|
||||
}, [reactionEvents, userState])
|
||||
|
||||
const getPubkey = async () => {
|
||||
let hexPubkey: string
|
||||
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
hexPubkey = userState.user.pubkey as string
|
||||
} else {
|
||||
hexPubkey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
|
||||
if (!hexPubkey) {
|
||||
toast.error('Could not get pubkey')
|
||||
return null
|
||||
}
|
||||
|
||||
return hexPubkey
|
||||
}
|
||||
|
||||
const handleReaction = async (isPositive?: boolean) => {
|
||||
if (!isDataLoaded || hasReactedPositively || hasReactedNegatively) return
|
||||
|
||||
if (isReactionInProgress) return
|
||||
|
||||
setIsReactionInProgress(true)
|
||||
|
||||
try {
|
||||
const pubkey = await getPubkey()
|
||||
if (!pubkey) return
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
kind: kinds.Reaction,
|
||||
created_at: now(),
|
||||
content: isPositive ? '+' : '-',
|
||||
pubkey,
|
||||
tags: [
|
||||
['e', params.eTag],
|
||||
['p', params.pubkey]
|
||||
]
|
||||
}
|
||||
|
||||
if (params.aTag) {
|
||||
unsignedEvent.tags.push(['a', params.aTag])
|
||||
}
|
||||
|
||||
const signedEvent = await window.nostr
|
||||
?.signEvent(unsignedEvent)
|
||||
.then((event) => event as Event)
|
||||
.catch((err) => {
|
||||
toast.error('Failed to sign the reaction event!')
|
||||
log(true, LogType.Error, 'Failed to sign the event!', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!signedEvent) return
|
||||
|
||||
setReactionEvents((prev) => [...prev, signedEvent])
|
||||
|
||||
const publishedOnRelays = await RelayController.getInstance().publish(
|
||||
signedEvent as Event,
|
||||
params.pubkey,
|
||||
UserRelaysType.Read
|
||||
)
|
||||
|
||||
if (publishedOnRelays.length === 0) {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
'Failed to publish reaction event on any relay'
|
||||
)
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
setIsReactionInProgress(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { likesCount, disLikesCount } = useMemo(() => {
|
||||
let positiveCount = 0
|
||||
let negativeCount = 0
|
||||
|
||||
reactionEvents.forEach((event) => {
|
||||
if (
|
||||
REACTIONS.positive.emojis.includes(event.content) ||
|
||||
REACTIONS.positive.shortCodes.includes(event.content)
|
||||
) {
|
||||
positiveCount++
|
||||
} else if (
|
||||
REACTIONS.negative.emojis.includes(event.content) ||
|
||||
REACTIONS.negative.shortCodes.includes(event.content)
|
||||
) {
|
||||
negativeCount++
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
likesCount: abbreviateNumber(positiveCount),
|
||||
disLikesCount: abbreviateNumber(negativeCount)
|
||||
}
|
||||
}, [reactionEvents])
|
||||
|
||||
return {
|
||||
isDataLoaded,
|
||||
likesCount,
|
||||
disLikesCount,
|
||||
hasReactedPositively,
|
||||
hasReactedNegatively,
|
||||
handleReaction
|
||||
}
|
||||
}
|
@ -2,21 +2,18 @@ import {
|
||||
init as initNostrLogin,
|
||||
launch as launchNostrLoginDialog
|
||||
} from 'nostr-login'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { Banner } from '../components/Banner'
|
||||
import { LoadingSpinner } from '../components/LoadingSpinner'
|
||||
import { ZapButtons, ZapPresets, ZapQR } from '../components/Zap'
|
||||
import { MetadataController, ZapController } from '../controllers'
|
||||
import { useAppDispatch, useAppSelector } from '../hooks'
|
||||
import { ZapPopUp } from '../components/Zap'
|
||||
import { MetadataController } from '../controllers'
|
||||
import { useAppDispatch, useAppSelector, useDidMount } from '../hooks'
|
||||
import { appRoutes } from '../routes'
|
||||
import { setAuth, setUser } from '../store/reducers/user'
|
||||
import mainStyles from '../styles//main.module.scss'
|
||||
import navStyles from '../styles/nav.module.scss'
|
||||
import '../styles/popup.css'
|
||||
import { PaymentRequest } from '../types'
|
||||
import { formatNumber, npubToHex, unformatNumber } from '../utils'
|
||||
import { npubToHex } from '../utils'
|
||||
|
||||
export const Header = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
@ -173,7 +170,9 @@ export const Header = () => {
|
||||
<div className={navStyles.NavMainBottom}>
|
||||
<div className={mainStyles.ContainerMain}>
|
||||
<div className={navStyles.NavMainBottomInside}>
|
||||
<div className={`${navStyles.NavMainBottomInsideOther} ${navStyles.NavMainBottomInsideOtherLeft}`}></div>
|
||||
<div
|
||||
className={`${navStyles.NavMainBottomInsideOther} ${navStyles.NavMainBottomInsideOtherLeft}`}
|
||||
></div>
|
||||
<div className={navStyles.NavMainBottomInsideLinks}>
|
||||
<Link
|
||||
to={appRoutes.games}
|
||||
@ -200,31 +199,48 @@ export const Header = () => {
|
||||
Blog
|
||||
</Link>
|
||||
</div>
|
||||
<div className={`${navStyles.NavMainBottomInsideOther} ${navStyles.NavMainBottomInsideOtherRight}`}>
|
||||
<a className={navStyles.NavMainBottomInsideOtherLink} href="https://primal.net/p/npub17jl3ldd6305rnacvwvchx03snauqsg4nz8mruq0emj9thdpglr2sst825x" target="_blank">
|
||||
<img src="https://image.nostr.build/fb557f1b6d58c7bbcdf4d1edb1b48090c76ff1d1384b9d1aae13d652e7a3cfe4.gif" width="15px" />
|
||||
<div
|
||||
className={`${navStyles.NavMainBottomInsideOther} ${navStyles.NavMainBottomInsideOtherRight}`}
|
||||
>
|
||||
<a
|
||||
className={navStyles.NavMainBottomInsideOtherLink}
|
||||
href='https://primal.net/p/npub17jl3ldd6305rnacvwvchx03snauqsg4nz8mruq0emj9thdpglr2sst825x'
|
||||
target='_blank'
|
||||
>
|
||||
<img
|
||||
src='https://image.nostr.build/fb557f1b6d58c7bbcdf4d1edb1b48090c76ff1d1384b9d1aae13d652e7a3cfe4.gif'
|
||||
width='15px'
|
||||
/>
|
||||
</a>
|
||||
<a className={navStyles.NavMainBottomInsideOtherLink} href="https://x.com/DEGMods" target="_blank">
|
||||
<a
|
||||
className={navStyles.NavMainBottomInsideOtherLink}
|
||||
href='https://x.com/DEGMods'
|
||||
target='_blank'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z'></path>
|
||||
</svg>
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z'></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a className={navStyles.NavMainBottomInsideOtherLink} href="https://www.youtube.com/@DEGModsDotCom" target="_blank">
|
||||
<a
|
||||
className={navStyles.NavMainBottomInsideOtherLink}
|
||||
href='https://www.youtube.com/@DEGModsDotCom'
|
||||
target='_blank'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z'></path>
|
||||
</svg>
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z'></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -235,124 +251,13 @@ export const Header = () => {
|
||||
}
|
||||
|
||||
const TipButtonWithDialog = React.memo(() => {
|
||||
const [adminNpub, setAdminNpub] = useState<string | null>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
|
||||
const [amount, setAmount] = useState<number>(0)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest>()
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setPaymentRequest(undefined)
|
||||
setIsLoading(false)
|
||||
setIsOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleQRExpiry = useCallback(() => {
|
||||
setPaymentRequest(undefined)
|
||||
}, [])
|
||||
|
||||
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const unformattedValue = unformatNumber(event.target.value)
|
||||
setAmount(unformattedValue)
|
||||
}
|
||||
|
||||
const generatePaymentRequest =
|
||||
useCallback(async (): Promise<PaymentRequest | null> => {
|
||||
let userHexKey: string
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Getting user pubkey')
|
||||
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
userHexKey = userState.user.pubkey as string
|
||||
} else {
|
||||
userHexKey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
|
||||
if (!userHexKey) {
|
||||
setIsLoading(false)
|
||||
toast.error('Could not get pubkey')
|
||||
return null
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Getting admin metadata')
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
|
||||
const adminMetadata = await metadataController.findAdminMetadata()
|
||||
|
||||
if (!adminMetadata?.lud16) {
|
||||
setIsLoading(false)
|
||||
toast.error('Lighting address (lud16) is missing in admin metadata!')
|
||||
return null
|
||||
}
|
||||
|
||||
if (!adminMetadata?.pubkey) {
|
||||
setIsLoading(false)
|
||||
toast.error('pubkey is missing in admin metadata!')
|
||||
return null
|
||||
}
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
setLoadingSpinnerDesc('Creating zap request')
|
||||
return await zapController
|
||||
.getLightningPaymentRequest(
|
||||
adminMetadata.lud16,
|
||||
amount,
|
||||
adminMetadata.pubkey as string,
|
||||
userHexKey,
|
||||
message
|
||||
)
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [amount, message, userState])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const pr = await generatePaymentRequest()
|
||||
|
||||
if (!pr) return
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Sending payment!')
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
if (await zapController.isWeblnProviderExists()) {
|
||||
await zapController
|
||||
.sendPayment(pr.pr)
|
||||
.then(() => {
|
||||
toast.success(`Successfully sent ${amount} sats!`)
|
||||
handleClose()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
} else {
|
||||
toast.warn('Webln is not present. Use QR code to send zap.')
|
||||
setPaymentRequest(pr)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, [amount, handleClose, generatePaymentRequest])
|
||||
|
||||
const handleGenerateQRCode = async () => {
|
||||
const pr = await generatePaymentRequest()
|
||||
|
||||
if (!pr) return
|
||||
|
||||
setPaymentRequest(pr)
|
||||
}
|
||||
useDidMount(async () => {
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
setAdminNpub(metadataController.adminNpubs[0])
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -371,91 +276,38 @@ const TipButtonWithDialog = React.memo(() => {
|
||||
</svg>
|
||||
Tip
|
||||
</a>
|
||||
{isOpen && (
|
||||
<div id='PopUpMainZap' className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Tip/Zap DEG Mods</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z' />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='pUMCB_ZapsInsideAmount'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<p
|
||||
className='labelDescriptionMain'
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
If you don't want the development and maintenance of DEG
|
||||
Mods to stop, then a tip helps!
|
||||
</p>
|
||||
<label className='form-label labelMain'>
|
||||
Amount (Satoshis)
|
||||
</label>
|
||||
<input
|
||||
className='inputMain'
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
value={amount ? formatNumber(amount) : ''}
|
||||
onChange={handleAmountChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='pUMCB_ZapsInsideAmountOptions'>
|
||||
<ZapPresets setAmount={setAmount} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>
|
||||
Message (optional)
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ZapButtons
|
||||
disabled={!amount}
|
||||
handleGenerateQRCode={handleGenerateQRCode}
|
||||
handleSend={handleSend}
|
||||
/>
|
||||
{paymentRequest && (
|
||||
<ZapQR
|
||||
paymentRequest={paymentRequest}
|
||||
handleClose={handleClose}
|
||||
handleQRExpiry={handleQRExpiry}
|
||||
/>
|
||||
)}
|
||||
<div className='BTCAddressPopZap'>
|
||||
<p>
|
||||
DEG Mod's Silent Payment Bitcoin Address (Be careful. <a href='https://youtu.be/payDPlHzp58?t=215' className='linkMain' target='_blank'>Learn more</a>):<br />
|
||||
<span className='BTCAddressPopZapTextSpan'>sp1qq205tj23sq3z6qjxt5ts5ps8gdwcrkwypej3h2z2hdclmaptl25xxqjfqhc2de4gaxprgm0yqwfr737swpvvmrph9ctkeyk60knz6xpjhqumafrd</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && adminNpub && (
|
||||
<ZapPopUp
|
||||
title='Tip/Zap DEG Mods'
|
||||
receiver={adminNpub}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
labelDescriptionMain={
|
||||
<p className='labelDescriptionMain' style={{ textAlign: 'center' }}>
|
||||
If you don't want the development and maintenance of DEG Mods to
|
||||
stop, then a tip helps!
|
||||
</p>
|
||||
}
|
||||
lastNode={
|
||||
<div className='BTCAddressPopZap'>
|
||||
<p>
|
||||
DEG Mod's Silent Payment Bitcoin Address (Be careful.{' '}
|
||||
<a
|
||||
href='https://youtu.be/payDPlHzp58?t=215'
|
||||
className='linkMain'
|
||||
target='_blank'
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
):
|
||||
<br />
|
||||
<span className='BTCAddressPopZapTextSpan'>
|
||||
sp1qq205tj23sq3z6qjxt5ts5ps8gdwcrkwypej3h2z2hdclmaptl25xxqjfqhc2de4gaxprgm0yqwfr737swpvvmrph9ctkeyk60knz6xpjhqumafrd
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Footer } from './footer'
|
||||
import { Header } from './header'
|
||||
import { SocialNav } from './socialNav'
|
||||
|
||||
export const Layout = () => {
|
||||
return (
|
||||
@ -8,6 +9,7 @@ export const Layout = () => {
|
||||
<Header />
|
||||
<Outlet />
|
||||
<Footer />
|
||||
<SocialNav />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
115
src/layout/socialNav.tsx
Normal file
115
src/layout/socialNav.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { appRoutes, getProfilePageRoute } from 'routes'
|
||||
import 'styles/socialNav.css'
|
||||
|
||||
export const SocialNav = () => {
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(false)
|
||||
|
||||
const toggleNav = () => {
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='socialNav'
|
||||
style={{
|
||||
transform: isCollapsed ? 'translateX(0)' : 'translateX(50%)',
|
||||
right: isCollapsed ? '0%' : '50%'
|
||||
}}
|
||||
>
|
||||
<div className='socialNavInsideWrapper'>
|
||||
{!isCollapsed && (
|
||||
<div className='socialNavInside'>
|
||||
<Link
|
||||
to={appRoutes.home}
|
||||
className='btn btnMain socialNavInsideBtn socialNavInsideBtnActive'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -32 576 576'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M511.8 287.6L512.5 447.7C512.5 450.5 512.3 453.1 512 455.8V472C512 494.1 494.1 512 472 512H456C454.9 512 453.8 511.1 452.7 511.9C451.3 511.1 449.9 512 448.5 512H392C369.9 512 352 494.1 352 472V384C352 366.3 337.7 352 320 352H256C238.3 352 224 366.3 224 384V472C224 494.1 206.1 512 184 512H128.1C126.6 512 125.1 511.9 123.6 511.8C122.4 511.9 121.2 512 120 512H104C81.91 512 64 494.1 64 472V360C64 359.1 64.03 358.1 64.09 357.2V287.6H32.05C14.02 287.6 0 273.5 0 255.5C0 246.5 3.004 238.5 10.01 231.5L266.4 8.016C273.4 1.002 281.4 0 288.4 0C295.4 0 303.4 2.004 309.5 7.014L416 100.7V64C416 46.33 430.3 32 448 32H480C497.7 32 512 46.33 512 64V185L564.8 231.5C572.8 238.5 576.9 246.5 575.8 255.5C575.8 273.5 560.8 287.6 543.8 287.6L511.8 287.6z'></path>
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
className='btn btnMain socialNavInsideBtn'
|
||||
to={appRoutes.home}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M88 48C101.3 48 112 58.75 112 72V120C112 133.3 101.3 144 88 144H40C26.75 144 16 133.3 16 120V72C16 58.75 26.75 48 40 48H88zM480 64C497.7 64 512 78.33 512 96C512 113.7 497.7 128 480 128H192C174.3 128 160 113.7 160 96C160 78.33 174.3 64 192 64H480zM480 224C497.7 224 512 238.3 512 256C512 273.7 497.7 288 480 288H192C174.3 288 160 273.7 160 256C160 238.3 174.3 224 192 224H480zM480 384C497.7 384 512 398.3 512 416C512 433.7 497.7 448 480 448H192C174.3 448 160 433.7 160 416C160 398.3 174.3 384 192 384H480zM16 232C16 218.7 26.75 208 40 208H88C101.3 208 112 218.7 112 232V280C112 293.3 101.3 304 88 304H40C26.75 304 16 293.3 16 280V232zM88 368C101.3 368 112 378.7 112 392V440C112 453.3 101.3 464 88 464H40C26.75 464 16 453.3 16 440V392C16 378.7 26.75 368 40 368H88z'></path>
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
className='btn btnMain socialNavInsideBtn'
|
||||
to={appRoutes.home}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M256 32V51.2C329 66.03 384 130.6 384 208V226.8C384 273.9 401.3 319.2 432.5 354.4L439.9 362.7C448.3 372.2 450.4 385.6 445.2 397.1C440 408.6 428.6 416 416 416H32C19.4 416 7.971 408.6 2.809 397.1C-2.353 385.6-.2883 372.2 8.084 362.7L15.5 354.4C46.74 319.2 64 273.9 64 226.8V208C64 130.6 118.1 66.03 192 51.2V32C192 14.33 206.3 0 224 0C241.7 0 256 14.33 256 32H256zM224 512C207 512 190.7 505.3 178.7 493.3C166.7 481.3 160 464.1 160 448H288C288 464.1 281.3 481.3 269.3 493.3C257.3 505.3 240.1 512 224 512z'></path>
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
className='btn btnMain socialNavInsideBtn'
|
||||
to={appRoutes.search}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
className='btn btnMain socialNavInsideBtn'
|
||||
to={getProfilePageRoute('xyz')}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M256 288c79.53 0 144-64.47 144-144s-64.47-144-144-144c-79.52 0-144 64.47-144 144S176.5 288 256 288zM351.1 320H160c-88.36 0-160 71.63-160 160c0 17.67 14.33 32 31.1 32H480c17.67 0 31.1-14.33 31.1-32C512 391.6 440.4 320 351.1 320z'></path>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='socialNavCollapse' onClick={toggleNav}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-128 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='socialNavCollapseIcon'
|
||||
style={{
|
||||
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(180deg)'
|
||||
}}
|
||||
>
|
||||
<path d='M192 448c-8.188 0-16.38-3.125-22.62-9.375l-160-160c-12.5-12.5-12.5-32.75 0-45.25l160-160c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25L77.25 256l137.4 137.4c12.5 12.5 12.5 32.75 0 45.25C208.4 444.9 200.2 448 192 448z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
299
src/pages/game.tsx
Normal file
299
src/pages/game.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||
import { ModCard } from 'components/ModCard'
|
||||
import { PaginationWithPageNumbers } from 'components/Pagination'
|
||||
import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts'
|
||||
import { RelayController } from 'controllers'
|
||||
import { useAppSelector, useMuteLists } from 'hooks'
|
||||
import { Filter, kinds, nip19 } from 'nostr-tools'
|
||||
import { Subscription } from 'nostr-tools/abstract-relay'
|
||||
import React, {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { getModPageRoute } from 'routes'
|
||||
import { ModDetails } from 'types'
|
||||
import { extractModData, isModDataComplete, log, LogType } from 'utils'
|
||||
|
||||
enum SortByEnum {
|
||||
Latest = 'Latest',
|
||||
Oldest = 'Oldest',
|
||||
Best_Rated = 'Best Rated',
|
||||
Worst_Rated = 'Worst Rated'
|
||||
}
|
||||
|
||||
enum ModeratedFilterEnum {
|
||||
Moderated = 'Moderated',
|
||||
Unmoderated = 'Unmoderated',
|
||||
Unmoderated_Fully = 'Unmoderated Fully'
|
||||
}
|
||||
|
||||
interface FilterOptions {
|
||||
sort: SortByEnum
|
||||
moderated: ModeratedFilterEnum
|
||||
}
|
||||
|
||||
export const GamePage = () => {
|
||||
const params = useParams()
|
||||
const { name: gameName } = params
|
||||
const muteLists = useMuteLists()
|
||||
|
||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||
sort: SortByEnum.Latest,
|
||||
moderated: ModeratedFilterEnum.Moderated
|
||||
})
|
||||
const [mods, setMods] = useState<ModDetails[]>([])
|
||||
|
||||
const hasEffectRun = useRef(false)
|
||||
const [isSubscribing, setIsSubscribing] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const filteredMods = useMemo(() => {
|
||||
let filtered: ModDetails[] = [...mods]
|
||||
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||
const isUnmoderatedFully =
|
||||
filterOptions.moderated === ModeratedFilterEnum.Unmoderated_Fully
|
||||
|
||||
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
|
||||
if (!(isAdmin && isUnmoderatedFully)) {
|
||||
filtered = filtered.filter(
|
||||
(mod) =>
|
||||
!muteLists.admin.authors.includes(mod.author) &&
|
||||
!muteLists.admin.replaceableEvents.includes(mod.aTag)
|
||||
)
|
||||
}
|
||||
|
||||
if (filterOptions.moderated === ModeratedFilterEnum.Moderated) {
|
||||
filtered = filtered.filter(
|
||||
(mod) =>
|
||||
!muteLists.user.authors.includes(mod.author) &&
|
||||
!muteLists.user.replaceableEvents.includes(mod.aTag)
|
||||
)
|
||||
}
|
||||
|
||||
if (filterOptions.sort === SortByEnum.Latest) {
|
||||
filtered.sort((a, b) => b.published_at - a.published_at)
|
||||
} else if (filterOptions.sort === SortByEnum.Oldest) {
|
||||
filtered.sort((a, b) => a.published_at - b.published_at)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [
|
||||
mods,
|
||||
userState.user?.npub,
|
||||
filterOptions.sort,
|
||||
filterOptions.moderated,
|
||||
muteLists
|
||||
])
|
||||
|
||||
// Pagination logic
|
||||
const totalGames = filteredMods.length
|
||||
const totalPages = Math.ceil(totalGames / MAX_MODS_PER_PAGE)
|
||||
const startIndex = (currentPage - 1) * MAX_MODS_PER_PAGE
|
||||
const endIndex = startIndex + MAX_MODS_PER_PAGE
|
||||
const currentMods = filteredMods.slice(startIndex, endIndex)
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
setCurrentPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasEffectRun.current) {
|
||||
return
|
||||
}
|
||||
|
||||
hasEffectRun.current = true // Set it so the effect doesn't run again
|
||||
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.ClassifiedListing],
|
||||
'#t': [T_TAG_VALUE]
|
||||
}
|
||||
|
||||
setIsSubscribing(true)
|
||||
|
||||
let subscriptions: Subscription[] = []
|
||||
|
||||
RelayController.getInstance()
|
||||
.subscribeForEvents(filter, [], (event) => {
|
||||
if (isModDataComplete(event)) {
|
||||
const mod = extractModData(event)
|
||||
if (mod.game === gameName) setMods((prev) => [...prev, mod])
|
||||
}
|
||||
})
|
||||
.then((subs) => {
|
||||
subscriptions = subs
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
'An error occurred in subscribing to relays.',
|
||||
err
|
||||
)
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSubscribing(false)
|
||||
})
|
||||
|
||||
// Cleanup function to stop all subscriptions
|
||||
return () => {
|
||||
subscriptions.forEach((sub) => sub.close()) // close each subscription
|
||||
}
|
||||
}, [gameName])
|
||||
|
||||
if (!gameName) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSubscribing && (
|
||||
<LoadingSpinner desc='Subscribing to relays for mods' />
|
||||
)}
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
||||
<div className='IBMSecMain'>
|
||||
<div className='SearchMainWrapper'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>
|
||||
Game:
|
||||
<span className='IBMSMTitleMainHeadingSpan'>
|
||||
{gameName}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Filters
|
||||
filterOptions={filterOptions}
|
||||
setFilterOptions={setFilterOptions}
|
||||
/>
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMList'>
|
||||
{currentMods.map((mod) => {
|
||||
const route = getModPageRoute(
|
||||
nip19.naddrEncode({
|
||||
identifier: mod.aTag,
|
||||
pubkey: mod.author,
|
||||
kind: kinds.ClassifiedListing
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<ModCard
|
||||
key={mod.id}
|
||||
title={mod.title}
|
||||
gameName={mod.game}
|
||||
summary={mod.summary}
|
||||
imageUrl={mod.featuredImageUrl}
|
||||
route={route}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<PaginationWithPageNumbers
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
handlePageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type FiltersProps = {
|
||||
filterOptions: FilterOptions
|
||||
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
|
||||
}
|
||||
|
||||
const Filters = React.memo(
|
||||
({ filterOptions, setFilterOptions }: FiltersProps) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
<div className='FiltersMain'>
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
{filterOptions.sort}
|
||||
</button>
|
||||
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{Object.values(SortByEnum).map((item, index) => (
|
||||
<div
|
||||
key={`sortByItem-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
sort: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
{filterOptions.moderated}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{Object.values(ModeratedFilterEnum).map((item, index) => {
|
||||
if (item === ModeratedFilterEnum.Unmoderated_Fully) {
|
||||
const isAdmin =
|
||||
userState.user?.npub ===
|
||||
import.meta.env.VITE_REPORTING_NPUB
|
||||
|
||||
if (!isAdmin) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`moderatedFilterItem-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
moderated: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
@ -1,9 +1,51 @@
|
||||
import '../styles/pagination.css'
|
||||
import '../styles/styles.css'
|
||||
import '../styles/search.css'
|
||||
import { PaginationWithPageNumbers } from 'components/Pagination'
|
||||
import { MAX_GAMES_PER_PAGE } from 'constants.ts'
|
||||
import { useGames } from 'hooks'
|
||||
import { useRef, useState } from 'react'
|
||||
import { GameCard } from '../components/GameCard'
|
||||
import '../styles/pagination.css'
|
||||
import '../styles/search.css'
|
||||
import '../styles/styles.css'
|
||||
import { createSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { appRoutes } from 'routes'
|
||||
|
||||
export const GamesPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const searchTermRef = useRef<HTMLInputElement>(null)
|
||||
const games = useGames()
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
// Pagination logic
|
||||
const totalGames = games.length
|
||||
const totalPages = Math.ceil(totalGames / MAX_GAMES_PER_PAGE)
|
||||
const startIndex = (currentPage - 1) * MAX_GAMES_PER_PAGE
|
||||
const endIndex = startIndex + MAX_GAMES_PER_PAGE
|
||||
const currentGames = games.slice(startIndex, endIndex)
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
setCurrentPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
const value = searchTermRef.current?.value || '' // Access the input value from the ref
|
||||
if (value !== '') {
|
||||
const searchParams = createSearchParams({
|
||||
searchTerm: value,
|
||||
searching: 'Games'
|
||||
})
|
||||
navigate({ pathname: appRoutes.search, search: `?${searchParams}` })
|
||||
}
|
||||
}
|
||||
|
||||
// Handle "Enter" key press inside the input
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='ContainerMain'>
|
||||
@ -11,13 +53,23 @@ export const GamesPage = () => {
|
||||
<div className='IBMSecMain'>
|
||||
<div className='SearchMainWrapper'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>Games (WIP)</h2>
|
||||
<h2 className='IBMSMTitleMainHeading'>Games</h2>
|
||||
</div>
|
||||
<div className='SearchMain'>
|
||||
<div className='SearchMainInside'>
|
||||
<div className='SearchMainInsideWrapper'>
|
||||
<input type='text' className='SMIWInput' />
|
||||
<button className='btn btnMain SMIWButton' type='button'>
|
||||
<input
|
||||
type='text'
|
||||
className='SMIWInput'
|
||||
ref={searchTermRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Enter search term'
|
||||
/>
|
||||
<button
|
||||
className='btn btnMain SMIWButton'
|
||||
type='button'
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
@ -35,46 +87,20 @@ export const GamesPage = () => {
|
||||
</div>
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMList IBMSMListFeaturedAlt'>
|
||||
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSecMain'>
|
||||
<div className='PaginationMain'>
|
||||
<div className='PaginationMainInside'>
|
||||
<a
|
||||
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
|
||||
href='#'
|
||||
>
|
||||
<i className='fas fa-chevron-left'></i>
|
||||
</a>
|
||||
<div className='PaginationMainInsideBoxGroup'>
|
||||
<a className='PaginationMainInsideBox PMIBActive' href='#'>
|
||||
<p>1</p>{' '}
|
||||
</a>
|
||||
<a className='PaginationMainInsideBox' href='#'>
|
||||
<p>2</p>{' '}
|
||||
</a>
|
||||
<a className='PaginationMainInsideBox' href='#'>
|
||||
<p>3</p>
|
||||
</a>
|
||||
<p className='PaginationMainInsideBox PMIBDots'>...</p>
|
||||
<a className='PaginationMainInsideBox' href='#'>
|
||||
<p>8</p>
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
|
||||
href='#'
|
||||
>
|
||||
<i className='fas fa-chevron-right'></i>
|
||||
</a>
|
||||
</div>
|
||||
{currentGames.map((game) => (
|
||||
<GameCard
|
||||
key={game['Game Name']}
|
||||
title={game['Game Name']}
|
||||
imageUrl={game['Boxart image']}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PaginationWithPageNumbers
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
handlePageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,123 +1,61 @@
|
||||
import { Filter, kinds, nip19 } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { A11y, Navigation, Pagination, Autoplay } from 'swiper/modules'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import { BlogCard } from '../components/BlogCard'
|
||||
import { GameCard } from '../components/GameCard'
|
||||
import { ModCard } from '../components/ModCard'
|
||||
import { LANDING_PAGE_DATA } from '../constants'
|
||||
import { RelayController } from '../controllers'
|
||||
import { useDidMount } from '../hooks'
|
||||
import { appRoutes, getModPageRoute } from '../routes'
|
||||
import { ModDetails } from '../types'
|
||||
import {
|
||||
extractModData,
|
||||
fetchMods,
|
||||
handleModImageError,
|
||||
log,
|
||||
LogType
|
||||
} from '../utils'
|
||||
|
||||
import '../styles/cardLists.css'
|
||||
import '../styles/SimpleSlider.css'
|
||||
import '../styles/styles.css'
|
||||
|
||||
// Import Swiper styles
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/navigation'
|
||||
import 'swiper/css/pagination'
|
||||
|
||||
export const HomePage = () => {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='SliderWrapper'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='IBMSecMain'>
|
||||
<div className='simple-slider IBMSMSlider'>
|
||||
<div className='swiper-container IBMSMSliderContainer'>
|
||||
<div className='swiper-wrapper IBMSMSliderContainerWrapper'>
|
||||
<div className='swiper-slide IBMSMSliderContainerWrapperSlider'>
|
||||
<div
|
||||
className='IBMSMSCWSPic'
|
||||
style={{
|
||||
background:
|
||||
'url("/assets/img/DEGMods%20Placeholder%20Img.png") center / cover no-repeat'
|
||||
}}
|
||||
></div>
|
||||
<div className='IBMSMSCWSInfo'>
|
||||
<h3 className='IBMSMSCWSInfoHeading'>Placeholder</h3>
|
||||
<p className='IBMSMSCWSInfoText'>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
Integer nec odio. Praesent libero. Sed cursus ante
|
||||
dapibus diam. Sed nisi. Nulla quis sem at nibh elementum
|
||||
imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce
|
||||
nec tellus sed augue semper porta. Mauris massa.
|
||||
Vestibulum lacinia arcu eget nulla. className aptent
|
||||
taciti sociosqu ad litora torquent per conubia nostra,
|
||||
per inceptos himenaeos. Curabitur sodales ligula in
|
||||
libero.
|
||||
<br />
|
||||
</p>
|
||||
<div className='IBMSMSliderContainerWrapperSliderAction'>
|
||||
<a
|
||||
className='btn btnMain IBMSMSliderContainerWrapperSliderActionbtn'
|
||||
role='button'
|
||||
href='mods-inner.html'
|
||||
>
|
||||
Check it out
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='swiper-slide IBMSMSliderContainerWrapperSlider'>
|
||||
<div
|
||||
className='IBMSMSCWSPic'
|
||||
style={{
|
||||
background:
|
||||
'url("/assets/img/DEGMods%20Placeholder%20Img.png") center / cover no-repeat'
|
||||
}}
|
||||
></div>
|
||||
<div className='IBMSMSCWSInfo'>
|
||||
<h3 className='IBMSMSCWSInfoHeading'>Placeholder</h3>
|
||||
<p className='IBMSMSCWSInfoText'>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
Integer nec odio. Praesent libero. Sed cursus ante
|
||||
dapibus diam. Sed nisi. Nulla quis sem at nibh elementum
|
||||
imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce
|
||||
nec tellus sed augue semper porta. Mauris massa.
|
||||
Vestibulum lacinia arcu eget nulla. className aptent
|
||||
taciti sociosqu ad litora torquent per conubia nostra,
|
||||
per inceptos himenaeos. Curabitur sodales ligula in
|
||||
libero.
|
||||
<br />
|
||||
</p>
|
||||
<div className='IBMSMSliderContainerWrapperSliderAction'>
|
||||
<a
|
||||
className='btn btnMain IBMSMSliderContainerWrapperSliderActionbtn'
|
||||
role='button'
|
||||
href='mods-inner.html'
|
||||
>
|
||||
Check it out
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='swiper-slide IBMSMSliderContainerWrapperSlider'>
|
||||
<div
|
||||
className='IBMSMSCWSPic'
|
||||
style={{
|
||||
background:
|
||||
'url("/assets/img/DEGMods%20Placeholder%20Img.png") center / cover no-repeat'
|
||||
}}
|
||||
></div>
|
||||
<div className='IBMSMSCWSInfo'>
|
||||
<h3 className='IBMSMSCWSInfoHeading'>Placeholder</h3>
|
||||
<p className='IBMSMSCWSInfoText'>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
Integer nec odio. Praesent libero. Sed cursus ante
|
||||
dapibus diam. Sed nisi. Nulla quis sem at nibh elementum
|
||||
imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce
|
||||
nec tellus sed augue semper porta. Mauris massa.
|
||||
Vestibulum lacinia arcu eget nulla. className aptent
|
||||
taciti sociosqu ad litora torquent per conubia nostra,
|
||||
per inceptos himenaeos. Curabitur sodales ligula in
|
||||
libero.
|
||||
<br />
|
||||
</p>
|
||||
<div className='IBMSMSliderContainerWrapperSliderAction'>
|
||||
<a
|
||||
className='btn btnMain IBMSMSliderContainerWrapperSliderActionbtn'
|
||||
role='button'
|
||||
href='mods-inner.html'
|
||||
>
|
||||
Check it out
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='swiper-pagination'></div>
|
||||
<div className='swiper-button-prev'></div>
|
||||
<div className='swiper-button-next'></div>
|
||||
</div>
|
||||
<Swiper
|
||||
className='swiper-container IBMSMSliderContainer'
|
||||
wrapperClass='swiper-wrapper IBMSMSliderContainerWrapper'
|
||||
modules={[Navigation, Pagination, A11y, Autoplay]}
|
||||
pagination={{ clickable: true, dynamicBullets: true }}
|
||||
slidesPerView={1}
|
||||
autoplay={{ delay: 5000 }}
|
||||
speed={1000}
|
||||
navigation
|
||||
loop
|
||||
>
|
||||
{LANDING_PAGE_DATA.featuredSlider.map((naddr) => (
|
||||
<SwiperSlide
|
||||
key={naddr}
|
||||
className='swiper-slide IBMSMSliderContainerWrapperSlider'
|
||||
>
|
||||
<SlideContent naddr={naddr} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -126,20 +64,22 @@ export const HomePage = () => {
|
||||
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>Cool Games (WIP)</h2>
|
||||
<h2 className='IBMSMTitleMainHeading'>Cool Games</h2>
|
||||
</div>
|
||||
<div className='IBMSMList IBMSMListFeaturedAlt'>
|
||||
<GameCard backgroundLink='assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<GameCard backgroundLink='assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<GameCard backgroundLink='assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<GameCard backgroundLink='assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
<GameCard backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png' />
|
||||
{LANDING_PAGE_DATA.featuredGames.map((game) => (
|
||||
<GameCard
|
||||
key={game.title}
|
||||
title={game.title}
|
||||
imageUrl={game.imageUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className='IBMSMAction'>
|
||||
<a
|
||||
className='btn btnMain IBMSMActionBtn'
|
||||
role='button'
|
||||
href='blog.html'
|
||||
onClick={() => navigate(appRoutes.games)}
|
||||
>
|
||||
View All
|
||||
</a>
|
||||
@ -147,107 +87,24 @@ export const HomePage = () => {
|
||||
</div>
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>Awesome Mods (WIP)</h2>
|
||||
<h2 className='IBMSMTitleMainHeading'>Awesome Mods</h2>
|
||||
</div>
|
||||
<div className='IBMSMList IBMSMListAlt'>
|
||||
<ModCard
|
||||
title='Placeholder'
|
||||
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
|
||||
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
|
||||
handleClick={() => {
|
||||
alert(
|
||||
'these are dummy mods. So navigation on these are not implemented yet'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<ModCard
|
||||
title='Placeholder'
|
||||
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
|
||||
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
|
||||
handleClick={() => {
|
||||
alert(
|
||||
'these are dummy mods. So navigation on these are not implemented yet'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<ModCard
|
||||
title='Placeholder'
|
||||
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
|
||||
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
|
||||
handleClick={() => {
|
||||
alert(
|
||||
'these are dummy mods. So navigation on these are not implemented yet'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{LANDING_PAGE_DATA.awesomeMods.map((naddr) => (
|
||||
<DisplayMod key={naddr} naddr={naddr} />
|
||||
))}
|
||||
</div>
|
||||
<div className='IBMSMAction'>
|
||||
<a
|
||||
className='btn btnMain IBMSMActionBtn'
|
||||
role='button'
|
||||
href='blog.html'
|
||||
>
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>Latest Mods (WIP)</h2>
|
||||
</div>
|
||||
<div className='IBMSMList'>
|
||||
<ModCard
|
||||
title='Placeholder'
|
||||
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
|
||||
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
|
||||
handleClick={() => {
|
||||
alert(
|
||||
'these are dummy mods. So navigation on these are not implemented yet'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<ModCard
|
||||
title='Placeholder'
|
||||
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
|
||||
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
|
||||
handleClick={() => {
|
||||
alert(
|
||||
'these are dummy mods. So navigation on these are not implemented yet'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<ModCard
|
||||
title='Placeholder'
|
||||
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
|
||||
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
|
||||
handleClick={() => {
|
||||
alert(
|
||||
'these are dummy mods. So navigation on these are not implemented yet'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<ModCard
|
||||
title='Placeholder'
|
||||
summary='Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
|
||||
backgroundLink='/assets/img/DEGMods%20Placeholder%20Img.png'
|
||||
handleClick={() => {
|
||||
alert(
|
||||
'these are dummy mods. So navigation on these are not implemented yet'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='IBMSMAction'>
|
||||
<a
|
||||
className='btn btnMain IBMSMActionBtn'
|
||||
role='button'
|
||||
href='blog.html'
|
||||
onClick={() => navigate(appRoutes.mods)}
|
||||
>
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<DisplayLatestMods />
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>Blog Posts (WIP)</h2>
|
||||
@ -274,3 +131,197 @@ export const HomePage = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SlideContentProps = {
|
||||
naddr: string
|
||||
}
|
||||
|
||||
const SlideContent = ({ naddr }: SlideContentProps) => {
|
||||
const navigate = useNavigate()
|
||||
const [mod, setMod] = useState<ModDetails>()
|
||||
|
||||
useDidMount(() => {
|
||||
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||
const { identifier, kind, pubkey, relays = [] } = decoded.data
|
||||
|
||||
const filter: Filter = {
|
||||
'#a': [identifier],
|
||||
authors: [pubkey],
|
||||
kinds: [kind]
|
||||
}
|
||||
|
||||
RelayController.getInstance()
|
||||
.fetchEvent(filter, relays)
|
||||
.then((event) => {
|
||||
if (event) {
|
||||
const extracted = extractModData(event)
|
||||
setMod(extracted)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
'An error occurred in fetching mod details from relays',
|
||||
err
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
if (!mod) return <Spinner />
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='IBMSMSCWSPicWrapper'>
|
||||
<img
|
||||
src={mod.featuredImageUrl}
|
||||
onError={handleModImageError}
|
||||
className='IBMSMSCWSPic'
|
||||
/>
|
||||
</div>
|
||||
<div className='IBMSMSCWSInfo'>
|
||||
<h3 className='IBMSMSCWSInfoHeading'>{mod.title}</h3>
|
||||
<div className='IBMSMSCWSInfoTextWrapper'>
|
||||
<p className='IBMSMSCWSInfoText'>
|
||||
{mod.summary}
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
<p className='IBMSMSCWSInfoText IBMSMSCWSInfoText2'>
|
||||
{mod.game}
|
||||
<br />
|
||||
</p>
|
||||
<div className='IBMSMSliderContainerWrapperSliderAction'>
|
||||
<a
|
||||
className='btn btnMain IBMSMSliderContainerWrapperSliderActionbtn'
|
||||
role='button'
|
||||
onClick={() => navigate(getModPageRoute(naddr))}
|
||||
>
|
||||
Check it out
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type DisplayModProps = {
|
||||
naddr: string
|
||||
}
|
||||
|
||||
const DisplayMod = ({ naddr }: DisplayModProps) => {
|
||||
const [mod, setMod] = useState<ModDetails>()
|
||||
|
||||
useDidMount(() => {
|
||||
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||
const { identifier, kind, pubkey, relays = [] } = decoded.data
|
||||
|
||||
const filter: Filter = {
|
||||
'#a': [identifier],
|
||||
authors: [pubkey],
|
||||
kinds: [kind]
|
||||
}
|
||||
|
||||
RelayController.getInstance()
|
||||
.fetchEvent(filter, relays)
|
||||
.then((event) => {
|
||||
if (event) {
|
||||
const extracted = extractModData(event)
|
||||
setMod(extracted)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
'An error occurred in fetching mod details from relays',
|
||||
err
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
if (!mod) return <Spinner />
|
||||
|
||||
const route = getModPageRoute(naddr)
|
||||
|
||||
return (
|
||||
<ModCard
|
||||
title={mod.title}
|
||||
gameName={mod.game}
|
||||
summary={mod.summary}
|
||||
imageUrl={mod.featuredImageUrl}
|
||||
route={route}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DisplayLatestMods = () => {
|
||||
const navigate = useNavigate()
|
||||
const [isFetchingLatestMods, setIsFetchingLatestMods] = useState(true)
|
||||
const [latestMods, setLatestMods] = useState<ModDetails[]>([])
|
||||
|
||||
useDidMount(() => {
|
||||
fetchMods({ source: window.location.host })
|
||||
.then((res) => {
|
||||
const mods = res
|
||||
.sort((a, b) => b.published_at - a.published_at)
|
||||
.slice(0, 4)
|
||||
setLatestMods(mods)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetchingLatestMods(false)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>Latest Mods</h2>
|
||||
</div>
|
||||
<div className='IBMSMList'>
|
||||
{isFetchingLatestMods ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
latestMods.map((mod) => {
|
||||
const route = getModPageRoute(
|
||||
nip19.naddrEncode({
|
||||
identifier: mod.aTag,
|
||||
pubkey: mod.author,
|
||||
kind: kinds.ClassifiedListing
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<ModCard
|
||||
key={mod.id}
|
||||
title={mod.title}
|
||||
gameName={mod.game}
|
||||
summary={mod.summary}
|
||||
imageUrl={mod.featuredImageUrl}
|
||||
route={route}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='IBMSMAction'>
|
||||
<a
|
||||
className='btn btnMain IBMSMActionBtn'
|
||||
role='button'
|
||||
onClick={() => navigate(appRoutes.mods)}
|
||||
>
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Spinner = () => {
|
||||
return (
|
||||
<div className='spinner'>
|
||||
<div className='spinnerCircle'></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
1389
src/pages/mod/index.tsx
Normal file
1389
src/pages/mod/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
592
src/pages/mod/internal/comment/index.tsx
Normal file
592
src/pages/mod/internal/comment/index.tsx
Normal file
@ -0,0 +1,592 @@
|
||||
import { ZapPopUp } from 'components/Zap'
|
||||
import {
|
||||
MetadataController,
|
||||
RelayController,
|
||||
UserRelaysType
|
||||
} from 'controllers'
|
||||
import { formatDate } from 'date-fns'
|
||||
import { useAppSelector, useDidMount, useReactions } from 'hooks'
|
||||
import {
|
||||
Event,
|
||||
kinds,
|
||||
nip19,
|
||||
Filter as NostrEventFilter,
|
||||
UnsignedEvent
|
||||
} from 'nostr-tools'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { Dispatch, SetStateAction, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { getProfilePageRoute } from 'routes'
|
||||
import { ModDetails, UserProfile } from 'types'
|
||||
import { abbreviateNumber, hexToNpub, log, LogType, now } from 'utils'
|
||||
|
||||
enum SortByEnum {
|
||||
Latest = 'Latest',
|
||||
Oldest = 'Oldest'
|
||||
}
|
||||
|
||||
enum AuthorFilterEnum {
|
||||
All_Comments = 'All Comments',
|
||||
Creator_Comments = 'Creator Comments'
|
||||
}
|
||||
|
||||
type FilterOptions = {
|
||||
sort: SortByEnum
|
||||
author: AuthorFilterEnum
|
||||
}
|
||||
|
||||
enum CommentEventStatus {
|
||||
Publishing = 'Publishing comment...',
|
||||
Published = 'Published!',
|
||||
Failed = 'Failed to publish comment.'
|
||||
}
|
||||
|
||||
interface CommentEvent extends Event {
|
||||
status?: CommentEventStatus
|
||||
}
|
||||
|
||||
type Props = {
|
||||
modDetails: ModDetails
|
||||
setCommentCount: Dispatch<SetStateAction<number>>
|
||||
}
|
||||
|
||||
export const Comments = ({ modDetails, setCommentCount }: Props) => {
|
||||
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
|
||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||
sort: SortByEnum.Latest,
|
||||
author: AuthorFilterEnum.All_Comments
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setCommentCount(commentEvents.length)
|
||||
}, [commentEvents, setCommentCount])
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useDidMount(async () => {
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
|
||||
const authorReadRelays = await metadataController.findUserRelays(
|
||||
modDetails.author,
|
||||
UserRelaysType.Read
|
||||
)
|
||||
|
||||
const filter: NostrEventFilter = {
|
||||
kinds: [kinds.ShortTextNote],
|
||||
'#a': [modDetails.aTag]
|
||||
}
|
||||
|
||||
RelayController.getInstance().subscribeForEvents(
|
||||
filter,
|
||||
authorReadRelays,
|
||||
(event) => {
|
||||
setCommentEvents((prev) => {
|
||||
if (prev.find((e) => e.id === event.id)) {
|
||||
return [...prev]
|
||||
}
|
||||
|
||||
return [event, ...prev]
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const handleSubmit = async (content: string): Promise<boolean> => {
|
||||
if (content === '') return false
|
||||
|
||||
let pubkey: string
|
||||
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
pubkey = userState.user.pubkey as string
|
||||
} else {
|
||||
pubkey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
toast.error('Could not get user pubkey')
|
||||
return false
|
||||
}
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
content: content,
|
||||
pubkey: pubkey,
|
||||
kind: kinds.ShortTextNote,
|
||||
created_at: now(),
|
||||
tags: [
|
||||
['e', modDetails.id],
|
||||
['a', modDetails.aTag]
|
||||
]
|
||||
}
|
||||
|
||||
const signedEvent = await window.nostr
|
||||
?.signEvent(unsignedEvent)
|
||||
.then((event) => event as Event)
|
||||
.catch((err) => {
|
||||
toast.error('Failed to sign the event!')
|
||||
log(true, LogType.Error, 'Failed to sign the event!', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!signedEvent) return false
|
||||
|
||||
setCommentEvents((prev) => [
|
||||
{
|
||||
...signedEvent,
|
||||
status: CommentEventStatus.Publishing
|
||||
},
|
||||
...prev
|
||||
])
|
||||
|
||||
const publish = async () => {
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
const modAuthorReadRelays = await metadataController.findUserRelays(
|
||||
modDetails.author,
|
||||
UserRelaysType.Read
|
||||
)
|
||||
const commentatorWriteRelays = await metadataController.findUserRelays(
|
||||
pubkey,
|
||||
UserRelaysType.Write
|
||||
)
|
||||
|
||||
const combinedRelays = [
|
||||
...new Set(...modAuthorReadRelays, ...commentatorWriteRelays)
|
||||
]
|
||||
|
||||
const publishedOnRelays =
|
||||
await RelayController.getInstance().publishOnRelays(
|
||||
signedEvent,
|
||||
combinedRelays
|
||||
)
|
||||
|
||||
if (publishedOnRelays.length === 0) {
|
||||
setCommentEvents((prev) =>
|
||||
prev.map((event) => {
|
||||
if (event.id === signedEvent.id) {
|
||||
return {
|
||||
...event,
|
||||
status: CommentEventStatus.Failed
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
)
|
||||
} else {
|
||||
setCommentEvents((prev) =>
|
||||
prev.map((event) => {
|
||||
if (event.id === signedEvent.id) {
|
||||
return {
|
||||
...event,
|
||||
status: CommentEventStatus.Published
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// when an event is successfully published remove the status from it after 15 seconds
|
||||
setTimeout(() => {
|
||||
setCommentEvents((prev) =>
|
||||
prev.map((event) => {
|
||||
if (event.id === signedEvent.id) {
|
||||
delete event.status
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
)
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
publish()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const comments = useMemo(() => {
|
||||
let filteredComments = commentEvents
|
||||
if (filterOptions.author === AuthorFilterEnum.Creator_Comments) {
|
||||
filteredComments = filteredComments.filter(
|
||||
(comment) => comment.pubkey === modDetails.author
|
||||
)
|
||||
}
|
||||
|
||||
if (filterOptions.sort === SortByEnum.Latest) {
|
||||
filteredComments.sort((a, b) => b.created_at - a.created_at)
|
||||
} else if (filterOptions.sort === SortByEnum.Oldest) {
|
||||
filteredComments.sort((a, b) => a.created_at - b.created_at)
|
||||
}
|
||||
|
||||
return filteredComments
|
||||
}, [commentEvents, filterOptions, modDetails.author])
|
||||
|
||||
return (
|
||||
<div className='IBMSMSMBSSCommentsWrapper'>
|
||||
<h4 className='IBMSMSMBSSTitle'>Comments</h4>
|
||||
<div className='IBMSMSMBSSComments'>
|
||||
<CommentForm handleSubmit={handleSubmit} />
|
||||
<Filter
|
||||
filterOptions={filterOptions}
|
||||
setFilterOptions={setFilterOptions}
|
||||
/>
|
||||
<div className='IBMSMSMBSSCommentsList'>
|
||||
{comments.map((event) => (
|
||||
<Comment key={event.id} {...event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CommentFormProps = {
|
||||
handleSubmit: (content: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
const CommentForm = ({ handleSubmit }: CommentFormProps) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [commentText, setCommentText] = useState('')
|
||||
|
||||
const handleComment = async () => {
|
||||
setIsSubmitting(true)
|
||||
const submitted = await handleSubmit(commentText)
|
||||
if (submitted) setCommentText('')
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='IBMSMSMBSSCommentsCreation'>
|
||||
<div className='IBMSMSMBSSCC_Top'>
|
||||
<textarea
|
||||
className='IBMSMSMBSSCC_Top_Box'
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCC_Bottom'>
|
||||
<button
|
||||
className='btnMain'
|
||||
onClick={handleComment}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Comment'}
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FilterProps = {
|
||||
filterOptions: FilterOptions
|
||||
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
|
||||
}
|
||||
|
||||
const Filter = React.memo(
|
||||
({ filterOptions, setFilterOptions }: FilterProps) => {
|
||||
return (
|
||||
<div className='FiltersMain'>
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
{filterOptions.sort}
|
||||
</button>
|
||||
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{Object.values(SortByEnum).map((item) => (
|
||||
<div
|
||||
key={`sortBy-${item}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
sort: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
{filterOptions.author}
|
||||
</button>
|
||||
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{Object.values(AuthorFilterEnum).map((item) => (
|
||||
<div
|
||||
key={`sortBy-${item}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
author: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const Comment = (props: CommentEvent) => {
|
||||
const [profile, setProfile] = useState<UserProfile>()
|
||||
|
||||
useDidMount(async () => {
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
metadataController.findMetadata(props.pubkey).then((res) => {
|
||||
setProfile(res)
|
||||
})
|
||||
})
|
||||
|
||||
const profileRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: props.pubkey
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='IBMSMSMBSSCL_Comment'>
|
||||
<div className='IBMSMSMBSSCL_CommentTop'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CommentTopPP'
|
||||
to={profileRoute}
|
||||
style={{
|
||||
background: `url('${
|
||||
profile?.image || ''
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetails'>
|
||||
<a className='IBMSMSMBSSCL_CTD_Name' href='profile.html'>
|
||||
{profile?.displayName || profile?.name || ''}{' '}
|
||||
</a>
|
||||
<a className='IBMSMSMBSSCL_CTD_Address' href='profile.html'>
|
||||
{hexToNpub(props.pubkey)}
|
||||
</a>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
|
||||
<a className='IBMSMSMBSSCL_CADTime'>
|
||||
{formatDate(props.created_at * 1000, 'hh:mm aa')}{' '}
|
||||
</a>
|
||||
<a className='IBMSMSMBSSCL_CADDate'>
|
||||
{formatDate(props.created_at * 1000, 'dd/MM/yyyy')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentBottom'>
|
||||
{props.status && (
|
||||
<p className='IBMSMSMBSSCL_CBTextStatus'>
|
||||
<span className='IBMSMSMBSSCL_CBTextStatusSpan'>Status:</span>
|
||||
{props.status}
|
||||
</p>
|
||||
)}
|
||||
<p className='IBMSMSMBSSCL_CBText'>{props.content}</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentActions'>
|
||||
<div className='IBMSMSMBSSCL_CommentActionsInside'>
|
||||
<Reactions {...props} />
|
||||
<div
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost'
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -64 640 640'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>0</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
<Zap {...props} />
|
||||
<div
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>0</p>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>Replies</p>
|
||||
</div>
|
||||
<div
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReply'
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>Reply</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Reactions = (props: Event) => {
|
||||
const {
|
||||
isDataLoaded,
|
||||
likesCount,
|
||||
disLikesCount,
|
||||
handleReaction,
|
||||
hasReactedPositively,
|
||||
hasReactedNegatively
|
||||
} = useReactions({
|
||||
pubkey: props.pubkey,
|
||||
eTag: props.id
|
||||
})
|
||||
|
||||
if (!isDataLoaded) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp ${
|
||||
hasReactedPositively ? 'IBMSMSMBSSCL_CAEUpActive' : ''
|
||||
}`}
|
||||
onClick={() => handleReaction(true)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>{likesCount}</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${
|
||||
hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : ''
|
||||
}`}
|
||||
onClick={() => handleReaction()}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>{disLikesCount}</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Zap = (props: Event) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [hasZapped, setHasZapped] = useState(false)
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
|
||||
|
||||
useDidMount(() => {
|
||||
RelayController.getInstance()
|
||||
.getTotalZapAmount(
|
||||
props.pubkey,
|
||||
props.id,
|
||||
undefined,
|
||||
userState.user?.pubkey as string
|
||||
)
|
||||
.then((res) => {
|
||||
setTotalZappedAmount(res.accumulatedZapAmount)
|
||||
setHasZapped(res.hasZapped)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEBolt ${
|
||||
hasZapped ? 'IBMSMSMBSSCL_CAEBoltActive' : ''
|
||||
}`}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-64 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||
{abbreviateNumber(totalZappedAmount)}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ZapPopUp
|
||||
title='Tip/Zap'
|
||||
receiver={props.pubkey}
|
||||
eventId={props.id}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
setTotalZapAmount={setTotalZappedAmount}
|
||||
setHasZapped={setHasZapped}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
74
src/pages/mod/internal/reactions/index.tsx
Normal file
74
src/pages/mod/internal/reactions/index.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { useReactions } from 'hooks'
|
||||
import { ModDetails } from 'types'
|
||||
|
||||
type ReactionsProps = {
|
||||
modDetails: ModDetails
|
||||
}
|
||||
|
||||
export const Reactions = ({ modDetails }: ReactionsProps) => {
|
||||
const {
|
||||
isDataLoaded,
|
||||
likesCount,
|
||||
disLikesCount,
|
||||
handleReaction,
|
||||
hasReactedPositively,
|
||||
hasReactedNegatively
|
||||
} = useReactions({
|
||||
pubkey: modDetails.author,
|
||||
eTag: modDetails.id,
|
||||
aTag: modDetails.aTag
|
||||
})
|
||||
|
||||
if (!isDataLoaded) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactUp ${
|
||||
hasReactedPositively ? 'IBMSMSMBSS_D_CRUActive' : ''
|
||||
}`}
|
||||
onClick={() => handleReaction(true)}
|
||||
>
|
||||
<div className='IBMSMSMBSS_Details_CardVisual'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSS_Details_CardVisualIcon'
|
||||
>
|
||||
<path d='M0 190.9V185.1C0 115.2 50.52 55.58 119.4 44.1C164.1 36.51 211.4 51.37 244 84.02L256 96L267.1 84.02C300.6 51.37 347 36.51 392.6 44.1C461.5 55.58 512 115.2 512 185.1V190.9C512 232.4 494.8 272.1 464.4 300.4L283.7 469.1C276.2 476.1 266.3 480 256 480C245.7 480 235.8 476.1 228.3 469.1L47.59 300.4C17.23 272.1 .0003 232.4 .0003 190.9L0 190.9z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='IBMSMSMBSS_Details_CardText'>{likesCount}</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CReactDown ${
|
||||
hasReactedNegatively ? 'IBMSMSMBSS_D_CRDActive' : ''
|
||||
}`}
|
||||
onClick={() => handleReaction()}
|
||||
>
|
||||
<div className='IBMSMSMBSS_Details_CardVisual'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSS_Details_CardVisualIcon'
|
||||
>
|
||||
<path d='M512 440.1C512 479.9 479.7 512 439.1 512H71.92C32.17 512 0 479.8 0 440c0-35.88 26.19-65.35 60.56-70.85C43.31 356 32 335.4 32 312C32 272.2 64.25 240 104 240h13.99C104.5 228.2 96 211.2 96 192c0-35.38 28.56-64 63.94-64h16C220.1 128 256 92.12 256 48c0-17.38-5.784-33.35-15.16-46.47C245.8 .7754 250.9 0 256 0c53 0 96 43 96 96c0 11.25-2.288 22-5.913 32h5.879C387.3 128 416 156.6 416 192c0 19.25-8.59 36.25-22.09 48H408C447.8 240 480 272.2 480 312c0 23.38-11.38 44.01-28.63 57.14C485.7 374.6 512 404.3 512 440.1z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='IBMSMSMBSS_Details_CardText'>{disLikesCount}</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
256
src/pages/mod/internal/zap/index.tsx
Normal file
256
src/pages/mod/internal/zap/index.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||
import { ZapButtons, ZapPopUp, ZapPresets, ZapQR } from 'components/Zap'
|
||||
import { MetadataController, RelayController, ZapController } from 'controllers'
|
||||
import { useAppSelector, useDidMount } from 'hooks'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { ModDetails, PaymentRequest } from 'types'
|
||||
import { abbreviateNumber, formatNumber, unformatNumber } from 'utils'
|
||||
|
||||
type ZapProps = {
|
||||
modDetails: ModDetails
|
||||
}
|
||||
|
||||
export const Zap = ({ modDetails }: ZapProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [hasZapped, setHasZapped] = useState(false)
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
|
||||
|
||||
useDidMount(() => {
|
||||
RelayController.getInstance()
|
||||
.getTotalZapAmount(
|
||||
modDetails.author,
|
||||
modDetails.id,
|
||||
modDetails.aTag,
|
||||
userState.user?.pubkey as string
|
||||
)
|
||||
.then((res) => {
|
||||
setTotalZappedAmount(res.accumulatedZapAmount)
|
||||
setHasZapped(res.hasZapped)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
id='reactBolt'
|
||||
className={`IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CBolt ${
|
||||
hasZapped ? 'IBMSMSMBSS_D_CBActive' : ''
|
||||
}`}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<div className='IBMSMSMBSS_Details_CardVisual'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-64 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSS_Details_CardVisualIcon'
|
||||
>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='IBMSMSMBSS_Details_CardText'>
|
||||
{abbreviateNumber(totalZappedAmount)}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ZapPopUp
|
||||
title='Tip/Zap'
|
||||
receiver={modDetails.author}
|
||||
eventId={modDetails.id}
|
||||
aTag={modDetails.aTag}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
lastNode={<ZapSite />}
|
||||
notCloseAfterZap
|
||||
setTotalZapAmount={setTotalZappedAmount}
|
||||
setHasZapped={setHasZapped}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ZapSite = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
|
||||
const [amount, setAmount] = useState(0)
|
||||
|
||||
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest>()
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const unformattedValue = unformatNumber(event.target.value)
|
||||
setAmount(unformattedValue)
|
||||
}
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setPaymentRequest(undefined)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
const handleQRExpiry = useCallback(() => {
|
||||
setPaymentRequest(undefined)
|
||||
}, [])
|
||||
|
||||
const generatePaymentRequest =
|
||||
useCallback(async (): Promise<PaymentRequest | null> => {
|
||||
let userHexKey: string
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Getting user pubkey')
|
||||
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
userHexKey = userState.user.pubkey as string
|
||||
} else {
|
||||
userHexKey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
|
||||
if (!userHexKey) {
|
||||
setIsLoading(false)
|
||||
toast.error('Could not get pubkey')
|
||||
return null
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Getting admin metadata')
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
|
||||
const adminMetadata = await metadataController.findAdminMetadata()
|
||||
|
||||
if (!adminMetadata?.lud16) {
|
||||
setIsLoading(false)
|
||||
toast.error('Lighting address (lud16) is missing in admin metadata!')
|
||||
return null
|
||||
}
|
||||
|
||||
if (!adminMetadata?.pubkey) {
|
||||
setIsLoading(false)
|
||||
toast.error('pubkey is missing in admin metadata!')
|
||||
return null
|
||||
}
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
setLoadingSpinnerDesc('Creating zap request')
|
||||
return await zapController
|
||||
.getLightningPaymentRequest(
|
||||
adminMetadata.lud16,
|
||||
amount,
|
||||
adminMetadata.pubkey as string,
|
||||
userHexKey
|
||||
)
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [amount, userState])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const pr = await generatePaymentRequest()
|
||||
|
||||
if (!pr) return
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Sending payment!')
|
||||
|
||||
const zapController = ZapController.getInstance()
|
||||
|
||||
if (await zapController.isWeblnProviderExists()) {
|
||||
await zapController
|
||||
.sendPayment(pr.pr)
|
||||
.then(() => {
|
||||
toast.success(`Successfully sent ${amount} sats!`)
|
||||
handleClose()
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
} else {
|
||||
toast.warn('Webln is not present. Use QR code to send zap.')
|
||||
setPaymentRequest(pr)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, [amount, handleClose, generatePaymentRequest])
|
||||
|
||||
const handleGenerateQRCode = async () => {
|
||||
const pr = await generatePaymentRequest()
|
||||
|
||||
if (!pr) return
|
||||
|
||||
setPaymentRequest(pr)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>
|
||||
Tip DEG Mods too (Optional)
|
||||
</label>
|
||||
<div className='ZapSplitUserBox'>
|
||||
<div className='ZapSplitUserBoxUser'>
|
||||
<div
|
||||
className='ZapSplitUserBoxUserPic'
|
||||
style={{
|
||||
background: `url('/assets/img/Logo%20with%20circle.png')
|
||||
center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
<div className='ZapSplitUserBoxUserDetails'>
|
||||
<p className='ZapSplitUserBoxUserDetailsName'>DEG Mods</p>
|
||||
<p className='ZapSplitUserBoxUserDetailsHandle'>
|
||||
degmods@degmods.com
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className='ZapSplitUserBoxText'>
|
||||
Help with the development, maintenance, management, and growth of
|
||||
DEG Mods.
|
||||
</p>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>Amount (Satoshis)</label>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
inputMode='numeric'
|
||||
placeholder='69 or 420? or 69,420?'
|
||||
value={amount ? formatNumber(amount) : ''}
|
||||
onChange={handleAmountChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='pUMCB_ZapsInsideAmountOptions'>
|
||||
<ZapPresets setAmount={setAmount} />
|
||||
</div>
|
||||
<ZapButtons
|
||||
disabled={!amount}
|
||||
handleGenerateQRCode={handleGenerateQRCode}
|
||||
handleSend={handleSend}
|
||||
/>
|
||||
{paymentRequest && (
|
||||
<ZapQR
|
||||
paymentRequest={paymentRequest}
|
||||
handleClose={handleClose}
|
||||
handleQRExpiry={handleQRExpiry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { Pagination } from 'components/Pagination'
|
||||
import { kinds, nip19 } from 'nostr-tools'
|
||||
import React, {
|
||||
Dispatch,
|
||||
@ -7,19 +8,18 @@ import React, {
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { LoadingSpinner } from '../components/LoadingSpinner'
|
||||
import { ModCard } from '../components/ModCard'
|
||||
import { MOD_FILTER_LIMIT } from '../constants'
|
||||
import { MetadataController } from '../controllers'
|
||||
import { useDidMount } from '../hooks'
|
||||
import { getModsInnerPageRoute } from '../routes'
|
||||
import { useAppSelector, useDidMount, useMuteLists } from '../hooks'
|
||||
import { getModPageRoute } from '../routes'
|
||||
import '../styles/filters.css'
|
||||
import '../styles/pagination.css'
|
||||
import '../styles/search.css'
|
||||
import '../styles/styles.css'
|
||||
import { ModDetails, MuteLists } from '../types'
|
||||
import { ModDetails } from '../types'
|
||||
import { fetchMods } from '../utils'
|
||||
import { MOD_FILTER_LIMIT } from '../constants'
|
||||
|
||||
enum SortBy {
|
||||
Latest = 'Latest',
|
||||
@ -36,7 +36,8 @@ enum NSFWFilter {
|
||||
|
||||
enum ModeratedFilter {
|
||||
Moderated = 'Moderated',
|
||||
Unmoderated = 'Unmoderated'
|
||||
Unmoderated = 'Unmoderated',
|
||||
Unmoderated_Fully = 'Unmoderated Fully'
|
||||
}
|
||||
|
||||
interface FilterOptions {
|
||||
@ -47,7 +48,6 @@ interface FilterOptions {
|
||||
}
|
||||
|
||||
export const ModsPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [mods, setMods] = useState<ModDetails[]>([])
|
||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||
@ -56,23 +56,25 @@ export const ModsPage = () => {
|
||||
source: window.location.host,
|
||||
moderated: ModeratedFilter.Moderated
|
||||
})
|
||||
const [muteLists, setMuteLists] = useState<MuteLists>({
|
||||
authors: [],
|
||||
eventIds: []
|
||||
})
|
||||
const muteLists = useMuteLists()
|
||||
|
||||
const [nsfwList, setNSFWList] = useState<string[]>([])
|
||||
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useDidMount(async () => {
|
||||
const metadataController = await MetadataController.getInstance()
|
||||
metadataController.getAdminsMuteLists().then((lists) => {
|
||||
setMuteLists(lists)
|
||||
|
||||
metadataController.getNSFWList().then((list) => {
|
||||
setNSFWList(list)
|
||||
})
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setIsFetching(true)
|
||||
fetchMods(filterOptions.source)
|
||||
fetchMods({ source: filterOptions.source })
|
||||
.then((res) => {
|
||||
setMods(res)
|
||||
})
|
||||
@ -85,9 +87,12 @@ export const ModsPage = () => {
|
||||
setIsFetching(true)
|
||||
|
||||
const until =
|
||||
mods.length > 0 ? mods[mods.length - 1].edited_at - 1 : undefined
|
||||
mods.length > 0 ? mods[mods.length - 1].published_at - 1 : undefined
|
||||
|
||||
fetchMods(filterOptions.source, until)
|
||||
fetchMods({
|
||||
source: filterOptions.source,
|
||||
until
|
||||
})
|
||||
.then((res) => {
|
||||
setMods(res)
|
||||
setPage((prev) => prev + 1)
|
||||
@ -100,9 +105,12 @@ export const ModsPage = () => {
|
||||
const handlePrev = useCallback(() => {
|
||||
setIsFetching(true)
|
||||
|
||||
const since = mods.length > 0 ? mods[0].edited_at + 1 : undefined
|
||||
const since = mods.length > 0 ? mods[0].published_at + 1 : undefined
|
||||
|
||||
fetchMods(filterOptions.source, undefined, since)
|
||||
fetchMods({
|
||||
source: filterOptions.source,
|
||||
since
|
||||
})
|
||||
.then((res) => {
|
||||
setMods(res)
|
||||
setPage((prev) => prev - 1)
|
||||
@ -118,39 +126,54 @@ export const ModsPage = () => {
|
||||
switch (filterOptions.nsfw) {
|
||||
case NSFWFilter.Hide_NSFW:
|
||||
// If 'Hide_NSFW' is selected, filter out NSFW mods
|
||||
return mods.filter((mod) => !mod.nsfw)
|
||||
return mods.filter((mod) => !mod.nsfw && !nsfwList.includes(mod.aTag))
|
||||
case NSFWFilter.Show_NSFW:
|
||||
// If 'Show_NSFW' is selected, return all mods (no filtering)
|
||||
return mods
|
||||
case NSFWFilter.Only_NSFW:
|
||||
// If 'Only_NSFW' is selected, filter to show only NSFW mods
|
||||
return mods.filter((mod) => mod.nsfw)
|
||||
return mods.filter((mod) => mod.nsfw || nsfwList.includes(mod.aTag))
|
||||
}
|
||||
}
|
||||
|
||||
let filtered = nsfwFilter(mods)
|
||||
|
||||
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||
const isUnmoderatedFully =
|
||||
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
|
||||
|
||||
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
|
||||
if (!(isAdmin && isUnmoderatedFully)) {
|
||||
filtered = filtered.filter(
|
||||
(mod) =>
|
||||
!muteLists.admin.authors.includes(mod.author) &&
|
||||
!muteLists.admin.replaceableEvents.includes(mod.aTag)
|
||||
)
|
||||
}
|
||||
|
||||
if (filterOptions.moderated === ModeratedFilter.Moderated) {
|
||||
filtered = filtered.filter(
|
||||
(mod) =>
|
||||
!muteLists.authors.includes(mod.author) &&
|
||||
!muteLists.eventIds.includes(mod.id)
|
||||
!muteLists.user.authors.includes(mod.author) &&
|
||||
!muteLists.user.replaceableEvents.includes(mod.aTag)
|
||||
)
|
||||
}
|
||||
|
||||
if (filterOptions.sort === SortBy.Latest) {
|
||||
filtered.sort((a, b) => b.edited_at - a.edited_at)
|
||||
filtered.sort((a, b) => b.published_at - a.published_at)
|
||||
} else if (filterOptions.sort === SortBy.Oldest) {
|
||||
filtered.sort((a, b) => a.edited_at - b.edited_at)
|
||||
filtered.sort((a, b) => a.published_at - b.published_at)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [
|
||||
userState.user?.npub,
|
||||
filterOptions.sort,
|
||||
filterOptions.moderated,
|
||||
filterOptions.nsfw,
|
||||
mods,
|
||||
muteLists
|
||||
muteLists,
|
||||
nsfwList
|
||||
])
|
||||
|
||||
return (
|
||||
@ -167,25 +190,26 @@ export const ModsPage = () => {
|
||||
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMList'>
|
||||
{filteredModList.map((mod) => (
|
||||
<ModCard
|
||||
key={mod.id}
|
||||
title={mod.title}
|
||||
summary={mod.summary}
|
||||
backgroundLink={mod.featuredImageUrl}
|
||||
handleClick={() =>
|
||||
navigate(
|
||||
getModsInnerPageRoute(
|
||||
nip19.naddrEncode({
|
||||
identifier: mod.aTag,
|
||||
pubkey: mod.author,
|
||||
kind: kinds.ClassifiedListing
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{filteredModList.map((mod) => {
|
||||
const route = getModPageRoute(
|
||||
nip19.naddrEncode({
|
||||
identifier: mod.aTag,
|
||||
pubkey: mod.author,
|
||||
kind: kinds.ClassifiedListing
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<ModCard
|
||||
key={mod.id}
|
||||
title={mod.title}
|
||||
gameName={mod.game}
|
||||
summary={mod.summary}
|
||||
imageUrl={mod.featuredImageUrl}
|
||||
route={route}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -239,6 +263,8 @@ type FiltersProps = {
|
||||
|
||||
const Filters = React.memo(
|
||||
({ filterOptions, setFilterOptions }: FiltersProps) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
<div className='FiltersMain'>
|
||||
@ -282,20 +308,30 @@ const Filters = React.memo(
|
||||
{filterOptions.moderated}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{Object.values(ModeratedFilter).map((item, index) => (
|
||||
<div
|
||||
key={`moderatedFilterItem-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
moderated: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
{Object.values(ModeratedFilter).map((item, index) => {
|
||||
if (item === ModeratedFilter.Unmoderated_Fully) {
|
||||
const isAdmin =
|
||||
userState.user?.npub ===
|
||||
import.meta.env.VITE_REPORTING_NPUB
|
||||
|
||||
if (!isAdmin) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`moderatedFilterItem-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
moderated: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -370,42 +406,3 @@ const Filters = React.memo(
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
type PaginationProps = {
|
||||
page: number
|
||||
disabledNext: boolean
|
||||
handlePrev: () => void
|
||||
handleNext: () => void
|
||||
}
|
||||
|
||||
const Pagination = React.memo(
|
||||
({ page, disabledNext, handlePrev, handleNext }: PaginationProps) => {
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
<div className='PaginationMain'>
|
||||
<div className='PaginationMainInside'>
|
||||
<button
|
||||
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
|
||||
onClick={handlePrev}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<i className='fas fa-chevron-left'></i>
|
||||
</button>
|
||||
<div className='PaginationMainInsideBoxGroup'>
|
||||
<button className='PaginationMainInsideBox PMIBActive'>
|
||||
<p>{page}</p>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className='PaginationMainInsideBox PaginationMainInsideBoxArrows'
|
||||
onClick={handleNext}
|
||||
disabled={disabledNext}
|
||||
>
|
||||
<i className='fas fa-chevron-right'></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
3
src/pages/profile.tsx
Normal file
3
src/pages/profile.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export const ProfilePage = () => {
|
||||
return <h1>WIP</h1>
|
||||
}
|
586
src/pages/search.tsx
Normal file
586
src/pages/search.tsx
Normal file
@ -0,0 +1,586 @@
|
||||
import { NDKEvent, NDKUserProfile, profileFromEvent } from '@nostr-dev-kit/ndk'
|
||||
import { ErrorBoundary } from 'components/ErrorBoundary'
|
||||
import { GameCard } from 'components/GameCard'
|
||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||
import { ModCard } from 'components/ModCard'
|
||||
import { Pagination } from 'components/Pagination'
|
||||
import { Profile } from 'components/ProfileSection'
|
||||
import {
|
||||
MAX_GAMES_PER_PAGE,
|
||||
MAX_MODS_PER_PAGE,
|
||||
T_TAG_VALUE
|
||||
} from 'constants.ts'
|
||||
import { RelayController } from 'controllers'
|
||||
import { useAppSelector, useGames, useMuteLists } from 'hooks'
|
||||
import { Filter, kinds, nip19 } from 'nostr-tools'
|
||||
import { Subscription } from 'nostr-tools/abstract-relay'
|
||||
import React, {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { getModPageRoute } from 'routes'
|
||||
import { ModDetails, MuteLists } from 'types'
|
||||
import { extractModData, isModDataComplete, log, LogType } from 'utils'
|
||||
|
||||
enum SortByEnum {
|
||||
Latest = 'Latest',
|
||||
Oldest = 'Oldest',
|
||||
Best_Rated = 'Best Rated',
|
||||
Worst_Rated = 'Worst Rated'
|
||||
}
|
||||
|
||||
enum ModeratedFilterEnum {
|
||||
Moderated = 'Moderated',
|
||||
Unmoderated = 'Unmoderated',
|
||||
Unmoderated_Fully = 'Unmoderated Fully'
|
||||
}
|
||||
|
||||
enum SearchingFilterEnum {
|
||||
Mods = 'Mods',
|
||||
Games = 'Games',
|
||||
Users = 'Users'
|
||||
}
|
||||
|
||||
interface FilterOptions {
|
||||
sort: SortByEnum
|
||||
moderated: ModeratedFilterEnum
|
||||
searching: SearchingFilterEnum
|
||||
}
|
||||
|
||||
export const SearchPage = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const muteLists = useMuteLists()
|
||||
const searchTermRef = useRef<HTMLInputElement>(null)
|
||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||
sort: SortByEnum.Latest,
|
||||
moderated: ModeratedFilterEnum.Moderated,
|
||||
searching:
|
||||
(searchParams.get('searching') as SearchingFilterEnum) ||
|
||||
SearchingFilterEnum.Mods
|
||||
})
|
||||
const [searchTerm, setSearchTerm] = useState(
|
||||
searchParams.get('searchTerm') || ''
|
||||
)
|
||||
|
||||
const handleSearch = () => {
|
||||
const value = searchTermRef.current?.value || '' // Access the input value from the ref
|
||||
setSearchTerm(value)
|
||||
}
|
||||
|
||||
// Handle "Enter" key press inside the input
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
||||
<div className='IBMSecMain'>
|
||||
<div className='SearchMainWrapper'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>
|
||||
Search:
|
||||
<span className='IBMSMTitleMainHeadingSpan'>
|
||||
{searchTerm}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className='SearchMain'>
|
||||
<div className='SearchMainInside'>
|
||||
<div className='SearchMainInsideWrapper'>
|
||||
<input
|
||||
type='text'
|
||||
className='SMIWInput'
|
||||
ref={searchTermRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Enter search term'
|
||||
/>
|
||||
<button
|
||||
className='btn btnMain SMIWButton'
|
||||
type='button'
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Filters
|
||||
filterOptions={filterOptions}
|
||||
setFilterOptions={setFilterOptions}
|
||||
/>
|
||||
{filterOptions.searching === SearchingFilterEnum.Mods && (
|
||||
<ModsResult
|
||||
searchTerm={searchTerm}
|
||||
filterOptions={filterOptions}
|
||||
muteLists={muteLists}
|
||||
/>
|
||||
)}
|
||||
{filterOptions.searching === SearchingFilterEnum.Users && (
|
||||
<UsersResult
|
||||
searchTerm={searchTerm}
|
||||
muteLists={muteLists}
|
||||
moderationFilter={filterOptions.moderated}
|
||||
/>
|
||||
)}
|
||||
{filterOptions.searching === SearchingFilterEnum.Games && (
|
||||
<GamesResult searchTerm={searchTerm} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FiltersProps = {
|
||||
filterOptions: FilterOptions
|
||||
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
|
||||
}
|
||||
|
||||
const Filters = React.memo(
|
||||
({ filterOptions, setFilterOptions }: FiltersProps) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
<div className='FiltersMain'>
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
{filterOptions.sort}
|
||||
</button>
|
||||
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{Object.values(SortByEnum).map((item, index) => (
|
||||
<div
|
||||
key={`sortByItem-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
sort: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
{filterOptions.moderated}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{Object.values(ModeratedFilterEnum).map((item, index) => {
|
||||
if (item === ModeratedFilterEnum.Unmoderated_Fully) {
|
||||
const isAdmin =
|
||||
userState.user?.npub ===
|
||||
import.meta.env.VITE_REPORTING_NPUB
|
||||
|
||||
if (!isAdmin) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`moderatedFilterItem-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
moderated: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
Searching: {filterOptions.searching}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{Object.values(SearchingFilterEnum).map((item, index) => (
|
||||
<div
|
||||
key={`searchingFilterItem-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
searching: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
type ModsResultProps = {
|
||||
filterOptions: FilterOptions
|
||||
searchTerm: string
|
||||
muteLists: {
|
||||
admin: MuteLists
|
||||
user: MuteLists
|
||||
}
|
||||
}
|
||||
|
||||
const ModsResult = ({
|
||||
filterOptions,
|
||||
searchTerm,
|
||||
muteLists
|
||||
}: ModsResultProps) => {
|
||||
const hasEffectRun = useRef(false)
|
||||
const [isSubscribing, setIsSubscribing] = useState(false)
|
||||
const [mods, setMods] = useState<ModDetails[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasEffectRun.current) {
|
||||
return
|
||||
}
|
||||
|
||||
hasEffectRun.current = true // Set it so the effect doesn't run again
|
||||
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.ClassifiedListing],
|
||||
'#t': [T_TAG_VALUE]
|
||||
}
|
||||
|
||||
setIsSubscribing(true)
|
||||
|
||||
let subscriptions: Subscription[] = []
|
||||
|
||||
RelayController.getInstance()
|
||||
.subscribeForEvents(filter, [], (event) => {
|
||||
if (isModDataComplete(event)) {
|
||||
const mod = extractModData(event)
|
||||
setMods((prev) => [...prev, mod])
|
||||
}
|
||||
})
|
||||
.then((subs) => {
|
||||
subscriptions = subs
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
'An error occurred in subscribing to relays.',
|
||||
err
|
||||
)
|
||||
toast.error(err.message || err)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSubscribing(false)
|
||||
})
|
||||
|
||||
// Cleanup function to stop all subscriptions
|
||||
return () => {
|
||||
subscriptions.forEach((sub) => sub.close()) // close each subscription
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [searchTerm])
|
||||
|
||||
const filteredMods = useMemo(() => {
|
||||
if (searchTerm === '') return []
|
||||
|
||||
const lowerCaseSearchTerm = searchTerm.toLowerCase()
|
||||
|
||||
const filterFn = (mod: ModDetails) =>
|
||||
mod.title.toLowerCase().includes(lowerCaseSearchTerm) ||
|
||||
mod.game.toLowerCase().includes(lowerCaseSearchTerm) ||
|
||||
mod.summary.toLowerCase().includes(lowerCaseSearchTerm) ||
|
||||
mod.body.toLowerCase().includes(lowerCaseSearchTerm) ||
|
||||
mod.tags.findIndex((tag) =>
|
||||
tag.toLowerCase().includes(lowerCaseSearchTerm)
|
||||
) > -1
|
||||
|
||||
return mods.filter(filterFn)
|
||||
}, [mods, searchTerm])
|
||||
|
||||
const filteredModList = useMemo(() => {
|
||||
let filtered: ModDetails[] = [...filteredMods]
|
||||
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||
const isUnmoderatedFully =
|
||||
filterOptions.moderated === ModeratedFilterEnum.Unmoderated_Fully
|
||||
|
||||
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
|
||||
if (!(isAdmin && isUnmoderatedFully)) {
|
||||
filtered = filtered.filter(
|
||||
(mod) =>
|
||||
!muteLists.admin.authors.includes(mod.author) &&
|
||||
!muteLists.admin.replaceableEvents.includes(mod.aTag)
|
||||
)
|
||||
}
|
||||
|
||||
if (filterOptions.moderated === ModeratedFilterEnum.Moderated) {
|
||||
filtered = filtered.filter(
|
||||
(mod) =>
|
||||
!muteLists.user.authors.includes(mod.author) &&
|
||||
!muteLists.user.replaceableEvents.includes(mod.aTag)
|
||||
)
|
||||
}
|
||||
|
||||
if (filterOptions.sort === SortByEnum.Latest) {
|
||||
filtered.sort((a, b) => b.published_at - a.published_at)
|
||||
} else if (filterOptions.sort === SortByEnum.Oldest) {
|
||||
filtered.sort((a, b) => a.published_at - b.published_at)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [
|
||||
filteredMods,
|
||||
userState.user?.npub,
|
||||
filterOptions.sort,
|
||||
filterOptions.moderated,
|
||||
muteLists
|
||||
])
|
||||
|
||||
const handleNext = () => {
|
||||
setPage((prev) => prev + 1)
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
setPage((prev) => prev - 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSubscribing && (
|
||||
<LoadingSpinner desc='Subscribing to relays for mods' />
|
||||
)}
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMList'>
|
||||
{filteredModList
|
||||
.slice((page - 1) * MAX_MODS_PER_PAGE, page * MAX_MODS_PER_PAGE)
|
||||
.map((mod) => {
|
||||
const route = getModPageRoute(
|
||||
nip19.naddrEncode({
|
||||
identifier: mod.aTag,
|
||||
pubkey: mod.author,
|
||||
kind: kinds.ClassifiedListing
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<ModCard
|
||||
key={mod.id}
|
||||
title={mod.title}
|
||||
gameName={mod.game}
|
||||
summary={mod.summary}
|
||||
imageUrl={mod.featuredImageUrl}
|
||||
route={route}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
disabledNext={filteredModList.length <= page * MAX_MODS_PER_PAGE}
|
||||
handlePrev={handlePrev}
|
||||
handleNext={handleNext}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type UsersResultProps = {
|
||||
searchTerm: string
|
||||
moderationFilter: ModeratedFilterEnum
|
||||
muteLists: {
|
||||
admin: MuteLists
|
||||
user: MuteLists
|
||||
}
|
||||
}
|
||||
|
||||
const UsersResult = ({
|
||||
searchTerm,
|
||||
moderationFilter,
|
||||
muteLists
|
||||
}: UsersResultProps) => {
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [profiles, setProfiles] = useState<NDKUserProfile[]>([])
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTerm === '') {
|
||||
setProfiles([])
|
||||
} else {
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.Metadata],
|
||||
search: searchTerm
|
||||
}
|
||||
|
||||
setIsFetching(true)
|
||||
RelayController.getInstance()
|
||||
.fetchEvents(filter, ['wss://purplepag.es', 'wss://user.kindpag.es'])
|
||||
.then((events) => {
|
||||
const results = events.map((event) => {
|
||||
const ndkEvent = new NDKEvent(undefined, event)
|
||||
const profile = profileFromEvent(ndkEvent)
|
||||
return profile
|
||||
})
|
||||
setProfiles(results)
|
||||
})
|
||||
.catch((err) => {
|
||||
log(true, LogType.Error, 'An error occurred in fetching users', err)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false)
|
||||
})
|
||||
}
|
||||
}, [searchTerm])
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
let filtered = [...profiles]
|
||||
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||
const isUnmoderatedFully =
|
||||
moderationFilter === ModeratedFilterEnum.Unmoderated_Fully
|
||||
|
||||
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
|
||||
if (!(isAdmin && isUnmoderatedFully)) {
|
||||
filtered = filtered.filter(
|
||||
(profile) => !muteLists.admin.authors.includes(profile.pubkey as string)
|
||||
)
|
||||
}
|
||||
|
||||
if (moderationFilter === ModeratedFilterEnum.Moderated) {
|
||||
filtered = filtered.filter(
|
||||
(profile) => !muteLists.user.authors.includes(profile.pubkey as string)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [userState.user?.npub, moderationFilter, profiles, muteLists])
|
||||
return (
|
||||
<>
|
||||
{isFetching && <LoadingSpinner desc='Fetching Profiles' />}
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMList'>
|
||||
{filteredProfiles.map((profile) => {
|
||||
if (profile.pubkey) {
|
||||
return (
|
||||
<ErrorBoundary key={profile.pubkey}>
|
||||
<Profile profile={profile} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type GamesResultProps = {
|
||||
searchTerm: string
|
||||
}
|
||||
|
||||
const GamesResult = ({ searchTerm }: GamesResultProps) => {
|
||||
const games = useGames()
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// Reset the page to 1 whenever searchTerm changes
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [searchTerm])
|
||||
|
||||
const filteredGames = useMemo(() => {
|
||||
if (searchTerm === '') return []
|
||||
|
||||
const lowerCaseSearchTerm = searchTerm.toLowerCase()
|
||||
|
||||
return games.filter((game) =>
|
||||
game['Game Name'].toLowerCase().includes(lowerCaseSearchTerm)
|
||||
)
|
||||
}, [searchTerm, games])
|
||||
|
||||
const handleNext = () => {
|
||||
setPage((prev) => prev + 1)
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
setPage((prev) => prev - 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMList IBMSMListFeaturedAlt'>
|
||||
{filteredGames
|
||||
.slice((page - 1) * MAX_GAMES_PER_PAGE, page * MAX_GAMES_PER_PAGE)
|
||||
.map((game) => (
|
||||
<GameCard
|
||||
key={game['Game Name']}
|
||||
title={game['Game Name']}
|
||||
imageUrl={game['Boxart image']}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
disabledNext={filteredGames.length <= page * MAX_GAMES_PER_PAGE}
|
||||
handlePrev={handlePrev}
|
||||
handleNext={handleNext}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
@ -14,9 +14,12 @@ import '../styles/settings.css'
|
||||
import '../styles/styles.css'
|
||||
import '../styles/write.css'
|
||||
import { copyTextToClipboard } from '../utils'
|
||||
import { MetadataController } from '../controllers'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation()
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
return (
|
||||
<div className='InnerBodyMain'>
|
||||
@ -34,7 +37,9 @@ export const SettingsPage = () => {
|
||||
<PreferencesSetting />
|
||||
)}
|
||||
{location.pathname === appRoutes.settingsAdmin && <AdminSetting />}
|
||||
<ProfileSection />
|
||||
{userState.auth && userState.user?.pubkey && (
|
||||
<ProfileSection pubkey={userState.user.pubkey as string} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -44,8 +49,21 @@ export const SettingsPage = () => {
|
||||
|
||||
const SettingTabs = () => {
|
||||
const location = useLocation()
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
useEffect(() => {
|
||||
MetadataController.getInstance().then((controller) => {
|
||||
if (userState.auth && userState.user?.npub) {
|
||||
setIsAdmin(
|
||||
controller.adminNpubs.includes(userState.user.npub as string)
|
||||
)
|
||||
} else {
|
||||
setIsAdmin(false)
|
||||
}
|
||||
})
|
||||
}, [userState])
|
||||
|
||||
const handleSignOut = () => {
|
||||
logout()
|
||||
}
|
||||
@ -117,26 +135,28 @@ const SettingTabs = () => {
|
||||
</svg>
|
||||
Preference
|
||||
</Link>
|
||||
<Link
|
||||
className={`btn btnMain btnMainAltText btnMainClear ${
|
||||
location.pathname === appRoutes.settingsAdmin
|
||||
? 'btnMainClearActive'
|
||||
: ''
|
||||
}`}
|
||||
role='button'
|
||||
to={appRoutes.settingsAdmin}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -32 576 576'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
{isAdmin && (
|
||||
<Link
|
||||
className={`btn btnMain btnMainAltText btnMainClear ${
|
||||
location.pathname === appRoutes.settingsAdmin
|
||||
? 'btnMainClearActive'
|
||||
: ''
|
||||
}`}
|
||||
role='button'
|
||||
to={appRoutes.settingsAdmin}
|
||||
>
|
||||
<path d='M560 448H512V113.5c0-27.25-21.5-49.5-48-49.5L352 64.01V128h96V512h112c8.875 0 16-7.125 16-15.1v-31.1C576 455.1 568.9 448 560 448zM280.3 1.007l-192 49.75C73.1 54.51 64 67.76 64 82.88V448H16c-8.875 0-16 7.125-16 15.1v31.1C0 504.9 7.125 512 16 512H320V33.13C320 11.63 300.5-4.243 280.3 1.007zM232 288c-13.25 0-24-14.37-24-31.1c0-17.62 10.75-31.1 24-31.1S256 238.4 256 256C256 273.6 245.3 288 232 288z'></path>
|
||||
</svg>
|
||||
Admin
|
||||
</Link>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -32 576 576'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M560 448H512V113.5c0-27.25-21.5-49.5-48-49.5L352 64.01V128h96V512h112c8.875 0 16-7.125 16-15.1v-31.1C576 455.1 568.9 448 560 448zM280.3 1.007l-192 49.75C73.1 54.51 64 67.76 64 82.88V448H16c-8.875 0-16 7.125-16 15.1v31.1C0 504.9 7.125 512 16 512H320V33.13C320 11.63 300.5-4.243 280.3 1.007zM232 288c-13.25 0-24-14.37-24-31.1c0-17.62 10.75-31.1 24-31.1S256 238.4 256 256C256 273.6 245.3 288 232 288z'></path>
|
||||
</svg>
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userState.auth &&
|
||||
@ -268,9 +288,7 @@ const ProfileSettings = () => {
|
||||
<path d='M144 32C170.5 32 192 53.49 192 80V176C192 202.5 170.5 224 144 224H48C21.49 224 0 202.5 0 176V80C0 53.49 21.49 32 48 32H144zM128 96H64V160H128V96zM144 288C170.5 288 192 309.5 192 336V432C192 458.5 170.5 480 144 480H48C21.49 480 0 458.5 0 432V336C0 309.5 21.49 288 48 288H144zM128 352H64V416H128V352zM256 80C256 53.49 277.5 32 304 32H400C426.5 32 448 53.49 448 80V176C448 202.5 426.5 224 400 224H304C277.5 224 256 202.5 256 176V80zM320 160H384V96H320V160zM352 448H384V480H352V448zM448 480H416V448H448V480zM416 288H448V416H352V384H320V480H256V288H352V320H416V288z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
>
|
||||
<div className='IBMSMSMSSS_Author_Top_IconWrapped'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
@ -279,14 +297,15 @@ const ProfileSettings = () => {
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d="M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z"></path>
|
||||
<path d='M240.5 224H352C365.3 224 377.3 232.3 381.1 244.7C386.6 257.2 383.1 271.3 373.1 280.1L117.1 504.1C105.8 513.9 89.27 514.7 77.19 505.9C65.1 497.1 60.7 481.1 66.59 467.4L143.5 288H31.1C18.67 288 6.733 279.7 2.044 267.3C-2.645 254.8 .8944 240.7 10.93 231.9L266.9 7.918C278.2-1.92 294.7-2.669 306.8 6.114C318.9 14.9 323.3 30.87 317.4 44.61L240.5 224z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_Details'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Bio'>
|
||||
user bio, this is a long string of temporary text that would be replaced with the user bio from their metada address
|
||||
user bio, this is a long string of temporary text that would
|
||||
be replaced with the user bio from their metada address
|
||||
</p>
|
||||
<div
|
||||
id='OwnerFollowLogin-1'
|
||||
|
@ -11,7 +11,7 @@ import { ModDetails } from '../types'
|
||||
import { toast } from 'react-toastify'
|
||||
import { useState } from 'react'
|
||||
import { LoadingSpinner } from '../components/LoadingSpinner'
|
||||
import { useDidMount } from '../hooks'
|
||||
import { useAppSelector, useDidMount } from '../hooks'
|
||||
|
||||
export const SubmitModPage = () => {
|
||||
const location = useLocation()
|
||||
@ -19,6 +19,8 @@ export const SubmitModPage = () => {
|
||||
const [modData, setModData] = useState<ModDetails>()
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const title = location.pathname.startsWith('/edit-mod')
|
||||
? 'Edit Mod'
|
||||
: 'Submit a mod'
|
||||
@ -74,7 +76,9 @@ export const SubmitModPage = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ProfileSection />
|
||||
{userState.auth && userState.user?.pubkey && (
|
||||
<ProfileSection pubkey={userState.user.pubkey as string} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,10 +1,13 @@
|
||||
import { CheckboxField, InputField } from '../components/Inputs'
|
||||
import { ProfileSection } from '../components/ProfileSection'
|
||||
import { useAppSelector } from '../hooks'
|
||||
import '../styles/innerPage.css'
|
||||
import '../styles/styles.css'
|
||||
import '../styles/write.css'
|
||||
|
||||
export const WritePage = () => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
return (
|
||||
<div className='InnerBodyMain'>
|
||||
<div className='ContainerMain'>
|
||||
@ -12,7 +15,9 @@ export const WritePage = () => {
|
||||
<div className='IBMSMSplitMain'>
|
||||
<div className='IBMSMSplitMainBigSide'>
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>Write a blog post (WIP)</h2>
|
||||
<h2 className='IBMSMTitleMainHeading'>
|
||||
Write a blog post (WIP)
|
||||
</h2>
|
||||
</div>
|
||||
<div className='IBMSMSMBS_Write'>
|
||||
<InputField
|
||||
@ -58,7 +63,9 @@ export const WritePage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileSection />
|
||||
{userState.auth && userState.user?.pubkey && (
|
||||
<ProfileSection pubkey={userState.user.pubkey as string} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,36 +1,48 @@
|
||||
import { SearchPage } from 'pages/search'
|
||||
import { AboutPage } from '../pages/about'
|
||||
import { BlogsPage } from '../pages/blogs'
|
||||
import { GamesPage } from '../pages/games'
|
||||
import { HomePage } from '../pages/home'
|
||||
import { InnerModPage } from '../pages/innerMod'
|
||||
import { ModPage } from '../pages/mod'
|
||||
import { ModsPage } from '../pages/mods'
|
||||
import { ProfilePage } from '../pages/profile'
|
||||
import { SettingsPage } from '../pages/settings'
|
||||
import { SubmitModPage } from '../pages/submitMod'
|
||||
import { WritePage } from '../pages/write'
|
||||
import { GamePage } from 'pages/game'
|
||||
|
||||
export const appRoutes = {
|
||||
index: '/',
|
||||
home: '/home',
|
||||
games: '/games',
|
||||
game: '/game/:name',
|
||||
mods: '/mods',
|
||||
modsInner: '/mods-inner/:naddr',
|
||||
mod: '/mod/:naddr',
|
||||
about: '/about',
|
||||
blog: '/blog',
|
||||
submitMod: '/submit-mod',
|
||||
editMod: '/edit-mod/:naddr',
|
||||
write: '/write',
|
||||
search: '/search',
|
||||
settingsProfile: '/settings-profile',
|
||||
settingsRelays: '/settings-relays',
|
||||
settingsPreferences: '/settings-preferences',
|
||||
settingsAdmin: '/settings-admin'
|
||||
settingsAdmin: '/settings-admin',
|
||||
profile: '/profile/:nprofile'
|
||||
}
|
||||
|
||||
export const getModsInnerPageRoute = (eventId: string) =>
|
||||
appRoutes.modsInner.replace(':naddr', eventId)
|
||||
export const getGamePageRoute = (name: string) =>
|
||||
appRoutes.game.replace(':name', name)
|
||||
|
||||
export const getModPageRoute = (eventId: string) =>
|
||||
appRoutes.mod.replace(':naddr', eventId)
|
||||
|
||||
export const getModsEditPageRoute = (eventId: string) =>
|
||||
appRoutes.editMod.replace(':naddr', eventId)
|
||||
|
||||
export const getProfilePageRoute = (nprofile: string) =>
|
||||
appRoutes.profile.replace(':nprofile', nprofile)
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: appRoutes.index,
|
||||
@ -44,13 +56,17 @@ export const routes = [
|
||||
path: appRoutes.games,
|
||||
element: <GamesPage />
|
||||
},
|
||||
{
|
||||
path: appRoutes.game,
|
||||
element: <GamePage />
|
||||
},
|
||||
{
|
||||
path: appRoutes.mods,
|
||||
element: <ModsPage />
|
||||
},
|
||||
{
|
||||
path: appRoutes.modsInner,
|
||||
element: <InnerModPage />
|
||||
path: appRoutes.mod,
|
||||
element: <ModPage />
|
||||
},
|
||||
{
|
||||
path: appRoutes.about,
|
||||
@ -72,6 +88,10 @@ export const routes = [
|
||||
path: appRoutes.write,
|
||||
element: <WritePage />
|
||||
},
|
||||
{
|
||||
path: appRoutes.search,
|
||||
element: <SearchPage />
|
||||
},
|
||||
{
|
||||
path: appRoutes.settingsProfile,
|
||||
element: <SettingsPage />
|
||||
@ -87,5 +107,9 @@ export const routes = [
|
||||
{
|
||||
path: appRoutes.settingsAdmin,
|
||||
element: <SettingsPage />
|
||||
},
|
||||
{
|
||||
path: appRoutes.profile,
|
||||
element: <ProfilePage />
|
||||
}
|
||||
]
|
||||
|
@ -1,6 +1,6 @@
|
||||
.swiper-pagination-bullet-active {
|
||||
background: rgba(255,255,255,0.5);
|
||||
box-shadow: 0 0 4px 0 rgba(0,0,0,0.5);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.simple-slider .swiper-slide {
|
||||
@ -22,16 +22,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
.simple-slider .swiper-button-next, .simple-slider .swiper-button-prev {
|
||||
.simple-slider .swiper-button-next,
|
||||
.simple-slider .swiper-button-prev {
|
||||
width: 50px;
|
||||
margin-left: 00px;
|
||||
margin-right: 00px;
|
||||
color: rgba(255,255,255,0.5);
|
||||
background: linear-gradient(rgba(255,255,255,0.05), rgba(255,255,255,0.05)), linear-gradient(to top right, #262626, #292929, #262626), linear-gradient(to top right, #262626, #292929, #262626);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background: linear-gradient(
|
||||
rgba(255, 255, 255, 0.05),
|
||||
rgba(255, 255, 255, 0.05)
|
||||
),
|
||||
linear-gradient(to top right, #262626, #292929, #262626),
|
||||
linear-gradient(to top right, #262626, #292929, #262626);
|
||||
padding: 10px;
|
||||
height: 75px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
|
||||
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@ -39,19 +45,27 @@
|
||||
margin-top: -35px;
|
||||
}
|
||||
|
||||
.simple-slider .swiper-button-next:hover, .simple-slider .swiper-button-prev:hover {
|
||||
background: linear-gradient(rgba(255,255,255,0.1), rgba(255,255,255,0.1)), linear-gradient(to top right, #262626, #292929, #262626), linear-gradient(to top right, #262626, #292929, #262626);
|
||||
.simple-slider .swiper-button-next:hover,
|
||||
.simple-slider .swiper-button-prev:hover {
|
||||
background: linear-gradient(
|
||||
rgba(255, 255, 255, 0.1),
|
||||
rgba(255, 255, 255, 0.1)
|
||||
),
|
||||
linear-gradient(to top right, #262626, #292929, #262626),
|
||||
linear-gradient(to top right, #262626, #292929, #262626);
|
||||
}
|
||||
|
||||
.swiper-button-next:after, .swiper-button-prev:after {
|
||||
font-size: 18px;
|
||||
.swiper-button-next:after,
|
||||
.swiper-button-prev:after {
|
||||
font-size: 18px!important;
|
||||
}
|
||||
|
||||
@media (max-width:992px) {
|
||||
.simple-slider .swiper-button-next, .simple-slider .swiper-button-prev {
|
||||
@media (max-width: 992px) {
|
||||
.simple-slider .swiper-button-next,
|
||||
.simple-slider .swiper-button-prev {
|
||||
bottom: 0;
|
||||
top: unset;
|
||||
width: 48%;
|
||||
width: 45%;
|
||||
height: unset;
|
||||
padding: 10px;
|
||||
}
|
||||
@ -88,14 +102,17 @@
|
||||
@media (max-width: 992px) {
|
||||
.swiper-slide.IBMSMSliderContainerWrapperSlider {
|
||||
grid-template-columns: 1.15fr 0.85fr;
|
||||
padding: 0 0 25px 0;
|
||||
padding: 0 5px 25px 5px;
|
||||
grid-gap: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.swiper-slide.IBMSMSliderContainerWrapperSlider {
|
||||
grid-template-columns: 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 15px;
|
||||
height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,7 +132,12 @@
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
background: linear-gradient(rgba(255,255,255,0.15), rgba(255,255,255,0.15)), linear-gradient(to top right, #262626, #292929, #262626), linear-gradient(to top right, #262626, #292929, #262626);
|
||||
background: linear-gradient(
|
||||
rgba(255, 255, 255, 0.15),
|
||||
rgba(255, 255, 255, 0.15)
|
||||
),
|
||||
linear-gradient(to top right, #262626, #292929, #262626),
|
||||
linear-gradient(to top right, #262626, #292929, #262626);
|
||||
z-index: -1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
@ -129,12 +151,15 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.swiper-container-horizontal > .swiper-pagination-bullets, .swiper-pagination-custom, .swiper-pagination-fraction {
|
||||
.swiper-container-horizontal > .swiper-pagination-bullets,
|
||||
.swiper-pagination-custom,
|
||||
.swiper-pagination-fraction {
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.swiper-button-next, .swiper-button-prev {
|
||||
.swiper-button-next,
|
||||
.swiper-button-prev {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@ -161,23 +186,41 @@
|
||||
.SliderWrapper {
|
||||
width: 100%;
|
||||
padding: 50px 0;
|
||||
background: rgba(0,0,0,0.1);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: -25px 0 0 0;
|
||||
border-bottom: solid 1px rgba(255,255,255,0.05);
|
||||
border-bottom: solid 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.IBMSMSCWSPic {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: solid 1px rgba(255,255,255,0.05);
|
||||
padding-top: 50%;
|
||||
border: solid 1px rgba(255, 255, 255, 0.05);
|
||||
z-index: 1;
|
||||
box-shadow: 0 0 8px 0 rgba(0,0,0,0.25);
|
||||
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.25);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; /* Ensures the image covers the container like a background image */
|
||||
}
|
||||
|
||||
.IBMSMSCWSPicWrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.IBMSMSCWSPicWrapper {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.IBMSMSCWSInfo {
|
||||
@ -187,14 +230,20 @@
|
||||
justify-content: center;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(rgba(255,255,255,0), rgba(255,255,255,0)), linear-gradient(to top right, rgb(38,38,38), rgb(41,41,41), rgb(38,38,38));
|
||||
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
|
||||
border: solid 1px rgba(255,255,255,0.05);
|
||||
background: linear-gradient(rgba(255, 255, 255, 0), rgba(255, 255, 255, 0)),
|
||||
linear-gradient(
|
||||
to top right,
|
||||
rgb(38, 38, 38),
|
||||
rgb(41, 41, 41),
|
||||
rgb(38, 38, 38)
|
||||
);
|
||||
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
|
||||
border: solid 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.IBMSMSCWSInfo {
|
||||
/*margin: -25px 10px 0 10px;*/
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -212,19 +261,22 @@
|
||||
-webkit-line-clamp: 2;
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
color: rgba(255,255,255,0.75);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.IBMSMSCWSInfoTextWrapper {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.IBMSMSCWSInfoText {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 8;
|
||||
color: rgba(255,255,255,0.5);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
@ -234,7 +286,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.IBMSMSCWSInfoText.IBMSMSCWSInfoText2 {
|
||||
-webkit-line-clamp: 1;
|
||||
border-top: solid 1px rgba(255,255,255,0.1);
|
||||
padding: 10px 0 0 5px;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.swiper-pagination {
|
||||
display: none;
|
||||
bottom: -10px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
@ -244,7 +305,7 @@
|
||||
}
|
||||
|
||||
.swiper-pagination-bullet {
|
||||
background: rgba(0,0,0,0.5);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 1;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
@ -252,6 +313,5 @@
|
||||
}
|
||||
|
||||
.swiper-pagination-bullet.swiper-pagination-bullet-active {
|
||||
background: rgba(128,0,255,0.5);
|
||||
background: rgba(128, 0, 255, 0.5);
|
||||
}
|
||||
|
||||
|
@ -56,6 +56,8 @@
|
||||
}
|
||||
|
||||
.IBMSMSMSSS_Author_Top_Icon {
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.IBMSMSMSSS_Author_Top_Address {
|
||||
@ -143,10 +145,11 @@
|
||||
}
|
||||
|
||||
.IBMSMSMSSS_Author_Top_Name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: rgba(255,255,255,0.75);
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.IBMSMSMSSS_Author_Top_PPWrapper {
|
||||
@ -158,6 +161,15 @@
|
||||
|
||||
.IBMSMSMSSS_Author_Top_Handle {
|
||||
color: rgba(255,255,255,0.5);
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.IBMSMSMSSS_Author_Top_Handle.IBMSMSMSSS_Author_Top_HandleNomen {
|
||||
color: #F7931A;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.IBMSMSMSSS_Author_Top_NostrLinksLink.IBMSMSMSSS_A_T_NLL_IBMSMSMSSSFollow {
|
||||
|
@ -26,6 +26,7 @@
|
||||
}
|
||||
|
||||
.cardBlogMainInside {
|
||||
transition: ease 0.4s;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
@ -36,5 +37,12 @@
|
||||
justify-content: end;
|
||||
align-items: start;
|
||||
padding: 15px;
|
||||
background: linear-gradient(rgb(0 0 0 / 0%) 0%, rgb(0 0 0 / 75%) 100%);
|
||||
}
|
||||
|
||||
.cardBlogMainInside:hover {
|
||||
transition: ease 0.4s;
|
||||
background: linear-gradient(rgb(0 0 0 / 35%) 0%, rgb(0 0 0 / 85%) 100%);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
|
@ -17,16 +17,24 @@
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.cardGameMain {
|
||||
.cardGameMainWrapper {
|
||||
position: relative;
|
||||
padding-top: 150%;
|
||||
}
|
||||
|
||||
.cardGameMain {
|
||||
border-radius: 15px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
|
||||
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
object-fit: cover; /* Ensures the image covers the container like a background image */
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.cardGameMainTitle {
|
||||
transition: ease 0.4s;
|
||||
color: rgba(255,255,255,0.5);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
padding: 0 15px;
|
||||
font-weight: bold;
|
||||
display: -webkit-box;
|
||||
@ -35,5 +43,5 @@
|
||||
-webkit-line-clamp: 1;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
@ -9,11 +9,18 @@
|
||||
background: linear-gradient(to top right, #262626, #292929, #262626);
|
||||
}
|
||||
|
||||
.cMMPicture {
|
||||
.cMMPictureWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 56.25%;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.cMMPicture {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
object-fit: cover; /* Ensures the image covers the container like a background image */
|
||||
}
|
||||
|
||||
.cMMBody {
|
||||
@ -99,12 +106,26 @@
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-line-clamp: 2;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cMMBodyGame {
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
flex-direction: row;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.cMMFootReactions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
@ -31,7 +31,7 @@
|
||||
border-radius: 10px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
|
||||
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CommentTopDetails {
|
||||
@ -42,11 +42,13 @@
|
||||
|
||||
.IBMSMSMBSSCL_CommentBottom {
|
||||
padding: 20px;
|
||||
color: rgba(255,255,255,0.75);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: linear-gradient(to top right, #262626, #292929, #262626);
|
||||
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
|
||||
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
|
||||
border-radius: 10px;
|
||||
/*border: solid 1px rgba(255,255,255,0.1);*/
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 5px;
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CommentTopPPWrapper {
|
||||
@ -59,6 +61,20 @@
|
||||
.IBMSMSMBSSCL_CBText {
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CBTextStatus {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
grid-gap: 0px;
|
||||
border-radius: 4px;
|
||||
border: solid 1px rgba(255, 255, 255, 0.1);
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CBTextStatusSpan {
|
||||
font-weight: 600;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CommentActions {
|
||||
margin: -10px 0 0 0;
|
||||
display: grid;
|
||||
@ -83,7 +99,7 @@
|
||||
grid-gap: 10px;
|
||||
padding: 5px 15px;
|
||||
border-radius: 10px;
|
||||
color: rgba(255,255,255,0.25);
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-weight: bold;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@ -118,20 +134,20 @@
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
|
||||
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElementText {
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElementIcon {
|
||||
background: rgba(255,255,255,0);
|
||||
background: rgba(255, 255, 255, 0);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CTD_Name {
|
||||
font-weight: bold;
|
||||
color: rgba(255,255,255,0.5);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
@ -139,7 +155,7 @@
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CTD_Address {
|
||||
color: rgba(255,255,255,0.25);
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
@ -147,42 +163,42 @@
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEReply {
|
||||
border: solid 1px rgba(255,255,255,0.05);
|
||||
border: solid 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEReply:hover {
|
||||
transition: ease 0.4s;
|
||||
border: solid 1px rgba(255,255,255,0.05);
|
||||
color: rgba(255,255,255,0.5);
|
||||
border: solid 1px rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEReplies:hover {
|
||||
transition: ease 0.4s;
|
||||
color: rgba(173,90,255,0.75);
|
||||
color: rgba(173, 90, 255, 0.75);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAERepost.IBMSMSMBSSCL_CAERepostActive {
|
||||
color: rgba(255,255,255,0.75);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAERepost:hover {
|
||||
transition: ease 0.4s;
|
||||
color: rgba(255,255,255,0.75);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEDown:hover {
|
||||
transition: ease 0.4s;
|
||||
color: rgba(255,114,54,0.85);
|
||||
color: rgba(255, 114, 54, 0.85);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEUp:hover {
|
||||
transition: ease 0.4s;
|
||||
color: rgba(255,70,70,0.85);
|
||||
color: rgba(255, 70, 70, 0.85);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEBolt:hover {
|
||||
transition: ease 0.4s;
|
||||
color: rgba(255,255,0,0.85);
|
||||
color: rgba(255, 255, 0, 0.85);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElement:hover {
|
||||
@ -196,11 +212,15 @@
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEUp.IBMSMSMBSSCL_CAEUpActive {
|
||||
color: rgba(255,70,70,0.85);
|
||||
color: rgba(255, 70, 70, 0.85);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEDown.IBMSMSMBSSCL_CAEDownActive {
|
||||
color: rgba(255, 114, 54, 0.85);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAElement.IBMSMSMBSSCL_CAEBolt.IBMSMSMBSSCL_CAEBoltActive {
|
||||
color: rgba(255,255,0,0.85);
|
||||
color: rgba(255, 255, 0, 0.85);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CommentActionsInside {
|
||||
@ -212,7 +232,7 @@
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CommentActionsDetails {
|
||||
color: rgba(255,255,255,0.25);
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -233,12 +253,12 @@
|
||||
|
||||
.IBMSMSMBSSCL_CADDate {
|
||||
transition: ease 0.4s;
|
||||
color: rgba(255,255,255,0.25);
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CADTime {
|
||||
transition: ease 0.4s;
|
||||
color: rgba(255,255,255,0.25);
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CommentTopDetailsWrapper {
|
||||
@ -277,16 +297,16 @@
|
||||
.IBMSMSMBSSCC_Top_Box {
|
||||
transition: border, background, box-shadow ease 0.4s;
|
||||
width: 100%;
|
||||
background: rgba(0,0,0,0.05);
|
||||
border: solid 1px rgba(255,255,255,0.05);
|
||||
box-shadow: inset 0 0 8px 0 rgb(0,0,0,0.1);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border: solid 1px rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset 0 0 8px 0 rgb(0, 0, 0, 0.1);
|
||||
border-radius: 10px;
|
||||
min-height: 100px;
|
||||
height: 100px;
|
||||
min-width: 100%;
|
||||
outline: unset;
|
||||
padding: 15px 20px;
|
||||
color: rgba(255,255,255,0.75);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
@ -296,36 +316,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCC_Top_Box:focus, hover {
|
||||
.IBMSMSMBSSCC_Top_Box:focus,
|
||||
hover {
|
||||
transition: border, background, box-shadow ease 0.4s;
|
||||
background: rgba(0,0,0,0.1);
|
||||
border: solid 1px rgba(255,255,255,0.1);
|
||||
box-shadow: inset 0 0 8px 0 rgb(0,0,0,0.15);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border: solid 1px rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 0 8px 0 rgb(0, 0, 0, 0.15);
|
||||
outline: unset;
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCC_BottomButton {
|
||||
transition: ease 0.4s;
|
||||
text-decoration: unset;
|
||||
color: rgba(255,255,255,0.25);
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-weight: bold;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 8px 0 rgba(0,0,0,0);
|
||||
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0);
|
||||
font-size: 16px;
|
||||
transform: scale(1);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border: solid 1px rgba(255,255,255,0.1);
|
||||
border: solid 1px rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCC_BottomButton:hover {
|
||||
transition: ease 0.4s;
|
||||
text-decoration: unset;
|
||||
color: rgba(255,255,255,0.75);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
|
||||
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
|
||||
font-size: 16px;
|
||||
transform: scale(1.03);
|
||||
/*border: solid 1px rgba(255,255,255,0);*/
|
||||
@ -370,9 +391,9 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-radius: 10px;
|
||||
border: solid 1px rgba(255,255,255,0.1);
|
||||
border: solid 1px rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
color: rgba(255,255,255,0.25);
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@ -389,14 +410,14 @@
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: rgba(255,255,255,0.25);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CTOLink:hover {
|
||||
transition: ease 0.4s;
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: rgba(255,255,255,0.5);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CTOLink:active > .IBMSMSMBSSCL_CTOLinkIcon {
|
||||
@ -439,7 +460,7 @@
|
||||
}
|
||||
|
||||
.btnMain.CommentsToggleBtn.CommentsToggleActive {
|
||||
background: rgba(255,255,255,0.1);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@ -450,11 +471,11 @@
|
||||
}
|
||||
|
||||
.IBMSMSMBSSTitle {
|
||||
color: rgba(255,255,255,0.5);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CommentNoteRepliesTitle {
|
||||
color: rgba(255,255,255,0.5);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElementLoadWrapper {
|
||||
@ -468,7 +489,7 @@
|
||||
}
|
||||
|
||||
.IBMSMSMBSSCL_CAElementLoad {
|
||||
background: rgba(255,255,255,0.5);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
@ -476,4 +497,3 @@
|
||||
padding: 5px 10px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,7 @@
|
||||
grid-gap: 25px;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
@media (max-width: 1200px) {
|
||||
.IBMSMSplitMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -281,7 +281,7 @@
|
||||
|
||||
.NavMainBottomInsideOther {
|
||||
display: flex;
|
||||
lex-direction: row;
|
||||
flex-direction: row;
|
||||
grid-gap: 10px;
|
||||
height: 100%;
|
||||
justify-content: end;
|
||||
@ -308,6 +308,8 @@
|
||||
}
|
||||
|
||||
.NavMainBottomInsideOtherLeft {
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
@ -315,7 +317,8 @@
|
||||
}
|
||||
|
||||
.NavMainBottomInsideOtherRight {
|
||||
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -43,6 +43,7 @@
|
||||
border: solid 1px rgba(255, 255, 255, 0);
|
||||
color: rgba(255, 255, 255, 0.1);
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.PaginationMainInsideBox.PaginationMainInsideBoxArrows {
|
||||
@ -93,8 +94,15 @@
|
||||
.PaginationMainInsideBoxGroup {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
justify-content: start;
|
||||
grid-gap: 10px;
|
||||
overflow: auto;
|
||||
max-width: 470px;
|
||||
height: 47px;
|
||||
}
|
||||
|
||||
.PaginationMainInsideBoxGroup::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@ -102,5 +110,6 @@
|
||||
width: 100%;
|
||||
order: 1;
|
||||
justify-content: space-around;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
@ -53,7 +53,6 @@
|
||||
align-items: center;
|
||||
padding: 25px;
|
||||
overflow: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.popUpMainCardBottomQR {
|
||||
|
@ -40,6 +40,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.IBMSMSMBSSPostBody > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.IBMSMSMBSSPostTitleHeading {
|
||||
width: 100%;
|
||||
}
|
||||
|
121
src/styles/socialNav.css
Normal file
121
src/styles/socialNav.css
Normal file
@ -0,0 +1,121 @@
|
||||
.socialNav {
|
||||
transition: ease 0.4s;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
right: 50%;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.socialNav {
|
||||
transition: ease 0.4s;
|
||||
right: 100%;
|
||||
transform: translateX(100%);
|
||||
width: 100%;
|
||||
align-items: end;
|
||||
}
|
||||
}
|
||||
|
||||
.socialNavInside {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 15px;
|
||||
Background: linear-gradient(to top right, rgba(27,27,27,0.9), rgba(35,35,35,0.9), rgba(27,27,27,0.9));
|
||||
box-shadow: 0 0 8px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
grid-gap: 5px;
|
||||
border: solid 2px rgba(255,255,255,0.05);
|
||||
overflow-x: auto;
|
||||
max-width: 80vw;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.socialNavInside {
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.socialNavInside::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btnMain.socialNavInsideBtn {
|
||||
transition: ease 0.4s;
|
||||
padding: 0 15px;
|
||||
font-size: 24px;
|
||||
height: 45px;
|
||||
width: 55px;
|
||||
border-radius: 10px;
|
||||
Background: linear-gradient(to top right, rgba(50,50,50,0), rgba(55,55,55,0), rgba(50,50,50,0));
|
||||
color: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.btnMain.socialNavInsideBtn:hover {
|
||||
transition: ease 0.4s;
|
||||
background: #434343;
|
||||
}
|
||||
|
||||
.btnMain.socialNavInsideBtn.socialNavInsideBtnActive {
|
||||
Background: #434343;
|
||||
box-shadow: 0 0 8px 0 rgb(0,0,0,0.1);
|
||||
color: rgba(255,255,255,0.75);
|
||||
}
|
||||
|
||||
.socialNavInsideWrapper {
|
||||
margin: 10px 0 15px 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
grid-gap: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.socialNavInsideWrapper {
|
||||
width: 100%;
|
||||
justify-content: end;
|
||||
}
|
||||
}
|
||||
|
||||
.socialNavCollapse {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
Background: linear-gradient(to top right, rgba(27,27,27,0.75), rgba(35,35,35,0.75), rgba(27,27,27,0.75));
|
||||
box-shadow: 0 0 8px rgba(0,0,0,0.2);
|
||||
padding: 15px 5px;
|
||||
border-radius: 5px;
|
||||
border: solid 1px rgba(255,255,255,0.05);
|
||||
backdrop-filter: blur(5px);
|
||||
cursor: pointer;
|
||||
transform: scale(1);
|
||||
color: rgba(255,255,255,0.75);
|
||||
}
|
||||
|
||||
.socialNavCollapse:hover {
|
||||
Background: linear-gradient(rgba(255,255,255,0.01), rgba(255,255,255,0.01)), linear-gradient(to right top, rgba(27,27,27,0.75), rgba(35,35,35,0.75), rgba(27,27,27,0.75));
|
||||
color: rgb(255,255,255);
|
||||
}
|
||||
|
||||
.socialNavCollapseIcon {
|
||||
transition: ease 0.4s;
|
||||
}
|
||||
|
||||
.btnMain.socialNavInsideBtn::before {
|
||||
background: linear-gradient(rgba(255,255,255,0.05), rgba(255,255,255,0.05)), linear-gradient(to top right, #262626, #292929, #262626), linear-gradient(to top right, #262626, #292929, #262626);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.btnMain.socialNavInsideBtn:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
@ -47,27 +47,27 @@ h6 {
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 38px
|
||||
font-size: 38px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 32px
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 24px
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 20px
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 18px
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 16px
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.IBMSecMain {
|
||||
@ -273,29 +273,28 @@ h6 {
|
||||
/* the 4 classes below here are a temp fix for the games dropdown stylings */
|
||||
|
||||
.dropdownMainMenu.dropdownMainMenuAlt {
|
||||
max-height: unset!important;
|
||||
|
||||
max-height: unset !important;
|
||||
}
|
||||
|
||||
.dropdownMainMenu.dropdownMainMenuAlt > div {
|
||||
height: unset!important;
|
||||
height: unset !important;
|
||||
}
|
||||
|
||||
.dropdownMainMenu.dropdownMainMenuAlt > div > div {
|
||||
height: unset!important;
|
||||
width: 100%!important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
padding: 5px;
|
||||
height: unset !important;
|
||||
width: 100% !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.dropdownMainMenu.dropdownMainMenuAlt > div > div > div {
|
||||
position: relative!important;
|
||||
left: unset!important;
|
||||
top: unset!important;
|
||||
position: relative !important;
|
||||
left: unset !important;
|
||||
top: unset !important;
|
||||
}
|
||||
|
||||
.dropdownMainMenuItem {
|
||||
@ -346,6 +345,38 @@ h6 {
|
||||
border: solid 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.ProseMirror-focused {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.button-group button:disabled {
|
||||
background-color: #cccccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-group button.is-active {
|
||||
background-color: rgb(255 255 255 / 15%);
|
||||
color: rgb(255 255 255 / 85%);
|
||||
font-weight: bold;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.labelMain {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
@ -624,3 +655,28 @@ a:hover {
|
||||
|
||||
.errorMainText {
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.spinnerCircle {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
@ -27,6 +27,7 @@
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
.IBMSMSMBSSTagsTag:active {
|
||||
|
104
src/styles/tiptap.scss
Normal file
104
src/styles/tiptap.scss
Normal file
@ -0,0 +1,104 @@
|
||||
/* Basic editor styles */
|
||||
.tiptap {
|
||||
/* List styles */
|
||||
p {
|
||||
margin: 5px 0px;
|
||||
}
|
||||
ul,
|
||||
ol {
|
||||
padding: 0 1rem;
|
||||
margin: 1.25rem 1rem 1.25rem 0.4rem;
|
||||
|
||||
li p {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
/* Heading styles */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1.1;
|
||||
margin: 10px 0px;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--purple-light); // todo: fix the color
|
||||
border-radius: 0.4rem;
|
||||
color: var(--black);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25em 0.3em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--black); // todo: fix the color
|
||||
color: var(--white);
|
||||
font-family: 'JetBrainsMono', monospace;
|
||||
margin: 1.5rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #00000030;
|
||||
border-radius: 5px;
|
||||
border: solid 2px rebeccapurple;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-radius: 0 10px 10px 0;
|
||||
border-left: solid 6px rgba(255, 255, 255, 0.1);
|
||||
padding: 25px;
|
||||
background: #232323;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toolbar Styling */
|
||||
.control-group {
|
||||
padding: 5px 0px 15px 0px;
|
||||
border-radius: 0px;
|
||||
border-bottom: solid 1px rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
min-height: 75px;
|
||||
}
|
||||
|
||||
.btnMain.btnMainTipTap {
|
||||
padding: 5px 10px;
|
||||
height: 35px;
|
||||
font-size: 14px;
|
||||
}
|
@ -1,3 +1,9 @@
|
||||
export type Game = {
|
||||
'Game Name': string
|
||||
'16 by 9 image': string
|
||||
'Boxart image': string
|
||||
}
|
||||
|
||||
export interface ModFormState {
|
||||
dTag: string
|
||||
aTag: string
|
||||
@ -32,5 +38,5 @@ export interface ModDetails extends Omit<ModFormState, 'tags'> {
|
||||
|
||||
export interface MuteLists {
|
||||
authors: string[]
|
||||
eventIds: string[]
|
||||
replaceableEvents: string[]
|
||||
}
|
||||
|
@ -2,3 +2,4 @@ export * from './mod'
|
||||
export * from './nostr'
|
||||
export * from './url'
|
||||
export * from './utils'
|
||||
export * from './zap'
|
||||
|
@ -134,6 +134,13 @@ export const initializeFormState = (
|
||||
]
|
||||
})
|
||||
|
||||
interface FetchModsOptions {
|
||||
source?: string
|
||||
until?: number
|
||||
since?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a list of mods based on the provided source.
|
||||
*
|
||||
@ -144,15 +151,16 @@ export const initializeFormState = (
|
||||
* @returns A promise that resolves to an array of `ModDetails` objects. In case of an error,
|
||||
* it logs the error and shows a notification, then returns an empty array.
|
||||
*/
|
||||
export const fetchMods = async (
|
||||
source: string,
|
||||
until?: number,
|
||||
since?: number
|
||||
): Promise<ModDetails[]> => {
|
||||
export const fetchMods = async ({
|
||||
source,
|
||||
until,
|
||||
since,
|
||||
limit
|
||||
}: FetchModsOptions): Promise<ModDetails[]> => {
|
||||
// Define the filter criteria for fetching mods
|
||||
const filter: Filter = {
|
||||
kinds: [kinds.ClassifiedListing], // Specify the kind of events to fetch
|
||||
limit: MOD_FILTER_LIMIT, // Limit the number of events fetched to 20
|
||||
limit: limit || MOD_FILTER_LIMIT, // Limit the number of events fetched to 20
|
||||
'#t': [T_TAG_VALUE],
|
||||
until, // Optional filter to fetch events until this timestamp
|
||||
since // Optional filter to fetch events from this timestamp
|
||||
@ -167,8 +175,6 @@ export const fetchMods = async (
|
||||
return RelayController.getInstance()
|
||||
.fetchEvents(filter, []) // Pass the filter and an empty array of options
|
||||
.then((events) => {
|
||||
console.log('events :>> ', events)
|
||||
|
||||
// Convert the fetched events into a list of mods
|
||||
const modList = constructModListFromEvents(events)
|
||||
return modList // Return the list of mods
|
||||
|
@ -1,4 +1,16 @@
|
||||
import { nip19, Event } from 'nostr-tools'
|
||||
import {
|
||||
Event,
|
||||
finalizeEvent,
|
||||
generateSecretKey,
|
||||
getPublicKey,
|
||||
kinds,
|
||||
nip04,
|
||||
nip19,
|
||||
UnsignedEvent
|
||||
} from 'nostr-tools'
|
||||
import { toast } from 'react-toastify'
|
||||
import { RelayController } from '../controllers'
|
||||
import { log, LogType } from './utils'
|
||||
|
||||
/**
|
||||
* Get the current time in seconds since the Unix epoch (January 1, 1970).
|
||||
@ -92,32 +104,118 @@ export const npubToHex = (pubKey: string): string | null => {
|
||||
* @returns The zap amount in the form of a number, converted from the extracted data, or 0 if the amount cannot be determined.
|
||||
*/
|
||||
export const extractZapAmount = (event: Event): number => {
|
||||
// Find the 'description' tag within the event's tags
|
||||
const description = event.tags.find(
|
||||
(tag) => tag[0] === 'description' && typeof tag[1] === 'string'
|
||||
// Find the 'amount' tag within the parsed description's tags
|
||||
const amountTag = event.tags.find(
|
||||
(tag) => tag[0] === 'amount' && typeof tag[1] === 'string'
|
||||
)
|
||||
|
||||
// If the 'description' tag is found and it has a valid value
|
||||
if (description && description[1]) {
|
||||
try {
|
||||
// Parse the description as JSON to get additional details
|
||||
const parsedDescription: Event = JSON.parse(description[1])
|
||||
|
||||
// Find the 'amount' tag within the parsed description's tags
|
||||
const amountTag = parsedDescription.tags.find(
|
||||
(tag) => tag[0] === 'amount' && typeof tag[1] === 'string'
|
||||
)
|
||||
|
||||
// If the 'amount' tag is found and it has a valid value, convert it to an integer and return
|
||||
if (amountTag && amountTag[1]) return parseInt(amountTag[1]) / 1000
|
||||
} catch (error) {
|
||||
// Log an error message if JSON parsing fails
|
||||
console.log(
|
||||
`An error occurred while parsing description of zap event: ${error}`
|
||||
)
|
||||
}
|
||||
}
|
||||
// If the 'amount' tag is found and it has a valid value, convert it to an integer and return
|
||||
if (amountTag && amountTag[1]) return parseInt(amountTag[1]) / 1000
|
||||
|
||||
// Return 0 if the zap amount cannot be determined
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs and publishes an event to user's relays.
|
||||
*
|
||||
* @param unsignedEvent - The event object which needs to be signed before publishing.
|
||||
* @returns - A promise that resolves to boolean indicating whether the event was successfully signed and published
|
||||
*/
|
||||
export const signAndPublish = async (unsignedEvent: UnsignedEvent) => {
|
||||
// Sign the event. This returns a signed event or null if signing fails.
|
||||
const signedEvent = await window.nostr
|
||||
?.signEvent(unsignedEvent)
|
||||
.then((event) => event as Event)
|
||||
.catch((err) => {
|
||||
// If signing the event fails, display an error toast and log the error.
|
||||
toast.error('Failed to sign the event!')
|
||||
log(true, LogType.Error, 'Failed to sign the event!', err)
|
||||
return null
|
||||
})
|
||||
|
||||
// If the event couldn't be signed, exit the function and return null.
|
||||
if (!signedEvent) return false
|
||||
|
||||
// Publish the signed event to the relays using the RelayController.
|
||||
// This returns an array of relay URLs where the event was successfully published.
|
||||
const publishedOnRelays = await RelayController.getInstance().publish(
|
||||
signedEvent as Event
|
||||
)
|
||||
|
||||
// Handle cases where publishing to the relays failed
|
||||
if (publishedOnRelays.length === 0) {
|
||||
// Display an error toast if the event could not be published to any relay.
|
||||
toast.error('Failed to publish event on any relay')
|
||||
return false
|
||||
}
|
||||
|
||||
// Display a success toast with the list of relays where the event was successfully published.
|
||||
toast.success(
|
||||
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
|
||||
'\n'
|
||||
)}`
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an encrypted direct message (DM) to a receiver using a randomly generated secret key.
|
||||
*
|
||||
* @param message - The plaintext message content to be sent.
|
||||
* @param receiver - The public key of the receiver to whom the message is being sent.
|
||||
* @returns - A promise that resolves to true if the message was successfully sent, or false if an error occurred.
|
||||
*/
|
||||
export const sendDMUsingRandomKey = async (
|
||||
message: string,
|
||||
receiver: string
|
||||
) => {
|
||||
// Generate a random secret key for encrypting the message
|
||||
const secretKey = generateSecretKey()
|
||||
|
||||
// Encrypt the message using the generated secret key and the receiver's public key
|
||||
const encryptedMessage = await nip04
|
||||
.encrypt(secretKey, receiver, message)
|
||||
.catch((err) => {
|
||||
// If encryption fails, display an error toast
|
||||
toast.error(
|
||||
`An error occurred in encrypting message content: ${err.message || err}`
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
// If encryption failed, exit the function and return false
|
||||
if (!encryptedMessage) return false
|
||||
|
||||
// Construct the unsigned event containing the encrypted message and relevant metadata
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
pubkey: getPublicKey(secretKey),
|
||||
kind: kinds.EncryptedDirectMessage,
|
||||
created_at: now(),
|
||||
tags: [['p', receiver]],
|
||||
content: encryptedMessage
|
||||
}
|
||||
|
||||
// Finalize and sign the event using the generated secret key
|
||||
const signedEvent = finalizeEvent(unsignedEvent, secretKey)
|
||||
|
||||
// Publish the signed event (the encrypted DM) to the relays
|
||||
const publishedOnRelays = await RelayController.getInstance().publishDM(
|
||||
signedEvent,
|
||||
receiver
|
||||
)
|
||||
|
||||
// Handle cases where publishing to the relays failed
|
||||
if (publishedOnRelays.length === 0) {
|
||||
// Display an error toast if the event could not be published to any relay
|
||||
toast.error('Failed to publish encrypted direct message on any relay')
|
||||
return false
|
||||
}
|
||||
|
||||
// Display a success toast if the event was successfully published to one or more relays
|
||||
toast.success(`Report successfully submitted!`)
|
||||
|
||||
// Return true indicating that the DM was successfully sent
|
||||
return true
|
||||
}
|
||||
|
@ -123,3 +123,15 @@ export const abbreviateNumber = (value: number): string => {
|
||||
return value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
export const handleGameImageError = (
|
||||
e: React.SyntheticEvent<HTMLImageElement, Event>
|
||||
) => {
|
||||
e.currentTarget.src = import.meta.env.VITE_FALLBACK_GAME_IMAGE
|
||||
}
|
||||
|
||||
export const handleModImageError = (
|
||||
e: React.SyntheticEvent<HTMLImageElement, Event>
|
||||
) => {
|
||||
e.currentTarget.src = import.meta.env.VITE_FALLBACK_MOD_IMAGE
|
||||
}
|
||||
|
38
src/utils/zap.ts
Normal file
38
src/utils/zap.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { ZapReceipt, ZapRequest } from 'types'
|
||||
|
||||
/**
|
||||
* Gets value of description tag.
|
||||
* @param receipt - zap receipt.
|
||||
* @returns value of description tag.
|
||||
*/
|
||||
export const getDescription = (receipt: ZapReceipt) => {
|
||||
return receipt.tags.filter((tag) => tag[0] === 'description')[0][1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets value of amount tag.
|
||||
* @param request - zap receipt.
|
||||
* @returns value of amount tag.
|
||||
*/
|
||||
export const getAmount = (request: ZapRequest) => {
|
||||
return request.tags.filter((tag) => tag[0] === 'amount')[0][1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets zap amount.
|
||||
* @param receipt - zap receipt.
|
||||
* @returns zap amount
|
||||
*/
|
||||
export const getZapAmount = (receipt: ZapReceipt) => {
|
||||
const description = getDescription(receipt)
|
||||
let request: ZapRequest
|
||||
|
||||
try {
|
||||
request = JSON.parse(description)
|
||||
} catch (err) {
|
||||
throw 'An error occurred in parsing description tag from zapReceipt'
|
||||
}
|
||||
|
||||
// Zap amount is stored in mili sats, to get the zap amount we'll divide it by 1000
|
||||
return parseInt(getAmount(request)) / 1000
|
||||
}
|
3
src/vite-env.d.ts
vendored
3
src/vite-env.d.ts
vendored
@ -3,6 +3,9 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_RELAY: string
|
||||
readonly VITE_ADMIN_NPUBS: string
|
||||
readonly VITE_REPORTING_NPUB: string
|
||||
readonly VITE_FALLBACK_MOD_IMAGE: string
|
||||
readonly VITE_FALLBACK_GAME_IMAGE: string
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,11 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"plugins": [{ "name": "ts-css-modules-vite-plugin" }]
|
||||
"plugins": [{ "name": "ts-css-modules-vite-plugin" }],
|
||||
|
||||
"paths": {
|
||||
"*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tsconfigPaths()]
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user