categories,18popup,clear.TextEditorSwap,GameCardHover #177

Merged
freakoverse merged 64 commits from staging into master 2024-12-24 19:44:30 +00:00
62 changed files with 56858 additions and 51679 deletions

4537
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,16 +11,12 @@
},
"dependencies": {
"@getalby/lightning-tools": "5.0.3",
"@mdxeditor/editor": "^3.20.0",
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@reduxjs/toolkit": "2.2.6",
"@tiptap/core": "2.9.1",
"@tiptap/extension-image": "^2.9.1",
"@tiptap/extension-link": "2.9.1",
"@tiptap/react": "2.9.1",
"@tiptap/starter-kit": "2.9.1",
"@types/react-helmet": "^6.1.11",
"axios": "1.7.3",
"axios": "^1.7.9",
"bech32": "2.0.0",
"buffer": "6.0.3",
"date-fns": "3.6.0",
@ -30,6 +26,7 @@
"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",

View File

@ -0,0 +1,12 @@
[
{ "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"
]

View File

@ -5,3 +5,5 @@ Vintage Story,,https://image.nostr.build/9efe683d339cc864032a99047ce26b2b5c19fab
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
1 Game Name 16 by 9 image Boxart image
5 Yandere Simulator https://image.nostr.build/54ba56b752bb9d411cbdc1d249fa0cb74c6062a305bcd0a70ecacb61b8d50030.png
6 Genshin Impact https://image.nostr.build/999fccf93cf16a2e0dd8e6f00595b0ab3b5cc6beff9fe4a52f64f427cce9aedd.jpg
7 Zenless Zone Zero https://image.nostr.build/4a9b9c2cbef619552d0c123f8794286f35710dc7ca1ca0010380a630883eb2ca.jpg
8 Ananta https://image.nostr.build/09fb30fe2c22784079e4c0a848410e709ff359af09b3f96b651c7dc249a35cdd.jpg
9 Bloodborne https://image.nostr.build/9d20c246b539e43f1bebcf602f996fa6eb45cf585f05cc19a1d9f86a53201485.jpg

View File

@ -33053,7 +33053,7 @@ LightStrike,,
Elderine: Dreams to Destiny Soundtrack,,
Riot of the numbers,,
A God-Like Backhand!,,
Ys VIII: Lacrimosa of Dana,,
Ys VIII: Lacrimosa of Dana,,https://image.nostr.build/571d2478fbed6b6587edb7a55dd3f0ab85e14a1d9a44b582b726b2e1a406cf65.jpg
Syndrome VR,,
Alpacapaca Dash,,
Ashes of the Singularity: Escalation - Inception DLC,,
@ -40243,9 +40243,6 @@ Halcyon 6: Lightspeed Edition - Soundtrack,,
Brass,,
Children of Zodiarcs Collector's Upgrade,,
DEFENDER OF EARTH VS THE ALIEN ARMADA,,
Ys VIII: Lacrimosa of DANA - Adols Adventure Essentials DLC,,
Ys VIII: Lacrimosa of DANA - Digital Mini Art Book,,
Ys VIII: Lacrimosa of DANA - Digital Soundtrack Sampler,,
Attack of the Gooobers,,
Tangrams Deluxe,,
Karmasutra,,
@ -42210,31 +42207,6 @@ Pawarumi Golden Chukaru,,
Anahita,,
Chess,,
Samurai Riot - Soundtrack,,
Ys VIII: Lacrimosa of DANA - Free Set 1,,
Ys VIII: Lacrimosa of DANA - Advanced Accessory Set,,
Ys VIII: Lacrimosa of DANA - Bottled Potion Set,,
Ys VIII: Lacrimosa of DANA - Castaway Start Dash Set,,
Ys VIII: Lacrimosa of DANA - Economy Ingredient Set,,
Ys VIII: Lacrimosa of DANA - Elixir Set 1,,
Ys VIII: Lacrimosa of DANA - Elixir Set 2,,
Ys VIII: Lacrimosa of DANA - Elixir Set 3,,
Ys VIII: Lacrimosa of DANA - Elixir Set 4,,
Ys VIII: Lacrimosa of DANA - Elixir Set 5,,
Ys VIII: Lacrimosa of DANA - Fish Bait Set 1,,
Ys VIII: Lacrimosa of DANA - Fish Bait Set 2,,
Ys VIII: Lacrimosa of DANA - Fish Bait Set 3,,
Ys VIII: Lacrimosa of DANA - Free Set 2,,
"Ys VIII: Lacrimosa of DANA - Laxia's ""Eternian Scholar"" Costume",,
Ys VIII: Lacrimosa of DANA - Premium Material Set,,
Ys VIII: Lacrimosa of DANA - Ripe Fruit Set,,
Ys VIII: Lacrimosa of DANA - Status Recovery Set,,
Ys VIII: Lacrimosa of DANA - HQ Texture Pack,,
Ys VIII: Lacrimosa of DANA - Tempest Set 1,,
Ys VIII: Lacrimosa of DANA - Tempest Set 2,,
Ys VIII: Lacrimosa of DANA - Tempest Set 3,,
Ys VIII: Lacrimosa of DANA - Tempest Set 4,,
Ys VIII: Lacrimosa of DANA - Tempest Set 5,,
Ys VIII: Lacrimosa of DANA - Useful Accessory Set,,
The Base,,
Prison Chainball Massacre,,
Pathfinder Adventures - A Fighters Tale,,

Can't render this file because it is too large.

View File

@ -38027,7 +38027,7 @@ SwayBods,,
Lutnak·Quest,,
Oceanarium World,,
Circle Defense,,
Marvel Rivals,,
Marvel Rivals,,https://image.nostr.build/e6917aae1da362be2a7b275740fc66f06a3f1868ad70986bf31ed6e6565a823e.jpg
十字防守(X Defense: Timing TD),,
TOTO ZOO,,
名利游戏 Demo,,
@ -48802,7 +48802,6 @@ Botworld Odyssey,,
ReSetna,,
极限侦探,,
The Leviathan's Fantasy武士与阴阳师,,
Marvel Rivals Playtest,,
5 Minute Raid,,
不会要俺来拯救世界吧!,,
玖玖麻将,,

Can't render this file because it is too large.

View File

@ -8289,7 +8289,7 @@ Super Alloy Crush,,
宗门志 Playtest,,
宗门志 Demo,,
TerraCube,,
inZOI: Character Studio,,
inZOI,,https://image.nostr.build/a5a19f75020072cec1a24a6a5efe36af331e24fb7a00d736a85b9e7b122c2c26.jpg
Fast video cutter joiner,,
Alchemy in Dungeon,,
Godly Visage,,

1 Game Name 16 by 9 image Boxart image
8289 宗门志 Playtest
8290 宗门志 Demo
8291 TerraCube
8292 inZOI: Character Studio inZOI https://image.nostr.build/a5a19f75020072cec1a24a6a5efe36af331e24fb7a00d736a85b9e7b122c2c26.jpg
8293 Fast video cutter joiner
8294 Alchemy in Dungeon
8295 Godly Visage

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 326 KiB

View File

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

View File

@ -0,0 +1,333 @@
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>&gt;</p>
</div>,
curr
])
return (
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
{categories}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -1,16 +1,11 @@
import { useAppSelector, useLocalStorage } from 'hooks'
import React from 'react'
import {
FilterOptions,
ModeratedFilter,
NSFWFilter,
SortBy,
WOTFilterOptions
} from 'types'
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
@ -115,19 +110,7 @@ export const BlogsFilter = React.memo(
{/* nsfw filter options */}
<Dropdown label={filterOptions.nsfw}>
{Object.values(NSFWFilter).map((item, index) => (
<Option
key={`nsfwFilterItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
>
{item}
</Option>
))}
<NsfwFilterOptions filterKey={filterKey} />
</Dropdown>
{/* source filter options */}

View File

@ -0,0 +1,3 @@
.noResult:not(:only-child) {
display: none;
}

View File

@ -0,0 +1,550 @@
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&apos;s where your custom categories appear (You can add them in the above field. Example &gt; banana &gt; 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&apos;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}
/>
)
}
})}
</>
)}
</>
)
}

View File

@ -1,17 +1,17 @@
import { useAppSelector, useLocalStorage } from 'hooks'
import React from 'react'
import React, { PropsWithChildren } from 'react'
import {
FilterOptions,
SortBy,
ModeratedFilter,
WOTFilterOptions,
NSFWFilter,
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
@ -19,7 +19,7 @@ type Props = {
}
export const ModFilter = React.memo(
({ author, filterKey = 'filter' }: Props) => {
({ author, filterKey = 'filter', children }: PropsWithChildren<Props>) => {
const userState = useAppSelector((state) => state.user)
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
filterKey,
@ -115,19 +115,7 @@ export const ModFilter = React.memo(
{/* nsfw filter options */}
<Dropdown label={filterOptions.nsfw}>
{Object.values(NSFWFilter).map((item, index) => (
<Option
key={`nsfwFilterItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
>
{item}
</Option>
))}
<NsfwFilterOptions filterKey={filterKey} />
</Dropdown>
{/* repost filter options */}
@ -176,6 +164,8 @@ export const ModFilter = React.memo(
Show All
</Option>
</Dropdown>
{children}
</Filter>
)
}

View File

@ -0,0 +1,64 @@
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)}
/>
)}
</>
)
}

View File

@ -1,15 +1,10 @@
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import { Editor, EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React, { useEffect } from 'react'
import React from 'react'
import '../styles/styles.css'
import '../styles/tiptap.scss'
interface InputFieldProps {
label: string | React.ReactElement
description?: string
type?: 'text' | 'textarea' | 'richtext'
type?: 'text' | 'textarea'
placeholder: string
name: string
inputMode?: 'url'
@ -48,11 +43,6 @@ export const InputField = React.memo(
value={value}
onChange={handleChange}
></textarea>
) : type === 'richtext' ? (
<RichTextEditor
content={value}
updateContent={(content) => onChange(name, content)}
/>
) : (
<input
type={type}
@ -121,216 +111,8 @@ export const CheckboxField = React.memo(
)
)
type RichTextEditorProps = {
content: string
updateContent: (updatedContent: string) => void
}
const RichTextEditor = ({ content, updateContent }: RichTextEditorProps) => {
const editor = useEditor({
extensions: [
StarterKit,
Link,
Image.configure({
HTMLAttributes: {
class: 'IBMSMSMBSSPostImg'
}
})
],
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
}
export 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 setImage = () => {
let url = prompt('URL')
if (url) {
if (!/^(http|https):\/\//i.test(url)) {
url = `https://${url}`
}
return editor.chain().focus().setImage({ src: url }).run()
}
return false
}
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().setParagraph().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: 'Image',
isActive: editor.isActive('image'),
onClick: setImage
},
{
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' : ''}`}
type='button'
>
{label}
</button>
)
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
label: string
label: string | React.ReactElement
description?: string
error?: string
}

View File

@ -0,0 +1,10 @@
.formAction {
display: flex;
width: 100%;
justify-content: flex-end;
gap: var(--spacing-2);
}
.wrapper {
border-radius: 0;
}

View File

@ -0,0 +1,137 @@
import {
BlockTypeSelect,
BoldItalicUnderlineToggles,
codeBlockPlugin,
CodeToggle,
CreateLink,
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: () => (
<>
<UndoRedo />
<Separator />
<BoldItalicUnderlineToggles />
<CodeToggle />
<Separator />
<StrikeThroughSupSubToggles />
<Separator />
<ListsToggle />
<Separator />
<BlockTypeSelect />
<Separator />
<CreateLink />
<InsertImage />
<YouTubeButton />
<Separator />
<InsertTable />
<InsertThematicBreak />
<Separator />
<InsertCodeBlock />
</>
)
}),
headingsPlugin(),
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]
})
],
[]
)
return (
<MDXEditor
ref={editorRef}
contentEditableClassName='editor'
className='dark-theme dark-editor'
markdown={markdown}
plugins={plugins}
onChange={onChange}
{...rest}
/>
)
}),
() => true
)

View File

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

View File

@ -0,0 +1,306 @@
/* 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>
)
}

View File

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

View File

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

View File

@ -0,0 +1,36 @@
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')
}
}}
/>
)
}

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import _ from 'lodash'
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import React, {
Fragment,
useCallback,
@ -8,77 +7,45 @@ import React, {
useRef,
useState
} from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
import { FixedSizeList as List } from 'react-window'
import { v4 as uuidv4 } from 'uuid'
import { T_TAG_VALUE } from '../constants'
import { useAppSelector, useGames, useNDKContext } from '../hooks'
import { appRoutes, getModPageRoute } from '../routes'
import '../styles/styles.css'
import { DownloadUrl, ModDetails, ModFormState } from '../types'
import {
initializeFormState,
isReachable,
isValidImageUrl,
isValidUrl,
log,
LogType,
now
} from '../utils'
useActionData,
useLoaderData,
useNavigation,
useSubmit
} from 'react-router-dom'
import { FixedSizeList } from 'react-window'
import { useGames } from '../hooks'
import '../styles/styles.css'
import {
DownloadUrl,
FormErrors,
ModFormState,
ModPageLoaderResult
} from '../types'
import { initializeFormState } from '../utils'
import { CheckboxField, InputError, InputField } from './Inputs'
import { LoadingSpinner } from './LoadingSpinner'
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { OriginalAuthor } from './OriginalAuthor'
interface FormErrors {
game?: string
title?: string
body?: string
featuredImageUrl?: string
summary?: string
nsfw?: string
screenshotsUrls?: string[]
tags?: string
downloadUrls?: string[]
author?: string
originalAuthor?: string
}
import { CategoryAutocomplete } from './CategoryAutocomplete'
import { AlertPopup } from './AlertPopup'
import { Editor, EditorRef } from './Markdown/Editor'
interface GameOption {
value: string
label: string
}
type ModFormProps = {
existingModData?: ModDetails
}
export const ModForm = ({ existingModData }: ModFormProps) => {
const location = useLocation()
const navigate = useNavigate()
const { ndk, publish } = useNDKContext()
export const ModForm = () => {
const data = useLoaderData() as ModPageLoaderResult
const mod = data?.mod
const formErrors = useActionData() as FormErrors
const navigation = useNavigation()
const submit = useSubmit()
const games = useGames()
const userState = useAppSelector((state) => state.user)
const [isPublishing, setIsPublishing] = useState(false)
const [gameOptions, setGameOptions] = useState<GameOption[]>([])
const [formState, setFormState] = useState<ModFormState>(
initializeFormState()
initializeFormState(mod)
)
const [formErrors, setFormErrors] = useState<FormErrors>({})
useEffect(() => {
if (location.pathname === appRoutes.submitMod) {
setFormState(initializeFormState())
}
}, [location.pathname]) // Only trigger when the pathname changes to submit-mod
useEffect(() => {
if (existingModData) {
setFormState(initializeFormState(existingModData))
}
}, [existingModData])
const editorRef = useRef<EditorRef>(null)
useEffect(() => {
const options = games.map((game) => ({
@ -174,196 +141,50 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
[]
)
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
}
if (!hexPubkey) {
toast.error('Could not get pubkey')
return
}
if (!(await validateState())) {
setIsPublishing(false)
return
}
const uuid = formState.dTag || uuidv4()
const currentTimeStamp = now()
const aTag =
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
const tags = [
['d', 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()],
['repost', formState.repost.toString()],
['screenshotsUrls', ...formState.screenshotsUrls],
['tags', ...formState.tags.split(',')],
[
'downloadUrls',
...formState.downloadUrls.map((downloadUrl) =>
JSON.stringify(downloadUrl)
)
]
]
if (formState.repost && formState.originalAuthor) {
tags.push(['originalAuthor', formState.originalAuthor])
}
const unsignedEvent: UnsignedEvent = {
kind: kinds.ClassifiedListing,
created_at: currentTimeStamp,
pubkey: hexPubkey,
content: formState.body,
tags
}
const signedEvent = await window.nostr
?.signEvent(unsignedEvent)
.then((event) => event as Event)
.catch((err) => {
toast.error('Failed to sign the event!')
log(true, LogType.Error, 'Failed to sign the event!', err)
return null
})
if (!signedEvent) {
setIsPublishing(false)
return
}
const ndkEvent = new NDKEvent(ndk, signedEvent)
const publishedOnRelays = await publish(ndkEvent)
// 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'
)}`
)
const naddr = nip19.naddrEncode({
identifier: aTag,
pubkey: signedEvent.pubkey,
kind: signedEvent.kind,
relays: publishedOnRelays
})
navigate(getModPageRoute(naddr))
}
setIsPublishing(false)
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
const handleReset = () => {
setShowConfirmPopup(true)
}
const handleResetConfirm = (confirm: boolean) => {
setShowConfirmPopup(false)
const validateState = async (): Promise<boolean> => {
const errors: FormErrors = {}
// Cancel if not confirmed
if (!confirm) return
if (formState.game === '') {
errors.game = 'Game field can not be empty'
// Editing
if (mod) {
const initial = initializeFormState(mod)
// Reset editor
editorRef.current?.setMarkdown(initial.body)
// Reset fields to the original existing data
setFormState(initial)
return
}
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.repost &&
(!formState.originalAuthor || formState.originalAuthor === '')
) {
errors.originalAuthor = 'Original author field can not be empty'
}
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
// New - set form state to the initial (clear form state)
setFormState(initializeFormState())
}
const handlePublish = () => {
submit(JSON.stringify(formState), {
method: mod ? 'put' : 'post',
encType: 'application/json'
})
}
return (
<>
{isPublishing && <LoadingSpinner desc='Publishing mod to relays' />}
<form
className='IBMSMSMBS_Write'
onSubmit={(e) => {
e.preventDefault()
handlePublish()
}}
>
<GameDropdown
options={gameOptions}
selected={formState.game}
error={formErrors.game}
selected={formState?.game}
error={formErrors?.game}
onChange={handleInputChange}
/>
@ -372,19 +193,32 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
placeholder='Return the banana mod'
name='title'
value={formState.title}
error={formErrors.title}
error={formErrors?.title}
onChange={handleInputChange}
/>
<InputField
label='Body'
type='richtext'
placeholder="Here's what this mod is all about"
name='body'
value={formState.body}
error={formErrors.body}
onChange={handleInputChange}
/>
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Body</label>
<div className='inputMain'>
<Editor
ref={editorRef}
markdown={formState.body}
placeholder="Here's what this mod is all about"
onChange={(md) => {
handleInputChange('body', md)
}}
/>
</div>
{typeof formErrors?.body !== 'undefined' && (
<InputError message={formErrors?.body} />
)}
<input
name='body'
hidden
value={encodeURIComponent(formState?.body)}
readOnly
/>
</div>
<InputField
label='Featured Image URL'
@ -394,7 +228,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
placeholder='Image URL'
name='featuredImageUrl'
value={formState.featuredImageUrl}
error={formErrors.featuredImageUrl}
error={formErrors?.featuredImageUrl}
onChange={handleInputChange}
/>
<InputField
@ -403,7 +237,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
placeholder='This is a quick description of my mod'
name='summary'
value={formState.summary}
error={formErrors.summary}
error={formErrors?.summary}
onChange={handleInputChange}
/>
<CheckboxField
@ -433,7 +267,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
placeholder="Original author's name, npub or nprofile"
name='originalAuthor'
value={formState.originalAuthor || ''}
error={formErrors.originalAuthor || ''}
error={formErrors?.originalAuthor}
onChange={handleInputChange}
/>
</>
@ -445,6 +279,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
className='btn btnMain btnMainAdd'
type='button'
onClick={addScreenshotUrl}
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -468,16 +303,16 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
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
@ -486,9 +321,14 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
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>
@ -496,6 +336,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
className='btn btnMain btnMainAdd'
type='button'
onClick={addDownloadUrl}
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -527,29 +368,51 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
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]} />
formErrors?.downloadUrls &&
formErrors?.downloadUrls[0] && (
<InputError message={formErrors?.downloadUrls[0]} />
)}
</div>
<div className='IBMSMSMBS_WriteAction'>
<button
className='btn btnMain'
type='button'
onClick={handlePublish}
disabled={isPublishing}
onClick={handleReset}
disabled={
navigation.state === 'loading' || navigation.state === 'submitting'
}
>
Publish
{mod ? 'Reset' : 'Clear fields'}
</button>
<button
className='btn btnMain'
type='submit'
disabled={
navigation.state === 'loading' || navigation.state === 'submitting'
}
>
{navigation.state === 'submitting' ? 'Publishing...' : 'Publish'}
</button>
</div>
</>
{showConfirmPopup && (
<AlertPopup
handleConfirm={handleResetConfirm}
handleClose={() => setShowConfirmPopup(false)}
header={'Are you sure?'}
label={
mod
? `Are you sure you want to clear all changes?`
: `Are you sure you want to clear all field data?`
}
/>
)}
</form>
)
}
type DownloadUrlFieldsProps = {
@ -597,6 +460,7 @@ const DownloadUrlFields = React.memo(
className='btn btnMain btnMainRemove'
type='button'
onClick={() => onRemove(index)}
title='Remove'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -751,6 +615,7 @@ const ScreenshotUrlFields = React.memo(
className='btn btnMain btnMainRemove'
type='button'
onClick={() => onRemove(index)}
title='Remove'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -831,6 +696,7 @@ const GameDropdown = ({
className='btn btnMain btnMainInsideField btnMainRemove'
type='button'
onClick={() => onChange('game', '')}
title='Remove'
>
<svg
xmlns='http://www.w3.org/2000/svg'
@ -843,7 +709,7 @@ const GameDropdown = ({
</svg>
</button>
<div className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt'>
<List
<FixedSizeList
height={500}
width={'100%'}
itemCount={filteredOptions.length}
@ -865,7 +731,7 @@ const GameDropdown = ({
{filteredOptions[index].label}
</div>
)}
</List>
</FixedSizeList>
</div>
</div>
</div>

View File

@ -0,0 +1,36 @@
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={handleClose}
handleConfirm={(confirm: boolean) => {
setConfirmNsfw(confirm)
handleConfirm(confirm)
handleClose()
}}
/>
)
)
}

View File

@ -3,12 +3,12 @@ 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[]
handleClose: () => void
}
} & PopupProps
export const ReportPopup = ({
openedAt,

View File

@ -24,7 +24,7 @@ export const LANDING_PAGE_DATA = {
featuredBlogPosts: [
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrzcfc8qurjefn943xyen9956rywp595unjc3h94nxvwfexymxxcfnvdjxxlyq37c',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjryv3k8qenydpj94nrscmp956xgwtp94snydtz95ekgvphvfnxvvrzvyexzsvsz9y',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2kwjtwvahns3n0tf8j6kjxggkkz4mff499ge7xzsz',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qyv8wumn8ghj7un9d3shjtnyv4nk6mmywvhxxmmd9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq3qamnwvaz7tmwdaehgu3wd4hk6tcppemhxue69uhkummn9ekx7mp0qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgawaehxw309ahx7um5wghxy6t5vdhkjmn9wgh8xmmrd9skctcpz4mhxue69uhkummnw3ezummcw3ezuer9wchsqfrxv33rvvfjxucz6d33vgcz6dp48qej6wryv9jz6errv33nqef3xy6kxvmrtmq496',
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrycf5vyunyd34943kydn9956rycmp943xydpc95cxge3cvguxgcmyxsmkyzpyj60'
]
}

View File

@ -111,7 +111,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
}
const ndk = useMemo(() => {
localStorage.setItem('debug', '*')
localStorage.removeItem('debug')
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' })
dexieAdapter.locking = true
const ndk = new NDK({

View File

@ -8,3 +8,4 @@ export * from './useReactions'
export * from './useNDKContext'
export * from './useScrollDisable'
export * from './useLocalStorage'
export * from './useSessionStorage'

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, { useMemo } from 'react'
import {
getLocalStorageItem,
removeLocalStorageItem,
@ -11,7 +11,11 @@ const useLocalStorageSubscribe = (callback: () => void) => {
}
function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T {
if (typeof storedValue === 'object' && storedValue !== null) {
if (
!Array.isArray(storedValue) &&
typeof storedValue === 'object' &&
storedValue !== null
) {
return { ...initialValue, ...storedValue }
}
return storedValue
@ -64,5 +68,7 @@ export function useLocalStorage<T>(
}
}, [key, initialValue])
return [JSON.parse(data) as T, setState]
const memoized = useMemo(() => JSON.parse(data) as T, [data])
return [memoized, setState]
}

View File

@ -0,0 +1,77 @@
import React, { useMemo } from 'react'
import {
getSessionStorageItem,
removeSessionStorageItem,
setSessionStorageItem
} from 'utils'
const useSessionStorageSubscribe = (callback: () => void) => {
window.addEventListener('sessionStorage', callback)
return () => window.removeEventListener('sessionStorage', callback)
}
function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T {
if (
!Array.isArray(storedValue) &&
typeof storedValue === 'object' &&
storedValue !== null
) {
return { ...initialValue, ...storedValue }
}
return storedValue
}
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]
}

View File

@ -91,10 +91,7 @@ export const Header = () => {
<div className={mainStyles.ContainerMain}>
<div className={navStyles.NavMainTopInside}>
<div className={navStyles.NMTI_Sec}>
<Link
to={appRoutes.index}
className={navStyles.NMTI_Sec_HomeLink}
>
<Link to={appRoutes.home} className={navStyles.NMTI_Sec_HomeLink}>
<div className={navStyles.NMTI_Sec_HomeLink_Logo}>
<img
className={navStyles.NMTI_Sec_HomeLink_LogoImg}

View File

@ -62,7 +62,7 @@ const FAQ_ITEMS: FAQItem[] = [
{
question: "Who's developing / maintaining DEG Mods?",
answer: `Considering this is an open-source project, anyone can contribute to its development and maintenance.
With that said, the initial idea-tor, designer, and frontend developer is [Freakoverse](https://primal.net/p/npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r), and the co-developer
With that said, the initial idea-tor, designer, and frontend developer is [Freakoverse](https://degmods.com/profile/nprofile1qqsre6jgq6c7r2vzn5cdtju20qq36sn3cer5avc4x8kfru5pzrlr7sqnancjp), and the co-developer
is [Nostr Dev](https://nostrdev.com/).`
},
{
@ -151,23 +151,23 @@ export const AboutPage = () => {
mods published by their creators. Mod creators provide
direct download links on their mod pages, allowing gamers to
access the mods effortlessly. If a link breaks or gets
censored, mod creators can remove that link and add another,
and people can rate download links based on if they're
working and virus free. A public regulating system.
censored, mod creators can remove that link and add another.
<br />
<br />
Also, everything is open sourced. Even if the site were to
shut down, someone can simply take the same code and run it
under a different name, and every mod would still be
accessible, along with their links, reactions/ratings, and
comments.
comments, as well as being completely functional as well.
You'd also be able to just simply run the site on your PC,
without having it up on a domain.
</p>
<h3 className='LearnTextHeading'>Tips / Donations</h3>
<p className='LearnTextPara'>
DEG Mods supports hassle-free money transfers for modders.
Fans can show their appreciation by directly tipping mod
creators via Bitcoin through the Lightning Network, an
action known as Zapping. Support creators so they can
action known as Zapping. Choose to support creators so they can
continue making more valuable game mods!
<br />
</p>
@ -187,7 +187,7 @@ export const AboutPage = () => {
them financially, even those in other countries where
"normal" methods of money payment/transfer are not an
option. You can just find the mod you want and download it,
or upload the mod you've created, and never even touch
or publish the mod you've created, and never even touch
Bitcoin.
<br />
</p>
@ -195,7 +195,8 @@ export const AboutPage = () => {
DEG Mods is a response to censorship and oppression, to
bring freedom and not hinder people's desires and
creativity. If you know a mod creator that's being censored,
then show them the way. Gamers just want to game in peace...
then show them the way. Modders just want to mod, and gamers
just want to game in peace...
<br />
</p>
<h3 className='LearnTextHeading'>
@ -209,14 +210,14 @@ export const AboutPage = () => {
description.
<br />
<br />
For what some people might call it:
Another way of describing it:
<br />
"It's a game mods website."
A true mod site.
</p>
<div className='learnLinks'>
<a
className='learnLinksLink'
href='https://primal.net/p/npub17jl3ldd6305rnacvwvchx03snauqsg4nz8mruq0emj9thdpglr2sst825x'
href='https://degmods.com/profile/nprofile1qqs0f0clkkagh6pe7ux8xvtn8ccf77qgy2e3ra37q8uaez4mks5034gfw4xg6'
target='_blank'
>
<img

View File

@ -5,12 +5,6 @@ import {
useNavigation,
useSubmit
} from 'react-router-dom'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import { EditorContent, useEditor } from '@tiptap/react'
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ProfileSection } from 'components/ProfileSection'
import { Comments } from 'components/comment'
@ -23,6 +17,7 @@ import { copyTextToClipboard } from 'utils'
import { toast } from 'react-toastify'
import { useAppSelector, useBodyScrollDisable } from 'hooks'
import { ReportPopup } from 'components/ReportPopup'
import { Viewer } from 'components/Markdown/Viewer'
const BLOG_REPORT_REASONS = [
{ label: 'Actually CP', key: 'actuallyCP' },
@ -42,25 +37,6 @@ export const BlogPage = () => {
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
const navigation = useNavigation()
const [commentCount, setCommentCount] = useState(0)
const html = marked.parse(blog?.content || '', { async: false })
const sanitized = DOMPurify.sanitize(html)
const editor = useEditor(
{
content: sanitized,
extensions: [
StarterKit,
Link,
Image.configure({
inline: true,
HTMLAttributes: {
class: 'IBMSMSMBSSPostImg'
}
})
],
editable: false
},
[sanitized]
)
const [showReportPopUp, setShowReportPopUp] = useState<number>()
useBodyScrollDisable(!!showReportPopUp)
@ -266,7 +242,10 @@ export const BlogPage = () => {
</h1>
</div>
<div className='IBMSMSMBSSPostBody'>
<EditorContent editor={editor} />
<Viewer
key={blog.id}
markdown={blog?.content || ''}
/>
</div>
<div className='IBMSMSMBSSTags'>
{blog.nsfw && (

View File

@ -14,17 +14,16 @@ import { LoadingSpinner } from 'components/LoadingSpinner'
import { Filter } from 'components/Filters'
import { Dropdown } from 'components/Filters/Dropdown'
import { Option } from 'components/Filters/Option'
import { NsfwFilterOptions } from 'components/Filters/NsfwFilterOptions'
export const BlogsPage = () => {
const navigation = useNavigation()
const blogs = useLoaderData() as Partial<BlogCardDetails>[] | undefined
const [filterOptions, setFilterOptions] = useLocalStorage(
'filter-blog-curated',
{
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW
}
)
const filterKey = 'filter-blog-curated'
const [filterOptions, setFilterOptions] = useLocalStorage(filterKey, {
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW
})
// Search
const searchTermRef = useRef<HTMLInputElement>(null)
@ -147,19 +146,7 @@ export const BlogsPage = () => {
</Dropdown>
<Dropdown label={filterOptions.nsfw}>
{Object.values(NSFWFilter).map((item, index) => (
<Option
key={`nsfwFilterItem-${index}`}
onClick={() =>
setFilterOptions((prev) => ({
...prev,
nsfw: item
}))
}
>
{item}
</Option>
))}
<NsfwFilterOptions filterKey={filterKey} />
</Dropdown>
</Filter>

View File

@ -14,7 +14,8 @@ import {
useLocalStorage,
useMuteLists,
useNDKContext,
useNSFWList
useNSFWList,
useSessionStorage
} from 'hooks'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
@ -27,6 +28,7 @@ import {
scrollIntoView
} from 'utils'
import { useCuratedSet } from 'hooks/useCuratedSet'
import { CategoryFilterPopup } from 'components/Filters/CategoryFilterPopup'
export const GamePage = () => {
const scrollTargetRef = useRef<HTMLDivElement>(null)
@ -52,6 +54,13 @@ export const GamePage = () => {
const [searchParams, setSearchParams] = useSearchParams()
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '')
// Categories filter
const [categories, setCategories] = useSessionStorage<string[]>('l', [])
const [hierarchies, setHierarchies] = useSessionStorage<string[]>('h', [])
const [showCategoryPopup, setShowCategoryPopup] = useState(false)
const linkedHierarchy = searchParams.get('h')
const isCategoryFilterActive = categories.length + hierarchies.length > 0
const handleSearch = () => {
const value = searchTermRef.current?.value || '' // Access the input value from the ref
setSearchTerm(value)
@ -82,8 +91,38 @@ export const GamePage = () => {
return true
}
// If search term is missing, only filter by sources
if (searchTerm === '') return mods.filter(filterSourceFn)
const filterCategoryFn = (mod: ModDetails) => {
// Linked overrides the category popup selection
if (linkedHierarchy && linkedHierarchy !== '') {
return mod.LTags.includes(linkedHierarchy)
}
// If no selections are active return true
if (!(hierarchies.length || categories.length)) {
return true
}
// Hierarchy selection active
if (hierarchies.length) {
const isMatch = mod.LTags.some((item) => hierarchies.includes(item))
// Matched hierarchy, return true immediately otherwise check categories
if (isMatch) return isMatch
}
// Category selection
if (categories.length) {
// Return result immediately
return mod.lTags.some((item) => categories.includes(item))
}
// No matches
return false
}
// If search term is missing, only filter by sources and category
if (searchTerm === '')
return mods.filter(filterSourceFn).filter(filterCategoryFn)
const lowerCaseSearchTerm = searchTerm.toLowerCase()
@ -96,8 +135,15 @@ export const GamePage = () => {
tag.toLowerCase().includes(lowerCaseSearchTerm)
) > -1
return mods.filter(filterFn).filter(filterSourceFn)
}, [filterOptions.source, mods, searchTerm])
return mods.filter(filterFn).filter(filterSourceFn).filter(filterCategoryFn)
}, [
categories,
filterOptions.source,
hierarchies,
linkedHierarchy,
mods,
searchTerm
])
const filteredModList = useFilteredMods(
filteredMods,
@ -123,6 +169,8 @@ export const GamePage = () => {
}
useEffect(() => {
if (!gameName) return
const filter: NDKFilter = {
kinds: [NDKKind.Classified],
'#t': [T_TAG_VALUE]
@ -188,7 +236,42 @@ export const GamePage = () => {
/>
</div>
</div>
<ModFilter />
<ModFilter>
<div className='FiltersMainElement'>
<button
className='btn btnMain btnMainDropdown'
type='button'
onClick={() => {
setShowCategoryPopup(true)
}}
>
Categories
{isCategoryFilterActive ||
(linkedHierarchy && linkedHierarchy !== '') ? (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 576 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M 3.9,22.9 C 10.5,8.9 24.5,0 40,0 h 432 c 15.5,0 29.5,8.9 36.1,22.9 6.6,14 4.6,30.5 -5.2,42.5 L 396.4,195.6 C 316.2,212.1 256,283 256,368 c 0,27.4 6.3,53.4 17.5,76.5 -1.6,-0.8 -3.2,-1.8 -4.7,-2.9 l -64,-48 C 196.7,387.6 192,378.1 192,368 V 288.9 L 9,65.3 C -0.7,53.4 -2.8,36.8 3.9,22.9 Z M 432,224 c 79.52906,0 143.99994,64.471 143.99994,144 0,79.529 -64.47088,144 -143.99994,144 -79.52906,0 -143.99994,-64.471 -143.99994,-144 0,-79.529 64.47088,-144 143.99994,-144 z' />
</svg>
) : (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M3.9 54.9C10.5 40.9 24.5 32 40 32l432 0c15.5 0 29.5 8.9 36.1 22.9s4.6 30.5-5.2 42.5L320 320.9 320 448c0 12.1-6.8 23.2-17.7 28.6s-23.8 4.3-33.5-3l-64-48c-8.1-6-12.8-15.5-12.8-25.6l0-79.1L9 97.3C-.7 85.4-2.8 68.8 3.9 54.9z' />
</svg>
)}
</button>
</div>
</ModFilter>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{currentMods.map((mod) => (
@ -204,6 +287,17 @@ export const GamePage = () => {
</div>
</div>
</div>
{showCategoryPopup && (
<CategoryFilterPopup
categories={categories}
setCategories={setCategories}
hierarchies={hierarchies}
setHierarchies={setHierarchies}
handleClose={() => {
setShowCategoryPopup(false)
}}
/>
)}
</>
)
}

View File

@ -1,9 +1,6 @@
import Link from '@tiptap/extension-link'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import FsLightbox from 'fslightbox-react'
import { nip19 } from 'nostr-tools'
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Link as ReactRouterLink,
useLoaderData,
@ -28,6 +25,7 @@ import '../../styles/tags.css'
import '../../styles/write.css'
import { DownloadUrl, ModPageLoaderResult } from '../../types'
import {
capitalizeEachWord,
copyTextToClipboard,
downloadFile,
getFilenameFromUrl
@ -39,6 +37,7 @@ import { ReportPopup } from 'components/ReportPopup'
import { Spinner } from 'components/Spinner'
import { RouterLoadingSpinner } from 'components/LoadingSpinner'
import { OriginalAuthor } from 'components/OriginalAuthor'
import { Viewer } from 'components/Markdown/Viewer'
const MOD_REPORT_REASONS = [
{ label: 'Actually CP', key: 'actuallyCP' },
@ -103,8 +102,10 @@ export const ModPage = () => {
featuredImageUrl={mod.featuredImageUrl}
title={mod.title}
body={mod.body}
game={mod.game}
screenshotsUrls={mod.screenshotsUrls}
tags={mod.tags}
LTags={mod.LTags}
nsfw={mod.nsfw}
repost={mod.repost}
originalAuthor={mod.originalAuthor}
@ -424,8 +425,10 @@ type BodyProps = {
featuredImageUrl: string
title: string
body: string
game: string
screenshotsUrls: string[]
tags: string[]
LTags: string[]
nsfw: boolean
repost: boolean
originalAuthor?: string
@ -433,17 +436,19 @@ type BodyProps = {
const Body = ({
featuredImageUrl,
game,
title,
body,
screenshotsUrls,
tags,
LTags,
nsfw,
repost,
originalAuthor
}: BodyProps) => {
const COLLAPSED_MAX_SIZE = 250
const postBodyRef = useRef<HTMLDivElement>(null)
const viewFullPostBtnRef = useRef<HTMLDivElement>(null)
const [lightBoxController, setLightBoxController] = useState({
toggler: false,
slide: 1
@ -456,6 +461,14 @@ const Body = ({
}))
}
useEffect(() => {
if (postBodyRef.current) {
if (postBodyRef.current.scrollHeight <= COLLAPSED_MAX_SIZE) {
viewFullPost()
}
}
}, [])
const viewFullPost = () => {
if (postBodyRef.current && viewFullPostBtnRef.current) {
postBodyRef.current.style.maxHeight = 'unset'
@ -464,12 +477,6 @@ const Body = ({
}
}
const editor = useEditor({
content: body,
extensions: [StarterKit, Link],
editable: false
})
return (
<>
<div className='IBMSMSMBSSPost'>
@ -486,9 +493,12 @@ const Body = ({
<div
ref={postBodyRef}
className='IBMSMSMBSSPostBody'
style={{ maxHeight: '250px', padding: '10px 18px' }}
style={{
maxHeight: `${COLLAPSED_MAX_SIZE}px`,
padding: '10px 18px'
}}
>
<EditorContent editor={editor} />
<Viewer markdown={body} />
<div ref={viewFullPostBtnRef} className='IBMSMSMBSSPostBodyHide'>
<div className='IBMSMSMBSSPostBodyHideText'>
<p onClick={viewFullPost}>Read Full</p>
@ -532,6 +542,50 @@ const Body = ({
{tag}
</a>
))}
{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 (
<ReactRouterLink
className='IBMSMSMBSSCategoriesBoxItem'
key={`category-${i}`}
target='_blank'
to={{
pathname: getGamePageRoute(game),
search: `h=${partialHierarchy}`
}}
>
<p>{capitalizeEachWord(c)}</p>
</ReactRouterLink>
)
})
.reduce((prev, curr, i) => [
prev,
<div
key={`separator-${i}`}
className='IBMSMSMBSSCategoriesBoxSeparator'
>
<p>&gt;</p>
</div>,
curr
])
return (
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
{categories}
</div>
)
})}
</div>
)}
</div>
</div>
</div>

View File

@ -28,7 +28,7 @@ export const modRouteLoader =
const { naddr } = params
if (!naddr) {
log(true, LogType.Error, 'Required naddr.')
return redirect(appRoutes.blogs)
return redirect(appRoutes.mods)
}
// Decode from naddr
@ -42,7 +42,7 @@ export const modRouteLoader =
pubkey = decoded.data.pubkey
} catch (error) {
log(true, LogType.Error, `Failed to decode naddr: ${naddr}`, error)
throw new Error('Failed to fetch the blog. The address might be wrong')
throw new Error('Failed to fetch the mod. The address might be wrong')
}
const userState = store.getState().user
@ -80,7 +80,7 @@ export const modRouteLoader =
latestFilter['#L'] = ['content-warning']
}
// Parallel fetch blog event, latest events, mute, and nsfw lists
// Parallel fetch mod event, latest events, mute, nsfw, repost lists
const settled = await Promise.allSettled([
ndkContext.fetchEvent(modFilter),
ndkContext.fetchEvents(latestFilter),
@ -106,7 +106,7 @@ export const modRouteLoader =
log(
true,
LogType.Error,
'Unable to fetch the blog event.',
'Unable to fetch the mod event.',
fetchEventResult.reason
)
}

View File

@ -1,6 +1,12 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { useAppDispatch, useAppSelector, useNDKContext } from 'hooks'
import { NsfwAlertPopup } from 'components/NsfwAlertPopup'
import {
useAppDispatch,
useAppSelector,
useNDKContext,
useLocalStorage
} from 'hooks'
import { kinds, UnsignedEvent, Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { toast } from 'react-toastify'
@ -19,6 +25,13 @@ export const PreferencesSetting = () => {
const [wotLevel, setWotLevel] = useState(userWotLevel)
const [isSaving, setIsSaving] = useState(false)
const [nsfw, setNsfw] = useState(false)
const [confirmNsfw] = useLocalStorage<boolean>('confirm-nsfw', false)
const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(false)
const handleNsfwConfirm = (confirm: boolean) => {
setNsfw(confirm)
}
useEffect(() => {
if (user?.pubkey) {
const hexPubkey = user.pubkey as string
@ -191,6 +204,14 @@ export const PreferencesSetting = () => {
type='checkbox'
className='CheckboxMain'
name='NSFWPreference'
checked={nsfw}
onChange={(e) => {
if (e.currentTarget.checked && !confirmNsfw) {
setShowNsfwPopup(true)
} else {
setNsfw(e.currentTarget.checked)
}
}}
/>
</div>
</div>
@ -238,6 +259,12 @@ export const PreferencesSetting = () => {
Save
</button>
</div>
{showNsfwPopup && (
<NsfwAlertPopup
handleConfirm={handleNsfwConfirm}
handleClose={() => setShowNsfwPopup(false)}
/>
)}
</div>
</div>
</div>

View File

@ -1,88 +0,0 @@
import { NDKFilter } from '@nostr-dev-kit/ndk'
import { nip19 } from 'nostr-tools'
import { useState } from 'react'
import { useLocation, useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../components/LoadingSpinner'
import { ModForm } from '../components/ModForm'
import { ProfileSection } from '../components/ProfileSection'
import { useAppSelector, useDidMount, useNDKContext } from '../hooks'
import '../styles/innerPage.css'
import '../styles/styles.css'
import '../styles/write.css'
import { ModDetails } from '../types'
import { extractModData, log, LogType } from '../utils'
export const SubmitModPage = () => {
const location = useLocation()
const { naddr } = useParams()
const { fetchEvent } = useNDKContext()
const [modData, setModData] = useState<ModDetails>()
const [isFetching, setIsFetching] = useState(false)
const userState = useAppSelector((state) => state.user)
const title = location.pathname.startsWith('/edit-mod')
? 'Edit Mod'
: 'Submit a mod'
useDidMount(async () => {
if (naddr) {
const decoded = nip19.decode<'naddr'>(naddr as `naddr1${string}`)
const { identifier, kind, pubkey } = decoded.data
const filter: NDKFilter = {
'#a': [identifier],
authors: [pubkey],
kinds: [kind]
}
setIsFetching(true)
fetchEvent(filter)
.then((event) => {
if (event) {
const extracted = extractModData(event)
setModData(extracted)
}
})
.catch((err) => {
log(
true,
LogType.Error,
'An error occurred in fetching mod details from relays',
err
)
toast.error('An error occurred in fetching mod details from relays')
})
.finally(() => {
setIsFetching(false)
})
}
})
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>{title}</h2>
</div>
<div className='IBMSMSMBS_Write'>
{isFetching ? (
<LoadingSpinner desc='Fetching mod details from relays' />
) : (
<ModForm existingModData={modData} />
)}
</div>
</div>
{userState.auth && userState.user?.pubkey && (
<ProfileSection pubkey={userState.user.pubkey as string} />
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,238 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { NDKContextType } from 'contexts/NDKContext'
import { kinds, nip19, Event, UnsignedEvent } from 'nostr-tools'
import { ActionFunctionArgs, redirect } from 'react-router-dom'
import { toast } from 'react-toastify'
import { getModPageRoute } from 'routes'
import { store } from 'store'
import { FormErrors, ModFormState } from 'types'
import {
isReachable,
isValidImageUrl,
isValidUrl,
log,
LogType,
now
} from 'utils'
import { v4 as uuidv4 } from 'uuid'
import { T_TAG_VALUE } from '../../constants'
export const submitModRouteAction =
(ndkContext: NDKContextType) =>
async ({ params, request }: ActionFunctionArgs) => {
const userState = store.getState().user
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) {
if (error instanceof Error) {
log(true, LogType.Error, 'Failed to get public key.', error)
}
toast.error('Failed to get public key.')
return null
}
}
if (!hexPubkey) {
toast.error('Could not get pubkey')
return null
}
// Get the form data from submit request
try {
const formState = (await request.json()) as ModFormState
// Check for errors
const formErrors = await validateState(formState)
// Return earily if there are any errors
if (Object.keys(formErrors).length) return formErrors
// Check if we are editing or this is a new mob
const { naddr } = params
const isEditing = naddr && request.method === 'PUT'
const uuid = formState.dTag || uuidv4()
const currentTimeStamp = now()
const aTag =
formState.aTag || `${kinds.ClassifiedListing}:${hexPubkey}:${uuid}`
const tags = [
['d', uuid],
['a', aTag],
['r', formState.rTag],
['t', T_TAG_VALUE],
[
'published_at',
isEditing
? formState.published_at.toString()
: currentTimeStamp.toString()
],
['game', formState.game],
['title', formState.title],
['featuredImageUrl', formState.featuredImageUrl],
['summary', formState.summary],
['nsfw', formState.nsfw.toString()],
['repost', formState.repost.toString()],
['screenshotsUrls', ...formState.screenshotsUrls],
['tags', ...formState.tags.split(',')],
[
'downloadUrls',
...formState.downloadUrls.map((downloadUrl) =>
JSON.stringify(downloadUrl)
)
]
]
if (formState.repost && formState.originalAuthor) {
tags.push(['originalAuthor', formState.originalAuthor])
}
// Prepend com.degmods to avoid leaking categories to 3rd party client's search
// Add hierarchical namespaces labels
if (formState.LTags.length > 0) {
for (let i = 0; i < formState.LTags.length; i++) {
tags.push(['L', `com.degmods:${formState.LTags[i]}`])
}
}
// Add category labels
if (formState.lTags.length > 0) {
for (let i = 0; i < formState.lTags.length; i++) {
tags.push(['l', `com.degmods:${formState.lTags[i]}`])
}
}
const unsignedEvent: UnsignedEvent = {
kind: kinds.ClassifiedListing,
created_at: currentTimeStamp,
pubkey: hexPubkey,
content: formState.body,
tags
}
const signedEvent = await window.nostr
?.signEvent(unsignedEvent)
.then((event) => event as Event)
.catch((err) => {
toast.error('Failed to sign the event!')
log(true, LogType.Error, 'Failed to sign the event!', err)
return null
})
if (!signedEvent) {
toast.error('Failed to sign the event!')
return null
}
const ndkEvent = new NDKEvent(ndkContext.ndk, signedEvent)
const publishedOnRelays = await ndkContext.publish(ndkEvent)
// 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'
)}`
)
const naddr = nip19.naddrEncode({
identifier: aTag,
pubkey: signedEvent.pubkey,
kind: signedEvent.kind,
relays: publishedOnRelays
})
return redirect(getModPageRoute(naddr))
}
} catch (error) {
log(true, LogType.Error, 'Failed to sign the event!', error)
toast.error('Failed to sign the event!')
return null
}
return null
}
const validateState = async (
formState: Partial<ModFormState>
): Promise<FormErrors> => {
const errors: FormErrors = {}
if (!formState.game || formState.game === '') {
errors.game = 'Game field can not be empty'
}
if (!formState.title || formState.title === '') {
errors.title = 'Title field can not be empty'
}
if (!formState.body || formState.body === '') {
errors.body = 'Body field can not be empty'
}
if (!formState.featuredImageUrl || 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 || formState.summary === '') {
errors.summary = 'Summary field can not be empty'
}
if (!formState.screenshotsUrls || 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.repost &&
(!formState.originalAuthor || formState.originalAuthor === '')
) {
errors.originalAuthor = 'Original author field can not be empty'
}
if (!formState.tags || formState.tags === '') {
errors.tags = 'Tags field can not be empty'
}
if (!formState.downloadUrls || 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'
}
}
}
return errors
}

View File

@ -0,0 +1,39 @@
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ModForm } from 'components/ModForm'
import { ProfileSection } from 'components/ProfileSection'
import { useAppSelector } from 'hooks'
import { useLoaderData, useNavigation } from 'react-router-dom'
import { ModPageLoaderResult } from 'types'
export const SubmitModPage = () => {
const data = useLoaderData() as ModPageLoaderResult
const mod = data?.mod
const navigation = useNavigation()
const userState = useAppSelector((state) => state.user)
const title = mod ? 'Edit Mod' : 'Submit a mod'
return (
<div className='InnerBodyMain'>
<div className='ContainerMain'>
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
<div className='IBMSMSplitMain'>
<div className='IBMSMSplitMainBigSide'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>{title}</h2>
</div>
{navigation.state === 'loading' && (
<LoadingSpinner desc='Fetching mod details from relays' />
)}
{navigation.state === 'submitting' && (
<LoadingSpinner desc='Publishing mod to relays' />
)}
<ModForm />
</div>
{userState.auth && userState.user?.pubkey && (
<ProfileSection pubkey={userState.user.pubkey as string} />
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -10,7 +10,6 @@ import {
now,
parseFormData
} from 'utils'
import TurndownService from 'turndown'
import { kinds, UnsignedEvent, Event, nip19 } from 'nostr-tools'
import { toast } from 'react-toastify'
import { NDKEvent } from '@nostr-dev-kit/ndk'
@ -57,9 +56,8 @@ export const writeRouteAction =
// Return earily if there are any errors
if (Object.keys(formErrors).length) return formErrors
// Get the markdown from the html
const turndownService = new TurndownService()
const content = turndownService.turndown(formSubmit.content!)
// Get the markdown from formData
const content = decodeURIComponent(formSubmit.content!)
// Check if we are editing or this is a new blog
const { naddr } = params
@ -154,11 +152,7 @@ const validateFormData = async (
errors.title = 'Title field can not be empty'
}
if (
!formData.content ||
formData.content.trim() === '' ||
formData.content.trim() === '<p></p>'
) {
if (!formData.content || formData.content.trim() === '') {
errors.content = 'Content field can not be empty'
}

View File

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import {
Form,
useActionData,
@ -8,8 +8,7 @@ import {
import {
CheckboxFieldUncontrolled,
InputError,
InputFieldUncontrolled,
MenuBar
InputFieldUncontrolled
} from '../../components/Inputs'
import { ProfileSection } from '../../components/ProfileSection'
import { useAppSelector } from '../../hooks'
@ -18,12 +17,8 @@ import '../../styles/innerPage.css'
import '../../styles/styles.css'
import '../../styles/write.css'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import { AlertPopup } from 'components/AlertPopup'
import { Editor, EditorRef } from 'components/Markdown/Editor'
export const WritePage = () => {
const userState = useAppSelector((state) => state.user)
@ -33,25 +28,26 @@ export const WritePage = () => {
const blog = data?.blog
const title = data?.blog ? 'Edit blog post' : 'Submit a blog post'
const html = marked.parse(blog?.content || '', { async: false })
const sanitized = DOMPurify.sanitize(html)
const [content, setContent] = useState<string>(sanitized)
const editor = useEditor({
content: content,
extensions: [
StarterKit,
Link,
Image.configure({
inline: true,
HTMLAttributes: {
class: 'IBMSMSMBSSPostImg'
}
})
],
onUpdate: ({ editor }) => {
setContent(editor.getHTML())
const [content, setContent] = useState(blog?.content || '')
const formRef = useRef<HTMLFormElement>(null)
const editorRef = useRef<EditorRef>(null)
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
const handleReset = () => {
setShowConfirmPopup(true)
}
const handleResetConfirm = (confirm: boolean) => {
setShowConfirmPopup(false)
// Cancel if not confirmed
if (!confirm) return
// Reset editor
if (blog?.content) {
editorRef.current?.setMarkdown(blog?.content)
}
})
formRef.current?.reset()
}
return (
<div className='InnerBodyMain'>
@ -68,26 +64,39 @@ export const WritePage = () => {
{navigation.state === 'submitting' && (
<LoadingSpinner desc='Publishing blog to relays' />
)}
<Form className='IBMSMSMBS_Write' method={blog ? 'put' : 'post'}>
<Form
ref={formRef}
className='IBMSMSMBS_Write'
method={blog ? 'put' : 'post'}
>
<InputFieldUncontrolled
label='Title'
name='title'
defaultValue={blog?.title}
error={formErrors?.title}
/>
{editor && (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Content</label>
<div className='inputMain'>
<MenuBar editor={editor} />
<EditorContent editor={editor} />
</div>
{typeof formErrors?.content !== 'undefined' && (
<InputError message={formErrors?.content} />
)}
<input name='content' hidden value={content} readOnly />
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Content</label>
<div className='inputMain'>
<Editor
ref={editorRef}
markdown={content}
onChange={(md) => {
setContent(md)
}}
/>
</div>
)}
{typeof formErrors?.content !== 'undefined' && (
<InputError message={formErrors?.content} />
)}
{/* encode to keep the markdown formatting */}
<input
name='content'
hidden
value={encodeURIComponent(content)}
readOnly
/>
</div>
<InputFieldUncontrolled
label='Featured Image URL'
name='image'
@ -130,6 +139,17 @@ export const WritePage = () => {
/>
)}
<div className='IBMSMSMBS_WriteAction'>
<button
className='btn btnMain'
type='button'
onClick={handleReset}
disabled={
navigation.state === 'loading' ||
navigation.state === 'submitting'
}
>
{blog ? 'Reset' : 'Clear fields'}
</button>
<button
className='btn btnMain'
type='submit'
@ -143,6 +163,18 @@ export const WritePage = () => {
: 'Publish'}
</button>
</div>
{showConfirmPopup && (
<AlertPopup
handleConfirm={handleResetConfirm}
handleClose={() => setShowConfirmPopup(false)}
header={'Are you sure?'}
label={
blog
? `Are you sure you want to clear all changes?`
: `Are you sure you want to clear all field data?`
}
/>
)}
</Form>
</div>
{userState.auth && userState.user?.pubkey && (

View File

@ -16,6 +16,7 @@ import { profileRouteLoader } from 'pages/profile/loader'
import { SettingsPage } from '../pages/settings'
import { GamePage } from '../pages/game'
import { NotFoundPage } from '../pages/404'
import { submitModRouteAction } from 'pages/submitMod/action'
import { FeedLayout } from '../layout/feed'
import { FeedPage } from '../pages/feed'
import { NotificationsPage } from '../pages/notifications'
@ -29,7 +30,6 @@ import { blogRouteAction } from '../pages/blog/action'
import { reportRouteAction } from '../actions/report'
export const appRoutes = {
index: '/',
home: '/',
games: '/games',
game: '/game/:name',
@ -75,7 +75,7 @@ export const routerWithNdkContext = (context: NDKContextType) =>
element: <Layout />,
children: [
{
path: appRoutes.index,
path: appRoutes.home,
element: <HomePage />
},
{
@ -131,11 +131,16 @@ export const routerWithNdkContext = (context: NDKContextType) =>
},
{
path: appRoutes.submitMod,
element: <SubmitModPage />
action: submitModRouteAction(context),
element: <SubmitModPage key='submit' />,
errorElement: <NotFoundPage title={'Something went wrong.'} />
},
{
path: appRoutes.editMod,
element: <SubmitModPage />
loader: modRouteLoader(context),
action: submitModRouteAction(context),
element: <SubmitModPage key='edit' />,
errorElement: <NotFoundPage title={'Something went wrong.'} />
},
{
path: appRoutes.write,

View File

@ -5,12 +5,17 @@
grid-gap: 10px;
text-decoration: unset;
cursor: pointer;
border-radius: 15px;
overflow: hidden;
padding: 5px;
}
.cardGameMainWrapperLink:hover {
transition: ease 0.4s;
transform: scale(1.02);
text-decoration: unset;
background: rgb(225,225,225,0.05);
box-shadow: 0 0 8px 0 rgb(0,0,0,0.15);
}
.cardGameMainWrapperLink:active {
@ -24,7 +29,7 @@
}
.cardGameMain {
border-radius: 15px;
border-radius: 10px;
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
width: 100%;
object-fit: cover; /* Ensures the image covers the container like a background image */
@ -36,7 +41,7 @@
.cardGameMainTitle {
transition: ease 0.4s;
color: rgba(255, 255, 255, 0.5);
padding: 0 15px;
padding: 0 10px 10px 10px;
font-weight: bold;
display: -webkit-box;
-webkit-box-orient: vertical;

154
src/styles/mdxEditor.scss Normal file
View File

@ -0,0 +1,154 @@
.editor,
.viewer {
padding: 0;
> {
p {
margin: 5px 0 10px 0;
}
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 15px 0 15px 0;
border-bottom: solid 1px rgb(255 255 255 / 10%);
padding: 0px 0 10px 0;
line-height: 1.5;
text-wrap: pretty;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
blockquote {
border-radius: 0 10px 10px 0;
border-left: solid 6px rgba(255, 255, 255, 0.1);
padding: 25px;
background: #232323;
color: rgba(255, 255, 255, 0.75);
margin: 10px 0;
}
}
code {
background-color: #666;
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
padding: 0.25em 0.3em;
outline: none;
&:empty::before {
content: '\00A0';
}
}
pre {
background: var(--black);
color: var(--white);
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
background: #00000030;
border-radius: 5px;
border: solid 2px rebeccapurple;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
img {
background: #232323;
border-radius: 10px;
max-width: 100%;
height: auto;
}
}
.editor {
--basePageBg: var(--slate-3);
padding-top: 10px;
min-height: 75px;
}
.viewer {
table {
table-layout: fixed;
width: 100%;
border-spacing: 0;
border-collapse: collapse;
& > tbody > tr > td,
& > thead > tr > th {
border: 1px solid #e0e1e6;
padding: 0.25rem 0.5rem;
white-space: normal;
& > div {
outline: none;
& > p {
margin: 0;
}
}
& > tbody > tr > td,
& > thead > tr > th {
[align='left'] {
text-align: left;
}
[align='center'] {
text-align: center;
}
[align='right'] {
text-align: right;
}
}
&:empty::before {
content: '\00A0';
}
}
}
}
.mdxeditor {
--baseBg: #262626;
}
.mdxeditor-toolbar {
top: 10px;
border: 1px solid rgb(255, 255, 255, 0.1);
}
.mdxeditor,
.mdxeditor-popup-container {
--basePageBg: var(--slate-3);
}

View File

@ -271,16 +271,16 @@ h6 {
}
/* the 4 classes below here are a temp fix for the games dropdown stylings */
.dropdownMainMenu.dropdownMainMenuAlt {
/* add an exception (not category) for normal dropdown - due !important */
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) {
max-height: unset !important;
}
.dropdownMainMenu.dropdownMainMenuAlt > div {
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div {
height: unset !important;
}
.dropdownMainMenu.dropdownMainMenuAlt > div > div {
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div > div {
height: unset !important;
width: 100% !important;
display: flex;
@ -291,14 +291,14 @@ h6 {
padding: 5px;
}
.dropdownMainMenu.dropdownMainMenuAlt > div > div > div {
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div > div > div {
position: relative !important;
left: unset !important;
top: unset !important;
}
.dropdownMainMenuItem {
transition: ease 0.4s;
transition: background ease 0.4s, color ease 0.4s;
background: linear-gradient(
rgba(255, 255, 255, 0.03),
rgba(255, 255, 255, 0.03)
@ -318,8 +318,16 @@ h6 {
cursor: pointer;
}
.dropdownMainMenuItem.dropdownMainMenuItemCategory {
position: relative;
}
.dropdownMainMenuItemCategory.dropdownMainMenuItemCategoryAlt {
display: flex;
align-items: center;
}
.dropdownMainMenuItem:hover {
transition: ease 0.4s;
transition: background ease 0.4s, color ease 0.4s;
background: linear-gradient(
rgba(255, 255, 255, 0.05),
rgba(255, 255, 255, 0.05)
@ -385,6 +393,10 @@ h6 {
justify-content: center;
}
.labelMain.labelMainCategory {
flex-grow: 1;
}
.inputWrapperMain {
width: 100%;
display: flex;
@ -530,6 +542,15 @@ h6 {
align-items: center;
}
.CheckboxMain.CheckboxIndeterminate::before {
content: '\2501';
transition: ease 0.2s;
transform: scale(1);
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
border-radius: 6px;
}
.CheckboxMain:checked::before {
transition: ease 0.2s;
transform: scale(1);
@ -632,6 +653,9 @@ a:hover {
bottom: 0;
border-radius: unset;
}
.btnMainInsideField + .btnMainInsideField {
right: 50px;
}
.inputMain.inputMainWithBtn {
padding-right: 50px;

View File

@ -54,3 +54,41 @@
cursor: default;
box-shadow: unset;
}
.IBMSMSMBSSCategories {
width: 100%;
display: flex;
flex-direction: column;
grid-gap: 5px;
}
.IBMSMSMBSSCategoriesBox {
width: 100%;
display: flex;
flex-direction: row;
grid-gap: 2px;
background: hsl(0deg 0% 100% / 0%);
border-radius: 5px;
overflow: hidden;
white-space: nowrap;
flex-wrap: wrap;
}
.IBMSMSMBSSCategoriesBoxItem {
padding: 10px 15px;
line-height: 1;
color: hsl(0deg 0% 100% / 35%);
background: hsl(0deg 0% 100% / 10%);
flex-grow: 1;
}
.IBMSMSMBSSCategoriesBoxSeparator {
padding: 5px;
line-height: 1;
color: hsl(0deg 0% 100% / 35%);
background: hsl(0deg 0% 100% / 10%);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}

View File

@ -1,104 +0,0 @@
/* Basic editor styles */
.tiptap {
/* List styles */
p {
margin: 5px 0px;
}
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
margin: 10px 0px;
text-wrap: pretty;
}
h1,
h2 {
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
code {
background-color: var(--purple-light); // todo: fix the color
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
padding: 0.25em 0.3em;
}
pre {
background: var(--black); // todo: fix the color
color: var(--white);
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
background: #00000030;
border-radius: 5px;
border: solid 2px rebeccapurple;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
blockquote {
border-radius: 0 10px 10px 0;
border-left: solid 6px rgba(255, 255, 255, 0.1);
padding: 25px;
background: #232323;
color: rgba(255, 255, 255, 0.75);
margin: 10px 0;
}
}
/* Toolbar Styling */
.control-group {
padding: 5px 0px 15px 0px;
border-radius: 0px;
border-bottom: solid 1px rgb(255 255 255 / 10%);
}
.ProseMirror {
min-height: 75px;
}
.btnMain.btnMainTipTap {
padding: 5px 10px;
height: 35px;
font-size: 14px;
}

View File

@ -11,5 +11,5 @@
flex-direction: row;
justify-content: end;
align-items: center;
gap: 25px;
}

12
src/types/category.ts Normal file
View File

@ -0,0 +1,12 @@
export interface Category {
name: string
sub?: (Category | string)[]
}
export type CategoriesData = Category[]
export interface Categories {
name: string
hierarchy: string
l: string[]
}

View File

@ -4,3 +4,5 @@ export * from './nostr'
export * from './user'
export * from './zap'
export * from './blog'
export * from './category'
export * from './popup'

View File

@ -31,7 +31,12 @@ export interface ModFormState {
originalAuthor?: string
screenshotsUrls: string[]
tags: string
/** Hierarchical labels */
LTags: string[]
/** Category labels for category search */
lTags: string[]
downloadUrls: DownloadUrl[]
published_at: number
}
export interface DownloadUrl {
@ -45,7 +50,6 @@ export interface DownloadUrl {
export interface ModDetails extends Omit<ModFormState, 'tags'> {
id: string
published_at: number
edited_at: number
author: string
tags: string[]
@ -63,3 +67,17 @@ export interface ModPageLoaderResult {
isBlocked: boolean
isRepost: boolean
}
export interface FormErrors {
game?: string
title?: string
body?: string
featuredImageUrl?: string
summary?: string
nsfw?: string
screenshotsUrls?: string[]
tags?: string
downloadUrls?: string[]
author?: string
originalAuthor?: string
}

9
src/types/popup.ts Normal file
View File

@ -0,0 +1,9 @@
export interface PopupProps {
handleClose: () => void
}
export interface AlertPopupProps extends PopupProps {
header: string
label: string
handleConfirm: (confirm: boolean) => void
}

84
src/utils/category.ts Normal file
View File

@ -0,0 +1,84 @@
import { Categories, Category } from 'types/category'
import categoriesData from './../assets/categories/categories.json'
export const flattenCategories = (
categories: (Category | string)[],
parentPath: string[] = []
): Categories[] => {
return categories.flatMap<Categories, Category | string>((cat) => {
if (typeof cat === 'string') {
const path = [...parentPath, cat]
const hierarchy = path.join(' > ')
return [{ name: cat, hierarchy, l: path }]
} else {
const path = [...parentPath, cat.name]
const hierarchy = path.join(' > ')
if (cat.sub) {
const obj: Categories = { name: cat.name, hierarchy, l: path }
return [obj].concat(flattenCategories(cat.sub, path))
}
return [{ name: cat.name, hierarchy, l: path }]
}
})
}
export const getCategories = () => {
return flattenCategories(categoriesData)
}
export const addToUserCategories = (
categories: (string | Category)[],
input: string
) => {
const segments = input.split('>').map((s) => s.trim())
let currentLevel: (string | Category)[] = categories
for (let i = 0; i < segments.length; i++) {
const segment = segments[i].trim()
const existingNode = currentLevel.find(
(item) => typeof item !== 'string' && item.name === segment
)
if (!existingNode) {
const newCategory: Category = { name: segment, sub: [] }
currentLevel.push(newCategory)
if (newCategory.sub) {
currentLevel = newCategory.sub
}
} else if (typeof existingNode !== 'string') {
if (!existingNode.sub) {
existingNode.sub = []
}
currentLevel = existingNode.sub
}
}
}
export const deleteFromUserCategories = (
categories: (string | Category)[],
input: string
) => {
const segments = input.split('>').map((s) => s.trim())
const value = segments.pop()
if (!value) {
return
}
let currentLevel: (string | Category)[] = categories
for (let i = 0; i < segments.length; i++) {
const key = segments[i]
const existingNode = currentLevel.find(
(item) => typeof item === 'object' && item.name === key
) as Category
if (existingNode && existingNode.sub) {
currentLevel = existingNode.sub
}
}
const valueIndex = currentLevel.findIndex(
(item) =>
item === value || (typeof item === 'object' && item.name === value)
)
if (valueIndex !== -1) {
currentLevel.splice(valueIndex, 1)
}
}

View File

@ -4,6 +4,8 @@ export * from './url'
export * from './utils'
export * from './zap'
export * from './localStorage'
export * from './sessionStorage'
export * from './consts'
export * from './blog'
export * from './curationSets'
export * from './category'

View File

@ -1,7 +1,7 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { Event } from 'nostr-tools'
import { ModDetails, ModFormState } from '../types'
import { getTagValue } from './nostr'
import { getTagValue, getTagValues } from './nostr'
/**
* Extracts and normalizes mod data from an event.
@ -43,6 +43,12 @@ export const extractModData = (event: Event | NDKEvent): ModDetails => {
originalAuthor: getFirstTagValue('originalAuthor'),
screenshotsUrls: getTagValue(event, 'screenshotsUrls') || [],
tags: getTagValue(event, 'tags') || [],
LTags: (getTagValues(event, 'L') || []).map((t) =>
t.replace('com.degmods:', '')
),
lTags: (getTagValues(event, 'l') || []).map((t) =>
t.replace('com.degmods:', '')
),
downloadUrls: (getTagValue(event, 'downloadUrls') || []).map((item) =>
JSON.parse(item)
)
@ -113,6 +119,7 @@ export const initializeFormState = (
): ModFormState => ({
dTag: existingModData?.dTag || '',
aTag: existingModData?.aTag || '',
published_at: existingModData?.published_at || 0,
rTag: existingModData?.rTag || window.location.host,
game: existingModData?.game || '',
title: existingModData?.title || '',
@ -124,6 +131,8 @@ export const initializeFormState = (
originalAuthor: existingModData?.originalAuthor || undefined,
screenshotsUrls: existingModData?.screenshotsUrls || [''],
tags: existingModData?.tags.join(',') || '',
lTags: existingModData?.lTags || [],
LTags: existingModData?.LTags || [],
downloadUrls: existingModData?.downloadUrls || [
{
url: '',

View File

@ -0,0 +1,32 @@
export function getSessionStorageItem<T>(key: string, defaultValue: T): string {
try {
const data = window.sessionStorage.getItem(key)
if (data === null) return JSON.stringify(defaultValue)
return data
} catch (err) {
console.error(`Error while fetching session storage value: `, err)
return JSON.stringify(defaultValue)
}
}
export function setSessionStorageItem(key: string, value: string) {
try {
window.sessionStorage.setItem(key, value)
dispatchSessionStorageEvent(key, value)
} catch (err) {
console.error(`Error while saving session storage value: `, err)
}
}
export function removeSessionStorageItem(key: string) {
try {
window.sessionStorage.removeItem(key)
dispatchSessionStorageEvent(key, null)
} catch (err) {
console.error(`Error while deleting session storage value: `, err)
}
}
function dispatchSessionStorageEvent(key: string, newValue: string | null) {
window.dispatchEvent(new StorageEvent('sessionStorage', { key, newValue }))
}

View File

@ -156,3 +156,7 @@ export const parseFormData = <T>(formData: FormData) => {
return result
}
export const capitalizeEachWord = (str: string): string => {
return str.replace(/\b\w/g, (char) => char.toUpperCase())
}