Compare commits
No commits in common. "staging" and "ndk-refactor" have entirely different histories.
staging
...
ndk-refact
@ -7,14 +7,8 @@ VITE_ADMIN_NPUBS= <A comma separated list of npubs>
|
||||
# A dedicated npub used for reporting mods, blogs, profile and etc.
|
||||
VITE_REPORTING_NPUB= <npub1...>
|
||||
|
||||
# A dedicated npub used for site WOT.
|
||||
VITE_SITE_WOT_NPUB= <npub1...>
|
||||
|
||||
# if there's no featured image, or if the image breaks somewhere down the line, then it should default to this image
|
||||
VITE_FALLBACK_MOD_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
|
||||
|
||||
# if there's no image, or if the image breaks somewhere down the line, then it should default to this image
|
||||
VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
|
||||
|
||||
# A comma separated list of npubs, this list is used to fetch just the posts from the admin
|
||||
VITE_BLOG_NPUBS= <A comma separated list of npubs>
|
||||
VITE_FALLBACK_GAME_IMAGE=https://image.nostr.build/1fa7afd3489302c2da8957993ac0fd6c4308eedd4b1b95b24ecfabe3651b2183.png
|
@ -1,10 +1,10 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2022: true },
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended'
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
@ -12,7 +12,7 @@ module.exports = {
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true }
|
||||
]
|
||||
}
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
name: Release to Production
|
||||
name: Release to Staging
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@ -25,10 +25,8 @@ jobs:
|
||||
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
|
||||
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
|
||||
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
|
||||
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
|
||||
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
|
||||
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
|
||||
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
|
||||
cat .env
|
||||
|
||||
- name: Create Build
|
||||
|
@ -25,10 +25,8 @@ jobs:
|
||||
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
|
||||
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
|
||||
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
|
||||
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
|
||||
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
|
||||
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
|
||||
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
|
||||
cat .env
|
||||
|
||||
- name: Create Build
|
||||
|
@ -32,11 +32,9 @@ jobs:
|
||||
run: |
|
||||
echo "VITE_APP_RELAY=${{ vars.VITE_APP_RELAY }}" >> .env
|
||||
echo "VITE_ADMIN_NPUBS=${{ vars.VITE_ADMIN_NPUBS }}" >> .env
|
||||
echo "VITE_SITE_WOT_NPUB=${{ vars.VITE_SITE_WOT_NPUB }}" >> .env
|
||||
echo "VITE_FALLBACK_GAME_IMAGE=${{ vars.VITE_FALLBACK_GAME_IMAGE }}" >> .env
|
||||
echo "VITE_FALLBACK_MOD_IMAGE=${{ vars.VITE_FALLBACK_MOD_IMAGE }}" >> .env
|
||||
echo "VITE_REPORTING_NPUB=${{ vars.VITE_REPORTING_NPUB }}" >> .env
|
||||
echo "VITE_BLOG_NPUBS=${{ vars.VITE_BLOG_NPUBS }}" >> .env
|
||||
cat .env
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
41
index.html
41
index.html
@ -9,47 +9,6 @@
|
||||
<link rel="stylesheet" href="/assets/fonts/fontawesome-all.min.css" />
|
||||
|
||||
<title>DEG Mods - Liberating Game Mods</title>
|
||||
|
||||
<!-- Start Hash Router Backwards Compatibility -->
|
||||
<script type="text/javascript">
|
||||
;(function (l) {
|
||||
if (l.hash.startsWith('#/')) {
|
||||
l.href = l.href.replace('#/', '')
|
||||
}
|
||||
})(window.location)
|
||||
</script>
|
||||
<!-- End Hash Router Backwards Compatibility -->
|
||||
|
||||
<!-- Start Single Page Apps for GitHub Pages -->
|
||||
<script type="text/javascript">
|
||||
// Single Page Apps for GitHub Pages
|
||||
// MIT License
|
||||
// https://github.com/rafgraph/spa-github-pages
|
||||
// This script checks to see if a redirect is present in the query string,
|
||||
// converts it back into the correct url and adds it to the
|
||||
// browser's history using window.history.replaceState(...),
|
||||
// which won't cause the browser to attempt to load the new url.
|
||||
// When the single page app is loaded further down in this file,
|
||||
// the correct url will be waiting in the browser's history for
|
||||
// the single page app to route accordingly.
|
||||
;(function (l) {
|
||||
if (l.search[1] === '/') {
|
||||
var decoded = l.search
|
||||
.slice(1)
|
||||
.split('&')
|
||||
.map(function (s) {
|
||||
return s.replace(/~and~/g, '&')
|
||||
})
|
||||
.join('?')
|
||||
window.history.replaceState(
|
||||
null,
|
||||
null,
|
||||
l.pathname.slice(0, -1) + decoded + l.hash
|
||||
)
|
||||
}
|
||||
})(window.location)
|
||||
</script>
|
||||
<!-- End Single Page Apps for GitHub Pages -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
4647
package-lock.json
generated
4647
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "degmods.com",
|
||||
"private": true,
|
||||
"version": "0.0.0-alpha-1",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -11,12 +11,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@getalby/lightning-tools": "5.0.3",
|
||||
"@mdxeditor/editor": "^3.20.0",
|
||||
"@nostr-dev-kit/ndk": "2.11.0",
|
||||
"@nostr-dev-kit/ndk-cache-dexie": "2.5.9",
|
||||
"@nostr-dev-kit/ndk": "2.10.0",
|
||||
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
|
||||
"@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",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "1.7.3",
|
||||
"bech32": "2.0.0",
|
||||
"buffer": "6.0.3",
|
||||
"date-fns": "3.6.0",
|
||||
@ -25,8 +28,6 @@
|
||||
"file-saver": "2.0.5",
|
||||
"fslightbox-react": "1.7.6",
|
||||
"lodash": "4.17.21",
|
||||
"marked": "^14.1.3",
|
||||
"marked-directive": "^1.0.7",
|
||||
"nostr-login": "1.5.2",
|
||||
"nostr-tools": "2.7.1",
|
||||
"papaparse": "5.4.1",
|
||||
@ -34,7 +35,6 @@
|
||||
"react": "^18.3.1",
|
||||
"react-countdown": "2.3.5",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-redux": "9.1.2",
|
||||
"react-router-dom": "^6.24.1",
|
||||
@ -53,7 +53,6 @@
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-window": "1.8.8",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
|
@ -1,51 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Single Page Apps for GitHub Pages</title>
|
||||
<script type="text/javascript">
|
||||
// Single Page Apps for GitHub Pages
|
||||
// MIT License
|
||||
// https://github.com/rafgraph/spa-github-pages
|
||||
// This script takes the current url and converts the path and query
|
||||
// string into just a query string, and then redirects the browser
|
||||
// to the new url with only a query string and hash fragment,
|
||||
// e.g. https://www.foo.tld/one/two?a=b&c=d#qwe, becomes
|
||||
// https://www.foo.tld/?/one/two&a=b~and~c=d#qwe
|
||||
// Note: this 404.html file must be at least 512 bytes for it to work
|
||||
// with Internet Explorer (it is currently > 512 bytes)
|
||||
|
||||
// If you're creating a Project Pages site and NOT using a custom domain,
|
||||
// then set pathSegmentsToKeep to 1 (enterprise users may need to set it to > 1).
|
||||
// This way the code will only replace the route part of the path, and not
|
||||
// the real directory in which the app resides, for example:
|
||||
// https://username.github.io/repo-name/one/two?a=b&c=d#qwe becomes
|
||||
// https://username.github.io/repo-name/?/one/two&a=b~and~c=d#qwe
|
||||
// Otherwise, leave pathSegmentsToKeep as 0.
|
||||
var pathSegmentsToKeep = 0
|
||||
|
||||
var l = window.location
|
||||
l.replace(
|
||||
l.protocol +
|
||||
'//' +
|
||||
l.hostname +
|
||||
(l.port ? ':' + l.port : '') +
|
||||
l.pathname
|
||||
.split('/')
|
||||
.slice(0, 1 + pathSegmentsToKeep)
|
||||
.join('/') +
|
||||
'/?/' +
|
||||
l.pathname
|
||||
.slice(1)
|
||||
.split('/')
|
||||
.slice(pathSegmentsToKeep)
|
||||
.join('/')
|
||||
.replace(/&/g, '~and~') +
|
||||
(l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
|
||||
l.hash
|
||||
)
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
25
src/App.tsx
25
src/App.tsx
@ -1,13 +1,10 @@
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { routerWithNdkContext as routerWithState } from 'routes'
|
||||
import { useNDKContext } from 'hooks'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { Layout } from './layout'
|
||||
import { routes } from './routes'
|
||||
import { useEffect } from 'react'
|
||||
import './styles/styles.css'
|
||||
|
||||
function App() {
|
||||
const ndkContext = useNDKContext()
|
||||
const router = useMemo(() => routerWithState(ndkContext), [ndkContext])
|
||||
|
||||
useEffect(() => {
|
||||
// Find the element with id 'root'
|
||||
const rootElement = document.getElementById('root')
|
||||
@ -25,7 +22,19 @@ function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <RouterProvider router={router} />
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
{routes.map((route, index) => (
|
||||
<Route
|
||||
key={route.path + index}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
@ -1,170 +0,0 @@
|
||||
import { NDKFilter } from '@nostr-dev-kit/ndk'
|
||||
import { NDKContextType } from 'contexts/NDKContext'
|
||||
import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||
import { ActionFunctionArgs } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { store } from 'store'
|
||||
import { UserRelaysType } from 'types'
|
||||
import {
|
||||
log,
|
||||
LogType,
|
||||
now,
|
||||
npubToHex,
|
||||
parseFormData,
|
||||
sendDMUsingRandomKey,
|
||||
signAndPublish
|
||||
} from 'utils'
|
||||
|
||||
export const reportRouteAction =
|
||||
(ndkContext: NDKContextType) =>
|
||||
async ({ params, request }: ActionFunctionArgs) => {
|
||||
// Check which post type is reported
|
||||
const url = new URL(request.url)
|
||||
const isModReport = url.pathname.startsWith('/mod/')
|
||||
const isBlogReport = url.pathname.startsWith('/blog/')
|
||||
const title = isModReport ? 'Mod' : isBlogReport ? 'Blog' : 'Post'
|
||||
const requestData = await request.formData()
|
||||
const { naddr } = params
|
||||
if (!naddr) {
|
||||
log(true, LogType.Error, 'Required naddr.')
|
||||
return false
|
||||
}
|
||||
|
||||
// Decode author from naddr
|
||||
let aTag: string | undefined
|
||||
try {
|
||||
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
|
||||
const { identifier, kind, pubkey } = decoded.data
|
||||
|
||||
aTag = `${kind}:${pubkey}:${identifier}`
|
||||
|
||||
if (isModReport) {
|
||||
aTag = identifier
|
||||
}
|
||||
} catch (error) {
|
||||
log(true, LogType.Error, 'Failed to decode naddr')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!aTag) {
|
||||
log(true, LogType.Error, 'Missing #a Tag')
|
||||
return false
|
||||
}
|
||||
|
||||
const userState = store.getState().user
|
||||
let hexPubkey: string | undefined
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
hexPubkey = userState.user.pubkey as string
|
||||
}
|
||||
|
||||
const reportingNpub = import.meta.env.VITE_REPORTING_NPUB
|
||||
const reportingPubkey = npubToHex(reportingNpub)
|
||||
|
||||
// Parse the the data
|
||||
const formSubmit = parseFormData(requestData)
|
||||
|
||||
const selectedOptionsCount = Object.values(formSubmit).filter(
|
||||
(checked) => checked === 'on'
|
||||
).length
|
||||
if (selectedOptionsCount === 0) {
|
||||
toast.error('At least one option should be checked!')
|
||||
return false
|
||||
}
|
||||
|
||||
if (reportingPubkey === hexPubkey) {
|
||||
// Define the event filter to search for the user's mute list events.
|
||||
// We look for events of a specific kind (Mutelist) authored by the given hexPubkey.
|
||||
const filter: NDKFilter = {
|
||||
kinds: [kinds.Mutelist],
|
||||
authors: [hexPubkey]
|
||||
}
|
||||
|
||||
// Fetch the mute list event from the relays. This returns the event containing the user's mute list.
|
||||
const muteListEvent = await ndkContext.fetchEventFromUserRelays(
|
||||
filter,
|
||||
hexPubkey,
|
||||
UserRelaysType.Write
|
||||
)
|
||||
|
||||
let unsignedEvent: UnsignedEvent
|
||||
if (muteListEvent) {
|
||||
const tags = muteListEvent.tags
|
||||
const alreadyExists =
|
||||
tags.findIndex((item) => item[0] === 'a' && item[1] === aTag) !== -1
|
||||
if (alreadyExists) {
|
||||
toast.warn(`${title} reference is already in user's mute list`)
|
||||
return false
|
||||
}
|
||||
tags.push(['a', aTag])
|
||||
unsignedEvent = {
|
||||
pubkey: muteListEvent.pubkey,
|
||||
kind: kinds.Mutelist,
|
||||
content: muteListEvent.content,
|
||||
created_at: now(),
|
||||
tags: [...tags]
|
||||
}
|
||||
} else {
|
||||
unsignedEvent = {
|
||||
pubkey: hexPubkey,
|
||||
kind: kinds.Mutelist,
|
||||
content: '',
|
||||
created_at: now(),
|
||||
tags: [['a', aTag]]
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
hexPubkey = await window.nostr?.getPublicKey()
|
||||
} catch (error) {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
`Could not get pubkey for reporting ${title.toLowerCase()}!`,
|
||||
error
|
||||
)
|
||||
toast.error(
|
||||
`Could not get pubkey for reporting ${title.toLowerCase()}!`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const isUpdated = await signAndPublish(
|
||||
unsignedEvent,
|
||||
ndkContext.ndk,
|
||||
ndkContext.publish
|
||||
)
|
||||
return { isSent: isUpdated }
|
||||
} else if (reportingPubkey) {
|
||||
const href = window.location.href
|
||||
let message = `I'd like to report ${href} due to following reasons:\n`
|
||||
Object.entries(formSubmit).forEach(([key, value]) => {
|
||||
if (value === 'on') {
|
||||
message += `* ${key}\n`
|
||||
}
|
||||
})
|
||||
try {
|
||||
const isSent = await sendDMUsingRandomKey(
|
||||
message,
|
||||
reportingPubkey,
|
||||
ndkContext.ndk,
|
||||
ndkContext.publish
|
||||
)
|
||||
return { isSent: isSent }
|
||||
} catch (error) {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
`Failed to send a ${title.toLowerCase()} report`,
|
||||
error
|
||||
)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
log(
|
||||
true,
|
||||
LogType.Error,
|
||||
`Failed to send a ${title.toLowerCase()} report: VITE_REPORTING_NPUB missing`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
[
|
||||
{ "name": "gameplay ", "sub": ["difficulty"] },
|
||||
{ "name": "input", "sub": ["key mapping", "macro"] },
|
||||
{
|
||||
"name": "visual",
|
||||
"sub": ["textures", "lighting", "character models", "environment models"]
|
||||
},
|
||||
{ "name": "audio", "sub": ["sfx", "music", "voice"] },
|
||||
{ "name": "user interface", "sub": ["hud", "menu"] },
|
||||
{
|
||||
"name": "quality of life",
|
||||
"sub": ["bug fixes", "performance", "accessibility"]
|
||||
},
|
||||
"total conversions",
|
||||
"translation",
|
||||
"multiplayer",
|
||||
"clothing",
|
||||
"mod manager"
|
||||
]
|
@ -1,14 +1,5 @@
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
(Unlisted Game),,
|
||||
Minecraft,,https://image.nostr.build/b75b2d3a7855370230f2976567e2d5f913a567c57ac61adfb60c7e1102f05117.jpg
|
||||
Vintage Story,,https://image.nostr.build/9efe683d339cc864032a99047ce26b2b5c19fab1ec4dcc6d4db96e2785c44eda.png
|
||||
Yandere Simulator,,https://image.nostr.build/54ba56b752bb9d411cbdc1d249fa0cb74c6062a305bcd0a70ecacb61b8d50030.png
|
||||
Genshin Impact,,https://image.nostr.build/999fccf93cf16a2e0dd8e6f00595b0ab3b5cc6beff9fe4a52f64f427cce9aedd.jpg
|
||||
Zenless Zone Zero,,https://image.nostr.build/4a9b9c2cbef619552d0c123f8794286f35710dc7ca1ca0010380a630883eb2ca.jpg
|
||||
Ananta,,https://image.nostr.build/09fb30fe2c22784079e4c0a848410e709ff359af09b3f96b651c7dc249a35cdd.jpg
|
||||
Bloodborne,,https://image.nostr.build/9d20c246b539e43f1bebcf602f996fa6eb45cf585f05cc19a1d9f86a53201485.jpg
|
||||
The Elder Scrolls: Skyblivion,,https://cdn.nostrcheck.me/3cea4806b1e1a9829d30d5cb8a78011d4271c6474eb31531ec91f28110fe3f40/87868c8134d0ed30e74b99750e292fc85ad708d0add24c7c9113b086cd0784c3.webp
|
||||
Stellar Blade,,https://image.nostr.build/4dd76ef8ff985d2afc8b3eba323f18de7165659c4e925b2f06ae8b130372d5ae.jpg
|
||||
Bayonetta 2,,https://image.nostr.build/916f0b1fd4938114867654d7625f8e817b7f710d7729c81c911e1011fa74afad.jpg
|
||||
Grand Theft Auto: Vice City,,https://image.nostr.build/6f364ebb635cc878b284a06e8131dcec5eb4b574eece7ab206df8a9af639ddf6.jpg
|
||||
Alan Wake 2,,https://image.nostr.build/9c185ddb6f3c3b1f56835be8d36200eda7de0f36888b02523f4d39ade235ffab.jpg
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
(Unlisted Game),,
|
||||
Minecraft,,https://image.nostr.build/b75b2d3a7855370230f2976567e2d5f913a567c57ac61adfb60c7e1102f05117.jpg
|
||||
Vintage Story,,https://image.nostr.build/9efe683d339cc864032a99047ce26b2b5c19fab1ec4dcc6d4db96e2785c44eda.png
|
||||
Yandere Simulator,,https://image.nostr.build/54ba56b752bb9d411cbdc1d249fa0cb74c6062a305bcd0a70ecacb61b8d50030.png
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,10 +1,2 @@
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
Marvel's Spider-Man 2,,https://image.nostr.build/b5ef5ef8bd99daab73148145b024a1e6177160fd287ce829f82ba46e821490b6.jpg
|
||||
S.T.A.L.K.E.R. 2: Heart of Chornobyl,,https://image.nostr.build/f5b61071bebcc8deccfd71362696fb708649b9c528bec1c6964262ded4157843.jpg
|
||||
FINAL FANTASY VII REBIRTH,,https://image.nostr.build/e16228ccfad19669f17f83517ac621142ad7129c82d0e5f346a3523643f98a28.jpg
|
||||
NINJA GAIDEN 2 Black,,https://image.nostr.build/867b5d4ae7580f138b9d7e3bc41c0184e59fc22a758d2dcd9e941f8adf6d6e6e.jpg
|
||||
Rise of the Ronin,,https://image.nostr.build/5eba5c6efed335e1e6c74e7af29790a9cc519dbccdd81122e84336848a7e7866.jpg
|
||||
NINJA GAIDEN 4,,https://image.nostr.build/2b49b1571ba90450f95a9eb306d2ef9f3ad632dc6282125cc1651d17da17a439.jpg
|
||||
Batman Arkham Asylum,,https://image.nostr.build/ba5c07be4747957380213ad86ab83b8a4cb6b8ef0123ebb9863318ed1de6e43e.jpg
|
||||
Kingdom Hearts,,https://image.nostr.build/883b71c52b5b498aac20218b52af471ba89afb5cbb7072dc403da7446ca04e39.jpg
|
||||
Kingdom Hearts II,,https://image.nostr.build/24b6002029e91e4ad99b56aca9f20b076feb594ae48b320ba9122254add6b57e.jpg
|
||||
Game Name,16 by 9 image,Boxart image
|
||||
Marvel's Spider-Man 2,,https://s7.ezgif.com/tmp/ezgif-7-9ad5dabde6.webp
|
|
Binary file not shown.
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 277 KiB |
@ -1,72 +0,0 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AlertPopupProps } from 'types'
|
||||
|
||||
export const AlertPopup = ({
|
||||
header,
|
||||
label,
|
||||
handleConfirm,
|
||||
handleClose
|
||||
}: AlertPopupProps) => {
|
||||
return createPortal(
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>{header}</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
gap: '10px'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={() => handleConfirm(true)}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={() => handleConfirm(false)}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
@ -1,33 +1,37 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { BlogCardDetails } from 'types'
|
||||
import { getBlogPageRoute } from 'routes'
|
||||
import '../styles/cardBlogs.css'
|
||||
import placeholder from '../assets/img/DEGMods Placeholder Img.png'
|
||||
|
||||
type BlogCardProps = Partial<BlogCardDetails>
|
||||
|
||||
export const BlogCard = ({ title, image, nsfw, naddr }: BlogCardProps) => {
|
||||
if (!naddr) return null
|
||||
type BlogCardProps = {
|
||||
backgroundLink: string
|
||||
}
|
||||
|
||||
export const BlogCard = ({ backgroundLink }: BlogCardProps) => {
|
||||
return (
|
||||
<Link to={getBlogPageRoute(naddr)} className='cardBlogMainWrapperLink'>
|
||||
<a className='cardBlogMainWrapperLink' href='blog-inner.html'>
|
||||
<div
|
||||
className='cardBlogMain'
|
||||
style={{
|
||||
background: `url("${
|
||||
image ? image : placeholder
|
||||
}") center / cover no-repeat`
|
||||
background: `url("${backgroundLink}") center / cover no-repeat`
|
||||
}}
|
||||
>
|
||||
<div className='cardBlogMainInside'>
|
||||
<h3 className='cardBlogMainInsideTitle'>{title}</h3>
|
||||
{nsfw && (
|
||||
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW IBMSMSMBSSTagsTagNSFWCard IBMSMSMBSSTagsTagNSFWCardAlt'>
|
||||
<p>NSFW</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className='cardBlogMainInside'
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
WebkitLineClamp: 2,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1.5,
|
||||
color: 'rgba(255, 255, 255, 0.75)',
|
||||
textShadow: '0 0 8px rgba(0, 0, 0, 0.25)'
|
||||
}}
|
||||
>
|
||||
This is a blog title, the best blog title in the world!
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>{' '}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
@ -1,333 +0,0 @@
|
||||
import { useLocalStorage } from 'hooks'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getGamePageRoute } from 'routes'
|
||||
import { ModFormState, Categories, Category } from 'types'
|
||||
import {
|
||||
getCategories,
|
||||
flattenCategories,
|
||||
addToUserCategories,
|
||||
capitalizeEachWord
|
||||
} from 'utils'
|
||||
|
||||
interface CategoryAutocompleteProps {
|
||||
game: string
|
||||
LTags: string[]
|
||||
setFormState: (value: React.SetStateAction<ModFormState>) => void
|
||||
}
|
||||
|
||||
export const CategoryAutocomplete = ({
|
||||
game,
|
||||
LTags,
|
||||
setFormState
|
||||
}: CategoryAutocompleteProps) => {
|
||||
// Fetch the hardcoded categories from assets
|
||||
const flattenedCategories = useMemo(() => getCategories(), [])
|
||||
|
||||
// Fetch the user categories from local storage
|
||||
const [userHierarchies, setUserHierarchies] = useLocalStorage<
|
||||
(string | Category)[]
|
||||
>('user-hierarchies', [])
|
||||
const flattenedUserCategories = useMemo(
|
||||
() => flattenCategories(userHierarchies, []),
|
||||
[userHierarchies]
|
||||
)
|
||||
|
||||
// Create options and select categories from the mod LTags (hierarchies)
|
||||
const { selectedCategories, combinedOptions } = useMemo(() => {
|
||||
const combinedCategories = [
|
||||
...flattenedCategories,
|
||||
...flattenedUserCategories
|
||||
]
|
||||
const hierarchies = LTags.map((hierarchy) => {
|
||||
const existingCategory = combinedCategories.find(
|
||||
(cat) => cat.hierarchy === hierarchy.replace(/:/g, ' > ')
|
||||
)
|
||||
if (existingCategory) {
|
||||
return existingCategory
|
||||
} else {
|
||||
const segments = hierarchy.split(':')
|
||||
const lastSegment = segments[segments.length - 1]
|
||||
return { name: lastSegment, hierarchy: hierarchy, l: [lastSegment] }
|
||||
}
|
||||
})
|
||||
|
||||
// Selected categorires (based on the LTags)
|
||||
const selectedCategories = Array.from(new Set([...hierarchies]))
|
||||
|
||||
// Combine user, predefined category hierarchies and selected values (LTags in case some are missing)
|
||||
const combinedOptions = Array.from(
|
||||
new Set([...combinedCategories, ...selectedCategories])
|
||||
)
|
||||
|
||||
return { selectedCategories, combinedOptions }
|
||||
}, [LTags, flattenedCategories, flattenedUserCategories])
|
||||
|
||||
const [inputValue, setInputValue] = useState<string>('')
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
combinedOptions.filter((option) =>
|
||||
option.hierarchy.toLowerCase().includes(inputValue.toLowerCase())
|
||||
),
|
||||
[combinedOptions, inputValue]
|
||||
)
|
||||
|
||||
const getSelectedCategories = (cats: Categories[]) => {
|
||||
const uniqueValues = new Set(
|
||||
cats.reduce<string[]>((prev, cat) => [...prev, ...cat.l], [])
|
||||
)
|
||||
const concatenatedValue = Array.from(uniqueValues)
|
||||
return concatenatedValue
|
||||
}
|
||||
const getSelectedHierarchy = (cats: Categories[]) => {
|
||||
const hierarchies = cats.reduce<string[]>(
|
||||
(prev, cat) => [...prev, cat.hierarchy.replace(/ > /g, ':')],
|
||||
[]
|
||||
)
|
||||
const concatenatedValue = Array.from(hierarchies)
|
||||
return concatenatedValue
|
||||
}
|
||||
const handleReset = () => {
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
['lTags']: [],
|
||||
['LTags']: []
|
||||
}))
|
||||
setInputValue('')
|
||||
}
|
||||
const handleRemove = (option: Categories) => {
|
||||
const updatedCategories = selectedCategories.filter(
|
||||
(cat) => cat.hierarchy !== option.hierarchy
|
||||
)
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
['lTags']: getSelectedCategories(updatedCategories),
|
||||
['LTags']: getSelectedHierarchy(updatedCategories)
|
||||
}))
|
||||
}
|
||||
const handleSelect = (option: Categories) => {
|
||||
if (!selectedCategories.some((cat) => cat.hierarchy === option.hierarchy)) {
|
||||
const updatedCategories = [...selectedCategories, option]
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
['lTags']: getSelectedCategories(updatedCategories),
|
||||
['LTags']: getSelectedHierarchy(updatedCategories)
|
||||
}))
|
||||
}
|
||||
setInputValue('')
|
||||
}
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
}
|
||||
const handleAddNew = () => {
|
||||
if (inputValue) {
|
||||
const value = inputValue.trim().toLowerCase()
|
||||
const values = value.split('>').map((s) => s.trim())
|
||||
const newOption: Categories = {
|
||||
name: value,
|
||||
hierarchy: value,
|
||||
l: values
|
||||
}
|
||||
setUserHierarchies((prev) => {
|
||||
addToUserCategories(prev, value)
|
||||
return [...prev]
|
||||
})
|
||||
const updatedCategories = [...selectedCategories, newOption]
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
['lTags']: getSelectedCategories(updatedCategories),
|
||||
['LTags']: getSelectedHierarchy(updatedCategories)
|
||||
}))
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
const handleAddNewCustom = (option: Categories) => {
|
||||
setUserHierarchies((prev) => {
|
||||
addToUserCategories(prev, option.hierarchy)
|
||||
return [...prev]
|
||||
})
|
||||
}
|
||||
|
||||
const Row = ({ index }: { index: number }) => {
|
||||
return (
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
|
||||
onClick={() => handleSelect(filteredOptions[index])}
|
||||
>
|
||||
{capitalizeEachWord(filteredOptions[index].hierarchy)}
|
||||
|
||||
{/* Show "Remove" button when the category is selected */}
|
||||
{selectedCategories.some(
|
||||
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
|
||||
) && (
|
||||
<button
|
||||
type='button'
|
||||
className='btn btnMain btnMainInsideField btnMainRemove'
|
||||
onClick={() => handleRemove(filteredOptions[index])}
|
||||
title='Remove'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M323.3 32.01H188.7C172.3 32.01 160 44.31 160 60.73V96.01H32C14.33 96.01 0 110.3 0 128S14.33 160 32 160H480c17.67 0 32-14.33 32-32.01S497.7 96.01 480 96.01H352v-35.28C352 44.31 339.7 32.01 323.3 32.01zM64.9 477.5C66.5 492.3 79.31 504 94.72 504H417.3c15.41 0 28.22-11.72 29.81-26.5L480 192.2H32L64.9 477.5z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show "Add" button when the category is not included in the predefined or userdefined lists */}
|
||||
{!flattenedCategories.some(
|
||||
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
|
||||
) &&
|
||||
!flattenedUserCategories.some(
|
||||
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
|
||||
) && (
|
||||
<button
|
||||
type='button'
|
||||
className='btn btnMain btnMainInsideField btnMainAdd'
|
||||
onClick={() => handleAddNewCustom(filteredOptions[index])}
|
||||
title='Add'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>Categories</label>
|
||||
<p className='labelDescriptionMain'>You can select multiple categories</p>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<div className='inputWrapperMain inputWrapperMainAlt'>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain inputMainWithBtn dropdown-toggle'
|
||||
placeholder='Select some categories...'
|
||||
data-bs-toggle='dropdown'
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<button
|
||||
className='btn btnMain btnMainInsideField btnMainRemove'
|
||||
title='Remove'
|
||||
type='button'
|
||||
onClick={handleReset}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt category'
|
||||
style={{
|
||||
maxHeight: '500px'
|
||||
}}
|
||||
>
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((c, i) => <Row key={c.hierarchy} index={i} />)
|
||||
) : (
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
|
||||
onClick={handleAddNew}
|
||||
>
|
||||
{inputValue &&
|
||||
!filteredOptions?.find(
|
||||
(option) =>
|
||||
option.hierarchy.toLowerCase() === inputValue.toLowerCase()
|
||||
) ? (
|
||||
<>
|
||||
Add "{inputValue}"
|
||||
<button
|
||||
type='button'
|
||||
className='btn btnMain btnMainInsideField btnMainAdd'
|
||||
title='Add'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>No matches</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{LTags.length > 0 && (
|
||||
<div className='IBMSMSMBSSCategories'>
|
||||
{LTags.map((hierarchy) => {
|
||||
const hierarchicalCategories = hierarchy.split(`:`)
|
||||
const categories = hierarchicalCategories
|
||||
.map<React.ReactNode>((c, i) => {
|
||||
const partialHierarchy = hierarchicalCategories
|
||||
.slice(0, i + 1)
|
||||
.join(':')
|
||||
|
||||
return game ? (
|
||||
<Link
|
||||
key={`category-${i}`}
|
||||
target='_blank'
|
||||
to={{
|
||||
pathname: getGamePageRoute(game),
|
||||
search: `h=${partialHierarchy}`
|
||||
}}
|
||||
className='IBMSMSMBSSCategoriesBoxItem'
|
||||
>
|
||||
<p>{capitalizeEachWord(c)}</p>
|
||||
</Link>
|
||||
) : (
|
||||
<p className='IBMSMSMBSSCategoriesBoxItem'>
|
||||
{capitalizeEachWord(c)}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
.reduce((prev, curr, i) => [
|
||||
prev,
|
||||
<div
|
||||
key={`separator-${i}`}
|
||||
className='IBMSMSMBSSCategoriesBoxSeparator'
|
||||
>
|
||||
<p>></p>
|
||||
</div>,
|
||||
curr
|
||||
])
|
||||
|
||||
return (
|
||||
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
|
||||
{categories}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,113 +0,0 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { DownloadUrl } from '../types'
|
||||
|
||||
export const DownloadDetailsPopup = ({
|
||||
title,
|
||||
url,
|
||||
hash,
|
||||
signatureKey,
|
||||
malwareScanLink,
|
||||
modVersion,
|
||||
customNote,
|
||||
mediaUrl,
|
||||
handleClose
|
||||
}: DownloadUrl & {
|
||||
handleClose: () => void
|
||||
}) => {
|
||||
return createPortal(
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>{title || 'Authentication Details'}</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTable'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>Download URL</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<p>{url}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>SHA-256 hash</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<p>{hash}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>Signature from</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<p>{signatureKey}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>Scan</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<p>{malwareScanLink}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>Mod Version</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<p>{modVersion}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>Note</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<p>{customNote}</p>
|
||||
</div>
|
||||
</div>
|
||||
{typeof mediaUrl !== 'undefined' && mediaUrl !== '' && (
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRow'>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol IBMSMSMBSSDownloadsElementInsideAltTableRowColFirst'>
|
||||
<p>Media</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol'>
|
||||
<img
|
||||
src={mediaUrl}
|
||||
className='IBMSMSMBSSDownloadsElementInsideAltTableRowCol_Img'
|
||||
alt=''
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
@ -1,148 +0,0 @@
|
||||
import { useAppSelector, useLocalStorage } from 'hooks'
|
||||
import React from 'react'
|
||||
import { FilterOptions, ModeratedFilter, SortBy, WOTFilterOptions } from 'types'
|
||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||
import { Dropdown } from './Dropdown'
|
||||
import { Option } from './Option'
|
||||
import { Filter } from '.'
|
||||
import { NsfwFilterOptions } from './NsfwFilterOptions'
|
||||
|
||||
type Props = {
|
||||
author?: string | undefined
|
||||
filterKey?: string | undefined
|
||||
}
|
||||
|
||||
export const BlogsFilter = React.memo(
|
||||
({ author, filterKey = 'filter-blog' }: Props) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||
filterKey,
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
|
||||
return (
|
||||
<Filter>
|
||||
{/* sort filter options */}
|
||||
<Dropdown label={filterOptions.sort}>
|
||||
{Object.values(SortBy).map((item, index) => (
|
||||
<div
|
||||
key={`sortByItem-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
sort: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</Dropdown>
|
||||
|
||||
{/* moderation filter options */}
|
||||
<Dropdown label={filterOptions.moderated}>
|
||||
{Object.values(ModeratedFilter).map((item) => {
|
||||
if (item === ModeratedFilter.Unmoderated_Fully) {
|
||||
const isAdmin =
|
||||
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||
|
||||
const isOwnProfile =
|
||||
author && userState.auth && userState.user?.pubkey === author
|
||||
|
||||
if (!(isAdmin || isOwnProfile)) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Option
|
||||
key={`sort-${item}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
moderated: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
)
|
||||
})}
|
||||
</Dropdown>
|
||||
|
||||
{/* wot filter options */}
|
||||
<Dropdown label={<>Trust: {filterOptions.wot}</>}>
|
||||
{Object.values(WOTFilterOptions).map((item, index) => {
|
||||
// when user is not logged in
|
||||
if (item === WOTFilterOptions.Site_And_Mine && !userState.auth) {
|
||||
return null
|
||||
}
|
||||
|
||||
// when logged in user not admin
|
||||
if (
|
||||
item === WOTFilterOptions.None ||
|
||||
item === WOTFilterOptions.Mine_Only ||
|
||||
item === WOTFilterOptions.Exclude
|
||||
) {
|
||||
const isWoTNpub =
|
||||
userState.user?.npub === import.meta.env.VITE_SITE_WOT_NPUB
|
||||
|
||||
const isOwnProfile =
|
||||
author && userState.auth && userState.user?.pubkey === author
|
||||
|
||||
if (!(isWoTNpub || isOwnProfile)) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Option
|
||||
key={`wotFilterOption-${index}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
wot: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
)
|
||||
})}
|
||||
</Dropdown>
|
||||
|
||||
{/* nsfw filter options */}
|
||||
<Dropdown label={filterOptions.nsfw}>
|
||||
<NsfwFilterOptions filterKey={filterKey} />
|
||||
</Dropdown>
|
||||
|
||||
{/* source filter options */}
|
||||
<Dropdown
|
||||
label={
|
||||
filterOptions.source === window.location.host
|
||||
? `Show From: ${filterOptions.source}`
|
||||
: 'Show All'
|
||||
}
|
||||
>
|
||||
<Option
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
source: window.location.host
|
||||
}))
|
||||
}
|
||||
>
|
||||
Show From: {window.location.host}
|
||||
</Option>
|
||||
<Option
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
source: 'Show All'
|
||||
}))
|
||||
}
|
||||
>
|
||||
Show All
|
||||
</Option>
|
||||
</Dropdown>
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
)
|
@ -1,3 +0,0 @@
|
||||
.noResult:not(:only-child) {
|
||||
display: none;
|
||||
}
|
@ -1,550 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Category } from 'types'
|
||||
import {
|
||||
addToUserCategories,
|
||||
capitalizeEachWord,
|
||||
deleteFromUserCategories,
|
||||
flattenCategories
|
||||
} from 'utils'
|
||||
import { useLocalStorage } from 'hooks'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import styles from './CategoryFilterPopup.module.scss'
|
||||
import categoriesData from './../../assets/categories/categories.json'
|
||||
|
||||
interface CategoryFilterPopupProps {
|
||||
categories: string[]
|
||||
setCategories: React.Dispatch<React.SetStateAction<string[]>>
|
||||
hierarchies: string[]
|
||||
setHierarchies: React.Dispatch<React.SetStateAction<string[]>>
|
||||
handleClose: () => void
|
||||
}
|
||||
|
||||
export const CategoryFilterPopup = ({
|
||||
categories,
|
||||
setCategories,
|
||||
hierarchies,
|
||||
setHierarchies,
|
||||
handleClose
|
||||
}: CategoryFilterPopupProps) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const linkedHierarchy = searchParams.get('h')
|
||||
|
||||
const [userHierarchies, setUserHierarchies] = useLocalStorage<
|
||||
(string | Category)[]
|
||||
>('user-hierarchies', [])
|
||||
const [filterCategories, setFilterCategories] = useState(categories)
|
||||
const [filterHierarchies, setFilterHierarchies] = useState(hierarchies)
|
||||
const handleApply = () => {
|
||||
// Update selection with linked category if it exists
|
||||
if (linkedHierarchy !== null && linkedHierarchy !== '') {
|
||||
// Combine existing selection with the linked
|
||||
setFilterHierarchies((prev) => {
|
||||
prev.push(linkedHierarchy)
|
||||
const newFilterHierarchies = Array.from(new Set([...prev]))
|
||||
setHierarchies(newFilterHierarchies)
|
||||
return newFilterHierarchies
|
||||
})
|
||||
// Clear hierarchy link in search params
|
||||
searchParams.delete('h')
|
||||
setSearchParams(searchParams)
|
||||
} else {
|
||||
setHierarchies(filterHierarchies)
|
||||
}
|
||||
setCategories(filterCategories)
|
||||
}
|
||||
const [inputValue, setInputValue] = useState<string>('')
|
||||
const userHierarchiesMatching = useMemo(
|
||||
() =>
|
||||
flattenCategories(userHierarchies, []).some((h) =>
|
||||
h.hierarchy.includes(inputValue.toLowerCase())
|
||||
),
|
||||
[inputValue, userHierarchies]
|
||||
)
|
||||
// const hierarchiesMatching = useMemo(
|
||||
// () =>
|
||||
// flattenCategories(categoriesData, []).some((h) =>
|
||||
// h.hierarchy.includes(inputValue.toLowerCase())
|
||||
// ),
|
||||
// [inputValue]
|
||||
// )
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
}
|
||||
const handleSingleSelection = (category: string, isSelected: boolean) => {
|
||||
let updatedCategories = [...filterCategories]
|
||||
if (isSelected) {
|
||||
updatedCategories.push(category)
|
||||
} else {
|
||||
updatedCategories = updatedCategories.filter((item) => item !== category)
|
||||
}
|
||||
setFilterCategories(updatedCategories)
|
||||
}
|
||||
const handleCombinationSelection = (path: string[], isSelected: boolean) => {
|
||||
const pathString = path.join(':')
|
||||
let updatedHierarchies = [...filterHierarchies]
|
||||
if (isSelected) {
|
||||
updatedHierarchies.push(pathString)
|
||||
} else {
|
||||
updatedHierarchies = updatedHierarchies.filter(
|
||||
(item) => item !== pathString
|
||||
)
|
||||
}
|
||||
setFilterHierarchies(updatedHierarchies)
|
||||
}
|
||||
const handleAddNew = () => {
|
||||
if (inputValue) {
|
||||
const value = inputValue.toLowerCase()
|
||||
const values = value
|
||||
.trim()
|
||||
.split('>')
|
||||
.map((s) => s.trim())
|
||||
|
||||
setUserHierarchies((prev) => {
|
||||
addToUserCategories(prev, value)
|
||||
return [...prev]
|
||||
})
|
||||
|
||||
const path = values.join(':')
|
||||
|
||||
// Add new hierarchy to current selection and active selection
|
||||
// Convert through set to remove duplicates
|
||||
setFilterHierarchies((prev) => {
|
||||
prev.push(path)
|
||||
return Array.from(new Set([...prev]))
|
||||
})
|
||||
setHierarchies((prev) => {
|
||||
prev.push(path)
|
||||
return Array.from(new Set([...prev]))
|
||||
})
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Categories filter</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<div className='pUMCB_ZapsInside'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Choose categories...
|
||||
</label>
|
||||
<p className='labelDescriptionMain'>
|
||||
Choose one or more pre-definied or custom categories to filter out mods with.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain inputMainWithBtn'
|
||||
placeholder='Select some categories...'
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
{userHierarchies.length > 0 && (
|
||||
<>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Custom categories
|
||||
</label>
|
||||
<p className='labelDescriptionMain'>Here's where your custom categories appear (You can add them in the above field. Example > banana > seed)</p>
|
||||
</div>
|
||||
<div
|
||||
className='inputMain'
|
||||
style={{
|
||||
minHeight: '40px',
|
||||
maxHeight: '500px',
|
||||
height: '100%',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
{!userHierarchiesMatching && <div>No results.</div>}
|
||||
{userHierarchies
|
||||
.filter((c) => typeof c !== 'string')
|
||||
.map((c, i) => (
|
||||
<CategoryCheckbox
|
||||
key={`${c}_${i}`}
|
||||
inputValue={inputValue}
|
||||
category={c}
|
||||
path={[c.name]}
|
||||
handleSingleSelection={handleSingleSelection}
|
||||
handleCombinationSelection={
|
||||
handleCombinationSelection
|
||||
}
|
||||
selectedSingles={filterCategories}
|
||||
selectedCombinations={filterHierarchies}
|
||||
handleRemove={(path) => {
|
||||
setUserHierarchies((prev) => {
|
||||
deleteFromUserCategories(prev, path.join('>'))
|
||||
return [...prev]
|
||||
})
|
||||
|
||||
// Remove the deleted hierarchies from current filter selection and active selection
|
||||
setFilterHierarchies((prev) =>
|
||||
prev.filter(
|
||||
(h) => !h.startsWith(path.join(':'))
|
||||
)
|
||||
)
|
||||
setHierarchies((prev) =>
|
||||
prev.filter(
|
||||
(h) => !h.startsWith(path.join(':'))
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Categories
|
||||
</label>
|
||||
<p className='labelDescriptionMain'>Here's where you select any of the pre-defined categories</p>
|
||||
</div>
|
||||
<div
|
||||
className='inputMain'
|
||||
style={{
|
||||
minHeight: '40px',
|
||||
maxHeight: '500px',
|
||||
height: '100%',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<div className={`${styles.noResult}`}>
|
||||
<div>No results.</div>
|
||||
<br />
|
||||
{userHierarchiesMatching ? (
|
||||
<div>Already defined in your categories</div>
|
||||
) : (
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
|
||||
onClick={handleAddNew}
|
||||
>
|
||||
Add and search for "{inputValue}" category
|
||||
<button
|
||||
type='button'
|
||||
className='btn btnMain btnMainInsideField btnMainAdd'
|
||||
title='Add'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(categoriesData as Category[]).map((category) => {
|
||||
const name =
|
||||
typeof category === 'string' ? category : category.name
|
||||
return (
|
||||
<CategoryCheckbox
|
||||
key={name}
|
||||
inputValue={inputValue}
|
||||
category={category}
|
||||
path={[name]}
|
||||
handleSingleSelection={handleSingleSelection}
|
||||
handleCombinationSelection={
|
||||
handleCombinationSelection
|
||||
}
|
||||
selectedSingles={filterCategories}
|
||||
selectedCombinations={filterHierarchies}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
gap: '10px'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={() => {
|
||||
// Clear the linked hierarchy
|
||||
searchParams.delete('h')
|
||||
setSearchParams(searchParams)
|
||||
|
||||
// Clear current filters
|
||||
setFilterCategories([])
|
||||
setFilterHierarchies([])
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain btnMainPopup'
|
||||
type='button'
|
||||
onPointerDown={() => {
|
||||
handleApply()
|
||||
handleClose()
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
interface CategoryCheckboxProps {
|
||||
inputValue: string
|
||||
category: Category | string
|
||||
path: string[]
|
||||
handleSingleSelection: (category: string, isSelected: boolean) => void
|
||||
handleCombinationSelection: (path: string[], isSelected: boolean) => void
|
||||
selectedSingles: string[]
|
||||
selectedCombinations: string[]
|
||||
indentLevel?: number
|
||||
handleRemove?: (path: string[]) => void
|
||||
}
|
||||
|
||||
const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
|
||||
inputValue,
|
||||
category,
|
||||
path,
|
||||
handleSingleSelection,
|
||||
handleCombinationSelection,
|
||||
selectedSingles,
|
||||
selectedCombinations,
|
||||
indentLevel = 0,
|
||||
handleRemove
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const linkedHierarchy = searchParams.get('h')
|
||||
const name = typeof category === 'string' ? category : category.name
|
||||
const hierarchy = path.join(' > ').toLowerCase()
|
||||
const isMatching = hierarchy.includes(inputValue.toLowerCase())
|
||||
const isLinked =
|
||||
linkedHierarchy !== null &&
|
||||
hierarchy === linkedHierarchy.replace(/:/g, ' > ')
|
||||
const [isSingleChecked, setIsSingleChecked] = useState<boolean>(false)
|
||||
const [isCombinationChecked, setIsCombinationChecked] =
|
||||
useState<boolean>(false)
|
||||
const [isIndeterminate, setIsIndeterminate] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const pathString = path.join(':')
|
||||
setIsSingleChecked(selectedSingles.includes(name))
|
||||
setIsCombinationChecked(selectedCombinations.includes(pathString))
|
||||
// Recursive function to gather all descendant paths
|
||||
const collectChildPaths = (
|
||||
category: string | Category,
|
||||
basePath: string[]
|
||||
) => {
|
||||
if (!category.sub || !Array.isArray(category.sub)) {
|
||||
return []
|
||||
}
|
||||
let paths: string[] = []
|
||||
for (const sub of category.sub) {
|
||||
const subPath =
|
||||
typeof sub === 'string'
|
||||
? [...basePath, sub].join(':')
|
||||
: [...basePath, sub.name].join(':')
|
||||
paths.push(subPath)
|
||||
if (typeof sub === 'object') {
|
||||
paths = paths.concat(collectChildPaths(sub, [...basePath, sub.name]))
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
const childPaths = collectChildPaths(category, path)
|
||||
const anyChildCombinationSelected = childPaths.some((childPath) =>
|
||||
selectedCombinations.includes(childPath)
|
||||
)
|
||||
const anyChildCombinationLinked = childPaths.some(
|
||||
(childPath) =>
|
||||
linkedHierarchy !== null && linkedHierarchy.includes(childPath)
|
||||
)
|
||||
setIsIndeterminate(
|
||||
(anyChildCombinationSelected || anyChildCombinationLinked) &&
|
||||
!selectedCombinations.includes(pathString)
|
||||
)
|
||||
}, [
|
||||
category,
|
||||
linkedHierarchy,
|
||||
name,
|
||||
path,
|
||||
selectedCombinations,
|
||||
selectedSingles
|
||||
])
|
||||
|
||||
const handleSingleChange = () => {
|
||||
setIsSingleChecked(!isSingleChecked)
|
||||
handleSingleSelection(name, !isSingleChecked)
|
||||
}
|
||||
|
||||
const handleCombinationChange = () => {
|
||||
// If combination is linked, clicking it again we will delete it
|
||||
if (isLinked) {
|
||||
searchParams.delete('h')
|
||||
setSearchParams(searchParams)
|
||||
} else {
|
||||
setIsCombinationChecked(!isCombinationChecked)
|
||||
handleCombinationSelection(path, !isCombinationChecked)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMatching && (
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory dropdownMainMenuItemCategoryAlt'
|
||||
style={{
|
||||
marginLeft: `${indentLevel * 20}px`,
|
||||
width: `calc(100% - ${indentLevel * 20}px)`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`inputLabelWrapperMain inputLabelWrapperMainAlt stylized`}
|
||||
style={{
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={name}
|
||||
type='checkbox'
|
||||
ref={(input) => {
|
||||
if (input) {
|
||||
input.indeterminate = isIndeterminate
|
||||
}
|
||||
}}
|
||||
className={`CheckboxMain ${
|
||||
isIndeterminate ? 'CheckboxIndeterminate' : ''
|
||||
}`}
|
||||
checked={isCombinationChecked || isLinked}
|
||||
onChange={handleCombinationChange}
|
||||
/>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className='form-label labelMain labelMainCategory'
|
||||
>
|
||||
{capitalizeEachWord(name)}
|
||||
</label>
|
||||
<input
|
||||
style={{
|
||||
display: 'none'
|
||||
}}
|
||||
id={name}
|
||||
type='checkbox'
|
||||
className='CheckboxMain'
|
||||
name={name}
|
||||
checked={isSingleChecked}
|
||||
onChange={handleSingleChange}
|
||||
/>
|
||||
{typeof handleRemove === 'function' && (
|
||||
<button
|
||||
className='btn btnMain btnMainInsideField btnMainRemove'
|
||||
title='Remove'
|
||||
type='button'
|
||||
onClick={() => handleRemove(path)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M323.3 32.01H188.7C172.3 32.01 160 44.31 160 60.73V96.01H32C14.33 96.01 0 110.3 0 128S14.33 160 32 160H480c17.67 0 32-14.33 32-32.01S497.7 96.01 480 96.01H352v-35.28C352 44.31 339.7 32.01 323.3 32.01zM64.9 477.5C66.5 492.3 79.31 504 94.72 504H417.3c15.41 0 28.22-11.72 29.81-26.5L480 192.2H32L64.9 477.5z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{typeof category !== 'string' &&
|
||||
category.sub &&
|
||||
Array.isArray(category.sub) && (
|
||||
<>
|
||||
{category.sub.map((subCategory) => {
|
||||
if (typeof subCategory === 'string') {
|
||||
return (
|
||||
<CategoryCheckbox
|
||||
inputValue={inputValue}
|
||||
key={`${category.name}-${subCategory}`}
|
||||
category={{ name: subCategory }}
|
||||
path={[...path, subCategory]}
|
||||
handleSingleSelection={handleSingleSelection}
|
||||
handleCombinationSelection={handleCombinationSelection}
|
||||
selectedSingles={selectedSingles}
|
||||
selectedCombinations={selectedCombinations}
|
||||
indentLevel={indentLevel + 1}
|
||||
handleRemove={handleRemove}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<CategoryCheckbox
|
||||
inputValue={inputValue}
|
||||
key={subCategory.name}
|
||||
category={subCategory}
|
||||
path={[...path, subCategory.name]}
|
||||
handleSingleSelection={handleSingleSelection}
|
||||
handleCombinationSelection={handleCombinationSelection}
|
||||
selectedSingles={selectedSingles}
|
||||
selectedCombinations={selectedCombinations}
|
||||
indentLevel={indentLevel + 1}
|
||||
handleRemove={handleRemove}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
interface DropdownProps {
|
||||
label: React.ReactNode
|
||||
}
|
||||
export const Dropdown = ({
|
||||
label,
|
||||
children
|
||||
}: PropsWithChildren<DropdownProps>) => {
|
||||
return (
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,176 +0,0 @@
|
||||
import { useAppSelector, useLocalStorage } from 'hooks'
|
||||
import React, { PropsWithChildren } from 'react'
|
||||
import {
|
||||
FilterOptions,
|
||||
SortBy,
|
||||
ModeratedFilter,
|
||||
WOTFilterOptions,
|
||||
RepostFilter
|
||||
} from 'types'
|
||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||
import { Filter } from '.'
|
||||
import { Dropdown } from './Dropdown'
|
||||
import { Option } from './Option'
|
||||
import { NsfwFilterOptions } from './NsfwFilterOptions'
|
||||
|
||||
type Props = {
|
||||
author?: string | undefined
|
||||
filterKey?: string | undefined
|
||||
}
|
||||
|
||||
export const ModFilter = React.memo(
|
||||
({ author, filterKey = 'filter', children }: PropsWithChildren<Props>) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||
filterKey,
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
|
||||
return (
|
||||
<Filter>
|
||||
{/* sort filter options */}
|
||||
<Dropdown label={filterOptions.sort}>
|
||||
{Object.values(SortBy).map((item, index) => (
|
||||
<Option
|
||||
key={`sortByItem-${index}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
sort: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
))}
|
||||
</Dropdown>
|
||||
|
||||
{/* moderation filter options */}
|
||||
<Dropdown label={filterOptions.moderated}>
|
||||
{Object.values(ModeratedFilter).map((item, index) => {
|
||||
const isAdmin =
|
||||
userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||
|
||||
if (item === ModeratedFilter.Only_Blocked && !isAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (item === ModeratedFilter.Unmoderated_Fully) {
|
||||
const isOwnProfile =
|
||||
author && userState.auth && userState.user?.pubkey === author
|
||||
|
||||
if (!(isAdmin || isOwnProfile)) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Option
|
||||
key={`moderatedFilterItem-${index}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
moderated: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
)
|
||||
})}
|
||||
</Dropdown>
|
||||
|
||||
{/* wot filter options */}
|
||||
<Dropdown label={<>Trust: {filterOptions.wot}</>}>
|
||||
{Object.values(WOTFilterOptions).map((item, index) => {
|
||||
// when user is not logged in
|
||||
if (item === WOTFilterOptions.Site_And_Mine && !userState.auth) {
|
||||
return null
|
||||
}
|
||||
|
||||
// when logged in user not admin
|
||||
if (
|
||||
item === WOTFilterOptions.None ||
|
||||
item === WOTFilterOptions.Mine_Only ||
|
||||
item === WOTFilterOptions.Exclude
|
||||
) {
|
||||
const isWoTNpub =
|
||||
userState.user?.npub === import.meta.env.VITE_SITE_WOT_NPUB
|
||||
|
||||
const isOwnProfile =
|
||||
author && userState.auth && userState.user?.pubkey === author
|
||||
|
||||
if (!(isWoTNpub || isOwnProfile)) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Option
|
||||
key={`wotFilterOption-${index}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
wot: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
)
|
||||
})}
|
||||
</Dropdown>
|
||||
|
||||
{/* nsfw filter options */}
|
||||
<Dropdown label={filterOptions.nsfw}>
|
||||
<NsfwFilterOptions filterKey={filterKey} />
|
||||
</Dropdown>
|
||||
|
||||
{/* repost filter options */}
|
||||
<Dropdown label={filterOptions.repost}>
|
||||
{Object.values(RepostFilter).map((item, index) => (
|
||||
<Option
|
||||
key={`repostFilterItem-${index}`}
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
repost: item
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
))}
|
||||
</Dropdown>
|
||||
|
||||
{/* source filter options */}
|
||||
<Dropdown
|
||||
label={
|
||||
filterOptions.source === window.location.host
|
||||
? `Show From: ${filterOptions.source}`
|
||||
: 'Show All'
|
||||
}
|
||||
>
|
||||
<Option
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
source: window.location.host
|
||||
}))
|
||||
}
|
||||
>
|
||||
Show From: {window.location.host}
|
||||
</Option>
|
||||
<Option
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
source: 'Show All'
|
||||
}))
|
||||
}
|
||||
>
|
||||
Show All
|
||||
</Option>
|
||||
</Dropdown>
|
||||
|
||||
{children}
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
)
|
@ -1,64 +0,0 @@
|
||||
import { FilterOptions, NSFWFilter } from 'types'
|
||||
import { Option } from './Option'
|
||||
import { NsfwAlertPopup } from 'components/NsfwAlertPopup'
|
||||
import { useState } from 'react'
|
||||
import { useLocalStorage } from 'hooks'
|
||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||
|
||||
interface NsfwFilterOptionsProps {
|
||||
filterKey: string
|
||||
}
|
||||
|
||||
export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => {
|
||||
const [, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||
filterKey,
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(false)
|
||||
const [selectedNsfwOption, setSelectedNsfwOption] = useState<
|
||||
NSFWFilter | undefined
|
||||
>()
|
||||
const [confirmNsfw] = useLocalStorage<boolean>('confirm-nsfw', false)
|
||||
const handleConfirm = (confirm: boolean) => {
|
||||
if (confirm && selectedNsfwOption) {
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
nsfw: selectedNsfwOption
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.values(NSFWFilter).map((item, index) => (
|
||||
<Option
|
||||
key={`nsfwFilterItem-${index}`}
|
||||
onClick={() => {
|
||||
// Trigger NSFW popup
|
||||
if (
|
||||
(item === NSFWFilter.Only_NSFW ||
|
||||
item === NSFWFilter.Show_NSFW) &&
|
||||
!confirmNsfw
|
||||
) {
|
||||
setSelectedNsfwOption(item)
|
||||
setShowNsfwPopup(true)
|
||||
} else {
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
nsfw: item
|
||||
}))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</Option>
|
||||
))}
|
||||
{showNsfwPopup && (
|
||||
<NsfwAlertPopup
|
||||
handleConfirm={handleConfirm}
|
||||
handleClose={() => setShowNsfwPopup(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
interface OptionProps {
|
||||
onClick: React.MouseEventHandler<HTMLDivElement>
|
||||
}
|
||||
|
||||
export const Option = ({
|
||||
onClick,
|
||||
children
|
||||
}: PropsWithChildren<OptionProps>) => {
|
||||
return (
|
||||
<div className='dropdown-item dropdownMainMenuItem' onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
export const Filter = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
<div className='FiltersMain'>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
290
src/components/Inputs.tsx
Normal file
290
src/components/Inputs.tsx
Normal file
@ -0,0 +1,290 @@
|
||||
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'
|
||||
import '../styles/tiptap.scss'
|
||||
|
||||
interface InputFieldProps {
|
||||
label: string
|
||||
description?: string
|
||||
type?: 'text' | 'textarea' | 'richtext'
|
||||
placeholder: string
|
||||
name: string
|
||||
inputMode?: 'url'
|
||||
value: string
|
||||
error?: string
|
||||
onChange: (name: string, value: string) => void
|
||||
}
|
||||
|
||||
export const InputField = React.memo(
|
||||
({
|
||||
label,
|
||||
description,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
name,
|
||||
inputMode,
|
||||
value,
|
||||
error,
|
||||
onChange
|
||||
}: InputFieldProps) => {
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
onChange(name, e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>{label}</label>
|
||||
{description && <p className='labelDescriptionMain'>{description}</p>}
|
||||
{type === 'textarea' ? (
|
||||
<textarea
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
></textarea>
|
||||
) : type === 'richtext' ? (
|
||||
<RichTextEditor
|
||||
content={value}
|
||||
updateContent={(content) => onChange(name, content)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
inputMode={inputMode}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
{error && <InputError message={error} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
type InputErrorProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const InputError = ({ message }: InputErrorProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className='errorMain'>
|
||||
<div className='errorMainColor'></div>
|
||||
<p className='errorMainText'>{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CheckboxFieldProps {
|
||||
label: string
|
||||
name: string
|
||||
isChecked: boolean
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
}
|
||||
|
||||
export const CheckboxField = React.memo(
|
||||
({ label, name, isChecked, handleChange }: CheckboxFieldProps) => (
|
||||
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
|
||||
<label className='form-label labelMain'>{label}</label>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='CheckboxMain'
|
||||
name={name}
|
||||
checked={isChecked}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
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,14 +0,0 @@
|
||||
type InputErrorProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const InputError = ({ message }: InputErrorProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className='errorMain'>
|
||||
<div className='errorMainColor'></div>
|
||||
<p className='errorMainText'>{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
.spinner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
-webkit-backdrop-filter: blur(1px);
|
||||
backdrop-filter: blur(1px);
|
||||
pointer-events: none;
|
||||
}
|
@ -1,126 +0,0 @@
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
MediaOption,
|
||||
MEDIA_OPTIONS,
|
||||
ImageController,
|
||||
MEDIA_DROPZONE_OPTIONS
|
||||
} from '../../controllers'
|
||||
import { errorFeedback } from '../../types'
|
||||
import { MediaInputPopover } from './MediaInputPopover'
|
||||
import { Spinner } from 'components/Spinner'
|
||||
import styles from './ImageUpload.module.scss'
|
||||
|
||||
export interface ImageUploadProps {
|
||||
multiple?: boolean | undefined
|
||||
onChange: (values: string[]) => void
|
||||
}
|
||||
export const ImageUpload = React.memo(
|
||||
({ multiple = false, onChange }: ImageUploadProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [mediaOption, setMediaOption] = useState<MediaOption>(
|
||||
MEDIA_OPTIONS[0]
|
||||
)
|
||||
const handleOptionChange = useCallback(
|
||||
(mo: MediaOption) => () => {
|
||||
setMediaOption(mo)
|
||||
},
|
||||
[]
|
||||
)
|
||||
const handleUpload = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length) {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const imageController = new ImageController(mediaOption)
|
||||
const urls: string[] = []
|
||||
for (let i = 0; i < acceptedFiles.length; i++) {
|
||||
const file = acceptedFiles[i]
|
||||
urls.push(await imageController.post(file))
|
||||
}
|
||||
onChange(urls)
|
||||
} catch (error) {
|
||||
errorFeedback(error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[mediaOption, onChange]
|
||||
)
|
||||
const {
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
acceptedFiles,
|
||||
isFileDialogActive,
|
||||
isDragAccept,
|
||||
isDragReject,
|
||||
fileRejections
|
||||
} = useDropzone({
|
||||
...MEDIA_DROPZONE_OPTIONS,
|
||||
onDrop: handleUpload,
|
||||
multiple: multiple
|
||||
})
|
||||
|
||||
const dropzoneLabel = useMemo(
|
||||
() =>
|
||||
isFileDialogActive
|
||||
? 'Select files in dialog'
|
||||
: isDragActive
|
||||
? isDragAccept
|
||||
? 'Drop the files here...'
|
||||
: isDragReject
|
||||
? 'Drop the files here (one more more unsupported types)...'
|
||||
: 'Drop the files here...'
|
||||
: 'Click or drag files here',
|
||||
[isDragAccept, isDragActive, isDragReject, isFileDialogActive]
|
||||
)
|
||||
|
||||
return (
|
||||
<div aria-label='upload featuredImageUrl' className='uploadBoxMain'>
|
||||
<MediaInputPopover
|
||||
acceptedFiles={acceptedFiles}
|
||||
fileRejections={fileRejections}
|
||||
/>
|
||||
<div className='uploadBoxMainInside' {...getRootProps()} tabIndex={-1}>
|
||||
<input id='featuredImageUrl-upload' {...getInputProps()} />
|
||||
<span>{dropzoneLabel}</span>
|
||||
<div
|
||||
className='FiltersMainElement'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
Image Host: {mediaOption.name}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{MEDIA_OPTIONS.map((mo) => {
|
||||
return (
|
||||
<div
|
||||
key={mo.host}
|
||||
onClick={handleOptionChange(mo)}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
>
|
||||
{mo.name}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className={styles.spinner}>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
@ -1,14 +0,0 @@
|
||||
.accordion-button::after {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5) !important;
|
||||
|
||||
top: unset !important;
|
||||
bottom: unset !important;
|
||||
}
|
||||
.accordion-body > * {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.accordion-item + .accordion-item {
|
||||
margin-top: 10px;
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
import { FileError } from 'react-dropzone'
|
||||
import styles from './MediaInputError.module.scss'
|
||||
|
||||
type MediaInputErrorProps = {
|
||||
rootId: string
|
||||
index: number
|
||||
message: string
|
||||
errors?: readonly FileError[] | undefined
|
||||
}
|
||||
|
||||
export const MediaInputError = ({
|
||||
rootId,
|
||||
index,
|
||||
message,
|
||||
errors
|
||||
}: MediaInputErrorProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className={['accordion-item', styles['accordion-item']].join(' ')}>
|
||||
<h2 className='accordion-header' role='tab'>
|
||||
<button
|
||||
className={[
|
||||
'accordion-button collapsed',
|
||||
styles['accordion-button']
|
||||
].join(' ')}
|
||||
type='button'
|
||||
data-bs-toggle='collapse'
|
||||
data-bs-target={`#${rootId} .item-${index}`}
|
||||
aria-expanded='false'
|
||||
aria-controls={`${rootId} .item-${index}`}
|
||||
>
|
||||
<div className='errorMain'>
|
||||
<div className='errorMainColor'></div>
|
||||
<p className='errorMainText'>{message}</p>
|
||||
</div>
|
||||
</button>
|
||||
</h2>
|
||||
{errors && (
|
||||
<div
|
||||
className={`accordion-collapse collapse item-${index}`}
|
||||
role='tabpanel'
|
||||
data-bs-parent={`#${rootId}`}
|
||||
>
|
||||
<div
|
||||
className={['accordion-body', styles['accordion-body']].join(' ')}
|
||||
>
|
||||
{errors.map((e) => {
|
||||
return typeof e === 'string' ? (
|
||||
<div className='errorMain' key={e}>
|
||||
{e}
|
||||
</div>
|
||||
) : (
|
||||
<div className='errorMain' key={e.code}>
|
||||
{e.message}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
.popover {
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 0 16px 0px rgb(0 0 0 / 15%);
|
||||
background: #232323;
|
||||
z-index: 2;
|
||||
}
|
||||
.content {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding: 25px;
|
||||
> *:not(:first-child) {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
.trigger {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
right: 25px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.mediaInputError {
|
||||
--bs-accordion-color: unset;
|
||||
--bs-accordion-bg: unset;
|
||||
--bs-accordion-transition: unset;
|
||||
--bs-accordion-border-color: unset;
|
||||
--bs-accordion-border-width: unset;
|
||||
--bs-accordion-border-radius: unset;
|
||||
--bs-accordion-inner-border-radius: unset;
|
||||
--bs-accordion-btn-padding-x: unset;
|
||||
--bs-accordion-btn-padding-y: unset;
|
||||
--bs-accordion-btn-color: unset;
|
||||
--bs-accordion-btn-bg: unset;
|
||||
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='gray'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||
--bs-accordion-btn-icon-width: 1.25rem;
|
||||
--bs-accordion-btn-icon-transform: rotate(-180deg);
|
||||
--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;
|
||||
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='gray'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
|
||||
--bs-accordion-btn-focus-border-color: unset;
|
||||
--bs-accordion-btn-focus-box-shadow: unset;
|
||||
--bs-accordion-body-padding-x: unset;
|
||||
--bs-accordion-body-padding-y: unset;
|
||||
--bs-accordion-active-color: unset;
|
||||
--bs-accordion-active-bg: unset;
|
||||
}
|
@ -1,108 +0,0 @@
|
||||
import * as Popover from '@radix-ui/react-popover'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useMemo } from 'react'
|
||||
import { FileRejection, FileWithPath } from 'react-dropzone'
|
||||
import { MediaInputError } from './MediaInputError'
|
||||
import { InputSuccess } from './Success'
|
||||
import styles from './MediaInputPopover.module.scss'
|
||||
|
||||
interface MediaInputPopoverProps {
|
||||
acceptedFiles: readonly FileWithPath[]
|
||||
fileRejections: readonly FileRejection[]
|
||||
}
|
||||
|
||||
export const MediaInputPopover = ({
|
||||
acceptedFiles,
|
||||
fileRejections
|
||||
}: MediaInputPopoverProps) => {
|
||||
const uuid = useMemo(() => uuidv4(), [])
|
||||
const acceptedFileItems = useMemo(
|
||||
() =>
|
||||
acceptedFiles.map((file) => (
|
||||
<InputSuccess
|
||||
key={file.path}
|
||||
message={`${file.path} - ${file.size} bytes`}
|
||||
/>
|
||||
)),
|
||||
[acceptedFiles]
|
||||
)
|
||||
const fileRejectionItems = useMemo(() => {
|
||||
const id = `errors-${uuid}`
|
||||
return (
|
||||
<div
|
||||
className={`accordion accordion-flush ${styles.mediaInputError}`}
|
||||
role='tablist'
|
||||
id={id}
|
||||
>
|
||||
{fileRejections.map(({ file, errors }, index) => (
|
||||
<MediaInputError
|
||||
rootId={id}
|
||||
index={index}
|
||||
key={file.path}
|
||||
message={`${file.path} - ${file.size} bytes`}
|
||||
errors={errors}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}, [fileRejections, uuid])
|
||||
|
||||
if (acceptedFiles.length === 0 && fileRejections.length === 0) return null
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<div className={styles.trigger}>
|
||||
{acceptedFiles.length > 0 ? (
|
||||
<svg
|
||||
width='1.5em'
|
||||
height='1.5em'
|
||||
fill='currentColor'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 576 512'
|
||||
>
|
||||
<path d='M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 38.6C310.1 219.5 256 287.4 256 368c0 59.1 29.1 111.3 73.7 143.3c-3.2 .5-6.4 .7-9.7 .7L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128zM288 368a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm211.3-43.3c-6.2-6.2-16.4-6.2-22.6 0L416 385.4l-28.7-28.7c-6.2-6.2-16.4-6.2-22.6 0s-6.2 16.4 0 22.6l40 40c6.2 6.2 16.4 6.2 22.6 0l72-72c6.2-6.2 6.2-16.4 0-22.6z' />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width='1.5em'
|
||||
height='1.5em'
|
||||
fill='tomato'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 576 512'
|
||||
>
|
||||
<path d='M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 38.6C310.1 219.5 256 287.4 256 368c0 59.1 29.1 111.3 73.7 143.3c-3.2 .5-6.4 .7-9.7 .7L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128zm48 96a144 144 0 1 1 0 288 144 144 0 1 1 0-288zm0 240a24 24 0 1 0 0-48 24 24 0 1 0 0 48zm0-192c-8.8 0-16 7.2-16 16l0 80c0 8.8 7.2 16 16 16s16-7.2 16-16l0-80c0-8.8-7.2-16-16-16z' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content className={styles.popover} sideOffset={5}>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Selected files</h3>
|
||||
</div>
|
||||
<Popover.Close asChild aria-label='Close'>
|
||||
<div className='popUpMainCardTopClose'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</Popover.Close>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{acceptedFileItems}
|
||||
{fileRejectionItems}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
type InputSuccessProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const InputSuccess = ({ message }: InputSuccessProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className='successMain'>
|
||||
<div className='successMainColor'></div>
|
||||
<p className='successMainText'>{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,206 +0,0 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { InputError } from './Error'
|
||||
import { ImageUpload } from './ImageUpload'
|
||||
import '../../styles/styles.css'
|
||||
|
||||
interface InputFieldProps {
|
||||
label: string | React.ReactElement
|
||||
description?: string
|
||||
type?: 'text' | 'textarea'
|
||||
placeholder: string
|
||||
name: string
|
||||
inputMode?: 'url'
|
||||
value: string
|
||||
error?: string
|
||||
onChange: (name: string, value: string) => void
|
||||
}
|
||||
|
||||
export const InputField = React.memo(
|
||||
({
|
||||
label,
|
||||
description,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
name,
|
||||
inputMode,
|
||||
value,
|
||||
error,
|
||||
onChange
|
||||
}: InputFieldProps) => {
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
onChange(name, e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>{label}</label>
|
||||
{description && <p className='labelDescriptionMain'>{description}</p>}
|
||||
{type === 'textarea' ? (
|
||||
<textarea
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
></textarea>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
inputMode={inputMode}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
{error && <InputError message={error} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
interface CheckboxFieldProps {
|
||||
label: string
|
||||
name: string
|
||||
isChecked: boolean
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
type?: 'default' | 'stylized'
|
||||
}
|
||||
|
||||
export const CheckboxField = React.memo(
|
||||
({
|
||||
label,
|
||||
name,
|
||||
isChecked,
|
||||
handleChange,
|
||||
type = 'default'
|
||||
}: CheckboxFieldProps) => (
|
||||
<div
|
||||
className={`inputLabelWrapperMain inputLabelWrapperMainAlt${
|
||||
type === 'stylized' ? ` inputLabelWrapperMainAltStylized` : ''
|
||||
}`}
|
||||
>
|
||||
<label htmlFor={name} className='form-label labelMain'>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={name}
|
||||
type='checkbox'
|
||||
className='CheckboxMain'
|
||||
name={name}
|
||||
checked={isChecked}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
||||
label: string | React.ReactElement
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
/**
|
||||
* Uncontrolled input component with design classes, label, description and error support
|
||||
*
|
||||
* Extends {@link React.ComponentProps<'input'> React.ComponentProps<'input'>}
|
||||
* @param label
|
||||
* @param description
|
||||
* @param error
|
||||
*
|
||||
* @see {@link React.ComponentProps<'input'>}
|
||||
*/
|
||||
export const InputFieldUncontrolled = ({
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
...rest
|
||||
}: InputFieldUncontrolledProps) => (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label htmlFor={rest.id} className='form-label labelMain'>
|
||||
{label}
|
||||
</label>
|
||||
{description && <p className='labelDescriptionMain'>{description}</p>}
|
||||
<input className='inputMain' {...rest} />
|
||||
{error && <InputError message={error} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
||||
label: string
|
||||
}
|
||||
|
||||
export const CheckboxFieldUncontrolled = ({
|
||||
label,
|
||||
...rest
|
||||
}: CheckboxFieldUncontrolledProps) => (
|
||||
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
|
||||
<label htmlFor={rest.id} className='form-label labelMain'>
|
||||
{label}
|
||||
</label>
|
||||
<input type='checkbox' className='CheckboxMain' {...rest} />
|
||||
</div>
|
||||
)
|
||||
|
||||
interface InputFieldWithImageUploadProps {
|
||||
label: string | React.ReactElement
|
||||
description?: string
|
||||
placeholder: string
|
||||
name: string
|
||||
inputMode?: 'url'
|
||||
value: string
|
||||
error?: string
|
||||
onInputChange: (name: string, value: string) => void
|
||||
}
|
||||
|
||||
export const InputFieldWithImageUpload = React.memo(
|
||||
({
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
name,
|
||||
inputMode,
|
||||
value,
|
||||
error,
|
||||
onInputChange
|
||||
}: InputFieldWithImageUploadProps) => {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
onInputChange(name, e.currentTarget.value)
|
||||
},
|
||||
[name, onInputChange]
|
||||
)
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(values: string[]) => {
|
||||
onInputChange(name, values[0])
|
||||
},
|
||||
[name, onInputChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>{label}</label>
|
||||
{typeof description !== 'undefined' && (
|
||||
<p className='labelDescriptionMain'>{description}</p>
|
||||
)}
|
||||
|
||||
<ImageUpload onChange={handleFileChange} />
|
||||
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
inputMode={inputMode}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{error && <InputError message={error} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
@ -1,42 +0,0 @@
|
||||
import { Addressable } from 'types'
|
||||
import { abbreviateNumber } from 'utils'
|
||||
import { Zap } from './Zap'
|
||||
import { Reactions } from './Reactions'
|
||||
|
||||
type InteractionsProps = {
|
||||
addressable: Addressable
|
||||
commentCount: number
|
||||
}
|
||||
|
||||
export const Interactions = ({
|
||||
addressable,
|
||||
commentCount
|
||||
}: InteractionsProps) => {
|
||||
return (
|
||||
<div className='IBMSMSplitMainBigSideSec'>
|
||||
<div className='IBMSMSMBSS_Details'>
|
||||
<a style={{ textDecoration: 'unset', color: 'unset' }}>
|
||||
<div className='IBMSMSMBSS_Details_Card IBMSMSMBSS_D_CComments'>
|
||||
<div className='IBMSMSMBSS_Details_CardVisual'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSS_Details_CardVisualIcon'
|
||||
>
|
||||
<path d='M256 31.1c-141.4 0-255.1 93.12-255.1 208c0 49.62 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734c1.249 3 4.021 4.766 7.271 4.766c66.25 0 115.1-31.76 140.6-51.39c32.63 12.25 69.02 19.39 107.4 19.39c141.4 0 255.1-93.13 255.1-207.1S397.4 31.1 256 31.1zM127.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S145.7 271.1 127.1 271.1zM256 271.1c-17.75 0-31.1-14.25-31.1-31.1s14.25-32 31.1-32s31.1 14.25 31.1 32S273.8 271.1 256 271.1zM383.1 271.1c-17.75 0-32-14.25-32-31.1s14.25-32 32-32s32 14.25 32 32S401.7 271.1 383.1 271.1z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className='IBMSMSMBSS_Details_CardText'>
|
||||
{abbreviateNumber(commentCount)}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<Zap addressable={addressable} />
|
||||
<Reactions addressable={addressable} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
import { formatDate } from 'date-fns'
|
||||
|
||||
type PublishDetailsProps = {
|
||||
published_at: number
|
||||
edited_at: number
|
||||
site: string
|
||||
}
|
||||
|
||||
export const PublishDetails = ({
|
||||
published_at,
|
||||
edited_at,
|
||||
site
|
||||
}: PublishDetailsProps) => {
|
||||
return (
|
||||
<div className='IBMSMSplitMainBigSideSec'>
|
||||
<div className='IBMSMSMBSSPost_PostDetails'>
|
||||
<div
|
||||
data-bs-toggle='tooltip'
|
||||
data-bs-placement='left'
|
||||
className='IBMSMSMBSSPost_PDElement'
|
||||
title='Publish date'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSPost_PDElementIcon'
|
||||
data-bs-toggle='tooltip'
|
||||
data-bss-tooltip
|
||||
aria-label='Publish date'
|
||||
>
|
||||
<path d='M480 32H128C110.3 32 96 46.33 96 64v336C96 408.8 88.84 416 80 416S64 408.8 64 400V96H32C14.33 96 0 110.3 0 128v288c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V64C512 46.33 497.7 32 480 32zM272 416h-96C167.2 416 160 408.8 160 400C160 391.2 167.2 384 176 384h96c8.836 0 16 7.162 16 16C288 408.8 280.8 416 272 416zM272 320h-96C167.2 320 160 312.8 160 304C160 295.2 167.2 288 176 288h96C280.8 288 288 295.2 288 304C288 312.8 280.8 320 272 320zM432 416h-96c-8.836 0-16-7.164-16-16c0-8.838 7.164-16 16-16h96c8.836 0 16 7.162 16 16C448 408.8 440.8 416 432 416zM432 320h-96C327.2 320 320 312.8 320 304C320 295.2 327.2 288 336 288h96C440.8 288 448 295.2 448 304C448 312.8 440.8 320 432 320zM448 208C448 216.8 440.8 224 432 224h-256C167.2 224 160 216.8 160 208v-96C160 103.2 167.2 96 176 96h256C440.8 96 448 103.2 448 112V208z' />
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSPost_PDElementText'>
|
||||
{formatDate(
|
||||
(published_at !== -1 ? published_at : edited_at) * 1000,
|
||||
'dd/MM/yyyy hh:mm:ss aa'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
data-bs-toggle='tooltip'
|
||||
data-bs-placement='left'
|
||||
className='IBMSMSMBSSPost_PDElement'
|
||||
title='Last modified'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSPost_PDElementIcon'
|
||||
>
|
||||
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z' />
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSPost_PDElementText'>
|
||||
{formatDate(edited_at * 1000, 'dd/MM/yyyy hh:mm:ss aa')}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
data-bs-toggle='tooltip'
|
||||
data-bs-placement='left'
|
||||
className='IBMSMSMBSSPost_PDElement IBMSMSMBSSPost_PDElementLink'
|
||||
href='#'
|
||||
title='Published on'
|
||||
target='_blank'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -64 640 640'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSPost_PDElementIcon'
|
||||
>
|
||||
<path d='M172.5 131.1C228.1 75.51 320.5 75.51 376.1 131.1C426.1 181.1 433.5 260.8 392.4 318.3L391.3 319.9C381 334.2 361 337.6 346.7 327.3C332.3 317 328.9 297 339.2 282.7L340.3 281.1C363.2 249 359.6 205.1 331.7 177.2C300.3 145.8 249.2 145.8 217.7 177.2L105.5 289.5C73.99 320.1 73.99 372 105.5 403.5C133.3 431.4 177.3 435 209.3 412.1L210.9 410.1C225.3 400.7 245.3 404 255.5 418.4C265.8 432.8 262.5 452.8 248.1 463.1L246.5 464.2C188.1 505.3 110.2 498.7 60.21 448.8C3.741 392.3 3.741 300.7 60.21 244.3L172.5 131.1zM467.5 380C411 436.5 319.5 436.5 263 380C213 330 206.5 251.2 247.6 193.7L248.7 192.1C258.1 177.8 278.1 174.4 293.3 184.7C307.7 194.1 311.1 214.1 300.8 229.3L299.7 230.9C276.8 262.1 280.4 306.9 308.3 334.8C339.7 366.2 390.8 366.2 422.3 334.8L534.5 222.5C566 191 566 139.1 534.5 108.5C506.7 80.63 462.7 76.99 430.7 99.9L429.1 101C414.7 111.3 394.7 107.1 384.5 93.58C374.2 79.2 377.5 59.21 391.9 48.94L393.5 47.82C451 6.731 529.8 13.25 579.8 63.24C636.3 119.7 636.3 211.3 579.8 267.7L467.5 380z' />
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSPost_PDElementText'>{site}</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,12 +1,12 @@
|
||||
import { PropsWithChildren, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigation } from 'react-router-dom'
|
||||
import styles from '../../styles/loadingSpinner.module.scss'
|
||||
|
||||
interface Props {
|
||||
desc: string
|
||||
}
|
||||
|
||||
export const LoadingSpinner = ({ desc }: Props) => {
|
||||
export const LoadingSpinner = (props: Props) => {
|
||||
const { desc } = props
|
||||
|
||||
return (
|
||||
<div className={styles.loadingSpinnerOverlay}>
|
||||
<div className={styles.loadingSpinnerContainer}>
|
||||
@ -16,62 +16,3 @@ export const LoadingSpinner = ({ desc }: Props) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RouterLoadingSpinner = () => {
|
||||
const navigation = useNavigation()
|
||||
|
||||
if (navigation.state === 'idle') return null
|
||||
|
||||
const desc =
|
||||
navigation.state.charAt(0).toUpperCase() + navigation.state.slice(1)
|
||||
|
||||
return <LoadingSpinner desc={`${desc}...`} />
|
||||
}
|
||||
|
||||
interface TimerLoadingSpinner {
|
||||
timeoutMs?: number
|
||||
countdownMs?: number
|
||||
}
|
||||
|
||||
export const TimerLoadingSpinner = ({
|
||||
timeoutMs = 10000,
|
||||
countdownMs = 30000,
|
||||
children
|
||||
}: PropsWithChildren<TimerLoadingSpinner>) => {
|
||||
const [show, setShow] = useState(false)
|
||||
const [timer, setTimer] = useState(
|
||||
Math.floor((countdownMs - timeoutMs) / 1000)
|
||||
)
|
||||
const startTime = useMemo(() => Date.now(), [])
|
||||
|
||||
useEffect(() => {
|
||||
let interval: number
|
||||
const timeout = window.setTimeout(() => {
|
||||
setShow(true)
|
||||
interval = window.setInterval(() => {
|
||||
const time = Date.now() - startTime
|
||||
const diff = Math.max(0, countdownMs - time)
|
||||
setTimer(Math.floor(diff / 1000))
|
||||
}, 1000)
|
||||
}, timeoutMs)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [countdownMs, startTime, timeoutMs])
|
||||
|
||||
return (
|
||||
<div className={styles.loadingSpinnerOverlay}>
|
||||
<div className={styles.loadingSpinnerContainer}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
{children}
|
||||
{show && (
|
||||
<>
|
||||
<div>You can try again in {timer}s...</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,10 +0,0 @@
|
||||
.formAction {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
border-radius: 0;
|
||||
}
|
@ -1,148 +0,0 @@
|
||||
import {
|
||||
BlockTypeSelect,
|
||||
BoldItalicUnderlineToggles,
|
||||
codeBlockPlugin,
|
||||
CodeToggle,
|
||||
CreateLink,
|
||||
diffSourcePlugin,
|
||||
DiffSourceToggleWrapper,
|
||||
directivesPlugin,
|
||||
headingsPlugin,
|
||||
imagePlugin,
|
||||
InsertCodeBlock,
|
||||
InsertImage,
|
||||
InsertTable,
|
||||
InsertThematicBreak,
|
||||
linkDialogPlugin,
|
||||
linkPlugin,
|
||||
listsPlugin,
|
||||
ListsToggle,
|
||||
markdownShortcutPlugin,
|
||||
MDXEditor,
|
||||
MDXEditorMethods,
|
||||
MDXEditorProps,
|
||||
quotePlugin,
|
||||
Separator,
|
||||
StrikeThroughSupSubToggles,
|
||||
tablePlugin,
|
||||
thematicBreakPlugin,
|
||||
toolbarPlugin,
|
||||
UndoRedo
|
||||
} from '@mdxeditor/editor'
|
||||
import { PlainTextCodeEditorDescriptor } from './PlainTextCodeEditorDescriptor'
|
||||
import { YoutubeDirectiveDescriptor } from './YoutubeDirectiveDescriptor'
|
||||
import { YouTubeButton } from './YoutubeButton'
|
||||
import '@mdxeditor/editor/style.css'
|
||||
import '../../styles/mdxEditor.scss'
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react'
|
||||
import { ImageDialog } from './ImageDialog'
|
||||
import { LinkDialog } from './LinkDialog'
|
||||
|
||||
export interface EditorRef {
|
||||
setMarkdown: (md: string) => void
|
||||
}
|
||||
interface EditorProps extends MDXEditorProps {}
|
||||
/**
|
||||
* The editor component is small wrapper (`forwardRef`) around {@link MDXEditor MDXEditor} that sets up the toolbars and plugins, and requires `markdown` and `onChange`.
|
||||
* To reset editor markdown it's required to pass the {@link EditorRef EditorRef}.
|
||||
*
|
||||
* Extends {@link MDXEditorProps MDXEditorProps}
|
||||
*
|
||||
* **Important**: the markdown is not a state, but an _initialState_ and is not "controlled".
|
||||
* All updates are handled with onChange and will not be reflected on markdown prop.
|
||||
* This component should never re-render if used correctly.
|
||||
* @see https://mdxeditor.dev/editor/docs/getting-started#basic-usage
|
||||
*/
|
||||
export const Editor = React.memo(
|
||||
forwardRef<EditorRef, EditorProps>(({ markdown, onChange, ...rest }, ref) => {
|
||||
const editorRef = useRef<MDXEditorMethods>(null)
|
||||
const setMarkdown = useCallback((md: string) => {
|
||||
editorRef.current?.setMarkdown(md)
|
||||
}, [])
|
||||
useImperativeHandle(ref, () => ({ setMarkdown }))
|
||||
const plugins = useMemo(
|
||||
() => [
|
||||
toolbarPlugin({
|
||||
toolbarContents: () => (
|
||||
<DiffSourceToggleWrapper
|
||||
children={
|
||||
<>
|
||||
<UndoRedo />
|
||||
<Separator />
|
||||
<BoldItalicUnderlineToggles />
|
||||
<CodeToggle />
|
||||
<Separator />
|
||||
<StrikeThroughSupSubToggles />
|
||||
<Separator />
|
||||
<ListsToggle />
|
||||
<Separator />
|
||||
<BlockTypeSelect />
|
||||
<Separator />
|
||||
|
||||
<CreateLink />
|
||||
<InsertImage />
|
||||
<YouTubeButton />
|
||||
|
||||
<Separator />
|
||||
|
||||
<InsertTable />
|
||||
<InsertThematicBreak />
|
||||
|
||||
<Separator />
|
||||
<InsertCodeBlock />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
headingsPlugin(),
|
||||
diffSourcePlugin({
|
||||
viewMode: 'rich-text',
|
||||
diffMarkdown: markdown
|
||||
}),
|
||||
quotePlugin(),
|
||||
imagePlugin({
|
||||
ImageDialog: ImageDialog
|
||||
}),
|
||||
tablePlugin(),
|
||||
linkPlugin(),
|
||||
linkDialogPlugin({
|
||||
LinkDialog: LinkDialog
|
||||
}),
|
||||
listsPlugin(),
|
||||
thematicBreakPlugin(),
|
||||
directivesPlugin({
|
||||
directiveDescriptors: [YoutubeDirectiveDescriptor]
|
||||
}),
|
||||
markdownShortcutPlugin(),
|
||||
// HACK: due to a bug with shortcut interaction shortcut for code block is disabled
|
||||
// Editor freezes if you type in ```word and put a space in between ``` word
|
||||
codeBlockPlugin({
|
||||
defaultCodeBlockLanguage: '',
|
||||
codeBlockEditorDescriptors: [PlainTextCodeEditorDescriptor]
|
||||
})
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<MDXEditor
|
||||
ref={editorRef}
|
||||
contentEditableClassName='editor'
|
||||
className='dark-theme dark-editor'
|
||||
markdown={markdown}
|
||||
plugins={plugins}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
() => true
|
||||
)
|
@ -1,166 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useCellValues, usePublisher } from '@mdxeditor/gurx'
|
||||
import {
|
||||
closeImageDialog$,
|
||||
editorRootElementRef$,
|
||||
imageDialogState$,
|
||||
imageUploadHandler$,
|
||||
saveImage$
|
||||
} from '@mdxeditor/editor'
|
||||
import styles from './Dialog.module.scss'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
interface ImageFormFields {
|
||||
src: string
|
||||
title: string
|
||||
altText: string
|
||||
file: FileList
|
||||
}
|
||||
|
||||
export const ImageDialog: React.FC = () => {
|
||||
const [state, editorRootElementRef, imageUploadHandler] = useCellValues(
|
||||
imageDialogState$,
|
||||
editorRootElementRef$,
|
||||
imageUploadHandler$
|
||||
)
|
||||
const saveImage = usePublisher(saveImage$)
|
||||
const closeImageDialog = usePublisher(closeImageDialog$)
|
||||
const { register, handleSubmit, setValue, reset } = useForm<ImageFormFields>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
|
||||
values: state.type === 'editing' ? (state.initialValues as any) : {}
|
||||
})
|
||||
const [open, setOpen] = useState(state.type !== 'inactive')
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(state.type !== 'inactive')
|
||||
}, [state.type])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
closeImageDialog()
|
||||
reset({ src: '', title: '', altText: '' })
|
||||
}
|
||||
}, [closeImageDialog, open, reset])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
if (!open) return null
|
||||
if (!editorRootElementRef?.current) return null
|
||||
|
||||
return createPortal(
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Add an image</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<form
|
||||
className='pUMCB_ZapsInside'
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(saveImage)(e)
|
||||
reset({ src: '', title: '', altText: '' })
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{imageUploadHandler === null ? (
|
||||
<input type='hidden' accept='image/*' {...register('file')} />
|
||||
) : (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain' htmlFor='file'>
|
||||
Upload an image from your device:
|
||||
</label>
|
||||
<input type='file' accept='image/*' {...register('file')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain' htmlFor='src'>
|
||||
{imageUploadHandler !== null
|
||||
? 'Or add an image from an URL:'
|
||||
: 'Add an image from an URL:'}
|
||||
</label>
|
||||
<input
|
||||
defaultValue={
|
||||
state.type === 'editing'
|
||||
? state.initialValues.src ?? ''
|
||||
: ''
|
||||
}
|
||||
className='inputMain'
|
||||
size={40}
|
||||
autoFocus
|
||||
{...register('src')}
|
||||
onChange={(e) => setValue('src', e.currentTarget.value)}
|
||||
placeholder={'Paste an image src'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain' htmlFor='alt'>
|
||||
Alt:
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
{...register('altText')}
|
||||
className='inputMain'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain' htmlFor='title'>
|
||||
Title:
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
{...register('title')}
|
||||
className='inputMain'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formAction}>
|
||||
<button
|
||||
type='submit'
|
||||
title={'Save'}
|
||||
aria-label={'Save'}
|
||||
className='btn btnMain btnMainPopup'
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type='reset'
|
||||
title={'Cancel'}
|
||||
aria-label={'Cancel'}
|
||||
className='btn btnMain btnMainPopup'
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
editorRootElementRef?.current
|
||||
)
|
||||
}
|
@ -1,306 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import * as Popover from '@radix-ui/react-popover'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import React from 'react'
|
||||
|
||||
import {
|
||||
activeEditor$,
|
||||
editorRootElementRef$,
|
||||
iconComponentFor$,
|
||||
cancelLinkEdit$,
|
||||
linkDialogState$,
|
||||
onWindowChange$,
|
||||
removeLink$,
|
||||
switchFromPreviewToLinkEdit$,
|
||||
updateLink$,
|
||||
ClickLinkCallback
|
||||
} from '@mdxeditor/editor'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Cell, useCellValues, usePublisher } from '@mdxeditor/gurx'
|
||||
import styles from './Dialog.module.scss'
|
||||
|
||||
interface LinkEditFormProps {
|
||||
url: string
|
||||
title: string
|
||||
onSubmit: (link: { url: string; title: string }) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
interface LinkFormFields {
|
||||
url: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export function LinkEditForm({
|
||||
url,
|
||||
title,
|
||||
onSubmit,
|
||||
onCancel
|
||||
}: LinkEditFormProps) {
|
||||
const { register, handleSubmit, setValue } = useForm<LinkFormFields>({
|
||||
values: {
|
||||
url,
|
||||
title
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='pUMCB_Zaps'>
|
||||
<form
|
||||
className='pUMCB_ZapsInside'
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(onSubmit)(e)
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
onReset={(e) => {
|
||||
e.stopPropagation()
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain' htmlFor='file'>
|
||||
URL:
|
||||
</label>
|
||||
<input
|
||||
defaultValue={url}
|
||||
className='inputMain'
|
||||
size={40}
|
||||
autoFocus
|
||||
{...register('url')}
|
||||
onChange={(e) => setValue('url', e.currentTarget.value)}
|
||||
placeholder={'Paste an URL'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain' htmlFor='link-title'>
|
||||
Title:
|
||||
</label>
|
||||
<input
|
||||
id='link-title'
|
||||
className='inputMain'
|
||||
size={40}
|
||||
{...register('title')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formAction}>
|
||||
<button
|
||||
type='submit'
|
||||
title={'Set URL'}
|
||||
aria-label={'Set URL'}
|
||||
className='btn btnMain btnMainPopup'
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type='reset'
|
||||
title={'Cancel change'}
|
||||
aria-label={'Cancel change'}
|
||||
className='btn btnMain btnMainPopup'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const onClickLinkCallback$ = Cell<ClickLinkCallback | null>(null)
|
||||
|
||||
/** @internal */
|
||||
export const LinkDialog = () => {
|
||||
const [
|
||||
editorRootElementRef,
|
||||
activeEditor,
|
||||
iconComponentFor,
|
||||
linkDialogState,
|
||||
onClickLinkCallback
|
||||
] = useCellValues(
|
||||
editorRootElementRef$,
|
||||
activeEditor$,
|
||||
iconComponentFor$,
|
||||
linkDialogState$,
|
||||
onClickLinkCallback$
|
||||
)
|
||||
const publishWindowChange = usePublisher(onWindowChange$)
|
||||
const updateLink = usePublisher(updateLink$)
|
||||
const cancelLinkEdit = usePublisher(cancelLinkEdit$)
|
||||
const switchFromPreviewToLinkEdit = usePublisher(switchFromPreviewToLinkEdit$)
|
||||
const removeLink = usePublisher(removeLink$)
|
||||
|
||||
React.useEffect(() => {
|
||||
const update = () => {
|
||||
activeEditor?.getEditorState().read(() => {
|
||||
publishWindowChange(true)
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', update)
|
||||
window.addEventListener('scroll', update)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update)
|
||||
window.removeEventListener('scroll', update)
|
||||
}
|
||||
}, [activeEditor, publishWindowChange])
|
||||
|
||||
const [copyUrlTooltipOpen, setCopyUrlTooltipOpen] = React.useState(false)
|
||||
|
||||
const theRect = linkDialogState.rectangle
|
||||
|
||||
const urlIsExternal =
|
||||
linkDialogState.type === 'preview' && linkDialogState.url.startsWith('http')
|
||||
|
||||
return (
|
||||
<Popover.Root open={linkDialogState.type !== 'inactive'}>
|
||||
<Popover.Anchor
|
||||
data-visible={linkDialogState.type === 'edit'}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${theRect?.top ?? 0}px`,
|
||||
left: `${theRect?.left ?? 0}px`,
|
||||
width: `${theRect?.width ?? 0}px`,
|
||||
height: `${theRect?.height ?? 0}px`
|
||||
}}
|
||||
/>
|
||||
|
||||
<Popover.Portal container={editorRootElementRef?.current}>
|
||||
<Popover.Content
|
||||
sideOffset={5}
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
key={linkDialogState.linkNodeKey}
|
||||
className={[
|
||||
'popUpMainCard',
|
||||
...(linkDialogState.type === 'edit' ? [styles.wrapper] : [])
|
||||
].join(' ')}
|
||||
>
|
||||
{linkDialogState.type === 'edit' && (
|
||||
<LinkEditForm
|
||||
url={linkDialogState.url}
|
||||
title={linkDialogState.title}
|
||||
onSubmit={updateLink}
|
||||
onCancel={cancelLinkEdit.bind(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{linkDialogState.type === 'preview' && (
|
||||
<>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapper'>
|
||||
<div className='IBMSMSMSSS_Author_Top_AddressWrapped'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Address'>
|
||||
<a
|
||||
className={styles.linkDialogPreviewAnchor}
|
||||
href={linkDialogState.url}
|
||||
{...(urlIsExternal
|
||||
? { target: '_blank', rel: 'noreferrer' }
|
||||
: {})}
|
||||
onClick={(e) => {
|
||||
if (
|
||||
onClickLinkCallback !== null &&
|
||||
typeof onClickLinkCallback === 'function'
|
||||
) {
|
||||
e.preventDefault()
|
||||
onClickLinkCallback(linkDialogState.url)
|
||||
}
|
||||
}}
|
||||
title={
|
||||
urlIsExternal
|
||||
? `Open ${linkDialogState.url} in new window`
|
||||
: linkDialogState.url
|
||||
}
|
||||
>
|
||||
<span>{linkDialogState.url}</span>
|
||||
{urlIsExternal && iconComponentFor('open_in_new')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className='IBMSMSMSSS_Author_Top_IconWrapper'>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
onClick={() => {
|
||||
switchFromPreviewToLinkEdit()
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32zM421.7 220.3L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root open={copyUrlTooltipOpen}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
onClick={() => {
|
||||
void window.navigator.clipboard
|
||||
.writeText(linkDialogState.url)
|
||||
.then(() => {
|
||||
setCopyUrlTooltipOpen(true)
|
||||
setTimeout(() => {
|
||||
setCopyUrlTooltipOpen(false)
|
||||
}, 1000)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal container={editorRootElementRef?.current}>
|
||||
<Tooltip.Content sideOffset={5}>
|
||||
{'Copied!'}
|
||||
<Tooltip.Arrow />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_IconWrapped'
|
||||
onClick={() => {
|
||||
removeLink()
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 640 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMSSS_Author_Top_Icon'
|
||||
>
|
||||
<path d='M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5L373 389.9z' />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Popover.Arrow />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
import {
|
||||
CodeBlockEditorDescriptor,
|
||||
useCodeBlockEditorContext
|
||||
} from '@mdxeditor/editor'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
export const PlainTextCodeEditorDescriptor: CodeBlockEditorDescriptor = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
match: (_language, _meta) => true,
|
||||
priority: 0,
|
||||
Editor: ({ code, focusEmitter }) => {
|
||||
const { parentEditor, lexicalNode, setCode } = useCodeBlockEditorContext()
|
||||
const defaultValue = useRef(code)
|
||||
const codeRef = useRef<HTMLElement>(null)
|
||||
|
||||
const handleInput = useCallback(
|
||||
(e: React.FormEvent<HTMLElement>) => {
|
||||
setCode(e.currentTarget.innerHTML)
|
||||
},
|
||||
[setCode]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
if (codeRef.current) {
|
||||
codeRef.current.focus()
|
||||
}
|
||||
}
|
||||
focusEmitter.subscribe(handleFocus)
|
||||
}, [focusEmitter])
|
||||
|
||||
useEffect(() => {
|
||||
const currentRef = codeRef.current
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Backspace' || event.key === 'Delete') {
|
||||
if (codeRef.current?.textContent === '') {
|
||||
parentEditor.update(() => {
|
||||
lexicalNode.selectNext()
|
||||
lexicalNode.remove()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentRef) {
|
||||
currentRef.addEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
return () => {
|
||||
if (currentRef) {
|
||||
currentRef.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}
|
||||
}, [lexicalNode, parentEditor])
|
||||
|
||||
return (
|
||||
<pre>
|
||||
<code
|
||||
ref={codeRef}
|
||||
contentEditable={true}
|
||||
onInput={handleInput}
|
||||
dangerouslySetInnerHTML={{ __html: defaultValue.current }}
|
||||
/>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
import { createDirectives, presetDirectiveConfigs } from 'marked-directive'
|
||||
import { youtubeDirective } from './YoutubeDirective'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
interface ViewerProps {
|
||||
markdown: string
|
||||
}
|
||||
|
||||
export const Viewer = ({ markdown }: ViewerProps) => {
|
||||
const html = useMemo(() => {
|
||||
DOMPurify.addHook('beforeSanitizeAttributes', function (node) {
|
||||
if (node.nodeName && node.nodeName === 'IFRAME') {
|
||||
const src = node.attributes.getNamedItem('src')
|
||||
if (!(src && src.value.startsWith('https://www.youtube.com/embed/'))) {
|
||||
node.remove()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return DOMPurify.sanitize(
|
||||
marked
|
||||
.use(createDirectives([...presetDirectiveConfigs, youtubeDirective]))
|
||||
.parse(`${markdown}`, {
|
||||
async: false
|
||||
}),
|
||||
{
|
||||
ADD_TAGS: ['iframe']
|
||||
}
|
||||
)
|
||||
}, [markdown])
|
||||
|
||||
return (
|
||||
<div className='viewer' dangerouslySetInnerHTML={{ __html: html }}></div>
|
||||
)
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
import { LeafDirective } from 'mdast-util-directive'
|
||||
import { usePublisher, insertDirective$, DialogButton } from '@mdxeditor/editor'
|
||||
|
||||
function getId(url: string) {
|
||||
const regExp =
|
||||
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/
|
||||
const match = url.match(regExp)
|
||||
return match && match[7].length == 11 ? match[7] : false
|
||||
}
|
||||
|
||||
export const YouTubeButton = () => {
|
||||
const insertDirective = usePublisher(insertDirective$)
|
||||
|
||||
return (
|
||||
<DialogButton
|
||||
tooltipTitle='Insert Youtube video'
|
||||
submitButtonTitle='Insert video'
|
||||
dialogInputPlaceholder='Paste the youtube video URL'
|
||||
buttonContent='YT'
|
||||
onSubmit={(url) => {
|
||||
const videoId = getId(url)
|
||||
if (videoId) {
|
||||
insertDirective({
|
||||
name: 'youtube',
|
||||
type: 'leafDirective',
|
||||
|
||||
attributes: { id: videoId },
|
||||
children: []
|
||||
} as LeafDirective)
|
||||
} else {
|
||||
alert('Invalid YouTube URL')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
import { type DirectiveConfig } from 'marked-directive'
|
||||
|
||||
// defines `:youtube` directive
|
||||
export const youtubeDirective: DirectiveConfig = {
|
||||
level: 'block',
|
||||
marker: '::',
|
||||
renderer(token) {
|
||||
//https://www.youtube.com/embed/<VIDEO_ID>
|
||||
//::youtube{#<VIDEO_ID>}
|
||||
let vid: string = ''
|
||||
if (token.attrs && token.meta.name === 'youtube') {
|
||||
if (token.attrs.id) {
|
||||
vid = token.attrs.id as string // Get the video `id` attribute (common id style)
|
||||
} else if (token.attrs.vid) {
|
||||
vid = token.attrs.vid as string // Check for the `vid` attribute (youtube directive attribute style)
|
||||
} else {
|
||||
// Fallback for id
|
||||
// In case that video starts with the number it will not be recongizned as an id
|
||||
// We have to manually fetch it
|
||||
for (const attr in token.attrs) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(token.attrs, attr) &&
|
||||
attr.startsWith('#')
|
||||
) {
|
||||
vid = attr.replace('#', '')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (vid) {
|
||||
return `<iframe title="Video embed" width="560" height="315" src="https://www.youtube.com/embed/${vid}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
import { LeafDirective } from 'mdast-util-directive'
|
||||
import { DirectiveDescriptor } from '@mdxeditor/editor'
|
||||
|
||||
interface YoutubeDirectiveNode extends LeafDirective {
|
||||
name: 'youtube'
|
||||
attributes: { id: string }
|
||||
}
|
||||
|
||||
export const YoutubeDirectiveDescriptor: DirectiveDescriptor<YoutubeDirectiveNode> =
|
||||
{
|
||||
name: 'youtube',
|
||||
type: 'leafDirective',
|
||||
testNode(node) {
|
||||
return node.name === 'youtube'
|
||||
},
|
||||
attributes: ['id'],
|
||||
hasChildren: false,
|
||||
Editor: ({ mdastNode, lexicalNode, parentEditor }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
title='delete'
|
||||
className='btnMain'
|
||||
onClick={() => {
|
||||
parentEditor.update(() => {
|
||||
lexicalNode.selectNext()
|
||||
lexicalNode.remove()
|
||||
})
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 448 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M135.2 17.7L128 32 32 32C14.3 32 0 46.3 0 64S14.3 96 32 96l384 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-96 0-7.2-14.3C307.4 6.8 296.3 0 284.2 0L163.8 0c-12.1 0-23.2 6.8-28.6 17.7zM416 128L32 128 53.2 467c1.6 25.3 22.6 45 47.9 45l245.8 0c25.3 0 46.3-19.7 47.9-45L416 128z' />
|
||||
</svg>
|
||||
</button>
|
||||
<iframe
|
||||
width='560'
|
||||
height='315'
|
||||
src={`https://www.youtube.com/embed/${mdastNode.attributes.id}`}
|
||||
title='YouTube video player'
|
||||
frameBorder='0'
|
||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -12,7 +12,7 @@ import { useComments } from 'hooks/useComments'
|
||||
export const ModCard = React.memo((props: ModDetails) => {
|
||||
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
|
||||
const [commentCount, setCommentCount] = useState(0)
|
||||
const { commentEvents } = useComments(props.author, props.aTag)
|
||||
const { commentEvents } = useComments(props)
|
||||
const { likesCount, disLikesCount } = useReactions({
|
||||
pubkey: props.author,
|
||||
eTag: props.id,
|
||||
@ -50,18 +50,7 @@ export const ModCard = React.memo((props: ModDetails) => {
|
||||
src={props.featuredImageUrl}
|
||||
onError={handleModImageError}
|
||||
className='cMMPicture'
|
||||
alt={`featured image for mod ${props.title}`}
|
||||
/>
|
||||
{props.nsfw && (
|
||||
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagNSFW IBMSMSMBSSTagsTagNSFWCard'>
|
||||
<p>NSFW</p>
|
||||
</div>
|
||||
)}
|
||||
{props.repost && (
|
||||
<div className='IBMSMSMBSSTagsTag IBMSMSMBSSTagsTagRepost IBMSMSMBSSTagsTagRepostCard'>
|
||||
<p>REPOST</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='cMMBody'>
|
||||
<h3 className='cMMBodyTitle'>{props.title}</h3>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import _ from 'lodash'
|
||||
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
||||
import React, {
|
||||
Fragment,
|
||||
useCallback,
|
||||
@ -7,89 +8,74 @@ import React, {
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import {
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
useNavigation,
|
||||
useSubmit
|
||||
} from 'react-router-dom'
|
||||
import { FixedSizeList } from 'react-window'
|
||||
import { useGames } from '../hooks'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { FixedSizeList as List } from 'react-window'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { T_TAG_VALUE } from '../constants'
|
||||
import { useAppSelector, useGames, useNDKContext } from '../hooks'
|
||||
import { appRoutes, getModPageRoute } from '../routes'
|
||||
import '../styles/styles.css'
|
||||
import {
|
||||
DownloadUrl,
|
||||
ModFormState,
|
||||
ModPageLoaderResult,
|
||||
ModPermissions,
|
||||
MODPERMISSIONS_CONF,
|
||||
MODPERMISSIONS_DESC,
|
||||
SubmitModActionResult
|
||||
} from '../types'
|
||||
import { DownloadUrl, ModDetails, ModFormState } from '../types'
|
||||
import {
|
||||
initializeFormState,
|
||||
isReachable,
|
||||
isValidImageUrl,
|
||||
isValidUrl,
|
||||
log,
|
||||
LogType,
|
||||
MOD_DRAFT_CACHE_KEY
|
||||
now
|
||||
} from '../utils'
|
||||
import { CheckboxField, InputField, InputFieldWithImageUpload } from './Inputs'
|
||||
import { OriginalAuthor } from './OriginalAuthor'
|
||||
import { CategoryAutocomplete } from './CategoryAutocomplete'
|
||||
import { AlertPopup } from './AlertPopup'
|
||||
import { Editor, EditorRef } from './Markdown/Editor'
|
||||
import { MEDIA_OPTIONS } from 'controllers'
|
||||
import { InputError } from './Inputs/Error'
|
||||
import { ImageUpload } from './Inputs/ImageUpload'
|
||||
import { useLocalCache } from 'hooks/useLocalCache'
|
||||
import { toast } from 'react-toastify'
|
||||
import { CheckboxField, InputError, InputField } from './Inputs'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||
|
||||
interface FormErrors {
|
||||
game?: string
|
||||
title?: string
|
||||
body?: string
|
||||
featuredImageUrl?: string
|
||||
summary?: string
|
||||
nsfw?: string
|
||||
screenshotsUrls?: string[]
|
||||
tags?: string
|
||||
downloadUrls?: string[]
|
||||
}
|
||||
|
||||
interface GameOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const ModForm = () => {
|
||||
const data = useLoaderData() as ModPageLoaderResult
|
||||
const mod = data?.mod
|
||||
const actionData = useActionData() as SubmitModActionResult
|
||||
const formErrors = useMemo(
|
||||
() => (actionData?.type === 'validation' ? actionData.error : undefined),
|
||||
[actionData]
|
||||
)
|
||||
const navigation = useNavigation()
|
||||
const submit = useSubmit()
|
||||
type ModFormProps = {
|
||||
existingModData?: ModDetails
|
||||
}
|
||||
|
||||
export const ModForm = ({ existingModData }: ModFormProps) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { ndk, publish } = useNDKContext()
|
||||
const games = useGames()
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const [isPublishing, setIsPublishing] = useState(false)
|
||||
const [gameOptions, setGameOptions] = useState<GameOption[]>([])
|
||||
|
||||
// Enable cache for the new mod
|
||||
const isEditing = typeof mod !== 'undefined'
|
||||
const [cache, setCache, clearCache] =
|
||||
useLocalCache<ModFormState>(MOD_DRAFT_CACHE_KEY)
|
||||
const [formState, setFormState] = useState<ModFormState>(
|
||||
isEditing ? initializeFormState(mod) : cache ? cache : initializeFormState()
|
||||
initializeFormState()
|
||||
)
|
||||
|
||||
// Enable backwards compatibility with the mods that used html
|
||||
const body = useMemo(() => {
|
||||
// Replace the most problematic HTML tags (<br>)
|
||||
const fixed = formState.body.replaceAll(/<br>/g, '\r\n')
|
||||
return fixed
|
||||
}, [formState.body])
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
const newCache = _.cloneDeep(formState)
|
||||
|
||||
// Remove aTag, dTag and published_at from cache
|
||||
// These are used for editing and try again timeout
|
||||
newCache.aTag = ''
|
||||
newCache.dTag = ''
|
||||
newCache.published_at = 0
|
||||
|
||||
setCache(newCache)
|
||||
if (location.pathname === appRoutes.submitMod) {
|
||||
setFormState(initializeFormState())
|
||||
}
|
||||
}, [formState, isEditing, setCache])
|
||||
}, [location.pathname]) // Only trigger when the pathname changes to submit-mod
|
||||
|
||||
const editorRef = useRef<EditorRef>(null)
|
||||
useEffect(() => {
|
||||
if (existingModData) {
|
||||
setFormState(initializeFormState(existingModData))
|
||||
}
|
||||
}, [existingModData])
|
||||
|
||||
useEffect(() => {
|
||||
const options = games.map((game) => ({
|
||||
@ -117,13 +103,6 @@ export const ModForm = () => {
|
||||
[]
|
||||
)
|
||||
|
||||
const handleRadioChange = useCallback((name: string, value: boolean) => {
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
[name]: value
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const addScreenshotUrl = useCallback(() => {
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
@ -191,85 +170,184 @@ export const ModForm = () => {
|
||||
},
|
||||
[]
|
||||
)
|
||||
const [showTryAgainPopup, setShowTryAgainPopup] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
const isTimeout = actionData?.type === 'timeout'
|
||||
setShowTryAgainPopup(isTimeout)
|
||||
if (isTimeout) {
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
aTag: actionData.data.aTag,
|
||||
dTag: actionData.data.dTag,
|
||||
published_at: actionData.data.published_at
|
||||
}))
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true)
|
||||
|
||||
let hexPubkey: string
|
||||
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
hexPubkey = userState.user.pubkey as string
|
||||
} else {
|
||||
hexPubkey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
}, [actionData])
|
||||
const handleTryAgainConfirm = useCallback(
|
||||
(confirm: boolean) => {
|
||||
setShowTryAgainPopup(false)
|
||||
|
||||
// Cancel if not confirmed
|
||||
if (!confirm) return
|
||||
submit(JSON.stringify(formState), {
|
||||
method: isEditing ? 'put' : 'post',
|
||||
encType: 'application/json'
|
||||
if (!hexPubkey) {
|
||||
toast.error('Could not get pubkey')
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await validateState())) {
|
||||
setIsPublishing(false)
|
||||
return
|
||||
}
|
||||
|
||||
const uuid = uuidv4()
|
||||
const currentTimeStamp = now()
|
||||
|
||||
const aTag =
|
||||
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
kind: kinds.ClassifiedListing,
|
||||
created_at: currentTimeStamp,
|
||||
pubkey: hexPubkey,
|
||||
content: formState.body,
|
||||
tags: [
|
||||
['d', formState.dTag || uuid],
|
||||
['a', aTag],
|
||||
['r', formState.rTag],
|
||||
['t', T_TAG_VALUE],
|
||||
[
|
||||
'published_at',
|
||||
existingModData
|
||||
? existingModData.published_at.toString()
|
||||
: currentTimeStamp.toString()
|
||||
],
|
||||
['game', formState.game],
|
||||
['title', formState.title],
|
||||
['featuredImageUrl', formState.featuredImageUrl],
|
||||
['summary', formState.summary],
|
||||
['nsfw', formState.nsfw.toString()],
|
||||
['screenshotsUrls', ...formState.screenshotsUrls],
|
||||
['tags', ...formState.tags.split(',')],
|
||||
[
|
||||
'downloadUrls',
|
||||
...formState.downloadUrls.map((downloadUrl) =>
|
||||
JSON.stringify(downloadUrl)
|
||||
)
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
},
|
||||
[formState, isEditing, submit]
|
||||
)
|
||||
|
||||
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
|
||||
const handleReset = useCallback(() => {
|
||||
setShowConfirmPopup(true)
|
||||
}, [])
|
||||
const handleResetConfirm = useCallback(
|
||||
(confirm: boolean) => {
|
||||
setShowConfirmPopup(false)
|
||||
if (!signedEvent) {
|
||||
setIsPublishing(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel if not confirmed
|
||||
if (!confirm) return
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
|
||||
// Reset fields to the initial or original existing data
|
||||
const initialState = initializeFormState(mod)
|
||||
// Handle cases where publishing failed or succeeded
|
||||
if (publishedOnRelays.length === 0) {
|
||||
toast.error('Failed to publish event on any relay')
|
||||
} else {
|
||||
toast.success(
|
||||
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
|
||||
'\n'
|
||||
)}`
|
||||
)
|
||||
|
||||
// Reset editor
|
||||
editorRef.current?.setMarkdown(initialState.body)
|
||||
setFormState(initialState)
|
||||
|
||||
// Clear cache
|
||||
!isEditing && clearCache()
|
||||
},
|
||||
[clearCache, isEditing, mod]
|
||||
)
|
||||
|
||||
const handlePublish = useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
submit(JSON.stringify(formState), {
|
||||
method: isEditing ? 'put' : 'post',
|
||||
encType: 'application/json'
|
||||
const naddr = nip19.naddrEncode({
|
||||
identifier: aTag,
|
||||
pubkey: signedEvent.pubkey,
|
||||
kind: signedEvent.kind,
|
||||
relays: publishedOnRelays
|
||||
})
|
||||
},
|
||||
[formState, isEditing, submit]
|
||||
)
|
||||
|
||||
const extraBoxRef = useRef<HTMLDivElement>(null)
|
||||
const handleExtraBoxButtonClick = () => {
|
||||
if (extraBoxRef.current) {
|
||||
if (extraBoxRef.current.style.display === '') {
|
||||
extraBoxRef.current.style.display = 'none'
|
||||
} else {
|
||||
extraBoxRef.current.style.display = ''
|
||||
navigate(getModPageRoute(naddr))
|
||||
}
|
||||
|
||||
setIsPublishing(false)
|
||||
}
|
||||
|
||||
const validateState = async (): Promise<boolean> => {
|
||||
const errors: FormErrors = {}
|
||||
|
||||
if (formState.game === '') {
|
||||
errors.game = 'Game field can not be empty'
|
||||
}
|
||||
|
||||
if (formState.title === '') {
|
||||
errors.title = 'Title field can not be empty'
|
||||
}
|
||||
|
||||
if (formState.body === '') {
|
||||
errors.body = 'Body field can not be empty'
|
||||
}
|
||||
|
||||
if (formState.featuredImageUrl === '') {
|
||||
errors.featuredImageUrl = 'FeaturedImageUrl field can not be empty'
|
||||
} else if (
|
||||
!isValidImageUrl(formState.featuredImageUrl) ||
|
||||
!(await isReachable(formState.featuredImageUrl))
|
||||
) {
|
||||
errors.featuredImageUrl =
|
||||
'FeaturedImageUrl must be a valid and reachable image URL'
|
||||
}
|
||||
|
||||
if (formState.summary === '') {
|
||||
errors.summary = 'Summary field can not be empty'
|
||||
}
|
||||
|
||||
if (formState.screenshotsUrls.length === 0) {
|
||||
errors.screenshotsUrls = ['Required at least one screenshot url']
|
||||
} else {
|
||||
for (let i = 0; i < formState.screenshotsUrls.length; i++) {
|
||||
const url = formState.screenshotsUrls[i]
|
||||
if (
|
||||
!isValidUrl(url) ||
|
||||
!isValidImageUrl(url) ||
|
||||
!(await isReachable(url))
|
||||
) {
|
||||
if (!errors.screenshotsUrls)
|
||||
errors.screenshotsUrls = Array(formState.screenshotsUrls.length)
|
||||
|
||||
errors.screenshotsUrls![i] =
|
||||
'All screenshot URLs must be valid and reachable image URLs'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (formState.tags === '') {
|
||||
errors.tags = 'Tags field can not be empty'
|
||||
}
|
||||
|
||||
if (formState.downloadUrls.length === 0) {
|
||||
errors.downloadUrls = ['Required at least one download url']
|
||||
} else {
|
||||
for (let i = 0; i < formState.downloadUrls.length; i++) {
|
||||
const downloadUrl = formState.downloadUrls[i]
|
||||
if (!isValidUrl(downloadUrl.url)) {
|
||||
if (!errors.downloadUrls)
|
||||
errors.downloadUrls = Array(formState.downloadUrls.length)
|
||||
|
||||
errors.downloadUrls![i] = 'Download url must be valid and reachable'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setFormErrors(errors)
|
||||
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
return (
|
||||
<form className='IBMSMSMBS_Write' onSubmit={handlePublish}>
|
||||
<>
|
||||
{isPublishing && <LoadingSpinner desc='Publishing mod to relays' />}
|
||||
<GameDropdown
|
||||
options={gameOptions}
|
||||
selected={formState?.game}
|
||||
error={formErrors?.game}
|
||||
selected={formState.game}
|
||||
error={formErrors.game}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
@ -278,46 +356,30 @@ export const ModForm = () => {
|
||||
placeholder='Return the banana mod'
|
||||
name='title'
|
||||
value={formState.title}
|
||||
error={formErrors?.title}
|
||||
error={formErrors.title}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>Body</label>
|
||||
<div className='inputMain'>
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
markdown={body}
|
||||
placeholder="Here's what this mod is all about"
|
||||
onChange={(md) => {
|
||||
handleInputChange('body', md)
|
||||
}}
|
||||
onError={(payload) => {
|
||||
toast.error('Markdown error. Fix manually in the source mode.')
|
||||
log(true, LogType.Error, payload.error)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{typeof formErrors?.body !== 'undefined' && (
|
||||
<InputError message={formErrors?.body} />
|
||||
)}
|
||||
<input
|
||||
name='body'
|
||||
hidden
|
||||
value={encodeURIComponent(formState?.body)}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<InputField
|
||||
label='Body'
|
||||
type='richtext'
|
||||
placeholder="Here's what this mod is all about"
|
||||
name='body'
|
||||
value={formState.body}
|
||||
error={formErrors.body}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<InputFieldWithImageUpload
|
||||
<InputField
|
||||
label='Featured Image URL'
|
||||
description={`We recommend to upload images to ${MEDIA_OPTIONS[0].host}`}
|
||||
description='We recommend to upload images to https://nostr.build/'
|
||||
type='text'
|
||||
inputMode='url'
|
||||
placeholder='Image URL'
|
||||
name='featuredImageUrl'
|
||||
value={formState.featuredImageUrl}
|
||||
error={formErrors?.featuredImageUrl}
|
||||
onInputChange={handleInputChange}
|
||||
error={formErrors.featuredImageUrl}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<InputField
|
||||
label='Summary'
|
||||
@ -325,7 +387,7 @@ export const ModForm = () => {
|
||||
placeholder='This is a quick description of my mod'
|
||||
name='summary'
|
||||
value={formState.summary}
|
||||
error={formErrors?.summary}
|
||||
error={formErrors.summary}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<CheckboxField
|
||||
@ -333,33 +395,7 @@ export const ModForm = () => {
|
||||
name='nsfw'
|
||||
isChecked={formState.nsfw}
|
||||
handleChange={handleCheckboxChange}
|
||||
type='stylized'
|
||||
/>
|
||||
<CheckboxField
|
||||
label='This is a repost of a mod I did not create'
|
||||
name='repost'
|
||||
isChecked={formState.repost}
|
||||
handleChange={handleCheckboxChange}
|
||||
type='stylized'
|
||||
/>
|
||||
{formState.repost && (
|
||||
<>
|
||||
<InputField
|
||||
label={
|
||||
<span>
|
||||
Created by:{' '}
|
||||
{<OriginalAuthor value={formState.originalAuthor || ''} />}
|
||||
</span>
|
||||
}
|
||||
type='text'
|
||||
placeholder="Original author's name, npub or nprofile"
|
||||
name='originalAuthor'
|
||||
value={formState.originalAuthor || ''}
|
||||
error={formErrors?.originalAuthor}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<div className='labelWrapperMain'>
|
||||
<label className='form-label labelMain'>Screenshots URLs</label>
|
||||
@ -367,7 +403,6 @@ export const ModForm = () => {
|
||||
className='btn btnMain btnMainAdd'
|
||||
type='button'
|
||||
onClick={addScreenshotUrl}
|
||||
title='Add'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -381,24 +416,8 @@ export const ModForm = () => {
|
||||
</button>
|
||||
</div>
|
||||
<p className='labelDescriptionMain'>
|
||||
We recommend to upload images to {MEDIA_OPTIONS[0].host}
|
||||
We recommend to upload images to https://nostr.build/
|
||||
</p>
|
||||
|
||||
<ImageUpload
|
||||
multiple={true}
|
||||
onChange={(values) => {
|
||||
setFormState((prevState) => ({
|
||||
...prevState,
|
||||
screenshotsUrls: Array.from(
|
||||
new Set([
|
||||
...prevState.screenshotsUrls.filter((url) => url),
|
||||
...values
|
||||
])
|
||||
)
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
|
||||
{formState.screenshotsUrls.map((url, index) => (
|
||||
<Fragment key={`screenShot-${index}`}>
|
||||
<ScreenshotUrlFields
|
||||
@ -407,16 +426,16 @@ export const ModForm = () => {
|
||||
onUrlChange={handleScreenshotUrlChange}
|
||||
onRemove={removeScreenshotUrl}
|
||||
/>
|
||||
{formErrors?.screenshotsUrls &&
|
||||
formErrors?.screenshotsUrls[index] && (
|
||||
<InputError message={formErrors?.screenshotsUrls[index]} />
|
||||
{formErrors.screenshotsUrls &&
|
||||
formErrors.screenshotsUrls[index] && (
|
||||
<InputError message={formErrors.screenshotsUrls[index]} />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{formState.screenshotsUrls.length === 0 &&
|
||||
formErrors?.screenshotsUrls &&
|
||||
formErrors?.screenshotsUrls[0] && (
|
||||
<InputError message={formErrors?.screenshotsUrls[0]} />
|
||||
formErrors.screenshotsUrls &&
|
||||
formErrors.screenshotsUrls[0] && (
|
||||
<InputError message={formErrors.screenshotsUrls[0]} />
|
||||
)}
|
||||
</div>
|
||||
<InputField
|
||||
@ -425,14 +444,9 @@ export const ModForm = () => {
|
||||
placeholder='Tags'
|
||||
name='tags'
|
||||
value={formState.tags}
|
||||
error={formErrors?.tags}
|
||||
error={formErrors.tags}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<CategoryAutocomplete
|
||||
game={formState.game}
|
||||
LTags={formState.LTags}
|
||||
setFormState={setFormState}
|
||||
/>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<div className='labelWrapperMain'>
|
||||
<label className='form-label labelMain'>Download URLs</label>
|
||||
@ -440,7 +454,6 @@ export const ModForm = () => {
|
||||
className='btn btnMain btnMainAdd'
|
||||
type='button'
|
||||
onClick={addDownloadUrl}
|
||||
title='Add'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -455,230 +468,56 @@ export const ModForm = () => {
|
||||
</div>
|
||||
<p className='labelDescriptionMain'>
|
||||
You can upload your game mod to Github, as an example, and keep
|
||||
updating it there (another option is{' '}
|
||||
<a
|
||||
href='https://catbox.moe/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
catbox.moe
|
||||
</a>
|
||||
). Also, it's advisable that you hash your package as well with your
|
||||
nostr public key. Malware scan service suggestion:{' '}
|
||||
<a
|
||||
href='https://virustotal.com'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
https://virustotal.com
|
||||
</a>
|
||||
updating it there (another option is catbox.moe). Also, it's advisable
|
||||
that you hash your package as well with your nostr public key.
|
||||
</p>
|
||||
|
||||
{formState.downloadUrls.map((download, index) => (
|
||||
<Fragment key={`download-${index}`}>
|
||||
<DownloadUrlFields
|
||||
index={index}
|
||||
title={download.title}
|
||||
url={download.url}
|
||||
hash={download.hash}
|
||||
signatureKey={download.signatureKey}
|
||||
malwareScanLink={download.malwareScanLink}
|
||||
modVersion={download.modVersion}
|
||||
customNote={download.customNote}
|
||||
mediaUrl={download.mediaUrl}
|
||||
onUrlChange={handleDownloadUrlChange}
|
||||
onRemove={removeDownloadUrl}
|
||||
/>
|
||||
{formErrors?.downloadUrls && formErrors?.downloadUrls[index] && (
|
||||
<InputError message={formErrors?.downloadUrls[index]} />
|
||||
{formErrors.downloadUrls && formErrors.downloadUrls[index] && (
|
||||
<InputError message={formErrors.downloadUrls[index]} />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{formState.downloadUrls.length === 0 &&
|
||||
formErrors?.downloadUrls &&
|
||||
formErrors?.downloadUrls[0] && (
|
||||
<InputError message={formErrors?.downloadUrls[0]} />
|
||||
)}
|
||||
</div>
|
||||
<div className='IBMSMSMBSSExtra'>
|
||||
<button
|
||||
className='btn btnMain IBMSMSMBSSExtraBtn'
|
||||
type='button'
|
||||
onClick={handleExtraBoxButtonClick}
|
||||
>
|
||||
Permissions & Details
|
||||
</button>
|
||||
<div
|
||||
className='IBMSMSMBSSExtraBox'
|
||||
ref={extraBoxRef}
|
||||
style={{
|
||||
display: 'none'
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className='labelDescriptionMain'
|
||||
style={{ marginBottom: `10px`, textAlign: `center` }}
|
||||
>
|
||||
What permissions users have with your published mod/post
|
||||
</p>
|
||||
<div className='IBMSMSMBSSExtraBoxElementWrapper'>
|
||||
{Object.keys(MODPERMISSIONS_CONF).map((k) => {
|
||||
const permKey = k as keyof ModPermissions
|
||||
const confKey = k as keyof typeof MODPERMISSIONS_CONF
|
||||
const modPermission = MODPERMISSIONS_CONF[confKey]
|
||||
const value = formState[permKey]
|
||||
|
||||
return (
|
||||
<div className='IBMSMSMBSSExtraBoxElement' key={k}>
|
||||
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColStart'>
|
||||
<p>{modPermission.header}</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColSecond'>
|
||||
<label
|
||||
htmlFor={`${permKey}_true`}
|
||||
className='IBMSMSMBSSExtraBoxElementColChoice'
|
||||
>
|
||||
<p>
|
||||
{MODPERMISSIONS_DESC[`${permKey}_true`]}
|
||||
<br />
|
||||
</p>
|
||||
<input
|
||||
className='IBMSMSMBSSExtraBoxElementColChoiceRadio'
|
||||
type='radio'
|
||||
name={permKey}
|
||||
id={`${permKey}_true`}
|
||||
value={'true'}
|
||||
checked={
|
||||
typeof value !== 'undefined'
|
||||
? value === true
|
||||
: modPermission.default === true
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleRadioChange(
|
||||
permKey,
|
||||
e.currentTarget.value === 'true'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className='IBMSMSMBSSExtraBoxElementColChoiceBox'></div>
|
||||
</label>
|
||||
<label
|
||||
htmlFor={`${permKey}_false`}
|
||||
className='IBMSMSMBSSExtraBoxElementColChoice'
|
||||
>
|
||||
<p>
|
||||
{MODPERMISSIONS_DESC[`${permKey}_false`]}
|
||||
<br />
|
||||
</p>
|
||||
<input
|
||||
className='IBMSMSMBSSExtraBoxElementColChoiceRadio'
|
||||
type='radio'
|
||||
id={`${permKey}_false`}
|
||||
value={'false'}
|
||||
name={permKey}
|
||||
checked={
|
||||
typeof value !== 'undefined'
|
||||
? value === false
|
||||
: modPermission.default === false
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleRadioChange(
|
||||
permKey,
|
||||
e.currentTarget.value === 'true'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className='IBMSMSMBSSExtraBoxElementColChoiceBox'></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className='IBMSMSMBSSExtraBoxElement'>
|
||||
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColStart'>
|
||||
<p>Publisher Notes</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColSecond'>
|
||||
<textarea
|
||||
className='inputMain'
|
||||
value={formState.publisherNotes || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange('publisherNotes', e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSExtraBoxElement'>
|
||||
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColStart'>
|
||||
<p>Extra Credits</p>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSExtraBoxElementCol IBMSMSMBSSExtraBoxElementColSecond'>
|
||||
<textarea
|
||||
className='inputMain'
|
||||
value={formState.extraCredits || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange('extraCredits', e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{formState.downloadUrls.length === 0 &&
|
||||
formErrors.downloadUrls &&
|
||||
formErrors.downloadUrls[0] && (
|
||||
<InputError message={formErrors.downloadUrls[0]} />
|
||||
)}
|
||||
</div>
|
||||
<div className='IBMSMSMBS_WriteAction'>
|
||||
<button
|
||||
className='btn btnMain'
|
||||
type='button'
|
||||
onClick={handleReset}
|
||||
disabled={
|
||||
navigation.state === 'loading' || navigation.state === 'submitting'
|
||||
}
|
||||
onClick={handlePublish}
|
||||
disabled={isPublishing}
|
||||
>
|
||||
{isEditing ? 'Reset' : 'Clear fields'}
|
||||
</button>
|
||||
<button
|
||||
className='btn btnMain'
|
||||
type='submit'
|
||||
disabled={
|
||||
navigation.state === 'loading' || navigation.state === 'submitting'
|
||||
}
|
||||
>
|
||||
{navigation.state === 'submitting' ? 'Publishing...' : 'Publish'}
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
{showTryAgainPopup && (
|
||||
<AlertPopup
|
||||
handleConfirm={handleTryAgainConfirm}
|
||||
handleClose={() => setShowTryAgainPopup(false)}
|
||||
header={'Publish'}
|
||||
label={`Submission timed out. Do you want to try again?`}
|
||||
/>
|
||||
)}
|
||||
{showConfirmPopup && (
|
||||
<AlertPopup
|
||||
handleConfirm={handleResetConfirm}
|
||||
handleClose={() => setShowConfirmPopup(false)}
|
||||
header={'Are you sure?'}
|
||||
label={
|
||||
isEditing
|
||||
? `Are you sure you want to clear all changes?`
|
||||
: `Are you sure you want to clear all field data?`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
type DownloadUrlFieldsProps = {
|
||||
index: number
|
||||
url: string
|
||||
title?: string
|
||||
hash: string
|
||||
signatureKey: string
|
||||
malwareScanLink: string
|
||||
modVersion: string
|
||||
customNote: string
|
||||
mediaUrl?: string
|
||||
onUrlChange: (index: number, field: keyof DownloadUrl, value: string) => void
|
||||
onRemove: (index: number) => void
|
||||
}
|
||||
@ -687,13 +526,11 @@ const DownloadUrlFields = React.memo(
|
||||
({
|
||||
index,
|
||||
url,
|
||||
title,
|
||||
hash,
|
||||
signatureKey,
|
||||
malwareScanLink,
|
||||
modVersion,
|
||||
customNote,
|
||||
mediaUrl,
|
||||
onUrlChange,
|
||||
onRemove
|
||||
}: DownloadUrlFieldsProps) => {
|
||||
@ -718,7 +555,6 @@ const DownloadUrlFields = React.memo(
|
||||
className='btn btnMain btnMainRemove'
|
||||
type='button'
|
||||
onClick={() => onRemove(index)}
|
||||
title='Remove'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -731,28 +567,6 @@ const DownloadUrlFields = React.memo(
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className='inputWrapperMain'>
|
||||
<div className='inputWrapperMainBox'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M320 448c0 17.67-14.31 32-32 32H64c-17.69 0-32-14.33-32-32v-384C32 46.34 46.31 32.01 64 32.01S96 46.34 96 64.01v352h192C305.7 416 320 430.3 320 448z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
name='title'
|
||||
placeholder='Download Title'
|
||||
value={title || ''}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className='inputWrapperMainBox'></div>
|
||||
</div>
|
||||
<div className='inputWrapperMain'>
|
||||
<div className='inputWrapperMainBox'>
|
||||
<svg
|
||||
@ -863,43 +677,6 @@ const DownloadUrlFields = React.memo(
|
||||
/>
|
||||
<div className='inputWrapperMainBox'></div>
|
||||
</div>
|
||||
<div className='inputWrapperMain'>
|
||||
<div className='inputWrapperMainBox'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M320 448c0 17.67-14.31 32-32 32H64c-17.69 0-32-14.33-32-32v-384C32 46.34 46.31 32.01 64 32.01S96 46.34 96 64.01v352h192C305.7 416 320 430.3 320 448z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px'
|
||||
}}
|
||||
>
|
||||
<ImageUpload
|
||||
onChange={(values) => {
|
||||
onUrlChange(index, 'mediaUrl', values[0])
|
||||
}}
|
||||
/>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
placeholder='Media URL'
|
||||
name='mediaUrl'
|
||||
value={mediaUrl || ''}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='inputWrapperMainBox'></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -924,7 +701,7 @@ const ScreenshotUrlFields = React.memo(
|
||||
type='text'
|
||||
className='inputMain'
|
||||
inputMode='url'
|
||||
placeholder='Image URL'
|
||||
placeholder='We recommend to upload images to https://nostr.build/'
|
||||
value={url}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
@ -932,7 +709,6 @@ const ScreenshotUrlFields = React.memo(
|
||||
className='btn btnMain btnMainRemove'
|
||||
type='button'
|
||||
onClick={() => onRemove(index)}
|
||||
title='Remove'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -1013,7 +789,6 @@ const GameDropdown = ({
|
||||
className='btn btnMain btnMainInsideField btnMainRemove'
|
||||
type='button'
|
||||
onClick={() => onChange('game', '')}
|
||||
title='Remove'
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -1026,7 +801,7 @@ const GameDropdown = ({
|
||||
</svg>
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt'>
|
||||
<FixedSizeList
|
||||
<List
|
||||
height={500}
|
||||
width={'100%'}
|
||||
itemCount={filteredOptions.length}
|
||||
@ -1048,7 +823,7 @@ const GameDropdown = ({
|
||||
{filteredOptions[index].label}
|
||||
</div>
|
||||
)}
|
||||
</FixedSizeList>
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
155
src/components/ModsFilter.tsx
Normal file
155
src/components/ModsFilter.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import { useAppSelector } from 'hooks'
|
||||
import React from 'react'
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
import { FilterOptions, ModeratedFilter, NSFWFilter, SortBy } from 'types'
|
||||
|
||||
type Props = {
|
||||
filterOptions: FilterOptions
|
||||
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
|
||||
}
|
||||
|
||||
export const ModFilter = React.memo(
|
||||
({ filterOptions, setFilterOptions }: Props) => {
|
||||
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(SortBy).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(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>
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||
aria-expanded='false'
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
{filterOptions.nsfw}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{Object.values(NSFWFilter).map((item, index) => (
|
||||
<div
|
||||
key={`nsfwFilterItem-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
nsfw: 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.source === window.location.host
|
||||
? `Show From: ${filterOptions.source}`
|
||||
: 'Show All'}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
source: window.location.host
|
||||
}))
|
||||
}
|
||||
>
|
||||
Show From: {window.location.host}
|
||||
</div>
|
||||
<div
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() =>
|
||||
setFilterOptions((prev) => ({
|
||||
...prev,
|
||||
source: 'Show All'
|
||||
}))
|
||||
}
|
||||
>
|
||||
Show All
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
@ -1,39 +0,0 @@
|
||||
import { AlertPopupProps } from 'types'
|
||||
import { AlertPopup } from './AlertPopup'
|
||||
import { useLocalStorage } from 'hooks'
|
||||
|
||||
type NsfwAlertPopup = Omit<AlertPopupProps, 'header' | 'label'>
|
||||
|
||||
/**
|
||||
* Triggers when the user wants to switch the filter to see any of the NSFW options
|
||||
* (including preferences)
|
||||
*
|
||||
* Option will be remembered for the session only and will not show the popup again
|
||||
*/
|
||||
export const NsfwAlertPopup = ({
|
||||
handleConfirm,
|
||||
handleClose
|
||||
}: NsfwAlertPopup) => {
|
||||
const [confirmNsfw, setConfirmNsfw] = useLocalStorage<boolean>(
|
||||
'confirm-nsfw',
|
||||
false
|
||||
)
|
||||
|
||||
return (
|
||||
!confirmNsfw && (
|
||||
<AlertPopup
|
||||
header='Confirm'
|
||||
label='Are you above 18 years of age?'
|
||||
handleClose={() => {
|
||||
handleConfirm(false)
|
||||
handleClose()
|
||||
}}
|
||||
handleConfirm={(confirm: boolean) => {
|
||||
setConfirmNsfw(confirm)
|
||||
handleConfirm(confirm)
|
||||
handleClose()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { appRoutes, getProfilePageRoute } from 'routes'
|
||||
import { npubToHex } from 'utils'
|
||||
import { ProfileLink } from './ProfileLink'
|
||||
|
||||
interface OriginalAuthorProps {
|
||||
value: string
|
||||
fallback?: boolean
|
||||
}
|
||||
|
||||
export const OriginalAuthor = ({
|
||||
value,
|
||||
fallback = false
|
||||
}: OriginalAuthorProps) => {
|
||||
let profilePubkey
|
||||
let displayName = '[name not set up]'
|
||||
|
||||
// Try to decode/encode depending on what we send to link
|
||||
let profileRoute = appRoutes.home
|
||||
try {
|
||||
if (value.startsWith('nprofile1')) {
|
||||
const decoded = nip19.decode(value as `nprofile1${string}`)
|
||||
profileRoute = getProfilePageRoute(value)
|
||||
profilePubkey = decoded?.data.pubkey
|
||||
} else if (value.startsWith('npub1')) {
|
||||
profilePubkey = npubToHex(value)
|
||||
const nprofile = profilePubkey
|
||||
? nip19.nprofileEncode({
|
||||
pubkey: profilePubkey
|
||||
})
|
||||
: undefined
|
||||
|
||||
if (nprofile) {
|
||||
profileRoute = getProfilePageRoute(nprofile)
|
||||
}
|
||||
} else {
|
||||
displayName = value
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create profile link:', error)
|
||||
displayName = value
|
||||
}
|
||||
|
||||
if (profileRoute && profilePubkey)
|
||||
return <ProfileLink pubkey={profilePubkey} profileRoute={profileRoute} />
|
||||
|
||||
return fallback ? displayName : null
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
interface PostWarningsProps {
|
||||
type: 'user' | 'admin'
|
||||
}
|
||||
|
||||
export const PostWarnings = ({ type }: PostWarningsProps) => (
|
||||
<div className='IBMSMSMBSSWarning'>
|
||||
<p>
|
||||
{type === 'admin' ? (
|
||||
<>
|
||||
Warning: This post has been blocked/hidden by the site for one of the
|
||||
following reasons:
|
||||
<br />
|
||||
Malware, Not a Mod, Illegal, Spam, Verified Report of Unauthorized
|
||||
Repost.
|
||||
<br />
|
||||
</>
|
||||
) : (
|
||||
<>Notice: You have blocked this post</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
@ -1,15 +0,0 @@
|
||||
import { useProfile } from 'hooks/useProfile'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface ProfileLinkProps {
|
||||
pubkey: string
|
||||
profileRoute: string
|
||||
}
|
||||
|
||||
export const ProfileLink = ({ pubkey, profileRoute }: ProfileLinkProps) => {
|
||||
const profile = useProfile(pubkey)
|
||||
const displayName =
|
||||
profile?.displayName || profile?.name || '[name not set up]'
|
||||
|
||||
return <Link to={profileRoute}>{displayName}</Link>
|
||||
}
|
@ -14,7 +14,7 @@ import { appRoutes, getProfilePageRoute } from '../routes'
|
||||
import '../styles/author.css'
|
||||
import '../styles/innerPage.css'
|
||||
import '../styles/socialPosts.css'
|
||||
import { UserRelaysType } from '../types'
|
||||
import { UserProfile, UserRelaysType } from '../types'
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
hexToNpub,
|
||||
@ -27,18 +27,37 @@ import { LoadingSpinner } from './LoadingSpinner'
|
||||
import { ZapPopUp } from './Zap'
|
||||
import placeholder from '../assets/img/DEGMods Placeholder Img.png'
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||
import { useProfile } from 'hooks/useProfile'
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export const ProfileSection = ({ pubkey }: Props) => {
|
||||
const { findMetadata } = useNDKContext()
|
||||
const [profile, setProfile] = useState<UserProfile>()
|
||||
|
||||
useDidMount(() => {
|
||||
findMetadata(pubkey).then((res) => {
|
||||
setProfile(res)
|
||||
})
|
||||
})
|
||||
|
||||
const displayName =
|
||||
profile?.displayName || profile?.name || '[name not set up]'
|
||||
const about = profile?.bio || profile?.about || '[bio not set up]'
|
||||
|
||||
return (
|
||||
<div className='IBMSMSplitMainSmallSide'>
|
||||
<div className='IBMSMSplitMainSmallSideSecWrapper'>
|
||||
<div className='IBMSMSplitMainSmallSideSec'>
|
||||
<Profile pubkey={pubkey} />
|
||||
<Profile
|
||||
pubkey={pubkey}
|
||||
displayName={displayName}
|
||||
about={about}
|
||||
image={profile?.image}
|
||||
nip05={profile?.nip05}
|
||||
lud16={profile?.lud16}
|
||||
/>
|
||||
</div>
|
||||
<div className='IBMSMSplitMainSmallSideSec'>
|
||||
<div className='IBMSMSMSSS_ShortPosts'>
|
||||
@ -90,18 +109,21 @@ export const ProfileSection = ({ pubkey }: Props) => {
|
||||
|
||||
type ProfileProps = {
|
||||
pubkey: string
|
||||
displayName: string
|
||||
about: string
|
||||
image?: string
|
||||
nip05?: string
|
||||
lud16?: string
|
||||
}
|
||||
|
||||
export const Profile = ({ pubkey }: ProfileProps) => {
|
||||
const profile = useProfile(pubkey)
|
||||
|
||||
const displayName =
|
||||
profile?.displayName || profile?.name || '[name not set up]'
|
||||
const about = profile?.bio || profile?.about || '[bio not set up]'
|
||||
const image = profile?.image || FALLBACK_PROFILE_IMAGE
|
||||
const nip05 = profile?.nip05
|
||||
const lud16 = profile?.lud16
|
||||
|
||||
export const Profile = ({
|
||||
pubkey,
|
||||
displayName,
|
||||
about,
|
||||
image,
|
||||
nip05,
|
||||
lud16
|
||||
}: ProfileProps) => {
|
||||
const npub = hexToNpub(pubkey)
|
||||
|
||||
const handleCopy = async () => {
|
||||
@ -116,20 +138,14 @@ export const Profile = ({ pubkey }: ProfileProps) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Try to encode
|
||||
let profileRoute = appRoutes.home
|
||||
let nprofile: string | undefined
|
||||
try {
|
||||
const hexPubkey = npubToHex(pubkey)
|
||||
nprofile = hexPubkey
|
||||
? nip19.nprofileEncode({
|
||||
pubkey: hexPubkey
|
||||
})
|
||||
: undefined
|
||||
profileRoute = nprofile ? getProfilePageRoute(nprofile) : appRoutes.home
|
||||
} catch (error) {
|
||||
// Silently ignore and redirect to home
|
||||
log(true, LogType.Error, 'Failed to encode profile.', error)
|
||||
const hexPubkey = npubToHex(pubkey)
|
||||
if (hexPubkey) {
|
||||
profileRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: hexPubkey
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -146,7 +162,9 @@ export const Profile = ({ pubkey }: ProfileProps) => {
|
||||
<div
|
||||
className='IBMSMSMSSS_Author_Top_PP'
|
||||
style={{
|
||||
background: `url('${image}') center / cover no-repeat`
|
||||
background: `url('${
|
||||
image || FALLBACK_PROFILE_IMAGE
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
@ -154,8 +172,7 @@ export const Profile = ({ pubkey }: ProfileProps) => {
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
|
||||
<div className='IBMSMSMSSS_Author_TopWrapper'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Name'>{displayName}</p>
|
||||
{/* Nip05 can sometimes be an empty object '{}' which causes the error */}
|
||||
{typeof nip05 === 'string' && nip05 !== '' && (
|
||||
{nip05 && (
|
||||
<p className='IBMSMSMSSS_Author_Top_Handle'>{nip05}</p>
|
||||
)}
|
||||
</div>
|
||||
@ -188,12 +205,8 @@ export const Profile = ({ pubkey }: ProfileProps) => {
|
||||
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
{typeof nprofile !== 'undefined' && (
|
||||
<ProfileQRButtonWithPopUp nprofile={nprofile} />
|
||||
)}
|
||||
{typeof lud16 !== 'undefined' && lud16 !== '' && (
|
||||
<ZapButtonWithPopUp pubkey={pubkey} />
|
||||
)}
|
||||
<ProfileQRButtonWithPopUp pubkey={pubkey} />
|
||||
{lud16 && <ZapButtonWithPopUp pubkey={pubkey} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -238,16 +251,20 @@ const posts: Post[] = [
|
||||
]
|
||||
|
||||
type QRButtonWithPopUpProps = {
|
||||
nprofile: string
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export const ProfileQRButtonWithPopUp = ({
|
||||
nprofile
|
||||
pubkey
|
||||
}: QRButtonWithPopUpProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
const nprofile = nip19.nprofileEncode({
|
||||
pubkey
|
||||
})
|
||||
|
||||
const onQrCodeClicked = async () => {
|
||||
const href = `https://njump.me/${nprofile}`
|
||||
const a = document.createElement('a')
|
||||
@ -383,12 +400,7 @@ const FollowButton = ({ pubkey }: FollowButtonProps) => {
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
return userState.user.pubkey as string
|
||||
} else {
|
||||
try {
|
||||
return (await window.nostr?.getPublicKey()) as string
|
||||
} catch (error) {
|
||||
log(true, LogType.Error, `Could not get pubkey`, error)
|
||||
return null
|
||||
}
|
||||
return (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,93 +0,0 @@
|
||||
import { useFetcher } from 'react-router-dom'
|
||||
import { CheckboxFieldUncontrolled } from 'components/Inputs'
|
||||
import { useEffect } from 'react'
|
||||
import { ReportReason } from 'types/report'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
import { PopupProps } from 'types'
|
||||
|
||||
type ReportPopupProps = {
|
||||
openedAt: number
|
||||
reasons: ReportReason[]
|
||||
} & PopupProps
|
||||
|
||||
export const ReportPopup = ({
|
||||
openedAt,
|
||||
reasons,
|
||||
handleClose
|
||||
}: ReportPopupProps) => {
|
||||
// Use openedAt to allow for multiple reports
|
||||
// by default, fetcher will remember the data
|
||||
const fetcher = useFetcher({ key: openedAt.toString() })
|
||||
|
||||
// Close automatically if action succeeds
|
||||
useEffect(() => {
|
||||
if (fetcher.data) {
|
||||
const { isSent } = fetcher.data
|
||||
if (isSent) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
}, [fetcher, handleClose])
|
||||
|
||||
return (
|
||||
<>
|
||||
{fetcher.state !== 'idle' && <LoadingSpinner desc={''} />}
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard popUpMainCardQR'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Report Post</h3>
|
||||
</div>
|
||||
<div className='popUpMainCardTopClose' onClick={handleClose}>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-96 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_Zaps'>
|
||||
<fetcher.Form
|
||||
className='pUMCB_ZapsInside'
|
||||
method='post'
|
||||
action='report'
|
||||
>
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label
|
||||
className='form-label labelMain'
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
Why are you reporting this?
|
||||
</label>
|
||||
{reasons.map((r) => (
|
||||
<CheckboxFieldUncontrolled
|
||||
key={r.key}
|
||||
label={r.label}
|
||||
name={r.key}
|
||||
defaultChecked={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className='btn btnMain pUMCB_Report'
|
||||
type='submit'
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Submit Report
|
||||
</button>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
interface SearchInputProps {
|
||||
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
handleSearch: () => void
|
||||
}
|
||||
|
||||
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
({ handleKeyDown, handleSearch }, ref) => (
|
||||
<div className='SearchMain'>
|
||||
<div className='SearchMainInside'>
|
||||
<div className='SearchMainInsideWrapper'>
|
||||
<input
|
||||
type='text'
|
||||
className='SMIWInput'
|
||||
ref={ref}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Enter search term'
|
||||
/>
|
||||
<button
|
||||
className='btn btnMain SMIWButton'
|
||||
type='button'
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
@ -1,9 +0,0 @@
|
||||
import styles from '../styles/dotsSpinner.module.scss'
|
||||
|
||||
export const Spinner = () => (
|
||||
<div className='spinner'>
|
||||
<div className='spinnerCircle'></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Dots = () => <span className={styles.loading}></span>
|
@ -1,26 +0,0 @@
|
||||
interface TabsProps {
|
||||
tabs: string[]
|
||||
tab: number
|
||||
setTab: React.Dispatch<React.SetStateAction<number>>
|
||||
}
|
||||
|
||||
export const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
|
||||
return (
|
||||
<div className='IBMSMSplitMainFullSideSec IBMSMSMFSSNav'>
|
||||
{tabs.map((t, i) => {
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
className={`btn btnMain IBMSMSMFSSNavBtn${
|
||||
tab === i ? ' IBMSMSMFSSNavBtnActive' : ''
|
||||
}`}
|
||||
type='button'
|
||||
onClick={() => setTab(i)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -2,7 +2,6 @@ import { getRelayListForUser } from '@nostr-dev-kit/ndk'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import React, {
|
||||
Dispatch,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
@ -20,9 +19,6 @@ import {
|
||||
formatNumber,
|
||||
getTagValue,
|
||||
getZapAmount,
|
||||
log,
|
||||
LogType,
|
||||
timeout,
|
||||
unformatNumber
|
||||
} from '../utils'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
@ -128,7 +124,6 @@ type ZapQRProps = {
|
||||
handleQRExpiry: () => void
|
||||
setTotalZapAmount?: Dispatch<SetStateAction<number>>
|
||||
setHasZapped?: Dispatch<SetStateAction<boolean>>
|
||||
profileImage?: string
|
||||
}
|
||||
|
||||
export const ZapQR = React.memo(
|
||||
@ -137,10 +132,8 @@ export const ZapQR = React.memo(
|
||||
handleClose,
|
||||
handleQRExpiry,
|
||||
setTotalZapAmount,
|
||||
setHasZapped,
|
||||
profileImage,
|
||||
children
|
||||
}: PropsWithChildren<ZapQRProps>) => {
|
||||
setHasZapped
|
||||
}: ZapQRProps) => {
|
||||
const { ndk } = useNDKContext()
|
||||
|
||||
useDidMount(() => {
|
||||
@ -180,10 +173,7 @@ export const ZapQR = React.memo(
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='inputLabelWrapperMain inputLabelWrapperMainQR'
|
||||
style={{ alignItems: 'center' }}
|
||||
>
|
||||
<div className='inputLabelWrapperMain' style={{ alignItems: 'center' }}>
|
||||
<QRCodeSVG
|
||||
className='popUpMainCardBottomQR'
|
||||
onClick={onQrCodeClicked}
|
||||
@ -191,21 +181,6 @@ export const ZapQR = React.memo(
|
||||
height={235}
|
||||
width={235}
|
||||
/>
|
||||
{profileImage && (
|
||||
<div style={{ marginTop: '-20px' }}>
|
||||
<img
|
||||
src={profileImage}
|
||||
alt='Profile Avatar'
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '50px',
|
||||
borderRadius: '8px',
|
||||
border: 'solid 2px #494949',
|
||||
boxShadow: '0 0 4px 0 rgb(0, 0, 0, 0.1)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<label
|
||||
className='popUpMainCardBottomLnurl'
|
||||
onClick={() => {
|
||||
@ -217,7 +192,6 @@ export const ZapQR = React.memo(
|
||||
{paymentRequest.pr}
|
||||
</label>
|
||||
<Timer onTimerExpired={handleQRExpiry} />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -284,7 +258,7 @@ export const ZapPopUp = ({
|
||||
const [amount, setAmount] = useState<number>(0)
|
||||
const [message, setMessage] = useState('')
|
||||
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest>()
|
||||
const [receiverMetadata, setRecieverMetadata] = useState<UserProfile>()
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@ -294,7 +268,7 @@ export const ZapPopUp = ({
|
||||
|
||||
const generatePaymentRequest =
|
||||
useCallback(async (): Promise<PaymentRequest | null> => {
|
||||
let userHexKey: string | undefined
|
||||
let userHexKey: string
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Getting user pubkey')
|
||||
@ -302,11 +276,7 @@ export const ZapPopUp = ({
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
userHexKey = userState.user.pubkey as string
|
||||
} else {
|
||||
try {
|
||||
userHexKey = (await window.nostr?.getPublicKey()) as string
|
||||
} catch (error) {
|
||||
log(true, LogType.Error, `Could not get pubkey`, error)
|
||||
}
|
||||
userHexKey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
|
||||
if (!userHexKey) {
|
||||
@ -315,7 +285,7 @@ export const ZapPopUp = ({
|
||||
return null
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Finding receiver metadata')
|
||||
setLoadingSpinnerDesc('finding receiver metadata')
|
||||
|
||||
const receiverMetadata = await findMetadata(receiver)
|
||||
|
||||
@ -327,17 +297,12 @@ export const ZapPopUp = ({
|
||||
|
||||
if (!receiverMetadata?.pubkey) {
|
||||
setIsLoading(false)
|
||||
toast.error('Pubkey is missing in receiver metadata!')
|
||||
toast.error('pubkey is missing in receiver metadata!')
|
||||
return null
|
||||
}
|
||||
|
||||
setRecieverMetadata(receiverMetadata)
|
||||
|
||||
// Find the receiver's read relays.
|
||||
const receiverRelays = await Promise.race([
|
||||
getRelayListForUser(receiver, ndk),
|
||||
timeout(2000)
|
||||
])
|
||||
const receiverRelays = await getRelayListForUser(receiver, ndk)
|
||||
.then((ndkRelayList) => {
|
||||
if (ndkRelayList) return ndkRelayList.readRelayUrls
|
||||
return [] // Return an empty array if ndkRelayList is undefined
|
||||
@ -503,7 +468,6 @@ export const ZapPopUp = ({
|
||||
handleQRExpiry={handleQRExpiry}
|
||||
setTotalZapAmount={setTotalZapAmount}
|
||||
setHasZapped={setHasZapped}
|
||||
profileImage={receiverMetadata?.image}
|
||||
/>
|
||||
)}
|
||||
{lastNode}
|
||||
@ -584,7 +548,7 @@ export const ZapSplit = ({
|
||||
const generatePaymentInvoices = async () => {
|
||||
if (!amount) return null
|
||||
|
||||
let userHexKey: string | undefined
|
||||
let userHexKey: string
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Getting user pubkey')
|
||||
@ -592,11 +556,7 @@ export const ZapSplit = ({
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
userHexKey = userState.user.pubkey as string
|
||||
} else {
|
||||
try {
|
||||
userHexKey = (await window.nostr?.getPublicKey()) as string
|
||||
} catch (error) {
|
||||
log(true, LogType.Error, `Could not get pubkey`, error)
|
||||
}
|
||||
userHexKey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
|
||||
if (!userHexKey) {
|
||||
@ -654,11 +614,7 @@ export const ZapSplit = ({
|
||||
|
||||
if (adminShare > 0 && admin?.pubkey && admin?.lud16) {
|
||||
// Find the receiver's read relays.
|
||||
// TODO: NDK should have native timeout in a future release
|
||||
const adminRelays = await Promise.race([
|
||||
getRelayListForUser(admin.pubkey as string, ndk),
|
||||
timeout(2000)
|
||||
])
|
||||
const adminRelays = await getRelayListForUser(admin.pubkey as string, ndk)
|
||||
.then((ndkRelayList) => {
|
||||
if (ndkRelayList) return ndkRelayList.readRelayUrls
|
||||
return [] // Return an empty array if ndkRelayList is undefined
|
||||
@ -759,8 +715,6 @@ export const ZapSplit = ({
|
||||
toast.warn('Webln is not present. Use QR code to send zap.')
|
||||
setInvoices(paymentInvoices)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const removeInvoice = (key: string) => {
|
||||
@ -775,56 +729,6 @@ export const ZapSplit = ({
|
||||
if (!invoices) return null
|
||||
|
||||
const authorInvoice = invoices.get('author')
|
||||
const feedback = (isFirst: boolean) => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
flexWrap: 'wrap',
|
||||
gridGap: '10px'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='btn btnMain'
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
cursor: 'default',
|
||||
background: isFirst ? undefined : 'unset'
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
1st Invoice
|
||||
</div>
|
||||
<div
|
||||
className='btn btnMain'
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
cursor: 'default',
|
||||
background: isFirst ? 'unset' : undefined
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
2nd Invoice
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
if (authorInvoice) {
|
||||
return (
|
||||
<ZapQR
|
||||
@ -834,10 +738,7 @@ export const ZapSplit = ({
|
||||
handleQRExpiry={() => removeInvoice('author')}
|
||||
setTotalZapAmount={setTotalZapAmount}
|
||||
setHasZapped={setHasZapped}
|
||||
profileImage={author?.image}
|
||||
>
|
||||
{feedback(true)}
|
||||
</ZapQR>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -852,10 +753,7 @@ export const ZapSplit = ({
|
||||
handleClose()
|
||||
}}
|
||||
handleQRExpiry={() => removeInvoice('admin')}
|
||||
profileImage={admin?.image}
|
||||
>
|
||||
{feedback(false)}
|
||||
</ZapQR>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,157 +0,0 @@
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk'
|
||||
import { formatDate } from 'date-fns'
|
||||
import { useDidMount, useNDKContext } from 'hooks'
|
||||
import { useState } from 'react'
|
||||
import { useParams, useLocation, Link } from 'react-router-dom'
|
||||
import { getModPageRoute, getBlogPageRoute, getProfilePageRoute } from 'routes'
|
||||
import { CommentEvent, UserProfile } from 'types'
|
||||
import { hexToNpub } from 'utils'
|
||||
import { Reactions } from './Reactions'
|
||||
import { Zap } from './Zap'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { CommentContent } from './CommentContent'
|
||||
|
||||
interface CommentProps {
|
||||
comment: CommentEvent
|
||||
}
|
||||
export const Comment = ({ comment }: CommentProps) => {
|
||||
const { naddr } = useParams()
|
||||
const location = useLocation()
|
||||
const { ndk } = useNDKContext()
|
||||
const isMod = location.pathname.includes('/mod/')
|
||||
const isBlog = location.pathname.includes('/blog/')
|
||||
const baseUrl = naddr
|
||||
? isMod
|
||||
? getModPageRoute(naddr)
|
||||
: isBlog
|
||||
? getBlogPageRoute(naddr)
|
||||
: undefined
|
||||
: undefined
|
||||
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
|
||||
const [profile, setProfile] = useState<UserProfile>()
|
||||
|
||||
useDidMount(() => {
|
||||
comment.event.author.fetchProfile().then((res) => setProfile(res))
|
||||
ndk
|
||||
.fetchEvents({
|
||||
kinds: [NDKKind.Text, NDKKind.GenericReply],
|
||||
'#e': [comment.event.id]
|
||||
})
|
||||
.then((ndkEventsSet) => {
|
||||
setCommentEvents(
|
||||
Array.from(ndkEventsSet).map((ndkEvent) => ({
|
||||
event: ndkEvent
|
||||
}))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const profileRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: comment.event.pubkey
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='IBMSMSMBSSCL_Comment'>
|
||||
<div className='IBMSMSMBSSCL_CommentTop'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CommentTopPP'
|
||||
to={profileRoute}
|
||||
style={{
|
||||
background: `url('${
|
||||
profile?.image || ''
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetails'>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Name' to={profileRoute}>
|
||||
{profile?.displayName || profile?.name || ''}{' '}
|
||||
</Link>
|
||||
<Link className='IBMSMSMBSSCL_CTD_Address' to={profileRoute}>
|
||||
{hexToNpub(comment.event.pubkey)}
|
||||
</Link>
|
||||
</div>
|
||||
{comment.event.created_at && (
|
||||
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
|
||||
<a className='IBMSMSMBSSCL_CADTime'>
|
||||
{formatDate(comment.event.created_at * 1000, 'hh:mm aa')}{' '}
|
||||
</a>
|
||||
<a className='IBMSMSMBSSCL_CADDate'>
|
||||
{formatDate(comment.event.created_at * 1000, 'dd/MM/yyyy')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentBottom'>
|
||||
{comment.status && (
|
||||
<p className='IBMSMSMBSSCL_CBTextStatus'>
|
||||
<span className='IBMSMSMBSSCL_CBTextStatusSpan'>Status:</span>
|
||||
{comment.status}
|
||||
</p>
|
||||
)}
|
||||
<CommentContent content={comment.event.content} />
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentActions'>
|
||||
<div className='IBMSMSMBSSCL_CommentActionsInside'>
|
||||
<Reactions {...comment.event.rawEvent()} />
|
||||
{/* <div
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost'
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -64 640 640'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>0</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div> */}
|
||||
{typeof profile?.lud16 !== 'undefined' && profile.lud16 !== '' && (
|
||||
<Zap {...comment.event.rawEvent()} />
|
||||
)}
|
||||
{comment.event.kind === NDKKind.GenericReply && (
|
||||
<>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'
|
||||
to={baseUrl + comment.event.encode()}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||
{commentEvents.length}
|
||||
</p>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>Replies</p>
|
||||
</Link>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReply'
|
||||
to={baseUrl + comment.event.encode()}
|
||||
>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>Reply</p>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import { useTextLimit } from 'hooks/useTextLimit'
|
||||
interface CommentContentProps {
|
||||
content: string
|
||||
}
|
||||
export const CommentContent = ({ content }: CommentContentProps) => {
|
||||
const { text, isTextOverflowing, isExpanded, toggle } = useTextLimit(content)
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className='IBMSMSMBSSCL_CBText'>{text}</p>
|
||||
{isTextOverflowing && (
|
||||
<div className='IBMSMSMBSSCL_CBExpand' onClick={toggle}>
|
||||
<p>{isExpanded ? 'Hide' : 'View'} full post</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
type CommentFormProps = {
|
||||
handleSubmit: (content: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
@ -1,335 +0,0 @@
|
||||
import { formatDate } from 'date-fns'
|
||||
import { useBodyScrollDisable, useNDKContext, useReplies } from 'hooks'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Link,
|
||||
useLoaderData,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams
|
||||
} from 'react-router-dom'
|
||||
import { getBlogPageRoute, getModPageRoute, getProfilePageRoute } from 'routes'
|
||||
import { CommentEvent, UserProfile } from 'types'
|
||||
import { CommentsLoaderResult } from 'types/comments'
|
||||
import { adjustTextareaHeight, handleCommentSubmit, hexToNpub } from 'utils'
|
||||
import { Reactions } from './Reactions'
|
||||
import { Zap } from './Zap'
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk'
|
||||
import { Comment } from './Comment'
|
||||
import { useComments } from 'hooks/useComments'
|
||||
import { CommentContent } from './CommentContent'
|
||||
import { Dots } from 'components/Spinner'
|
||||
|
||||
export const CommentsPopup = () => {
|
||||
const { naddr } = useParams()
|
||||
const location = useLocation()
|
||||
const { ndk } = useNDKContext()
|
||||
useBodyScrollDisable(true)
|
||||
const isMod = location.pathname.includes('/mod/')
|
||||
const isBlog = location.pathname.includes('/blog/')
|
||||
const baseUrl = naddr
|
||||
? isMod
|
||||
? getModPageRoute(naddr)
|
||||
: isBlog
|
||||
? getBlogPageRoute(naddr)
|
||||
: undefined
|
||||
: undefined
|
||||
|
||||
const { event } = useLoaderData() as CommentsLoaderResult
|
||||
const {
|
||||
size,
|
||||
parent: replyEvent,
|
||||
isComplete,
|
||||
root: rootEvent
|
||||
} = useReplies(event.tagValue('e'))
|
||||
const isRoot = event.tagValue('a') === event.tagValue('A')
|
||||
const [profile, setProfile] = useState<UserProfile>()
|
||||
const { commentEvents, setCommentEvents } = useComments(
|
||||
event.author.pubkey,
|
||||
undefined,
|
||||
event.id
|
||||
)
|
||||
useEffect(() => {
|
||||
event.author.fetchProfile().then((res) => setProfile(res))
|
||||
}, [event.author])
|
||||
const profileRoute = useMemo(
|
||||
() =>
|
||||
getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: event.pubkey
|
||||
})
|
||||
),
|
||||
[event.pubkey]
|
||||
)
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [replyText, setReplyText] = useState('')
|
||||
|
||||
const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.currentTarget.value
|
||||
setReplyText(value)
|
||||
adjustTextareaHeight(e.currentTarget)
|
||||
}, [])
|
||||
|
||||
const [visible, setVisible] = useState<CommentEvent[]>([])
|
||||
const discoveredCount = commentEvents.length - visible.length
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
// Initial loading to indicate comments fetching (stop after 5 seconds)
|
||||
const t = window.setTimeout(() => setIsLoading(false), 5000)
|
||||
return () => {
|
||||
window.clearTimeout(t)
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setVisible(commentEvents)
|
||||
}
|
||||
}, [commentEvents, isLoading])
|
||||
const handleDiscoveredClick = () => {
|
||||
setVisible(commentEvents)
|
||||
}
|
||||
const handleSubmit = handleCommentSubmit(
|
||||
event,
|
||||
setCommentEvents,
|
||||
setVisible,
|
||||
ndk
|
||||
)
|
||||
|
||||
const handleComment = async () => {
|
||||
setIsSubmitting(true)
|
||||
const submitted = await handleSubmit(replyText)
|
||||
if (submitted) setReplyText('')
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='popUpMain'>
|
||||
<div className='ContainerMain'>
|
||||
<div className='popUpMainCardWrapper'>
|
||||
<div className='popUpMainCard'>
|
||||
<div className='popUpMainCardTop'>
|
||||
<div className='popUpMainCardTopInfo'>
|
||||
<h3>Comment replies</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='popUpMainCardTopClose'
|
||||
onClick={() => navigate('..')}
|
||||
>
|
||||
<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'>
|
||||
<div className='pUMCB_PrimeComment'>
|
||||
<div className='IBMSMSMBSSCL_Comment'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopOther'>
|
||||
<div className='IBMSMSMBSSCL_CTO'>
|
||||
{replyEvent && (
|
||||
<Link
|
||||
style={{
|
||||
...(!isComplete ? { pointerEvents: 'none' } : {})
|
||||
}}
|
||||
className='IBMSMSMBSSCL_CTOLink'
|
||||
to={baseUrl + replyEvent.encode()}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CTOLinkIcon'
|
||||
>
|
||||
<path d='M447.1 256C447.1 273.7 433.7 288 416 288H109.3l105.4 105.4c12.5 12.5 12.5 32.75 0 45.25C208.4 444.9 200.2 448 192 448s-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.25L109.3 224H416C433.7 224 447.1 238.3 447.1 256z'></path>
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
<p className='IBMSMSMBSSCL_CTOText'>
|
||||
Reply Depth: <span>{size}</span>
|
||||
{!isComplete && <Dots />}
|
||||
</p>
|
||||
</div>
|
||||
{!isRoot && rootEvent && (
|
||||
<Link
|
||||
style={{
|
||||
...(!isComplete ? { pointerEvents: 'none' } : {})
|
||||
}}
|
||||
className='btn btnMain IBMSMSMBSSCL_CTOBtn'
|
||||
type='button'
|
||||
to={baseUrl + rootEvent.encode()}
|
||||
>
|
||||
Main Post {!isComplete && <Dots />}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentTop'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopPPWrapper'>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CommentTopPP'
|
||||
to={profileRoute}
|
||||
style={{
|
||||
background: `url('${
|
||||
profile?.image || ''
|
||||
}') center / cover no-repeat`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetailsWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CommentTopDetails'>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CTD_Name'
|
||||
to={profileRoute}
|
||||
>
|
||||
{profile?.displayName || profile?.name || ''}{' '}
|
||||
</Link>
|
||||
<Link
|
||||
className='IBMSMSMBSSCL_CTD_Address'
|
||||
to={profileRoute}
|
||||
>
|
||||
{hexToNpub(event.pubkey)}
|
||||
</Link>
|
||||
</div>
|
||||
{event.created_at && (
|
||||
<div className='IBMSMSMBSSCL_CommentActionsDetails'>
|
||||
<a className='IBMSMSMBSSCL_CADTime'>
|
||||
{formatDate(event.created_at * 1000, 'hh:mm aa')}{' '}
|
||||
</a>
|
||||
<a className='IBMSMSMBSSCL_CADDate'>
|
||||
{formatDate(event.created_at * 1000, 'dd/MM/yyyy')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentBottom'>
|
||||
<CommentContent content={event.content} />
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCL_CommentActions'>
|
||||
<div className='IBMSMSMBSSCL_CommentActionsInside'>
|
||||
<Reactions {...event.rawEvent()} />
|
||||
|
||||
{/* <div
|
||||
className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAERepost'
|
||||
style={{ cursor: 'not-allowed' }}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 -64 640 640'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M614.2 334.8C610.5 325.8 601.7 319.1 592 319.1H544V176C544 131.9 508.1 96 464 96h-128c-17.67 0-32 14.31-32 32s14.33 32 32 32h128C472.8 160 480 167.2 480 176v143.1h-48c-9.703 0-18.45 5.844-22.17 14.82s-1.656 19.29 5.203 26.16l80 80.02C499.7 445.7 505.9 448 512 448s12.28-2.344 16.97-7.031l80-80.02C615.8 354.1 617.9 343.8 614.2 334.8zM304 352h-128C167.2 352 160 344.8 160 336V192h48c9.703 0 18.45-5.844 22.17-14.82s1.656-19.29-5.203-26.16l-80-80.02C140.3 66.34 134.1 64 128 64S115.7 66.34 111 71.03l-80 80.02C24.17 157.9 22.11 168.2 25.83 177.2S38.3 192 48 192H96V336C96 380.1 131.9 416 176 416h128c17.67 0 32-14.31 32-32S321.7 352 304 352z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>0</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{typeof profile?.lud16 !== 'undefined' &&
|
||||
profile.lud16 !== '' && <Zap {...event.rawEvent()} />}
|
||||
|
||||
{event.kind === NDKKind.GenericReply && (
|
||||
<>
|
||||
<span className='IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEReplies'>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 512 512'
|
||||
width='1em'
|
||||
height='1em'
|
||||
fill='currentColor'
|
||||
className='IBMSMSMBSSCL_CAElementIcon'
|
||||
>
|
||||
<path d='M256 32C114.6 32 .0272 125.1 .0272 240c0 49.63 21.35 94.98 56.97 130.7c-12.5 50.37-54.27 95.27-54.77 95.77c-2.25 2.25-2.875 5.734-1.5 8.734C1.979 478.2 4.75 480 8 480c66.25 0 115.1-31.76 140.6-51.39C181.2 440.9 217.6 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32z'></path>
|
||||
</svg>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||
{commentEvents.length}
|
||||
</p>
|
||||
<p className='IBMSMSMBSSCL_CAElementText'>
|
||||
Replies
|
||||
</p>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='pUMCB_CommentToPrime'>
|
||||
<div className='IBMSMSMBSSCC_Top'>
|
||||
<textarea
|
||||
className='IBMSMSMBSSCC_Top_Box postSocialTextarea'
|
||||
placeholder='Got something to say?'
|
||||
value={replyText}
|
||||
onChange={handleChange}
|
||||
style={{ height: '0px' }}
|
||||
></textarea>
|
||||
</div>
|
||||
<div className='IBMSMSMBSSCC_Bottom'>
|
||||
{/* <a className='IBMSMSMBSSCC_BottomButton'>Quote-Repost</a> */}
|
||||
<button
|
||||
onClick={handleComment}
|
||||
disabled={isSubmitting}
|
||||
className='IBMSMSMBSSCC_BottomButton'
|
||||
>
|
||||
{isSubmitting ? 'Replying...' : 'Reply'}
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{commentEvents.length > 0 && (
|
||||
<>
|
||||
<h3 className='IBMSMSMBSSCL_CommentNoteRepliesTitle'>
|
||||
Replies
|
||||
<button
|
||||
type='button'
|
||||
className='btnMain IBMSMSMBSSCL_CommentNoteRepliesTitleBtn'
|
||||
onClick={
|
||||
discoveredCount ? handleDiscoveredClick : undefined
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{isLoading ? (
|
||||
<>
|
||||
Discovering replies
|
||||
<Dots />
|
||||
</>
|
||||
) : discoveredCount ? (
|
||||
<>Load {discoveredCount} discovered replies</>
|
||||
) : (
|
||||
<>No new replies</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<div className='pUMCB_RepliesToPrime'>
|
||||
{commentEvents.map((reply) => (
|
||||
<Comment key={reply.event.id} comment={reply} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,79 +0,0 @@
|
||||
import React, { Dispatch, SetStateAction } from 'react'
|
||||
import { AuthorFilterEnum, SortByEnum } from 'types'
|
||||
|
||||
export type FilterOptions = {
|
||||
sort: SortByEnum
|
||||
author: AuthorFilterEnum
|
||||
}
|
||||
|
||||
type FilterProps = {
|
||||
filterOptions: FilterOptions
|
||||
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
|
||||
}
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
)
|
@ -1,68 +0,0 @@
|
||||
import { NostrEvent } from '@nostr-dev-kit/ndk'
|
||||
import { Dots } from 'components/Spinner'
|
||||
import { useReactions } from 'hooks'
|
||||
|
||||
export const Reactions = (props: NostrEvent) => {
|
||||
const {
|
||||
isDataLoaded,
|
||||
likesCount,
|
||||
disLikesCount,
|
||||
handleReaction,
|
||||
hasReactedPositively,
|
||||
hasReactedNegatively
|
||||
} = useReactions({
|
||||
pubkey: props.pubkey,
|
||||
eTag: props.id!
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEUp ${
|
||||
hasReactedPositively ? 'IBMSMSMBSSCL_CAEUpActive' : ''
|
||||
}`}
|
||||
onClick={isDataLoaded ? () => handleReaction(true) : undefined}
|
||||
>
|
||||
<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'>
|
||||
{isDataLoaded ? likesCount : <Dots />}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`IBMSMSMBSSCL_CAElement IBMSMSMBSSCL_CAEDown ${
|
||||
hasReactedNegatively ? 'IBMSMSMBSSCL_CAEDownActive' : ''
|
||||
}`}
|
||||
onClick={isDataLoaded ? () => handleReaction() : undefined}
|
||||
>
|
||||
<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'>
|
||||
{isDataLoaded ? disLikesCount : <Dots />}
|
||||
</p>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoadWrapper'>
|
||||
<div className='IBMSMSMBSSCL_CAElementLoad'></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
import { NostrEvent } from '@nostr-dev-kit/ndk'
|
||||
import { ZapPopUp } from 'components/Zap'
|
||||
import {
|
||||
useAppSelector,
|
||||
useNDKContext,
|
||||
useBodyScrollDisable,
|
||||
useDidMount
|
||||
} from 'hooks'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { abbreviateNumber } from 'utils'
|
||||
|
||||
export const Zap = (props: NostrEvent) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [totalZappedAmount, setTotalZappedAmount] = useState(0)
|
||||
const [hasZapped, setHasZapped] = useState(false)
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const { getTotalZapAmount } = useNDKContext()
|
||||
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
useDidMount(() => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,131 +0,0 @@
|
||||
import { Dots } from 'components/Spinner'
|
||||
import { useNDKContext } from 'hooks'
|
||||
import { useComments } from 'hooks/useComments'
|
||||
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'
|
||||
import { useLoaderData } from 'react-router-dom'
|
||||
import {
|
||||
Addressable,
|
||||
AuthorFilterEnum,
|
||||
BlogPageLoaderResult,
|
||||
CommentEvent,
|
||||
ModPageLoaderResult,
|
||||
SortByEnum
|
||||
} from 'types'
|
||||
import { handleCommentSubmit } from 'utils'
|
||||
import { Filter, FilterOptions } from './Filter'
|
||||
import { CommentForm } from './CommentForm'
|
||||
import { Comment } from './Comment'
|
||||
|
||||
type Props = {
|
||||
addressable: Addressable
|
||||
setCommentCount: Dispatch<SetStateAction<number>>
|
||||
}
|
||||
|
||||
export const Comments = ({ addressable, setCommentCount }: Props) => {
|
||||
const { ndk } = useNDKContext()
|
||||
const { commentEvents, setCommentEvents } = useComments(
|
||||
addressable.author,
|
||||
addressable.aTag
|
||||
)
|
||||
const { event } = useLoaderData() as
|
||||
| ModPageLoaderResult
|
||||
| BlogPageLoaderResult
|
||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||
sort: SortByEnum.Latest,
|
||||
author: AuthorFilterEnum.All_Comments
|
||||
})
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
// Initial loading to indicate comments fetching (stop after 5 seconds)
|
||||
const t = window.setTimeout(() => setIsLoading(false), 5000)
|
||||
return () => {
|
||||
window.clearTimeout(t)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setCommentCount(commentEvents.length)
|
||||
}, [commentEvents, setCommentCount])
|
||||
|
||||
const handleDiscoveredClick = () => {
|
||||
setVisible(commentEvents)
|
||||
}
|
||||
const [visible, setVisible] = useState<CommentEvent[]>([])
|
||||
const handleSubmit = handleCommentSubmit(
|
||||
event,
|
||||
setCommentEvents,
|
||||
setVisible,
|
||||
ndk
|
||||
)
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setVisible(commentEvents)
|
||||
}
|
||||
}, [commentEvents, isLoading])
|
||||
|
||||
const comments = useMemo(() => {
|
||||
let filteredComments = visible
|
||||
if (filterOptions.author === AuthorFilterEnum.Creator_Comments) {
|
||||
filteredComments = filteredComments.filter(
|
||||
(comment) => comment.event.pubkey === addressable.author
|
||||
)
|
||||
}
|
||||
|
||||
if (filterOptions.sort === SortByEnum.Latest) {
|
||||
filteredComments.sort((a, b) =>
|
||||
a.event.created_at && b.event.created_at
|
||||
? b.event.created_at - a.event.created_at
|
||||
: 0
|
||||
)
|
||||
} else if (filterOptions.sort === SortByEnum.Oldest) {
|
||||
filteredComments.sort((a, b) =>
|
||||
a.event.created_at && b.event.created_at
|
||||
? a.event.created_at - b.event.created_at
|
||||
: 0
|
||||
)
|
||||
}
|
||||
|
||||
return filteredComments
|
||||
}, [visible, filterOptions.author, filterOptions.sort, addressable.author])
|
||||
|
||||
const discoveredCount = commentEvents.length - visible.length
|
||||
return (
|
||||
<div className='IBMSMSMBSSCommentsWrapper'>
|
||||
<h4 className='IBMSMSMBSSTitle'>Comments</h4>
|
||||
<div className='IBMSMSMBSSComments'>
|
||||
{/* Hide comment form if aTag is missing */}
|
||||
{!!addressable.aTag && <CommentForm handleSubmit={handleSubmit} />}
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
className='btnMain'
|
||||
onClick={discoveredCount ? handleDiscoveredClick : undefined}
|
||||
>
|
||||
<span>
|
||||
{isLoading ? (
|
||||
<>
|
||||
Discovering comments
|
||||
<Dots />
|
||||
</>
|
||||
) : discoveredCount ? (
|
||||
<>Load {discoveredCount} discovered comments</>
|
||||
) : (
|
||||
<>No new comments</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<Filter
|
||||
filterOptions={filterOptions}
|
||||
setFilterOptions={setFilterOptions}
|
||||
/>
|
||||
<div className='IBMSMSMBSSCommentsList'>
|
||||
{comments.map((comment) => (
|
||||
<Comment key={comment.event.id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -20,12 +20,6 @@ export const LANDING_PAGE_DATA = {
|
||||
'Cyberpunk 2077',
|
||||
'ELDEN RING',
|
||||
'The Coffin of Andy and Leyley'
|
||||
],
|
||||
featuredBlogPosts: [
|
||||
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrzcfc8qurjefn943xyen9956rywp595unjc3h94nxvwfexymxxcfnvdjxxlyq37c',
|
||||
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjryv3k8qenydpj94nrscmp956xgwtp94snydtz95ekgvphvfnxvvrzvyexzsvsz9y',
|
||||
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qyv8wumn8ghj7un9d3shjtnyv4nk6mmywvhxxmmd9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq3qamnwvaz7tmwdaehgu3wd4hk6tcppemhxue69uhkummn9ekx7mp0qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgawaehxw309ahx7um5wghxy6t5vdhkjmn9wgh8xmmrd9skctcpz4mhxue69uhkummnw3ezummcw3ezuer9wchsqfrxv33rvvfjxucz6d33vgcz6dp48qej6wryv9jz6errv33nqef3xy6kxvmrtmq496',
|
||||
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrycf5vyunyd34943kydn9956rycmp943xydpc95cxge3cvguxgcmyxsmkyzpyj60'
|
||||
]
|
||||
}
|
||||
// we use this object to check if a user has reacted positively or negatively to a post
|
||||
@ -118,9 +112,7 @@ export const REACTIONS = {
|
||||
|
||||
export const MAX_MODS_PER_PAGE = 10
|
||||
export const MAX_GAMES_PER_PAGE = 10
|
||||
|
||||
// todo: add game and mod fallback image here
|
||||
export const FALLBACK_PROFILE_IMAGE =
|
||||
'https://image.nostr.build/a305f4b43f74af3c6dcda42e6a798105a56ac1e3e7b74d7bef171896b3ba7520.png'
|
||||
|
||||
export const PROFILE_BLOG_FILTER_LIMIT = 20
|
||||
export const MAX_VISIBLE_TEXT_PER_COMMENT = 500
|
||||
|
@ -21,8 +21,7 @@ import {
|
||||
log,
|
||||
LogType,
|
||||
npubToHex,
|
||||
orderEventsChronologically,
|
||||
timeout
|
||||
orderEventsChronologically
|
||||
} from 'utils'
|
||||
|
||||
type FetchModsOptions = {
|
||||
@ -30,10 +29,9 @@ type FetchModsOptions = {
|
||||
until?: number
|
||||
since?: number
|
||||
limit?: number
|
||||
author?: string
|
||||
}
|
||||
|
||||
export interface NDKContextType {
|
||||
interface NDKContextType {
|
||||
ndk: NDK
|
||||
fetchMods: (opts: FetchModsOptions) => Promise<ModDetails[]>
|
||||
fetchEvents: (filter: NDKFilter) => Promise<NDKEvent[]>
|
||||
@ -74,6 +72,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
useEffect(() => {
|
||||
window.onunhandledrejection = async (event: PromiseRejectionEvent) => {
|
||||
event.preventDefault()
|
||||
console.log(event.reason)
|
||||
if (event.reason?.name === Dexie.errnames.DatabaseClosed) {
|
||||
console.log(
|
||||
'Could not open Dexie DB, probably version change. Deleting old DB and reloading...'
|
||||
@ -111,7 +110,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
}
|
||||
|
||||
const ndk = useMemo(() => {
|
||||
localStorage.removeItem('debug')
|
||||
localStorage.setItem('debug', '*')
|
||||
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' })
|
||||
dexieAdapter.locking = true
|
||||
const ndk = new NDK({
|
||||
@ -147,8 +146,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
source,
|
||||
until,
|
||||
since,
|
||||
limit,
|
||||
author
|
||||
limit
|
||||
}: FetchModsOptions): Promise<ModDetails[]> => {
|
||||
// Define the filter criteria for fetching mods
|
||||
const filter: NDKFilter = {
|
||||
@ -156,8 +154,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
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
|
||||
authors: author ? [author] : undefined // Optional filter to fetch events from only this author
|
||||
since // Optional filter to fetch events from this timestamp
|
||||
}
|
||||
|
||||
// If the source matches the current window location, add a filter condition
|
||||
@ -241,18 +238,15 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
hexKey: string,
|
||||
userRelaysType: UserRelaysType
|
||||
): Promise<NDKEvent[]> => {
|
||||
// Find the user's relays (10s timeout).
|
||||
const relayUrls = await Promise.race([
|
||||
getRelayListForUser(hexKey, ndk),
|
||||
timeout(3000)
|
||||
])
|
||||
// Find the user's relays.
|
||||
const relayUrls = await getRelayListForUser(hexKey, ndk)
|
||||
.then((ndkRelayList) => {
|
||||
if (ndkRelayList) return ndkRelayList[userRelaysType]
|
||||
return [] // Return an empty array if ndkRelayList is undefined
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
false, // Too many failed requests, turned off for clarity
|
||||
true,
|
||||
LogType.Error,
|
||||
`An error occurred in fetching user's (${hexKey}) ${userRelaysType}`,
|
||||
err
|
||||
@ -264,9 +258,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
.fetchEvents(
|
||||
filter,
|
||||
{ closeOnEose: true, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL },
|
||||
relayUrls.length
|
||||
? NDKRelaySet.fromRelayUrls(relayUrls, ndk, true)
|
||||
: undefined
|
||||
NDKRelaySet.fromRelayUrls(relayUrls, ndk, true)
|
||||
)
|
||||
.then((ndkEventSet) => {
|
||||
const ndkEvents = Array.from(ndkEventSet)
|
||||
@ -369,14 +361,16 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
const publish = async (event: NDKEvent): Promise<string[]> => {
|
||||
if (!event.sig) throw new Error('Before publishing first sign the event!')
|
||||
|
||||
try {
|
||||
const res = await event.publish(undefined, 10000)
|
||||
const relaysPublishedOn = Array.from(res)
|
||||
return relaysPublishedOn.map((relay) => relay.url)
|
||||
} catch (err) {
|
||||
console.error(`An error occurred in publishing event`, err)
|
||||
return []
|
||||
}
|
||||
return event
|
||||
.publish(undefined, 30000)
|
||||
.then((res) => {
|
||||
const relaysPublishedOn = Array.from(res)
|
||||
return relaysPublishedOn.map((relay) => relay.url)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`An error occurred in publishing event`, err)
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,74 +0,0 @@
|
||||
import { DropzoneOptions } from 'react-dropzone'
|
||||
import { NostrCheckServer } from './nostrcheck-server'
|
||||
import { BaseError } from 'types'
|
||||
|
||||
export interface MediaOperations {
|
||||
post: (file: File) => Promise<string>
|
||||
}
|
||||
export type MediaStrategy = Omit<MediaOperations, 'auth'>
|
||||
|
||||
export interface MediaOption {
|
||||
name: string
|
||||
host: string
|
||||
type: 'nostrcheck-server' | 'route96'
|
||||
}
|
||||
|
||||
// nostr.build based dropzone options
|
||||
export const MEDIA_DROPZONE_OPTIONS: DropzoneOptions = {
|
||||
maxSize: 7000000,
|
||||
accept: {
|
||||
'image/*': ['.jpeg', '.png', '.jpg', '.gif', '.webp']
|
||||
}
|
||||
}
|
||||
|
||||
export const MEDIA_OPTIONS: MediaOption[] = [
|
||||
// {
|
||||
// name: 'nostr.build',
|
||||
// host: 'https://nostr.build/',
|
||||
// type: 'nostrcheck-server'
|
||||
// },
|
||||
{
|
||||
name: 'nostrcheck.me',
|
||||
host: 'https://nostrcheck.me/',
|
||||
type: 'nostrcheck-server'
|
||||
},
|
||||
{
|
||||
name: 'nostpic.com',
|
||||
host: 'https://nostpic.com/',
|
||||
type: 'nostrcheck-server'
|
||||
},
|
||||
{
|
||||
name: 'files.sovbit.host',
|
||||
host: 'https://files.sovbit.host/',
|
||||
type: 'nostrcheck-server'
|
||||
}
|
||||
// {
|
||||
// name: 'void.cat',
|
||||
// host: 'https://void.cat/',
|
||||
// type: 'route96'
|
||||
// }
|
||||
]
|
||||
|
||||
enum ImageErrorType {
|
||||
'TYPE_MISSING' = 'Media Option must include a type.'
|
||||
}
|
||||
|
||||
export class ImageController implements MediaStrategy {
|
||||
post: (file: File) => Promise<string>
|
||||
|
||||
constructor(mediaOption: MediaOption) {
|
||||
let strategy: MediaStrategy
|
||||
switch (mediaOption.type) {
|
||||
case 'nostrcheck-server':
|
||||
strategy = new NostrCheckServer(mediaOption.host)
|
||||
this.post = strategy.post
|
||||
break
|
||||
|
||||
case 'route96':
|
||||
throw new Error('Not implemented.')
|
||||
|
||||
default:
|
||||
throw new BaseError(ImageErrorType.TYPE_MISSING)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,166 +0,0 @@
|
||||
import axios, { isAxiosError } from 'axios'
|
||||
import { NostrEvent, NDKKind } from '@nostr-dev-kit/ndk'
|
||||
import { type MediaOperations } from '.'
|
||||
import { store } from 'store'
|
||||
import { log, LogType, now } from 'utils'
|
||||
import { BaseError, handleError } from 'types'
|
||||
|
||||
// https://github.com/quentintaranpino/nostrcheck-server/blob/main/DOCS.md#media-post
|
||||
// Response object (other fields omitted for brevity)
|
||||
// {
|
||||
// "status": "success",
|
||||
// "nip94_event": {
|
||||
// "tags": [
|
||||
// [
|
||||
// "url",
|
||||
// "https://nostrcheck.me/media/62c76eb094369d938f5895442eef7f53ebbf019f69707d64e77d4d182b609309/c35277dbcedebb0e3b80361762c8baadb66dcdfb6396949e50630159a472c3b2.webp"
|
||||
// ],
|
||||
// ],
|
||||
// }
|
||||
// }
|
||||
|
||||
interface Response {
|
||||
status: 'success' | string
|
||||
nip94_event?: {
|
||||
tags?: string[][]
|
||||
}
|
||||
}
|
||||
|
||||
enum HandledErrorType {
|
||||
'PUBKEY' = 'Failed to get public key.',
|
||||
'SIGN' = 'Failed to sign the event.',
|
||||
'AXIOS_REQ' = 'Image upload failed. Try another host from the dropdown.',
|
||||
'AXIOS_RES' = 'Image upload failed. Reason: ',
|
||||
'AXIOS_ERR' = 'Image upload failed.',
|
||||
'NOSTR_CHECK_NO_SUCCESS' = 'Image upload was unsuccesfull.',
|
||||
'NOSTR_CHECK_BAD_EVENT' = 'Image upload failed. Please try again.'
|
||||
}
|
||||
|
||||
export class NostrCheckServer implements MediaOperations {
|
||||
#media = 'api/v2/media'
|
||||
#url: string
|
||||
|
||||
constructor(url: string) {
|
||||
this.#url = url[url.length - 1] === '/' ? url : `${url}/`
|
||||
}
|
||||
|
||||
post = async (file: File) => {
|
||||
const url = `${this.#url}${this.#media}`
|
||||
const auth = await this.auth()
|
||||
try {
|
||||
const response = await axios.postForm<Response>(
|
||||
url,
|
||||
{
|
||||
uploadType: 'media',
|
||||
file: file
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: 'Nostr ' + auth,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
responseType: 'json'
|
||||
}
|
||||
)
|
||||
|
||||
if (response.data.status !== 'success') {
|
||||
throw new BaseError(HandledErrorType.NOSTR_CHECK_NO_SUCCESS, {
|
||||
context: { ...response.data }
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
response.data &&
|
||||
response.data.nip94_event &&
|
||||
response.data.nip94_event.tags &&
|
||||
response.data.nip94_event.tags.length
|
||||
) {
|
||||
// Return first 'url' tag we find on the returned nip94 event
|
||||
const imageUrl = response.data.nip94_event.tags.find(
|
||||
(item) => item[0] === 'url'
|
||||
)
|
||||
|
||||
if (imageUrl) return imageUrl[1]
|
||||
}
|
||||
|
||||
throw new BaseError(HandledErrorType.NOSTR_CHECK_BAD_EVENT, {
|
||||
context: { ...response.data }
|
||||
})
|
||||
} catch (error) {
|
||||
// Handle axios errors
|
||||
if (isAxiosError(error)) {
|
||||
if (error.request) {
|
||||
// The request was made but no response was received
|
||||
throw new BaseError(HandledErrorType.AXIOS_REQ, {
|
||||
cause: error
|
||||
})
|
||||
} else if (error.response) {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
// nostrcheck-server can return different results, including message or description
|
||||
const data = error.response.data
|
||||
let message = error.message
|
||||
if (data) {
|
||||
message = data?.message || data?.description || error.message
|
||||
}
|
||||
throw new BaseError(HandledErrorType.AXIOS_RES + message, {
|
||||
cause: error
|
||||
})
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
throw new BaseError(HandledErrorType.AXIOS_ERR, {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
} else if (error instanceof BaseError) {
|
||||
throw error
|
||||
} else {
|
||||
throw handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auth = async () => {
|
||||
try {
|
||||
const url = `${this.#url}${this.#media}`
|
||||
|
||||
let hexPubkey: string | undefined
|
||||
const userState = store.getState().user
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
hexPubkey = userState.user.pubkey as string
|
||||
} else {
|
||||
try {
|
||||
hexPubkey = (await window.nostr?.getPublicKey()) as string
|
||||
} catch (error) {
|
||||
log(true, LogType.Error, `Could not get pubkey`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hexPubkey) {
|
||||
throw new BaseError(HandledErrorType.PUBKEY)
|
||||
}
|
||||
|
||||
const unsignedEvent: NostrEvent = {
|
||||
content: '',
|
||||
created_at: now(),
|
||||
kind: NDKKind.HttpAuth,
|
||||
pubkey: hexPubkey,
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', 'POST']
|
||||
]
|
||||
}
|
||||
|
||||
const signedEvent = await window.nostr?.signEvent(unsignedEvent)
|
||||
return btoa(JSON.stringify(signedEvent))
|
||||
} catch (error) {
|
||||
if (error instanceof BaseError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw new BaseError(HandledErrorType.SIGN, {
|
||||
cause: handleError(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import { MediaOperations } from '.'
|
||||
|
||||
export class route96 implements MediaOperations {
|
||||
post = () => {
|
||||
throw new Error('route96 post Not implemented.')
|
||||
}
|
||||
}
|
@ -1,2 +1 @@
|
||||
export * from './zap'
|
||||
export * from './image'
|
||||
|
@ -4,11 +4,6 @@ export * from './useFilteredMods'
|
||||
export * from './useGames'
|
||||
export * from './useMuteLists'
|
||||
export * from './useNSFWList'
|
||||
export * from './useRepostList'
|
||||
export * from './useReactions'
|
||||
export * from './useNDKContext'
|
||||
export * from './useScrollDisable'
|
||||
export * from './useLocalStorage'
|
||||
export * from './useSessionStorage'
|
||||
export * from './useLocalCache'
|
||||
export * from './useReplies'
|
||||
|
@ -7,87 +7,65 @@ import {
|
||||
NDKSubscriptionCacheUsage
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CommentEvent, UserRelaysType } from 'types'
|
||||
import { log, LogType, timeout } from 'utils'
|
||||
import { CommentEvent, ModDetails, UserRelaysType } from 'types'
|
||||
import { log, LogType } from 'utils'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
|
||||
export const useComments = (
|
||||
author: string | undefined,
|
||||
aTag: string | undefined,
|
||||
eTag?: string | undefined
|
||||
) => {
|
||||
export const useComments = (mod: ModDetails) => {
|
||||
const { ndk } = useNDKContext()
|
||||
const [commentEvents, setCommentEvents] = useState<CommentEvent[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!(author && (aTag || eTag))) {
|
||||
// Author and aTag/eTag are required
|
||||
return
|
||||
}
|
||||
|
||||
let subscription: NDKSubscription // Define the subscription variable here for cleanup
|
||||
|
||||
const setupSubscription = async () => {
|
||||
// Find the mod author's relays.
|
||||
|
||||
const authorReadRelays = await Promise.race([
|
||||
getRelayListForUser(author, ndk),
|
||||
timeout(10 * 1000) // add a 10 sec timeout
|
||||
])
|
||||
const authorReadRelays = await getRelayListForUser(mod.author, ndk)
|
||||
.then((ndkRelayList) => {
|
||||
if (ndkRelayList) return ndkRelayList[UserRelaysType.Read]
|
||||
return [] // Return an empty array if ndkRelayList is undefined
|
||||
})
|
||||
.catch((err) => {
|
||||
log(
|
||||
false, // Too many failed requests, turned off for clarity
|
||||
true,
|
||||
LogType.Error,
|
||||
`An error occurred in fetching user's (${author}) ${UserRelaysType.Read}`,
|
||||
`An error occurred in fetching user's (${mod.author}) ${UserRelaysType.Read}`,
|
||||
err
|
||||
)
|
||||
return [] as string[]
|
||||
})
|
||||
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Text, NDKKind.GenericReply],
|
||||
...(aTag
|
||||
? {
|
||||
'#a': [aTag]
|
||||
}
|
||||
: {}),
|
||||
...(eTag
|
||||
? {
|
||||
'#e': [eTag]
|
||||
}
|
||||
: {})
|
||||
kinds: [NDKKind.Text],
|
||||
'#a': [mod.aTag]
|
||||
}
|
||||
|
||||
const relayUrls = new Set<string>()
|
||||
|
||||
ndk.pool.urls().forEach((relayUrl) => {
|
||||
relayUrls.add(relayUrl)
|
||||
})
|
||||
|
||||
authorReadRelays.forEach((relayUrl) => relayUrls.add(relayUrl))
|
||||
|
||||
subscription = ndk.subscribe(
|
||||
filter,
|
||||
{
|
||||
closeOnEose: false,
|
||||
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
|
||||
},
|
||||
relayUrls.size
|
||||
? NDKRelaySet.fromRelayUrls(Array.from(relayUrls), ndk)
|
||||
: undefined
|
||||
NDKRelaySet.fromRelayUrls(authorReadRelays, ndk, true)
|
||||
)
|
||||
|
||||
subscription.on('event', (ndkEvent) => {
|
||||
setCommentEvents((prev) => {
|
||||
if (prev.find((e) => e.event.id === ndkEvent.id)) {
|
||||
if (prev.find((e) => e.id === ndkEvent.id)) {
|
||||
return [...prev]
|
||||
}
|
||||
|
||||
return [{ event: ndkEvent }, ...prev]
|
||||
const commentEvent: CommentEvent = {
|
||||
kind: NDKKind.Text,
|
||||
tags: ndkEvent.tags,
|
||||
content: ndkEvent.content,
|
||||
created_at: ndkEvent.created_at!,
|
||||
pubkey: ndkEvent.pubkey,
|
||||
id: ndkEvent.id,
|
||||
sig: ndkEvent.sig!
|
||||
}
|
||||
|
||||
return [commentEvent, ...prev]
|
||||
})
|
||||
})
|
||||
|
||||
@ -102,7 +80,7 @@ export const useComments = (
|
||||
subscription.stop()
|
||||
}
|
||||
}
|
||||
}, [aTag, author, eTag, ndk])
|
||||
}, [mod.aTag, mod.author, ndk])
|
||||
|
||||
return {
|
||||
commentEvents,
|
||||
|
@ -1,15 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
import { useDidMount } from './useDidMount'
|
||||
import { CurationSetIdentifiers, getReportingSet } from 'utils'
|
||||
|
||||
export const useCuratedSet = (type: CurationSetIdentifiers) => {
|
||||
const ndkContext = useNDKContext()
|
||||
const [curatedSet, setCuratedSet] = useState<string[]>([])
|
||||
|
||||
useDidMount(async () => {
|
||||
setCuratedSet(await getReportingSet(type, ndkContext))
|
||||
})
|
||||
|
||||
return curatedSet
|
||||
}
|
@ -6,13 +6,8 @@ import {
|
||||
ModeratedFilter,
|
||||
MuteLists,
|
||||
NSFWFilter,
|
||||
RepostFilter,
|
||||
SortBy,
|
||||
WOTFilterOptions
|
||||
SortBy
|
||||
} from 'types'
|
||||
import { npubToHex } from 'utils'
|
||||
import { useAppSelector } from './redux'
|
||||
import { isInWoT } from 'utils/wot'
|
||||
|
||||
export const useFilteredMods = (
|
||||
mods: ModDetails[],
|
||||
@ -22,25 +17,10 @@ export const useFilteredMods = (
|
||||
muteLists: {
|
||||
admin: MuteLists
|
||||
user: MuteLists
|
||||
},
|
||||
repostList: string[],
|
||||
author?: string | undefined
|
||||
}
|
||||
) => {
|
||||
const { siteWot, siteWotLevel, userWot, userWotLevel } = useAppSelector(
|
||||
(state) => state.wot
|
||||
)
|
||||
|
||||
return useMemo(() => {
|
||||
const nsfwFilter = (mods: ModDetails[]) => {
|
||||
// Add nsfw tag to mods included in nsfwList
|
||||
if (filterOptions.nsfw !== NSFWFilter.Hide_NSFW) {
|
||||
mods = mods.map((mod) => {
|
||||
return !mod.nsfw && nsfwList.includes(mod.aTag)
|
||||
? { ...mod, nsfw: true }
|
||||
: mod
|
||||
})
|
||||
}
|
||||
|
||||
// Determine the filtering logic based on the NSFW filter option
|
||||
switch (filterOptions.nsfw) {
|
||||
case NSFWFilter.Hide_NSFW:
|
||||
@ -55,92 +35,14 @@ export const useFilteredMods = (
|
||||
}
|
||||
}
|
||||
|
||||
const repostFilter = (mods: ModDetails[]) => {
|
||||
if (filterOptions.repost !== RepostFilter.Hide_Repost) {
|
||||
// Add repost tag to mods included in repostList
|
||||
mods = mods.map((mod) => {
|
||||
return !mod.repost && repostList.includes(mod.aTag)
|
||||
? { ...mod, repost: true }
|
||||
: mod
|
||||
})
|
||||
}
|
||||
// Determine the filtering logic based on the Repost filter option
|
||||
switch (filterOptions.repost) {
|
||||
case RepostFilter.Hide_Repost:
|
||||
return mods.filter(
|
||||
(mod) => !mod.repost && !repostList.includes(mod.aTag)
|
||||
)
|
||||
case RepostFilter.Show_Repost:
|
||||
return mods
|
||||
case RepostFilter.Only_Repost:
|
||||
return mods.filter(
|
||||
(mod) => mod.repost || repostList.includes(mod.aTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const wotFilter = (mods: ModDetails[]) => {
|
||||
// Determine the filtering logic based on the WOT filter option and user state
|
||||
// when user is not logged in use Site_Only
|
||||
if (!userState.auth) {
|
||||
return mods.filter((mod) => isInWoT(siteWot, siteWotLevel, mod.author))
|
||||
}
|
||||
// when user is logged, allow other filter selections
|
||||
const isWoTNpub =
|
||||
userState.user?.npub === import.meta.env.VITE_SITE_WOT_NPUB
|
||||
switch (filterOptions.wot) {
|
||||
case WOTFilterOptions.None:
|
||||
// Only admins can choose None, use siteWoT for others
|
||||
return isWoTNpub
|
||||
? mods
|
||||
: mods.filter((mod) => isInWoT(siteWot, siteWotLevel, mod.author))
|
||||
case WOTFilterOptions.Exclude:
|
||||
// Only admins can choose Exlude, use siteWot for others
|
||||
// Exlude returns the mods not in the site's WoT
|
||||
return isWoTNpub
|
||||
? mods.filter((mod) => !isInWoT(siteWot, siteWotLevel, mod.author))
|
||||
: mods.filter((mod) => isInWoT(siteWot, siteWotLevel, mod.author))
|
||||
case WOTFilterOptions.Site_Only:
|
||||
return mods.filter((mod) =>
|
||||
isInWoT(siteWot, siteWotLevel, mod.author)
|
||||
)
|
||||
case WOTFilterOptions.Mine_Only:
|
||||
// Only admins can choose Mine_Only, use siteWoT for others
|
||||
return isWoTNpub
|
||||
? mods.filter((mod) => isInWoT(userWot, userWotLevel, mod.author))
|
||||
: mods.filter((mod) => isInWoT(siteWot, siteWotLevel, mod.author))
|
||||
case WOTFilterOptions.Site_And_Mine:
|
||||
return mods.filter(
|
||||
(mod) =>
|
||||
isInWoT(siteWot, siteWotLevel, mod.author) &&
|
||||
isInWoT(userWot, userWotLevel, mod.author)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let filtered = nsfwFilter(mods)
|
||||
filtered = repostFilter(filtered)
|
||||
filtered = wotFilter(filtered)
|
||||
|
||||
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||
const isOwner =
|
||||
userState.user?.npub &&
|
||||
npubToHex(userState.user.npub as string) === author
|
||||
const isUnmoderatedFully =
|
||||
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
|
||||
const isOnlyBlocked =
|
||||
filterOptions.moderated === ModeratedFilter.Only_Blocked
|
||||
|
||||
if (isOnlyBlocked && isAdmin) {
|
||||
filtered = filtered.filter(
|
||||
(mod) =>
|
||||
muteLists.admin.authors.includes(mod.author) ||
|
||||
muteLists.admin.replaceableEvents.includes(mod.aTag)
|
||||
)
|
||||
} else if (isUnmoderatedFully && (isAdmin || isOwner)) {
|
||||
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
|
||||
// Allow "Unmoderated Fully" when author visits own profile
|
||||
} else {
|
||||
// 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) &&
|
||||
@ -164,21 +66,12 @@ export const useFilteredMods = (
|
||||
|
||||
return filtered
|
||||
}, [
|
||||
userState.auth,
|
||||
userState.user?.npub,
|
||||
filterOptions.sort,
|
||||
filterOptions.moderated,
|
||||
filterOptions.wot,
|
||||
filterOptions.nsfw,
|
||||
filterOptions.repost,
|
||||
author,
|
||||
mods,
|
||||
muteLists,
|
||||
nsfwList,
|
||||
repostList,
|
||||
siteWot,
|
||||
siteWotLevel,
|
||||
userWot,
|
||||
userWotLevel
|
||||
nsfwList
|
||||
])
|
||||
}
|
||||
|
@ -1,37 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { setLocalStorageItem, removeLocalStorageItem } from 'utils'
|
||||
|
||||
export function useLocalCache<T>(
|
||||
key: string
|
||||
): [
|
||||
T | undefined,
|
||||
React.Dispatch<React.SetStateAction<T | undefined>>,
|
||||
() => void
|
||||
] {
|
||||
const [cache, setCache] = useState<T | undefined>(() => {
|
||||
const storedValue = window.localStorage.getItem(key)
|
||||
if (storedValue === null) return undefined
|
||||
|
||||
// Parse the value
|
||||
const parsedStoredValue = JSON.parse(storedValue)
|
||||
return parsedStoredValue
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (cache) {
|
||||
setLocalStorageItem(key, JSON.stringify(cache))
|
||||
} else {
|
||||
removeLocalStorageItem(key)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
}
|
||||
}, [cache, key])
|
||||
|
||||
const clearCache = useCallback(() => {
|
||||
setCache(undefined)
|
||||
}, [])
|
||||
|
||||
return [cache, setCache, clearCache]
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import {
|
||||
getLocalStorageItem,
|
||||
mergeWithInitialValue,
|
||||
removeLocalStorageItem,
|
||||
setLocalStorageItem
|
||||
} from 'utils'
|
||||
|
||||
const useLocalStorageSubscribe = (callback: () => void) => {
|
||||
window.addEventListener('storage', callback)
|
||||
return () => window.removeEventListener('storage', callback)
|
||||
}
|
||||
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
const getSnapshot = () => {
|
||||
// Get the stored value
|
||||
const storedValue = getLocalStorageItem(key, initialValue)
|
||||
|
||||
// Parse the value
|
||||
const parsedStoredValue = JSON.parse(storedValue)
|
||||
|
||||
// Merge the default and the stored in case some of the required fields are missing
|
||||
return JSON.stringify(
|
||||
mergeWithInitialValue(parsedStoredValue, initialValue)
|
||||
)
|
||||
}
|
||||
|
||||
const data = React.useSyncExternalStore(useLocalStorageSubscribe, getSnapshot)
|
||||
|
||||
const setState: React.Dispatch<React.SetStateAction<T>> = React.useCallback(
|
||||
(v: React.SetStateAction<T>) => {
|
||||
try {
|
||||
const nextState =
|
||||
typeof v === 'function'
|
||||
? (v as (prevState: T) => T)(JSON.parse(data))
|
||||
: v
|
||||
|
||||
if (nextState === undefined || nextState === null) {
|
||||
removeLocalStorageItem(key)
|
||||
} else {
|
||||
setLocalStorageItem(key, JSON.stringify(nextState))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
}
|
||||
},
|
||||
[data, key]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
// Set local storage only when it's empty
|
||||
const data = window.localStorage.getItem(key)
|
||||
if (data === null) {
|
||||
setLocalStorageItem(key, JSON.stringify(initialValue))
|
||||
}
|
||||
}, [key, initialValue])
|
||||
|
||||
const memoized = useMemo(() => JSON.parse(data) as T, [data])
|
||||
|
||||
return [memoized, setState]
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { NDKContext, NDKContextType } from 'contexts/NDKContext'
|
||||
import { NDKContext } from 'contexts/NDKContext'
|
||||
import { useContext } from 'react'
|
||||
|
||||
export const useNDKContext = () => {
|
||||
@ -9,5 +9,5 @@ export const useNDKContext = () => {
|
||||
'NDKContext should not be used in out component tree hierarchy'
|
||||
)
|
||||
|
||||
return { ...ndkContext } as NDKContextType
|
||||
return { ...ndkContext }
|
||||
}
|
||||
|
@ -1,18 +0,0 @@
|
||||
import { useNDKContext } from 'hooks'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { UserProfile } from 'types'
|
||||
|
||||
export const useProfile = (pubkey?: string) => {
|
||||
const { findMetadata } = useNDKContext()
|
||||
const [profile, setProfile] = useState<UserProfile>()
|
||||
|
||||
useEffect(() => {
|
||||
if (pubkey) {
|
||||
findMetadata(pubkey).then((res) => {
|
||||
setProfile(res)
|
||||
})
|
||||
}
|
||||
}, [findMetadata, pubkey])
|
||||
|
||||
return profile
|
||||
}
|
@ -5,7 +5,7 @@ import { Event, kinds, UnsignedEvent } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { UserRelaysType } from 'types'
|
||||
import { abbreviateNumber, log, LogType, now, timeout } from 'utils'
|
||||
import { abbreviateNumber, log, LogType, now } from 'utils'
|
||||
|
||||
type UseReactionsParams = {
|
||||
pubkey: string
|
||||
@ -32,11 +32,7 @@ export const useReactions = (params: UseReactionsParams) => {
|
||||
filter['#e'] = [params.eTag]
|
||||
}
|
||||
|
||||
// 1 minute timeout
|
||||
Promise.race([
|
||||
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read),
|
||||
timeout(60000)
|
||||
])
|
||||
fetchEventsFromUserRelays(filter, params.pubkey, UserRelaysType.Read)
|
||||
.then((events) => {
|
||||
setReactionEvents(events)
|
||||
})
|
||||
@ -70,16 +66,12 @@ export const useReactions = (params: UseReactionsParams) => {
|
||||
}, [reactionEvents, userState])
|
||||
|
||||
const getPubkey = async () => {
|
||||
let hexPubkey: string | undefined
|
||||
let hexPubkey: string
|
||||
|
||||
if (userState.auth && userState.user?.pubkey) {
|
||||
hexPubkey = userState.user.pubkey as string
|
||||
} else {
|
||||
try {
|
||||
hexPubkey = (await window.nostr?.getPublicKey()) as string
|
||||
} catch (error) {
|
||||
log(true, LogType.Error, `Could not get pubkey`, error)
|
||||
}
|
||||
hexPubkey = (await window.nostr?.getPublicKey()) as string
|
||||
}
|
||||
|
||||
if (!hexPubkey) {
|
||||
|
@ -1,53 +0,0 @@
|
||||
import {
|
||||
NDKEvent,
|
||||
NDKKind,
|
||||
NDKSubscriptionCacheUsage
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import { useState } from 'react'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
import { useDidMount } from './useDidMount'
|
||||
|
||||
export const useReplies = (eTag: string | undefined) => {
|
||||
const { ndk } = useNDKContext()
|
||||
const [replies, setReplies] = useState<NDKEvent[]>([])
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
|
||||
useDidMount(async () => {
|
||||
if (!eTag) {
|
||||
setIsComplete(true)
|
||||
return
|
||||
}
|
||||
|
||||
let eDepth: string | undefined = eTag
|
||||
while (eDepth) {
|
||||
const previousReply = await ndk.fetchEvent(
|
||||
{
|
||||
kinds: [NDKKind.Text, NDKKind.GenericReply],
|
||||
ids: [eDepth]
|
||||
},
|
||||
{
|
||||
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST
|
||||
}
|
||||
)
|
||||
if (previousReply) {
|
||||
setReplies((p) => {
|
||||
if (p.findIndex((p) => p.id === previousReply.id) === -1) {
|
||||
p.push(previousReply)
|
||||
}
|
||||
return p
|
||||
})
|
||||
eDepth = previousReply.tagValue('e')
|
||||
} else {
|
||||
eDepth = undefined
|
||||
}
|
||||
}
|
||||
setIsComplete(true)
|
||||
})
|
||||
|
||||
return {
|
||||
size: replies.length,
|
||||
isComplete,
|
||||
parent: replies.length > 0 ? replies[0] : undefined,
|
||||
root: isComplete ? replies[replies.length - 1] : undefined
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useDidMount } from './useDidMount'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
import { CurationSetIdentifiers, getReportingSet } from 'utils'
|
||||
|
||||
export const useRepostList = () => {
|
||||
const ndkContext = useNDKContext()
|
||||
const [repostList, setRepostList] = useState<string[]>([])
|
||||
|
||||
useDidMount(async () => {
|
||||
const list = await getReportingSet(
|
||||
CurationSetIdentifiers.Repost,
|
||||
ndkContext
|
||||
)
|
||||
setRepostList(list)
|
||||
})
|
||||
|
||||
return repostList
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import {
|
||||
getSessionStorageItem,
|
||||
mergeWithInitialValue,
|
||||
removeSessionStorageItem,
|
||||
setSessionStorageItem
|
||||
} from 'utils'
|
||||
|
||||
const useSessionStorageSubscribe = (callback: () => void) => {
|
||||
window.addEventListener('sessionStorage', callback)
|
||||
return () => window.removeEventListener('sessionStorage', callback)
|
||||
}
|
||||
|
||||
export function useSessionStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
const getSnapshot = () => {
|
||||
// Get the stored value
|
||||
const storedValue = getSessionStorageItem(key, initialValue)
|
||||
|
||||
// Parse the value
|
||||
const parsedStoredValue = JSON.parse(storedValue)
|
||||
|
||||
// Merge the default and the stored in case some of the required fields are missing
|
||||
return JSON.stringify(
|
||||
mergeWithInitialValue(parsedStoredValue, initialValue)
|
||||
)
|
||||
}
|
||||
|
||||
const data = React.useSyncExternalStore(
|
||||
useSessionStorageSubscribe,
|
||||
getSnapshot
|
||||
)
|
||||
|
||||
const setState: React.Dispatch<React.SetStateAction<T>> = React.useCallback(
|
||||
(v: React.SetStateAction<T>) => {
|
||||
try {
|
||||
const nextState =
|
||||
typeof v === 'function'
|
||||
? (v as (prevState: T) => T)(JSON.parse(data))
|
||||
: v
|
||||
|
||||
if (nextState === undefined || nextState === null) {
|
||||
removeSessionStorageItem(key)
|
||||
} else {
|
||||
setSessionStorageItem(key, JSON.stringify(nextState))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
}
|
||||
},
|
||||
[data, key]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
// Set session storage only when it's empty
|
||||
const data = window.sessionStorage.getItem(key)
|
||||
if (data === null) {
|
||||
setSessionStorageItem(key, JSON.stringify(initialValue))
|
||||
}
|
||||
}, [key, initialValue])
|
||||
|
||||
const memoized = useMemo(() => JSON.parse(data) as T, [data])
|
||||
|
||||
return [memoized, setState]
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
import { MAX_VISIBLE_TEXT_PER_COMMENT } from '../constants'
|
||||
import { useState } from 'react'
|
||||
|
||||
export const useTextLimit = (
|
||||
text: string,
|
||||
limit: number = MAX_VISIBLE_TEXT_PER_COMMENT
|
||||
) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const isTextOverflowing = text.length > limit
|
||||
const updated =
|
||||
isExpanded || !isTextOverflowing ? text : text.slice(0, limit) + '…'
|
||||
|
||||
return {
|
||||
text: updated,
|
||||
isTextOverflowing,
|
||||
isExpanded,
|
||||
toggle: () => setIsExpanded((prev) => !prev)
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
export const FeedLayout = () => {
|
||||
return (
|
||||
<div className='InnerBodyMain'>
|
||||
<h1>WIP</h1>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,6 +1,4 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import styles from '../styles/footer.module.scss'
|
||||
import { appRoutes, getProfilePageRoute } from 'routes'
|
||||
|
||||
export const Footer = () => {
|
||||
return (
|
||||
@ -9,7 +7,6 @@ export const Footer = () => {
|
||||
<p className={styles.secMainFooterPara}>
|
||||
Built with
|
||||
<a
|
||||
rel='noopener'
|
||||
className={styles.secMainFooterParaLink}
|
||||
href='https://github.com/nostr-protocol/nostr'
|
||||
target='_blank'
|
||||
@ -17,26 +14,21 @@ export const Footer = () => {
|
||||
Nostr
|
||||
</a>{' '}
|
||||
by
|
||||
<Link
|
||||
<a
|
||||
className={styles.secMainFooterParaLink}
|
||||
to={getProfilePageRoute(
|
||||
'nprofile1qqsre6jgq6c7r2vzn5cdtju20qq36sn3cer5avc4x8kfru5pzrlr7sqnancjp'
|
||||
)}
|
||||
href='https://primal.net/p/npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r'
|
||||
target='_blank'
|
||||
>
|
||||
Freakoverse
|
||||
</Link>
|
||||
</a>
|
||||
, with the support of{' '}
|
||||
<Link
|
||||
className={styles.secMainFooterParaLink}
|
||||
to={appRoutes.supporters}
|
||||
>
|
||||
<a className={styles.secMainFooterParaLink} href='backers.html'>
|
||||
Supporters
|
||||
</Link>
|
||||
</a>
|
||||
. Check our
|
||||
<Link className={styles.secMainFooterParaLink} to={appRoutes.backup}>
|
||||
<a className={styles.secMainFooterParaLink} href='backup.html'>
|
||||
Backup Plan
|
||||
</Link>
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
@ -3,7 +3,7 @@ import {
|
||||
launch as launchNostrLoginDialog
|
||||
} from 'nostr-login'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Link, useRevalidator } from 'react-router-dom'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Banner } from '../components/Banner'
|
||||
import { ZapPopUp } from '../components/Zap'
|
||||
import {
|
||||
@ -21,14 +21,12 @@ import '../styles/popup.css'
|
||||
import { npubToHex } from '../utils'
|
||||
import logo from '../assets/img/DEG Mods Logo With Text.svg'
|
||||
import placeholder from '../assets/img/DEG Mods Default PP.png'
|
||||
import { resetUserWot } from 'store/reducers/wot'
|
||||
import { NDKNip07Signer } from '@nostr-dev-kit/ndk'
|
||||
|
||||
export const Header = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const { findMetadata, ndk } = useNDKContext()
|
||||
const { findMetadata } = useNDKContext()
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const revalidator = useRevalidator()
|
||||
|
||||
// Track nostr-login extension modal open state
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const handleOpen = () => setIsOpen(true)
|
||||
@ -50,8 +48,6 @@ export const Header = () => {
|
||||
if (opts.type === 'logout') {
|
||||
dispatch(setAuth(null))
|
||||
dispatch(setUser(null))
|
||||
dispatch(resetUserWot())
|
||||
ndk.signer = undefined
|
||||
} else {
|
||||
dispatch(
|
||||
setAuth({
|
||||
@ -65,7 +61,6 @@ export const Header = () => {
|
||||
pubkey: npubToHex(npub)!
|
||||
})
|
||||
)
|
||||
ndk.signer = new NDKNip07Signer()
|
||||
findMetadata(npub).then((userProfile) => {
|
||||
if (userProfile) {
|
||||
dispatch(
|
||||
@ -78,12 +73,8 @@ export const Header = () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// React router - revalidate loader states on auth changes
|
||||
revalidator.revalidate()
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch, findMetadata])
|
||||
|
||||
const handleLogin = () => {
|
||||
@ -98,7 +89,10 @@ export const Header = () => {
|
||||
<div className={mainStyles.ContainerMain}>
|
||||
<div className={navStyles.NavMainTopInside}>
|
||||
<div className={navStyles.NMTI_Sec}>
|
||||
<Link to={appRoutes.home} className={navStyles.NMTI_Sec_HomeLink}>
|
||||
<Link
|
||||
to={appRoutes.index}
|
||||
className={navStyles.NMTI_Sec_HomeLink}
|
||||
>
|
||||
<div className={navStyles.NMTI_Sec_HomeLink_Logo}>
|
||||
<img
|
||||
className={navStyles.NMTI_Sec_HomeLink_LogoImg}
|
||||
@ -218,7 +212,7 @@ export const Header = () => {
|
||||
About
|
||||
</Link>
|
||||
<Link
|
||||
to={appRoutes.blogs}
|
||||
to={appRoutes.blog}
|
||||
className={navStyles.NavMainBottomInsideLink}
|
||||
>
|
||||
Blog
|
||||
@ -229,7 +223,7 @@ export const Header = () => {
|
||||
>
|
||||
<a
|
||||
className={navStyles.NavMainBottomInsideOtherLink}
|
||||
href='https://degmods.com/profile/nprofile1qqs0f0clkkagh6pe7ux8xvtn8ccf77qgy2e3ra37q8uaez4mks5034gfw4xg6'
|
||||
href='https://primal.net/p/npub17jl3ldd6305rnacvwvchx03snauqsg4nz8mruq0emj9thdpglr2sst825x'
|
||||
target='_blank'
|
||||
>
|
||||
<img
|
||||
@ -385,30 +379,16 @@ const RegisterButtonWithDialog = () => {
|
||||
Browser Extensions (Windows)
|
||||
</label>
|
||||
<p className='labelDescriptionMain'>
|
||||
Once you create your "account" on any of these, come
|
||||
back and click login, then sign-in with extension.
|
||||
Here's a quick video guide, and here's a{' '}
|
||||
<a href='https://degmods.com/blog/naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrzcfc8qurjefn943xyen9956rywp595unjc3h94nxvwfexymxxcfnvdjxxlyq37c'>
|
||||
guide post
|
||||
</a>{' '}
|
||||
to help with this process.
|
||||
Once you create your "account" on any of these (
|
||||
<a
|
||||
href='https://video.nostr.build/765aa9bf16dd58bca701efee2572f7e77f29b2787cddd2bee8bbbdea35798153.mp4'
|
||||
target='blank_'
|
||||
>
|
||||
Here's a quick video guide
|
||||
</a>
|
||||
), come back and click login, then sign-in with
|
||||
extension.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<video controls style={{ width: '100%' }}>
|
||||
<source
|
||||
src='https://video.nostr.build/765aa9bf16dd58bca701efee2572f7e77f29b2787cddd2bee8bbbdea35798153.mp4'
|
||||
type='video/mp4'
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
className='btn btnMain btnMainPopup'
|
||||
@ -443,21 +423,6 @@ const RegisterButtonWithDialog = () => {
|
||||
nos2x
|
||||
</a>
|
||||
</div>
|
||||
<p
|
||||
className='labelDescriptionMain'
|
||||
style={{
|
||||
padding: '10px',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(205,44,255,0.1)',
|
||||
border: 'solid 2px rgba(255,66,235,0.3)',
|
||||
margin: '10px 0 0 0',
|
||||
color: '#ffffffbf'
|
||||
}}
|
||||
>
|
||||
Warning: Make sure you backup your private key
|
||||
somewhere safe. If you lose it or it gets leaked, we
|
||||
actually can't help you.
|
||||
</p>
|
||||
<p
|
||||
className='labelDescriptionMain'
|
||||
style={{
|
||||
|
@ -1,127 +1,17 @@
|
||||
import { Outlet, ScrollRestoration } from 'react-router-dom'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Footer } from './footer'
|
||||
import { Header } from './header'
|
||||
import { SocialNav } from './socialNav'
|
||||
import { Head } from './head'
|
||||
import { useAppDispatch, useAppSelector, useNDKContext } from 'hooks'
|
||||
import { useEffect } from 'react'
|
||||
import { npubToHex } from 'utils'
|
||||
import { calculateWot } from 'utils/wot'
|
||||
import {
|
||||
setSiteWot,
|
||||
setSiteWotLevel,
|
||||
setSiteWotStatus,
|
||||
setUserWot,
|
||||
setUserWotLevel,
|
||||
WOTStatus
|
||||
} from 'store/reducers/wot'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk'
|
||||
import { UserRelaysType } from 'types'
|
||||
|
||||
export const Layout = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const { ndk, fetchEventFromUserRelays } = useNDKContext()
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const { siteWotStatus } = useAppSelector((state) => state.wot)
|
||||
|
||||
// calculate site's wot
|
||||
useEffect(() => {
|
||||
if (ndk) {
|
||||
const SITE_WOT_NPUB = import.meta.env.VITE_SITE_WOT_NPUB
|
||||
const hexPubkey = npubToHex(SITE_WOT_NPUB)
|
||||
if (hexPubkey) {
|
||||
dispatch(setSiteWotStatus(WOTStatus.LOADING))
|
||||
calculateWot(hexPubkey, ndk)
|
||||
.then((wot) => {
|
||||
dispatch(setSiteWot(wot))
|
||||
})
|
||||
.catch((err) => {
|
||||
console.trace('An error occurred in calculating site WOT', err)
|
||||
toast.error('An error occurred in calculating site web-of-trust!')
|
||||
dispatch(setSiteWotStatus(WOTStatus.FAILED))
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [dispatch, ndk])
|
||||
|
||||
// calculate user's wot
|
||||
useEffect(() => {
|
||||
if (ndk && userState.user?.pubkey) {
|
||||
const hexPubkey = npubToHex(userState.user.pubkey as string)
|
||||
if (hexPubkey)
|
||||
calculateWot(hexPubkey, ndk)
|
||||
.then((wot) => {
|
||||
dispatch(setUserWot(wot))
|
||||
})
|
||||
.catch((err) => {
|
||||
console.trace('An error occurred in calculating user WOT', err)
|
||||
toast.error('An error occurred in calculating user web-of-trust!')
|
||||
})
|
||||
}
|
||||
}, [dispatch, ndk, userState.user?.pubkey])
|
||||
|
||||
// get site's wot level
|
||||
useEffect(() => {
|
||||
const SITE_WOT_NPUB = import.meta.env.VITE_SITE_WOT_NPUB
|
||||
const hexPubkey = npubToHex(SITE_WOT_NPUB)
|
||||
if (hexPubkey) {
|
||||
fetchEventFromUserRelays(
|
||||
{
|
||||
kinds: [NDKKind.AppSpecificData],
|
||||
'#d': ['degmods'],
|
||||
authors: [hexPubkey]
|
||||
},
|
||||
hexPubkey,
|
||||
UserRelaysType.Both
|
||||
).then((event) => {
|
||||
if (event) {
|
||||
const wot = event.tagValue('wot')
|
||||
if (wot) dispatch(setSiteWotLevel(parseInt(wot)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [dispatch, fetchEventFromUserRelays])
|
||||
|
||||
// get user's wot level
|
||||
useEffect(() => {
|
||||
if (userState.user?.pubkey) {
|
||||
const hexPubkey = npubToHex(userState.user.pubkey as string)
|
||||
|
||||
if (hexPubkey) {
|
||||
fetchEventFromUserRelays(
|
||||
{
|
||||
kinds: [NDKKind.AppSpecificData],
|
||||
'#d': ['degmods'],
|
||||
authors: [hexPubkey]
|
||||
},
|
||||
hexPubkey,
|
||||
UserRelaysType.Both
|
||||
).then((event) => {
|
||||
if (event) {
|
||||
const wot = event.tagValue('wot')
|
||||
if (wot) dispatch(setUserWotLevel(parseInt(wot)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [dispatch, fetchEventFromUserRelays, userState.user?.pubkey])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head />
|
||||
<Header />
|
||||
{siteWotStatus === WOTStatus.LOADED && <Outlet />}
|
||||
{siteWotStatus === WOTStatus.LOADING && (
|
||||
<LoadingSpinner desc="Loading site's web-of-trust" />
|
||||
)}
|
||||
{siteWotStatus === WOTStatus.FAILED && (
|
||||
<h3>Failed to load site's web-of-trust</h3>
|
||||
)}
|
||||
<Outlet />
|
||||
<Footer />
|
||||
<SocialNav />
|
||||
<ScrollRestoration />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,32 +1,24 @@
|
||||
import { useAppSelector } from 'hooks'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import { NavLink, NavLinkProps } from 'react-router-dom'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { appRoutes, getProfilePageRoute } from 'routes'
|
||||
import 'styles/socialNav.css'
|
||||
import { npubToHex } from 'utils'
|
||||
|
||||
export const SocialNav = () => {
|
||||
const location = useLocation()
|
||||
const currentPath = location.pathname
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(false)
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
let profileRoute = ''
|
||||
if (userState.auth && userState.user) {
|
||||
// Redirect to user's profile is no profile is linked
|
||||
const userHexKey = npubToHex(userState.user.npub as string)
|
||||
|
||||
if (userHexKey) {
|
||||
profileRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: userHexKey
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
const toggleNav = () => {
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}
|
||||
|
||||
const isOnHomePage =
|
||||
currentPath === appRoutes.index || currentPath === appRoutes.home
|
||||
const isOnSearchPage = currentPath === appRoutes.search
|
||||
const isOnProfilePage = new RegExp(
|
||||
`^${appRoutes.profile.replace(':nprofile', '[^/]+')}$`
|
||||
).test(currentPath)
|
||||
|
||||
return (
|
||||
<div
|
||||
className='socialNav'
|
||||
@ -40,27 +32,30 @@ export const SocialNav = () => {
|
||||
<div className='socialNavInside'>
|
||||
<NavButton
|
||||
to={appRoutes.home}
|
||||
viewBox='0 -32 576 576'
|
||||
isActive={isOnHomePage}
|
||||
viewBox="0 -32 576 576"
|
||||
svgPath='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'
|
||||
/>
|
||||
<NavButton
|
||||
to={appRoutes.feed}
|
||||
to={appRoutes.home}
|
||||
isActive={false}
|
||||
svgPath='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'
|
||||
/>
|
||||
<NavButton
|
||||
to={appRoutes.notifications}
|
||||
to={appRoutes.home}
|
||||
isActive={false}
|
||||
svgPath='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'
|
||||
/>
|
||||
<NavButton
|
||||
to={appRoutes.search}
|
||||
isActive={isOnSearchPage}
|
||||
svgPath='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'
|
||||
/>
|
||||
{!!userState.auth && (
|
||||
<NavButton
|
||||
to={profileRoute}
|
||||
svgPath='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'
|
||||
/>
|
||||
)}
|
||||
<NavButton
|
||||
to={getProfilePageRoute('xyz')}
|
||||
isActive={isOnProfilePage}
|
||||
svgPath='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'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='socialNavCollapse' onClick={toggleNav}>
|
||||
@ -83,23 +78,19 @@ export const SocialNav = () => {
|
||||
)
|
||||
}
|
||||
|
||||
interface NavButtonProps extends NavLinkProps {
|
||||
interface NavButtonProps {
|
||||
to: string
|
||||
isActive: boolean
|
||||
svgPath: string
|
||||
viewBox?: string
|
||||
}
|
||||
|
||||
const NavButton = ({
|
||||
svgPath,
|
||||
viewBox = '0 0 512 512',
|
||||
...rest
|
||||
}: NavButtonProps) => (
|
||||
<NavLink
|
||||
{...rest}
|
||||
className={({ isActive }) =>
|
||||
`btn btnMain socialNavInsideBtn ${
|
||||
isActive ? 'socialNavInsideBtnActive' : ''
|
||||
}`
|
||||
}
|
||||
const NavButton = ({ to, isActive, svgPath, viewBox = '0 0 512 512' }: NavButtonProps) => (
|
||||
<Link
|
||||
to={to}
|
||||
className={`btn btnMain socialNavInsideBtn ${
|
||||
isActive ? 'socialNavInsideBtnActive' : ''
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
@ -110,5 +101,7 @@ const NavButton = ({
|
||||
>
|
||||
<path d={svgPath}></path>
|
||||
</svg>
|
||||
</NavLink>
|
||||
)
|
||||
</Link>
|
||||
);
|
||||
|
||||
|
||||
|
@ -1,37 +0,0 @@
|
||||
import { NDKContextType } from 'contexts/NDKContext'
|
||||
import { LoaderFunctionArgs, redirect } from 'react-router-dom'
|
||||
import { CommentsLoaderResult } from 'types/comments'
|
||||
import { log, LogType } from 'utils'
|
||||
|
||||
export const commentsLoader =
|
||||
(ndkContext: NDKContextType) =>
|
||||
async ({ params }: LoaderFunctionArgs) => {
|
||||
const { nevent } = params
|
||||
|
||||
if (!nevent) {
|
||||
log(true, LogType.Error, 'Required nevent.')
|
||||
return redirect('..')
|
||||
}
|
||||
|
||||
try {
|
||||
const replyEvent = await ndkContext.ndk.fetchEvent(nevent)
|
||||
|
||||
if (!replyEvent) {
|
||||
throw new Error('We are unable to find the comment on the relays')
|
||||
}
|
||||
|
||||
const result: Partial<CommentsLoaderResult> = {
|
||||
event: replyEvent
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
let message = 'An error occurred in fetching comment from relays'
|
||||
log(true, LogType.Error, message, error)
|
||||
if (error instanceof Error) {
|
||||
message = error.message
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return redirect('..')
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user