categories,18popup,clear.TextEditorSwap,GameCardHover #177
4537
package-lock.json
generated
4537
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -11,16 +11,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getalby/lightning-tools": "5.0.3",
|
"@getalby/lightning-tools": "5.0.3",
|
||||||
|
"@mdxeditor/editor": "^3.20.0",
|
||||||
"@nostr-dev-kit/ndk": "2.10.0",
|
"@nostr-dev-kit/ndk": "2.10.0",
|
||||||
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
|
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
|
||||||
"@reduxjs/toolkit": "2.2.6",
|
"@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",
|
"@types/react-helmet": "^6.1.11",
|
||||||
"axios": "1.7.3",
|
"axios": "^1.7.9",
|
||||||
"bech32": "2.0.0",
|
"bech32": "2.0.0",
|
||||||
"buffer": "6.0.3",
|
"buffer": "6.0.3",
|
||||||
"date-fns": "3.6.0",
|
"date-fns": "3.6.0",
|
||||||
@ -30,6 +26,7 @@
|
|||||||
"fslightbox-react": "1.7.6",
|
"fslightbox-react": "1.7.6",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"marked": "^14.1.3",
|
"marked": "^14.1.3",
|
||||||
|
"marked-directive": "^1.0.7",
|
||||||
"nostr-login": "1.5.2",
|
"nostr-login": "1.5.2",
|
||||||
"nostr-tools": "2.7.1",
|
"nostr-tools": "2.7.1",
|
||||||
"papaparse": "5.4.1",
|
"papaparse": "5.4.1",
|
||||||
|
12
src/assets/categories/categories.json
Normal file
12
src/assets/categories/categories.json
Normal 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"
|
||||||
|
]
|
@ -4,4 +4,6 @@ Minecraft,,https://image.nostr.build/b75b2d3a7855370230f2976567e2d5f913a567c57ac
|
|||||||
Vintage Story,,https://image.nostr.build/9efe683d339cc864032a99047ce26b2b5c19fab1ec4dcc6d4db96e2785c44eda.png
|
Vintage Story,,https://image.nostr.build/9efe683d339cc864032a99047ce26b2b5c19fab1ec4dcc6d4db96e2785c44eda.png
|
||||||
Yandere Simulator,,https://image.nostr.build/54ba56b752bb9d411cbdc1d249fa0cb74c6062a305bcd0a70ecacb61b8d50030.png
|
Yandere Simulator,,https://image.nostr.build/54ba56b752bb9d411cbdc1d249fa0cb74c6062a305bcd0a70ecacb61b8d50030.png
|
||||||
Genshin Impact,,https://image.nostr.build/999fccf93cf16a2e0dd8e6f00595b0ab3b5cc6beff9fe4a52f64f427cce9aedd.jpg
|
Genshin Impact,,https://image.nostr.build/999fccf93cf16a2e0dd8e6f00595b0ab3b5cc6beff9fe4a52f64f427cce9aedd.jpg
|
||||||
Zenless Zone Zero,,https://image.nostr.build/4a9b9c2cbef619552d0c123f8794286f35710dc7ca1ca0010380a630883eb2ca.jpg
|
Zenless Zone Zero,,https://image.nostr.build/4a9b9c2cbef619552d0c123f8794286f35710dc7ca1ca0010380a630883eb2ca.jpg
|
||||||
|
Ananta,,https://image.nostr.build/09fb30fe2c22784079e4c0a848410e709ff359af09b3f96b651c7dc249a35cdd.jpg
|
||||||
|
Bloodborne,,https://image.nostr.build/9d20c246b539e43f1bebcf602f996fa6eb45cf585f05cc19a1d9f86a53201485.jpg
|
|
File diff suppressed because it is too large
Load Diff
@ -38027,7 +38027,7 @@ SwayBods,,
|
|||||||
Lutnak·Quest,,
|
Lutnak·Quest,,
|
||||||
Oceanarium World,,
|
Oceanarium World,,
|
||||||
Circle Defense,,
|
Circle Defense,,
|
||||||
Marvel Rivals,,
|
Marvel Rivals,,https://image.nostr.build/e6917aae1da362be2a7b275740fc66f06a3f1868ad70986bf31ed6e6565a823e.jpg
|
||||||
十字防守(X Defense: Timing TD),,
|
十字防守(X Defense: Timing TD),,
|
||||||
TOTO ZOO,,
|
TOTO ZOO,,
|
||||||
名利游戏 Demo,,
|
名利游戏 Demo,,
|
||||||
@ -48802,7 +48802,6 @@ Botworld Odyssey,,
|
|||||||
ReSetna,,
|
ReSetna,,
|
||||||
极限侦探,,
|
极限侦探,,
|
||||||
The Leviathan's Fantasy(武士与阴阳师),,
|
The Leviathan's Fantasy(武士与阴阳师),,
|
||||||
Marvel Rivals Playtest,,
|
|
||||||
5 Minute Raid,,
|
5 Minute Raid,,
|
||||||
不会要俺来拯救世界吧!,,
|
不会要俺来拯救世界吧!,,
|
||||||
玖玖麻将,,
|
玖玖麻将,,
|
||||||
|
Can't render this file because it is too large.
|
@ -8289,7 +8289,7 @@ Super Alloy Crush,,
|
|||||||
宗门志 Playtest,,
|
宗门志 Playtest,,
|
||||||
宗门志 Demo,,
|
宗门志 Demo,,
|
||||||
TerraCube,,
|
TerraCube,,
|
||||||
inZOI: Character Studio,,
|
inZOI,,https://image.nostr.build/a5a19f75020072cec1a24a6a5efe36af331e24fb7a00d736a85b9e7b122c2c26.jpg
|
||||||
Fast video cutter joiner,,
|
Fast video cutter joiner,,
|
||||||
Alchemy in Dungeon,,
|
Alchemy in Dungeon,,
|
||||||
Godly Visage,,
|
Godly Visage,,
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 326 KiB |
72
src/components/AlertPopup.tsx
Normal file
72
src/components/AlertPopup.tsx
Normal 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
|
||||||
|
)
|
||||||
|
}
|
333
src/components/CategoryAutocomplete.tsx
Normal file
333
src/components/CategoryAutocomplete.tsx
Normal 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>></p>
|
||||||
|
</div>,
|
||||||
|
curr
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
|
||||||
|
{categories}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,16 +1,11 @@
|
|||||||
import { useAppSelector, useLocalStorage } from 'hooks'
|
import { useAppSelector, useLocalStorage } from 'hooks'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {
|
import { FilterOptions, ModeratedFilter, SortBy, WOTFilterOptions } from 'types'
|
||||||
FilterOptions,
|
|
||||||
ModeratedFilter,
|
|
||||||
NSFWFilter,
|
|
||||||
SortBy,
|
|
||||||
WOTFilterOptions
|
|
||||||
} from 'types'
|
|
||||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||||
import { Dropdown } from './Dropdown'
|
import { Dropdown } from './Dropdown'
|
||||||
import { Option } from './Option'
|
import { Option } from './Option'
|
||||||
import { Filter } from '.'
|
import { Filter } from '.'
|
||||||
|
import { NsfwFilterOptions } from './NsfwFilterOptions'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
author?: string | undefined
|
author?: string | undefined
|
||||||
@ -115,19 +110,7 @@ export const BlogsFilter = React.memo(
|
|||||||
|
|
||||||
{/* nsfw filter options */}
|
{/* nsfw filter options */}
|
||||||
<Dropdown label={filterOptions.nsfw}>
|
<Dropdown label={filterOptions.nsfw}>
|
||||||
{Object.values(NSFWFilter).map((item, index) => (
|
<NsfwFilterOptions filterKey={filterKey} />
|
||||||
<Option
|
|
||||||
key={`nsfwFilterItem-${index}`}
|
|
||||||
onClick={() =>
|
|
||||||
setFilterOptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
nsfw: item
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
||||||
{/* source filter options */}
|
{/* source filter options */}
|
||||||
|
3
src/components/Filters/CategoryFilterPopup.module.scss
Normal file
3
src/components/Filters/CategoryFilterPopup.module.scss
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.noResult:not(:only-child) {
|
||||||
|
display: none;
|
||||||
|
}
|
550
src/components/Filters/CategoryFilterPopup.tsx
Normal file
550
src/components/Filters/CategoryFilterPopup.tsx
Normal 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's where your custom categories appear (You can add them in the above field. Example > banana > seed)</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className='inputMain'
|
||||||
|
style={{
|
||||||
|
minHeight: '40px',
|
||||||
|
maxHeight: '500px',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!userHierarchiesMatching && <div>No results.</div>}
|
||||||
|
{userHierarchies
|
||||||
|
.filter((c) => typeof c !== 'string')
|
||||||
|
.map((c, i) => (
|
||||||
|
<CategoryCheckbox
|
||||||
|
key={`${c}_${i}`}
|
||||||
|
inputValue={inputValue}
|
||||||
|
category={c}
|
||||||
|
path={[c.name]}
|
||||||
|
handleSingleSelection={handleSingleSelection}
|
||||||
|
handleCombinationSelection={
|
||||||
|
handleCombinationSelection
|
||||||
|
}
|
||||||
|
selectedSingles={filterCategories}
|
||||||
|
selectedCombinations={filterHierarchies}
|
||||||
|
handleRemove={(path) => {
|
||||||
|
setUserHierarchies((prev) => {
|
||||||
|
deleteFromUserCategories(prev, path.join('>'))
|
||||||
|
return [...prev]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove the deleted hierarchies from current filter selection and active selection
|
||||||
|
setFilterHierarchies((prev) =>
|
||||||
|
prev.filter(
|
||||||
|
(h) => !h.startsWith(path.join(':'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setHierarchies((prev) =>
|
||||||
|
prev.filter(
|
||||||
|
(h) => !h.startsWith(path.join(':'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className='inputLabelWrapperMain'>
|
||||||
|
<div className='inputLabelWrapperMain'>
|
||||||
|
<label
|
||||||
|
className='form-label labelMain'
|
||||||
|
style={{ fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
Categories
|
||||||
|
</label>
|
||||||
|
<p className='labelDescriptionMain'>Here's where you select any of the pre-defined categories</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className='inputMain'
|
||||||
|
style={{
|
||||||
|
minHeight: '40px',
|
||||||
|
maxHeight: '500px',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`${styles.noResult}`}>
|
||||||
|
<div>No results.</div>
|
||||||
|
<br />
|
||||||
|
{userHierarchiesMatching ? (
|
||||||
|
<div>Already defined in your categories</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory'
|
||||||
|
onClick={handleAddNew}
|
||||||
|
>
|
||||||
|
Add and search for "{inputValue}" category
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='btn btnMain btnMainInsideField btnMainAdd'
|
||||||
|
title='Add'
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='-32 0 512 512'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
>
|
||||||
|
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(categoriesData as Category[]).map((category) => {
|
||||||
|
const name =
|
||||||
|
typeof category === 'string' ? category : category.name
|
||||||
|
return (
|
||||||
|
<CategoryCheckbox
|
||||||
|
key={name}
|
||||||
|
inputValue={inputValue}
|
||||||
|
category={category}
|
||||||
|
path={[name]}
|
||||||
|
handleSingleSelection={handleSingleSelection}
|
||||||
|
handleCombinationSelection={
|
||||||
|
handleCombinationSelection
|
||||||
|
}
|
||||||
|
selectedSingles={filterCategories}
|
||||||
|
selectedCombinations={filterHierarchies}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
gap: '10px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className='btn btnMain btnMainPopup'
|
||||||
|
type='button'
|
||||||
|
onPointerDown={handleClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className='btn btnMain btnMainPopup'
|
||||||
|
type='button'
|
||||||
|
onPointerDown={() => {
|
||||||
|
// Clear the linked hierarchy
|
||||||
|
searchParams.delete('h')
|
||||||
|
setSearchParams(searchParams)
|
||||||
|
|
||||||
|
// Clear current filters
|
||||||
|
setFilterCategories([])
|
||||||
|
setFilterHierarchies([])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className='btn btnMain btnMainPopup'
|
||||||
|
type='button'
|
||||||
|
onPointerDown={() => {
|
||||||
|
handleApply()
|
||||||
|
handleClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryCheckboxProps {
|
||||||
|
inputValue: string
|
||||||
|
category: Category | string
|
||||||
|
path: string[]
|
||||||
|
handleSingleSelection: (category: string, isSelected: boolean) => void
|
||||||
|
handleCombinationSelection: (path: string[], isSelected: boolean) => void
|
||||||
|
selectedSingles: string[]
|
||||||
|
selectedCombinations: string[]
|
||||||
|
indentLevel?: number
|
||||||
|
handleRemove?: (path: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
|
||||||
|
inputValue,
|
||||||
|
category,
|
||||||
|
path,
|
||||||
|
handleSingleSelection,
|
||||||
|
handleCombinationSelection,
|
||||||
|
selectedSingles,
|
||||||
|
selectedCombinations,
|
||||||
|
indentLevel = 0,
|
||||||
|
handleRemove
|
||||||
|
}) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const linkedHierarchy = searchParams.get('h')
|
||||||
|
const name = typeof category === 'string' ? category : category.name
|
||||||
|
const hierarchy = path.join(' > ').toLowerCase()
|
||||||
|
const isMatching = hierarchy.includes(inputValue.toLowerCase())
|
||||||
|
const isLinked =
|
||||||
|
linkedHierarchy !== null &&
|
||||||
|
hierarchy === linkedHierarchy.replace(/:/g, ' > ')
|
||||||
|
const [isSingleChecked, setIsSingleChecked] = useState<boolean>(false)
|
||||||
|
const [isCombinationChecked, setIsCombinationChecked] =
|
||||||
|
useState<boolean>(false)
|
||||||
|
const [isIndeterminate, setIsIndeterminate] = useState<boolean>(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pathString = path.join(':')
|
||||||
|
setIsSingleChecked(selectedSingles.includes(name))
|
||||||
|
setIsCombinationChecked(selectedCombinations.includes(pathString))
|
||||||
|
// Recursive function to gather all descendant paths
|
||||||
|
const collectChildPaths = (
|
||||||
|
category: string | Category,
|
||||||
|
basePath: string[]
|
||||||
|
) => {
|
||||||
|
if (!category.sub || !Array.isArray(category.sub)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
let paths: string[] = []
|
||||||
|
for (const sub of category.sub) {
|
||||||
|
const subPath =
|
||||||
|
typeof sub === 'string'
|
||||||
|
? [...basePath, sub].join(':')
|
||||||
|
: [...basePath, sub.name].join(':')
|
||||||
|
paths.push(subPath)
|
||||||
|
if (typeof sub === 'object') {
|
||||||
|
paths = paths.concat(collectChildPaths(sub, [...basePath, sub.name]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
const childPaths = collectChildPaths(category, path)
|
||||||
|
const anyChildCombinationSelected = childPaths.some((childPath) =>
|
||||||
|
selectedCombinations.includes(childPath)
|
||||||
|
)
|
||||||
|
const anyChildCombinationLinked = childPaths.some(
|
||||||
|
(childPath) =>
|
||||||
|
linkedHierarchy !== null && linkedHierarchy.includes(childPath)
|
||||||
|
)
|
||||||
|
setIsIndeterminate(
|
||||||
|
(anyChildCombinationSelected || anyChildCombinationLinked) &&
|
||||||
|
!selectedCombinations.includes(pathString)
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
category,
|
||||||
|
linkedHierarchy,
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
selectedCombinations,
|
||||||
|
selectedSingles
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleSingleChange = () => {
|
||||||
|
setIsSingleChecked(!isSingleChecked)
|
||||||
|
handleSingleSelection(name, !isSingleChecked)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCombinationChange = () => {
|
||||||
|
// If combination is linked, clicking it again we will delete it
|
||||||
|
if (isLinked) {
|
||||||
|
searchParams.delete('h')
|
||||||
|
setSearchParams(searchParams)
|
||||||
|
} else {
|
||||||
|
setIsCombinationChecked(!isCombinationChecked)
|
||||||
|
handleCombinationSelection(path, !isCombinationChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isMatching && (
|
||||||
|
<div
|
||||||
|
className='dropdown-item dropdownMainMenuItem dropdownMainMenuItemCategory dropdownMainMenuItemCategoryAlt'
|
||||||
|
style={{
|
||||||
|
marginLeft: `${indentLevel * 20}px`,
|
||||||
|
width: `calc(100% - ${indentLevel * 20}px)`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`inputLabelWrapperMain inputLabelWrapperMainAlt stylized`}
|
||||||
|
style={{
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={name}
|
||||||
|
type='checkbox'
|
||||||
|
ref={(input) => {
|
||||||
|
if (input) {
|
||||||
|
input.indeterminate = isIndeterminate
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`CheckboxMain ${
|
||||||
|
isIndeterminate ? 'CheckboxIndeterminate' : ''
|
||||||
|
}`}
|
||||||
|
checked={isCombinationChecked || isLinked}
|
||||||
|
onChange={handleCombinationChange}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={name}
|
||||||
|
className='form-label labelMain labelMainCategory'
|
||||||
|
>
|
||||||
|
{capitalizeEachWord(name)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
style={{
|
||||||
|
display: 'none'
|
||||||
|
}}
|
||||||
|
id={name}
|
||||||
|
type='checkbox'
|
||||||
|
className='CheckboxMain'
|
||||||
|
name={name}
|
||||||
|
checked={isSingleChecked}
|
||||||
|
onChange={handleSingleChange}
|
||||||
|
/>
|
||||||
|
{typeof handleRemove === 'function' && (
|
||||||
|
<button
|
||||||
|
className='btn btnMain btnMainInsideField btnMainRemove'
|
||||||
|
title='Remove'
|
||||||
|
type='button'
|
||||||
|
onClick={() => handleRemove(path)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='-32 0 512 512'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
fill='currentColor'
|
||||||
|
>
|
||||||
|
<path d='M323.3 32.01H188.7C172.3 32.01 160 44.31 160 60.73V96.01H32C14.33 96.01 0 110.3 0 128S14.33 160 32 160H480c17.67 0 32-14.33 32-32.01S497.7 96.01 480 96.01H352v-35.28C352 44.31 339.7 32.01 323.3 32.01zM64.9 477.5C66.5 492.3 79.31 504 94.72 504H417.3c15.41 0 28.22-11.72 29.81-26.5L480 192.2H32L64.9 477.5z'></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{typeof category !== 'string' &&
|
||||||
|
category.sub &&
|
||||||
|
Array.isArray(category.sub) && (
|
||||||
|
<>
|
||||||
|
{category.sub.map((subCategory) => {
|
||||||
|
if (typeof subCategory === 'string') {
|
||||||
|
return (
|
||||||
|
<CategoryCheckbox
|
||||||
|
inputValue={inputValue}
|
||||||
|
key={`${category.name}-${subCategory}`}
|
||||||
|
category={{ name: subCategory }}
|
||||||
|
path={[...path, subCategory]}
|
||||||
|
handleSingleSelection={handleSingleSelection}
|
||||||
|
handleCombinationSelection={handleCombinationSelection}
|
||||||
|
selectedSingles={selectedSingles}
|
||||||
|
selectedCombinations={selectedCombinations}
|
||||||
|
indentLevel={indentLevel + 1}
|
||||||
|
handleRemove={handleRemove}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<CategoryCheckbox
|
||||||
|
inputValue={inputValue}
|
||||||
|
key={subCategory.name}
|
||||||
|
category={subCategory}
|
||||||
|
path={[...path, subCategory.name]}
|
||||||
|
handleSingleSelection={handleSingleSelection}
|
||||||
|
handleCombinationSelection={handleCombinationSelection}
|
||||||
|
selectedSingles={selectedSingles}
|
||||||
|
selectedCombinations={selectedCombinations}
|
||||||
|
indentLevel={indentLevel + 1}
|
||||||
|
handleRemove={handleRemove}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -1,17 +1,17 @@
|
|||||||
import { useAppSelector, useLocalStorage } from 'hooks'
|
import { useAppSelector, useLocalStorage } from 'hooks'
|
||||||
import React from 'react'
|
import React, { PropsWithChildren } from 'react'
|
||||||
import {
|
import {
|
||||||
FilterOptions,
|
FilterOptions,
|
||||||
SortBy,
|
SortBy,
|
||||||
ModeratedFilter,
|
ModeratedFilter,
|
||||||
WOTFilterOptions,
|
WOTFilterOptions,
|
||||||
NSFWFilter,
|
|
||||||
RepostFilter
|
RepostFilter
|
||||||
} from 'types'
|
} from 'types'
|
||||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||||
import { Filter } from '.'
|
import { Filter } from '.'
|
||||||
import { Dropdown } from './Dropdown'
|
import { Dropdown } from './Dropdown'
|
||||||
import { Option } from './Option'
|
import { Option } from './Option'
|
||||||
|
import { NsfwFilterOptions } from './NsfwFilterOptions'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
author?: string | undefined
|
author?: string | undefined
|
||||||
@ -19,7 +19,7 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ModFilter = React.memo(
|
export const ModFilter = React.memo(
|
||||||
({ author, filterKey = 'filter' }: Props) => {
|
({ author, filterKey = 'filter', children }: PropsWithChildren<Props>) => {
|
||||||
const userState = useAppSelector((state) => state.user)
|
const userState = useAppSelector((state) => state.user)
|
||||||
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
|
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||||
filterKey,
|
filterKey,
|
||||||
@ -115,19 +115,7 @@ export const ModFilter = React.memo(
|
|||||||
|
|
||||||
{/* nsfw filter options */}
|
{/* nsfw filter options */}
|
||||||
<Dropdown label={filterOptions.nsfw}>
|
<Dropdown label={filterOptions.nsfw}>
|
||||||
{Object.values(NSFWFilter).map((item, index) => (
|
<NsfwFilterOptions filterKey={filterKey} />
|
||||||
<Option
|
|
||||||
key={`nsfwFilterItem-${index}`}
|
|
||||||
onClick={() =>
|
|
||||||
setFilterOptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
nsfw: item
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
||||||
{/* repost filter options */}
|
{/* repost filter options */}
|
||||||
@ -176,6 +164,8 @@ export const ModFilter = React.memo(
|
|||||||
Show All
|
Show All
|
||||||
</Option>
|
</Option>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
||||||
|
{children}
|
||||||
</Filter>
|
</Filter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
64
src/components/Filters/NsfwFilterOptions.tsx
Normal file
64
src/components/Filters/NsfwFilterOptions.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -1,15 +1,10 @@
|
|||||||
import Link from '@tiptap/extension-link'
|
import React from 'react'
|
||||||
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 '../styles/styles.css'
|
import '../styles/styles.css'
|
||||||
import '../styles/tiptap.scss'
|
|
||||||
|
|
||||||
interface InputFieldProps {
|
interface InputFieldProps {
|
||||||
label: string | React.ReactElement
|
label: string | React.ReactElement
|
||||||
description?: string
|
description?: string
|
||||||
type?: 'text' | 'textarea' | 'richtext'
|
type?: 'text' | 'textarea'
|
||||||
placeholder: string
|
placeholder: string
|
||||||
name: string
|
name: string
|
||||||
inputMode?: 'url'
|
inputMode?: 'url'
|
||||||
@ -48,11 +43,6 @@ export const InputField = React.memo(
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
></textarea>
|
></textarea>
|
||||||
) : type === 'richtext' ? (
|
|
||||||
<RichTextEditor
|
|
||||||
content={value}
|
|
||||||
updateContent={(content) => onChange(name, content)}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type={type}
|
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'> {
|
interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
|
||||||
label: string
|
label: string | React.ReactElement
|
||||||
description?: string
|
description?: string
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
10
src/components/Markdown/Dialog.module.scss
Normal file
10
src/components/Markdown/Dialog.module.scss
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.formAction {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
137
src/components/Markdown/Editor.tsx
Normal file
137
src/components/Markdown/Editor.tsx
Normal 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
|
||||||
|
)
|
166
src/components/Markdown/ImageDialog.tsx
Normal file
166
src/components/Markdown/ImageDialog.tsx
Normal 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
|
||||||
|
)
|
||||||
|
}
|
306
src/components/Markdown/LinkDialog.tsx
Normal file
306
src/components/Markdown/LinkDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
65
src/components/Markdown/PlainTextCodeEditorDescriptor.tsx
Normal file
65
src/components/Markdown/PlainTextCodeEditorDescriptor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
37
src/components/Markdown/Viewer.tsx
Normal file
37
src/components/Markdown/Viewer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
36
src/components/Markdown/YoutubeButton.tsx
Normal file
36
src/components/Markdown/YoutubeButton.tsx
Normal 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')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
37
src/components/Markdown/YoutubeDirective.tsx
Normal file
37
src/components/Markdown/YoutubeDirective.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
59
src/components/Markdown/YoutubeDirectiveDescriptor.tsx
Normal file
59
src/components/Markdown/YoutubeDirectiveDescriptor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,4 @@
|
|||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
|
|
||||||
import React, {
|
import React, {
|
||||||
Fragment,
|
Fragment,
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -8,77 +7,45 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState
|
useState
|
||||||
} from 'react'
|
} 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 {
|
import {
|
||||||
initializeFormState,
|
useActionData,
|
||||||
isReachable,
|
useLoaderData,
|
||||||
isValidImageUrl,
|
useNavigation,
|
||||||
isValidUrl,
|
useSubmit
|
||||||
log,
|
} from 'react-router-dom'
|
||||||
LogType,
|
import { FixedSizeList } from 'react-window'
|
||||||
now
|
import { useGames } from '../hooks'
|
||||||
} from '../utils'
|
import '../styles/styles.css'
|
||||||
|
import {
|
||||||
|
DownloadUrl,
|
||||||
|
FormErrors,
|
||||||
|
ModFormState,
|
||||||
|
ModPageLoaderResult
|
||||||
|
} from '../types'
|
||||||
|
import { initializeFormState } from '../utils'
|
||||||
import { CheckboxField, InputError, InputField } from './Inputs'
|
import { CheckboxField, InputError, InputField } from './Inputs'
|
||||||
import { LoadingSpinner } from './LoadingSpinner'
|
|
||||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
|
||||||
import { OriginalAuthor } from './OriginalAuthor'
|
import { OriginalAuthor } from './OriginalAuthor'
|
||||||
|
import { CategoryAutocomplete } from './CategoryAutocomplete'
|
||||||
interface FormErrors {
|
import { AlertPopup } from './AlertPopup'
|
||||||
game?: string
|
import { Editor, EditorRef } from './Markdown/Editor'
|
||||||
title?: string
|
|
||||||
body?: string
|
|
||||||
featuredImageUrl?: string
|
|
||||||
summary?: string
|
|
||||||
nsfw?: string
|
|
||||||
screenshotsUrls?: string[]
|
|
||||||
tags?: string
|
|
||||||
downloadUrls?: string[]
|
|
||||||
author?: string
|
|
||||||
originalAuthor?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameOption {
|
interface GameOption {
|
||||||
value: string
|
value: string
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModFormProps = {
|
export const ModForm = () => {
|
||||||
existingModData?: ModDetails
|
const data = useLoaderData() as ModPageLoaderResult
|
||||||
}
|
const mod = data?.mod
|
||||||
|
const formErrors = useActionData() as FormErrors
|
||||||
export const ModForm = ({ existingModData }: ModFormProps) => {
|
const navigation = useNavigation()
|
||||||
const location = useLocation()
|
const submit = useSubmit()
|
||||||
const navigate = useNavigate()
|
|
||||||
const { ndk, publish } = useNDKContext()
|
|
||||||
const games = useGames()
|
const games = useGames()
|
||||||
const userState = useAppSelector((state) => state.user)
|
|
||||||
|
|
||||||
const [isPublishing, setIsPublishing] = useState(false)
|
|
||||||
const [gameOptions, setGameOptions] = useState<GameOption[]>([])
|
const [gameOptions, setGameOptions] = useState<GameOption[]>([])
|
||||||
const [formState, setFormState] = useState<ModFormState>(
|
const [formState, setFormState] = useState<ModFormState>(
|
||||||
initializeFormState()
|
initializeFormState(mod)
|
||||||
)
|
)
|
||||||
const [formErrors, setFormErrors] = useState<FormErrors>({})
|
const editorRef = useRef<EditorRef>(null)
|
||||||
|
|
||||||
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])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const options = games.map((game) => ({
|
const options = games.map((game) => ({
|
||||||
@ -174,196 +141,50 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
|
||||||
setIsPublishing(true)
|
const handleReset = () => {
|
||||||
|
setShowConfirmPopup(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 handleResetConfirm = (confirm: boolean) => {
|
||||||
|
setShowConfirmPopup(false)
|
||||||
|
|
||||||
const validateState = async (): Promise<boolean> => {
|
// Cancel if not confirmed
|
||||||
const errors: FormErrors = {}
|
if (!confirm) return
|
||||||
|
|
||||||
if (formState.game === '') {
|
// Editing
|
||||||
errors.game = 'Game field can not be empty'
|
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 === '') {
|
// New - set form state to the initial (clear form state)
|
||||||
errors.title = 'Title field can not be empty'
|
setFormState(initializeFormState())
|
||||||
}
|
}
|
||||||
|
const handlePublish = () => {
|
||||||
if (formState.body === '') {
|
submit(JSON.stringify(formState), {
|
||||||
errors.body = 'Body field can not be empty'
|
method: mod ? 'put' : 'post',
|
||||||
}
|
encType: 'application/json'
|
||||||
|
})
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<form
|
||||||
{isPublishing && <LoadingSpinner desc='Publishing mod to relays' />}
|
className='IBMSMSMBS_Write'
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handlePublish()
|
||||||
|
}}
|
||||||
|
>
|
||||||
<GameDropdown
|
<GameDropdown
|
||||||
options={gameOptions}
|
options={gameOptions}
|
||||||
selected={formState.game}
|
selected={formState?.game}
|
||||||
error={formErrors.game}
|
error={formErrors?.game}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -372,19 +193,32 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
|||||||
placeholder='Return the banana mod'
|
placeholder='Return the banana mod'
|
||||||
name='title'
|
name='title'
|
||||||
value={formState.title}
|
value={formState.title}
|
||||||
error={formErrors.title}
|
error={formErrors?.title}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputField
|
<div className='inputLabelWrapperMain'>
|
||||||
label='Body'
|
<label className='form-label labelMain'>Body</label>
|
||||||
type='richtext'
|
<div className='inputMain'>
|
||||||
placeholder="Here's what this mod is all about"
|
<Editor
|
||||||
name='body'
|
ref={editorRef}
|
||||||
value={formState.body}
|
markdown={formState.body}
|
||||||
error={formErrors.body}
|
placeholder="Here's what this mod is all about"
|
||||||
onChange={handleInputChange}
|
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
|
<InputField
|
||||||
label='Featured Image URL'
|
label='Featured Image URL'
|
||||||
@ -394,7 +228,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
|||||||
placeholder='Image URL'
|
placeholder='Image URL'
|
||||||
name='featuredImageUrl'
|
name='featuredImageUrl'
|
||||||
value={formState.featuredImageUrl}
|
value={formState.featuredImageUrl}
|
||||||
error={formErrors.featuredImageUrl}
|
error={formErrors?.featuredImageUrl}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
@ -403,7 +237,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
|||||||
placeholder='This is a quick description of my mod'
|
placeholder='This is a quick description of my mod'
|
||||||
name='summary'
|
name='summary'
|
||||||
value={formState.summary}
|
value={formState.summary}
|
||||||
error={formErrors.summary}
|
error={formErrors?.summary}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
@ -433,7 +267,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
|||||||
placeholder="Original author's name, npub or nprofile"
|
placeholder="Original author's name, npub or nprofile"
|
||||||
name='originalAuthor'
|
name='originalAuthor'
|
||||||
value={formState.originalAuthor || ''}
|
value={formState.originalAuthor || ''}
|
||||||
error={formErrors.originalAuthor || ''}
|
error={formErrors?.originalAuthor}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@ -445,6 +279,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
|||||||
className='btn btnMain btnMainAdd'
|
className='btn btnMain btnMainAdd'
|
||||||
type='button'
|
type='button'
|
||||||
onClick={addScreenshotUrl}
|
onClick={addScreenshotUrl}
|
||||||
|
title='Add'
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
@ -468,16 +303,16 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
|||||||
onUrlChange={handleScreenshotUrlChange}
|
onUrlChange={handleScreenshotUrlChange}
|
||||||
onRemove={removeScreenshotUrl}
|
onRemove={removeScreenshotUrl}
|
||||||
/>
|
/>
|
||||||
{formErrors.screenshotsUrls &&
|
{formErrors?.screenshotsUrls &&
|
||||||
formErrors.screenshotsUrls[index] && (
|
formErrors?.screenshotsUrls[index] && (
|
||||||
<InputError message={formErrors.screenshotsUrls[index]} />
|
<InputError message={formErrors?.screenshotsUrls[index]} />
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
{formState.screenshotsUrls.length === 0 &&
|
{formState.screenshotsUrls.length === 0 &&
|
||||||
formErrors.screenshotsUrls &&
|
formErrors?.screenshotsUrls &&
|
||||||
formErrors.screenshotsUrls[0] && (
|
formErrors?.screenshotsUrls[0] && (
|
||||||
<InputError message={formErrors.screenshotsUrls[0]} />
|
<InputError message={formErrors?.screenshotsUrls[0]} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<InputField
|
<InputField
|
||||||
@ -486,9 +321,14 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
|||||||
placeholder='Tags'
|
placeholder='Tags'
|
||||||
name='tags'
|
name='tags'
|
||||||
value={formState.tags}
|
value={formState.tags}
|
||||||
error={formErrors.tags}
|
error={formErrors?.tags}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
|
<CategoryAutocomplete
|
||||||
|
game={formState.game}
|
||||||
|
LTags={formState.LTags}
|
||||||
|
setFormState={setFormState}
|
||||||
|
/>
|
||||||
<div className='inputLabelWrapperMain'>
|
<div className='inputLabelWrapperMain'>
|
||||||
<div className='labelWrapperMain'>
|
<div className='labelWrapperMain'>
|
||||||
<label className='form-label labelMain'>Download URLs</label>
|
<label className='form-label labelMain'>Download URLs</label>
|
||||||
@ -496,6 +336,7 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
|||||||
className='btn btnMain btnMainAdd'
|
className='btn btnMain btnMainAdd'
|
||||||
type='button'
|
type='button'
|
||||||
onClick={addDownloadUrl}
|
onClick={addDownloadUrl}
|
||||||
|
title='Add'
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
@ -527,29 +368,51 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
|
|||||||
onUrlChange={handleDownloadUrlChange}
|
onUrlChange={handleDownloadUrlChange}
|
||||||
onRemove={removeDownloadUrl}
|
onRemove={removeDownloadUrl}
|
||||||
/>
|
/>
|
||||||
{formErrors.downloadUrls && formErrors.downloadUrls[index] && (
|
{formErrors?.downloadUrls && formErrors?.downloadUrls[index] && (
|
||||||
<InputError message={formErrors.downloadUrls[index]} />
|
<InputError message={formErrors?.downloadUrls[index]} />
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{formState.downloadUrls.length === 0 &&
|
{formState.downloadUrls.length === 0 &&
|
||||||
formErrors.downloadUrls &&
|
formErrors?.downloadUrls &&
|
||||||
formErrors.downloadUrls[0] && (
|
formErrors?.downloadUrls[0] && (
|
||||||
<InputError message={formErrors.downloadUrls[0]} />
|
<InputError message={formErrors?.downloadUrls[0]} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='IBMSMSMBS_WriteAction'>
|
<div className='IBMSMSMBS_WriteAction'>
|
||||||
<button
|
<button
|
||||||
className='btn btnMain'
|
className='btn btnMain'
|
||||||
type='button'
|
type='button'
|
||||||
onClick={handlePublish}
|
onClick={handleReset}
|
||||||
disabled={isPublishing}
|
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>
|
</button>
|
||||||
</div>
|
</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 = {
|
type DownloadUrlFieldsProps = {
|
||||||
@ -597,6 +460,7 @@ const DownloadUrlFields = React.memo(
|
|||||||
className='btn btnMain btnMainRemove'
|
className='btn btnMain btnMainRemove'
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => onRemove(index)}
|
onClick={() => onRemove(index)}
|
||||||
|
title='Remove'
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
@ -751,6 +615,7 @@ const ScreenshotUrlFields = React.memo(
|
|||||||
className='btn btnMain btnMainRemove'
|
className='btn btnMain btnMainRemove'
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => onRemove(index)}
|
onClick={() => onRemove(index)}
|
||||||
|
title='Remove'
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
@ -831,6 +696,7 @@ const GameDropdown = ({
|
|||||||
className='btn btnMain btnMainInsideField btnMainRemove'
|
className='btn btnMain btnMainInsideField btnMainRemove'
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => onChange('game', '')}
|
onClick={() => onChange('game', '')}
|
||||||
|
title='Remove'
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
@ -843,7 +709,7 @@ const GameDropdown = ({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt'>
|
<div className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt'>
|
||||||
<List
|
<FixedSizeList
|
||||||
height={500}
|
height={500}
|
||||||
width={'100%'}
|
width={'100%'}
|
||||||
itemCount={filteredOptions.length}
|
itemCount={filteredOptions.length}
|
||||||
@ -865,7 +731,7 @@ const GameDropdown = ({
|
|||||||
{filteredOptions[index].label}
|
{filteredOptions[index].label}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</List>
|
</FixedSizeList>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
36
src/components/NsfwAlertPopup.tsx
Normal file
36
src/components/NsfwAlertPopup.tsx
Normal 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()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
@ -3,12 +3,12 @@ import { CheckboxFieldUncontrolled } from 'components/Inputs'
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { ReportReason } from 'types/report'
|
import { ReportReason } from 'types/report'
|
||||||
import { LoadingSpinner } from './LoadingSpinner'
|
import { LoadingSpinner } from './LoadingSpinner'
|
||||||
|
import { PopupProps } from 'types'
|
||||||
|
|
||||||
type ReportPopupProps = {
|
type ReportPopupProps = {
|
||||||
openedAt: number
|
openedAt: number
|
||||||
reasons: ReportReason[]
|
reasons: ReportReason[]
|
||||||
handleClose: () => void
|
} & PopupProps
|
||||||
}
|
|
||||||
|
|
||||||
export const ReportPopup = ({
|
export const ReportPopup = ({
|
||||||
openedAt,
|
openedAt,
|
||||||
|
@ -24,7 +24,7 @@ export const LANDING_PAGE_DATA = {
|
|||||||
featuredBlogPosts: [
|
featuredBlogPosts: [
|
||||||
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrzcfc8qurjefn943xyen9956rywp595unjc3h94nxvwfexymxxcfnvdjxxlyq37c',
|
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrzcfc8qurjefn943xyen9956rywp595unjc3h94nxvwfexymxxcfnvdjxxlyq37c',
|
||||||
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjryv3k8qenydpj94nrscmp956xgwtp94snydtz95ekgvphvfnxvvrzvyexzsvsz9y',
|
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjryv3k8qenydpj94nrscmp956xgwtp94snydtz95ekgvphvfnxvvrzvyexzsvsz9y',
|
||||||
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qq2kwjtwvahns3n0tf8j6kjxggkkz4mff499ge7xzsz',
|
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qyv8wumn8ghj7un9d3shjtnyv4nk6mmywvhxxmmd9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq3qamnwvaz7tmwdaehgu3wd4hk6tcppemhxue69uhkummn9ekx7mp0qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgawaehxw309ahx7um5wghxy6t5vdhkjmn9wgh8xmmrd9skctcpz4mhxue69uhkummnw3ezummcw3ezuer9wchsqfrxv33rvvfjxucz6d33vgcz6dp48qej6wryv9jz6errv33nqef3xy6kxvmrtmq496',
|
||||||
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrycf5vyunyd34943kydn9956rycmp943xydpc95cxge3cvguxgcmyxsmkyzpyj60'
|
'naddr1qvzqqqr4gupzpa9lr76m4zlg88mscue3wvlrp8mcpq3txy0k8cqlnhy2hw6z37x4qqjrycf5vyunyd34943kydn9956rycmp943xydpc95cxge3cvguxgcmyxsmkyzpyj60'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -111,7 +111,7 @@ export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ndk = useMemo(() => {
|
const ndk = useMemo(() => {
|
||||||
localStorage.setItem('debug', '*')
|
localStorage.removeItem('debug')
|
||||||
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' })
|
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'degmod-db' })
|
||||||
dexieAdapter.locking = true
|
dexieAdapter.locking = true
|
||||||
const ndk = new NDK({
|
const ndk = new NDK({
|
||||||
|
@ -8,3 +8,4 @@ export * from './useReactions'
|
|||||||
export * from './useNDKContext'
|
export * from './useNDKContext'
|
||||||
export * from './useScrollDisable'
|
export * from './useScrollDisable'
|
||||||
export * from './useLocalStorage'
|
export * from './useLocalStorage'
|
||||||
|
export * from './useSessionStorage'
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
getLocalStorageItem,
|
getLocalStorageItem,
|
||||||
removeLocalStorageItem,
|
removeLocalStorageItem,
|
||||||
@ -11,7 +11,11 @@ const useLocalStorageSubscribe = (callback: () => void) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T {
|
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 { ...initialValue, ...storedValue }
|
||||||
}
|
}
|
||||||
return storedValue
|
return storedValue
|
||||||
@ -64,5 +68,7 @@ export function useLocalStorage<T>(
|
|||||||
}
|
}
|
||||||
}, [key, initialValue])
|
}, [key, initialValue])
|
||||||
|
|
||||||
return [JSON.parse(data) as T, setState]
|
const memoized = useMemo(() => JSON.parse(data) as T, [data])
|
||||||
|
|
||||||
|
return [memoized, setState]
|
||||||
}
|
}
|
||||||
|
77
src/hooks/useSessionStorage.tsx
Normal file
77
src/hooks/useSessionStorage.tsx
Normal 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]
|
||||||
|
}
|
@ -91,10 +91,7 @@ export const Header = () => {
|
|||||||
<div className={mainStyles.ContainerMain}>
|
<div className={mainStyles.ContainerMain}>
|
||||||
<div className={navStyles.NavMainTopInside}>
|
<div className={navStyles.NavMainTopInside}>
|
||||||
<div className={navStyles.NMTI_Sec}>
|
<div className={navStyles.NMTI_Sec}>
|
||||||
<Link
|
<Link to={appRoutes.home} className={navStyles.NMTI_Sec_HomeLink}>
|
||||||
to={appRoutes.index}
|
|
||||||
className={navStyles.NMTI_Sec_HomeLink}
|
|
||||||
>
|
|
||||||
<div className={navStyles.NMTI_Sec_HomeLink_Logo}>
|
<div className={navStyles.NMTI_Sec_HomeLink_Logo}>
|
||||||
<img
|
<img
|
||||||
className={navStyles.NMTI_Sec_HomeLink_LogoImg}
|
className={navStyles.NMTI_Sec_HomeLink_LogoImg}
|
||||||
|
@ -62,7 +62,7 @@ const FAQ_ITEMS: FAQItem[] = [
|
|||||||
{
|
{
|
||||||
question: "Who's developing / maintaining DEG Mods?",
|
question: "Who's developing / maintaining DEG Mods?",
|
||||||
answer: `Considering this is an open-source project, anyone can contribute to its development and maintenance.
|
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/).`
|
is [Nostr Dev](https://nostrdev.com/).`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -151,23 +151,23 @@ export const AboutPage = () => {
|
|||||||
mods published by their creators. Mod creators provide
|
mods published by their creators. Mod creators provide
|
||||||
direct download links on their mod pages, allowing gamers to
|
direct download links on their mod pages, allowing gamers to
|
||||||
access the mods effortlessly. If a link breaks or gets
|
access the mods effortlessly. If a link breaks or gets
|
||||||
censored, mod creators can remove that link and add another,
|
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.
|
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
Also, everything is open sourced. Even if the site were to
|
Also, everything is open sourced. Even if the site were to
|
||||||
shut down, someone can simply take the same code and run it
|
shut down, someone can simply take the same code and run it
|
||||||
under a different name, and every mod would still be
|
under a different name, and every mod would still be
|
||||||
accessible, along with their links, reactions/ratings, and
|
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>
|
</p>
|
||||||
<h3 className='LearnTextHeading'>Tips / Donations</h3>
|
<h3 className='LearnTextHeading'>Tips / Donations</h3>
|
||||||
<p className='LearnTextPara'>
|
<p className='LearnTextPara'>
|
||||||
DEG Mods supports hassle-free money transfers for modders.
|
DEG Mods supports hassle-free money transfers for modders.
|
||||||
Fans can show their appreciation by directly tipping mod
|
Fans can show their appreciation by directly tipping mod
|
||||||
creators via Bitcoin through the Lightning Network, an
|
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!
|
continue making more valuable game mods!
|
||||||
<br />
|
<br />
|
||||||
</p>
|
</p>
|
||||||
@ -187,7 +187,7 @@ export const AboutPage = () => {
|
|||||||
them financially, even those in other countries where
|
them financially, even those in other countries where
|
||||||
"normal" methods of money payment/transfer are not an
|
"normal" methods of money payment/transfer are not an
|
||||||
option. You can just find the mod you want and download it,
|
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.
|
Bitcoin.
|
||||||
<br />
|
<br />
|
||||||
</p>
|
</p>
|
||||||
@ -195,7 +195,8 @@ export const AboutPage = () => {
|
|||||||
DEG Mods is a response to censorship and oppression, to
|
DEG Mods is a response to censorship and oppression, to
|
||||||
bring freedom and not hinder people's desires and
|
bring freedom and not hinder people's desires and
|
||||||
creativity. If you know a mod creator that's being censored,
|
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 />
|
<br />
|
||||||
</p>
|
</p>
|
||||||
<h3 className='LearnTextHeading'>
|
<h3 className='LearnTextHeading'>
|
||||||
@ -209,14 +210,14 @@ export const AboutPage = () => {
|
|||||||
description.
|
description.
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
For what some people might call it:
|
Another way of describing it:
|
||||||
<br />
|
<br />
|
||||||
"It's a game mods website."
|
A true mod site.
|
||||||
</p>
|
</p>
|
||||||
<div className='learnLinks'>
|
<div className='learnLinks'>
|
||||||
<a
|
<a
|
||||||
className='learnLinksLink'
|
className='learnLinksLink'
|
||||||
href='https://primal.net/p/npub17jl3ldd6305rnacvwvchx03snauqsg4nz8mruq0emj9thdpglr2sst825x'
|
href='https://degmods.com/profile/nprofile1qqs0f0clkkagh6pe7ux8xvtn8ccf77qgy2e3ra37q8uaez4mks5034gfw4xg6'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
@ -5,12 +5,6 @@ import {
|
|||||||
useNavigation,
|
useNavigation,
|
||||||
useSubmit
|
useSubmit
|
||||||
} from 'react-router-dom'
|
} 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 { LoadingSpinner } from 'components/LoadingSpinner'
|
||||||
import { ProfileSection } from 'components/ProfileSection'
|
import { ProfileSection } from 'components/ProfileSection'
|
||||||
import { Comments } from 'components/comment'
|
import { Comments } from 'components/comment'
|
||||||
@ -23,6 +17,7 @@ import { copyTextToClipboard } from 'utils'
|
|||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import { useAppSelector, useBodyScrollDisable } from 'hooks'
|
import { useAppSelector, useBodyScrollDisable } from 'hooks'
|
||||||
import { ReportPopup } from 'components/ReportPopup'
|
import { ReportPopup } from 'components/ReportPopup'
|
||||||
|
import { Viewer } from 'components/Markdown/Viewer'
|
||||||
|
|
||||||
const BLOG_REPORT_REASONS = [
|
const BLOG_REPORT_REASONS = [
|
||||||
{ label: 'Actually CP', key: 'actuallyCP' },
|
{ label: 'Actually CP', key: 'actuallyCP' },
|
||||||
@ -42,25 +37,6 @@ export const BlogPage = () => {
|
|||||||
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
|
userState.user.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
const [commentCount, setCommentCount] = useState(0)
|
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>()
|
const [showReportPopUp, setShowReportPopUp] = useState<number>()
|
||||||
useBodyScrollDisable(!!showReportPopUp)
|
useBodyScrollDisable(!!showReportPopUp)
|
||||||
@ -266,7 +242,10 @@ export const BlogPage = () => {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className='IBMSMSMBSSPostBody'>
|
<div className='IBMSMSMBSSPostBody'>
|
||||||
<EditorContent editor={editor} />
|
<Viewer
|
||||||
|
key={blog.id}
|
||||||
|
markdown={blog?.content || ''}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='IBMSMSMBSSTags'>
|
<div className='IBMSMSMBSSTags'>
|
||||||
{blog.nsfw && (
|
{blog.nsfw && (
|
||||||
|
@ -14,17 +14,16 @@ import { LoadingSpinner } from 'components/LoadingSpinner'
|
|||||||
import { Filter } from 'components/Filters'
|
import { Filter } from 'components/Filters'
|
||||||
import { Dropdown } from 'components/Filters/Dropdown'
|
import { Dropdown } from 'components/Filters/Dropdown'
|
||||||
import { Option } from 'components/Filters/Option'
|
import { Option } from 'components/Filters/Option'
|
||||||
|
import { NsfwFilterOptions } from 'components/Filters/NsfwFilterOptions'
|
||||||
|
|
||||||
export const BlogsPage = () => {
|
export const BlogsPage = () => {
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
const blogs = useLoaderData() as Partial<BlogCardDetails>[] | undefined
|
const blogs = useLoaderData() as Partial<BlogCardDetails>[] | undefined
|
||||||
const [filterOptions, setFilterOptions] = useLocalStorage(
|
const filterKey = 'filter-blog-curated'
|
||||||
'filter-blog-curated',
|
const [filterOptions, setFilterOptions] = useLocalStorage(filterKey, {
|
||||||
{
|
sort: SortBy.Latest,
|
||||||
sort: SortBy.Latest,
|
nsfw: NSFWFilter.Hide_NSFW
|
||||||
nsfw: NSFWFilter.Hide_NSFW
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
const searchTermRef = useRef<HTMLInputElement>(null)
|
const searchTermRef = useRef<HTMLInputElement>(null)
|
||||||
@ -147,19 +146,7 @@ export const BlogsPage = () => {
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
||||||
<Dropdown label={filterOptions.nsfw}>
|
<Dropdown label={filterOptions.nsfw}>
|
||||||
{Object.values(NSFWFilter).map((item, index) => (
|
<NsfwFilterOptions filterKey={filterKey} />
|
||||||
<Option
|
|
||||||
key={`nsfwFilterItem-${index}`}
|
|
||||||
onClick={() =>
|
|
||||||
setFilterOptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
nsfw: item
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Filter>
|
</Filter>
|
||||||
|
|
||||||
|
@ -14,7 +14,8 @@ import {
|
|||||||
useLocalStorage,
|
useLocalStorage,
|
||||||
useMuteLists,
|
useMuteLists,
|
||||||
useNDKContext,
|
useNDKContext,
|
||||||
useNSFWList
|
useNSFWList,
|
||||||
|
useSessionStorage
|
||||||
} from 'hooks'
|
} from 'hooks'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useParams, useSearchParams } from 'react-router-dom'
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
@ -27,6 +28,7 @@ import {
|
|||||||
scrollIntoView
|
scrollIntoView
|
||||||
} from 'utils'
|
} from 'utils'
|
||||||
import { useCuratedSet } from 'hooks/useCuratedSet'
|
import { useCuratedSet } from 'hooks/useCuratedSet'
|
||||||
|
import { CategoryFilterPopup } from 'components/Filters/CategoryFilterPopup'
|
||||||
|
|
||||||
export const GamePage = () => {
|
export const GamePage = () => {
|
||||||
const scrollTargetRef = useRef<HTMLDivElement>(null)
|
const scrollTargetRef = useRef<HTMLDivElement>(null)
|
||||||
@ -52,6 +54,13 @@ export const GamePage = () => {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '')
|
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 handleSearch = () => {
|
||||||
const value = searchTermRef.current?.value || '' // Access the input value from the ref
|
const value = searchTermRef.current?.value || '' // Access the input value from the ref
|
||||||
setSearchTerm(value)
|
setSearchTerm(value)
|
||||||
@ -82,8 +91,38 @@ export const GamePage = () => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If search term is missing, only filter by sources
|
const filterCategoryFn = (mod: ModDetails) => {
|
||||||
if (searchTerm === '') return mods.filter(filterSourceFn)
|
// 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()
|
const lowerCaseSearchTerm = searchTerm.toLowerCase()
|
||||||
|
|
||||||
@ -96,8 +135,15 @@ export const GamePage = () => {
|
|||||||
tag.toLowerCase().includes(lowerCaseSearchTerm)
|
tag.toLowerCase().includes(lowerCaseSearchTerm)
|
||||||
) > -1
|
) > -1
|
||||||
|
|
||||||
return mods.filter(filterFn).filter(filterSourceFn)
|
return mods.filter(filterFn).filter(filterSourceFn).filter(filterCategoryFn)
|
||||||
}, [filterOptions.source, mods, searchTerm])
|
}, [
|
||||||
|
categories,
|
||||||
|
filterOptions.source,
|
||||||
|
hierarchies,
|
||||||
|
linkedHierarchy,
|
||||||
|
mods,
|
||||||
|
searchTerm
|
||||||
|
])
|
||||||
|
|
||||||
const filteredModList = useFilteredMods(
|
const filteredModList = useFilteredMods(
|
||||||
filteredMods,
|
filteredMods,
|
||||||
@ -123,6 +169,8 @@ export const GamePage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!gameName) return
|
||||||
|
|
||||||
const filter: NDKFilter = {
|
const filter: NDKFilter = {
|
||||||
kinds: [NDKKind.Classified],
|
kinds: [NDKKind.Classified],
|
||||||
'#t': [T_TAG_VALUE]
|
'#t': [T_TAG_VALUE]
|
||||||
@ -188,7 +236,42 @@ export const GamePage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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='IBMSecMain IBMSMListWrapper'>
|
||||||
<div className='IBMSMList'>
|
<div className='IBMSMList'>
|
||||||
{currentMods.map((mod) => (
|
{currentMods.map((mod) => (
|
||||||
@ -204,6 +287,17 @@ export const GamePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showCategoryPopup && (
|
||||||
|
<CategoryFilterPopup
|
||||||
|
categories={categories}
|
||||||
|
setCategories={setCategories}
|
||||||
|
hierarchies={hierarchies}
|
||||||
|
setHierarchies={setHierarchies}
|
||||||
|
handleClose={() => {
|
||||||
|
setShowCategoryPopup(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 FsLightbox from 'fslightbox-react'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Link as ReactRouterLink,
|
Link as ReactRouterLink,
|
||||||
useLoaderData,
|
useLoaderData,
|
||||||
@ -28,6 +25,7 @@ import '../../styles/tags.css'
|
|||||||
import '../../styles/write.css'
|
import '../../styles/write.css'
|
||||||
import { DownloadUrl, ModPageLoaderResult } from '../../types'
|
import { DownloadUrl, ModPageLoaderResult } from '../../types'
|
||||||
import {
|
import {
|
||||||
|
capitalizeEachWord,
|
||||||
copyTextToClipboard,
|
copyTextToClipboard,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
getFilenameFromUrl
|
getFilenameFromUrl
|
||||||
@ -39,6 +37,7 @@ import { ReportPopup } from 'components/ReportPopup'
|
|||||||
import { Spinner } from 'components/Spinner'
|
import { Spinner } from 'components/Spinner'
|
||||||
import { RouterLoadingSpinner } from 'components/LoadingSpinner'
|
import { RouterLoadingSpinner } from 'components/LoadingSpinner'
|
||||||
import { OriginalAuthor } from 'components/OriginalAuthor'
|
import { OriginalAuthor } from 'components/OriginalAuthor'
|
||||||
|
import { Viewer } from 'components/Markdown/Viewer'
|
||||||
|
|
||||||
const MOD_REPORT_REASONS = [
|
const MOD_REPORT_REASONS = [
|
||||||
{ label: 'Actually CP', key: 'actuallyCP' },
|
{ label: 'Actually CP', key: 'actuallyCP' },
|
||||||
@ -103,8 +102,10 @@ export const ModPage = () => {
|
|||||||
featuredImageUrl={mod.featuredImageUrl}
|
featuredImageUrl={mod.featuredImageUrl}
|
||||||
title={mod.title}
|
title={mod.title}
|
||||||
body={mod.body}
|
body={mod.body}
|
||||||
|
game={mod.game}
|
||||||
screenshotsUrls={mod.screenshotsUrls}
|
screenshotsUrls={mod.screenshotsUrls}
|
||||||
tags={mod.tags}
|
tags={mod.tags}
|
||||||
|
LTags={mod.LTags}
|
||||||
nsfw={mod.nsfw}
|
nsfw={mod.nsfw}
|
||||||
repost={mod.repost}
|
repost={mod.repost}
|
||||||
originalAuthor={mod.originalAuthor}
|
originalAuthor={mod.originalAuthor}
|
||||||
@ -424,8 +425,10 @@ type BodyProps = {
|
|||||||
featuredImageUrl: string
|
featuredImageUrl: string
|
||||||
title: string
|
title: string
|
||||||
body: string
|
body: string
|
||||||
|
game: string
|
||||||
screenshotsUrls: string[]
|
screenshotsUrls: string[]
|
||||||
tags: string[]
|
tags: string[]
|
||||||
|
LTags: string[]
|
||||||
nsfw: boolean
|
nsfw: boolean
|
||||||
repost: boolean
|
repost: boolean
|
||||||
originalAuthor?: string
|
originalAuthor?: string
|
||||||
@ -433,17 +436,19 @@ type BodyProps = {
|
|||||||
|
|
||||||
const Body = ({
|
const Body = ({
|
||||||
featuredImageUrl,
|
featuredImageUrl,
|
||||||
|
game,
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
screenshotsUrls,
|
screenshotsUrls,
|
||||||
tags,
|
tags,
|
||||||
|
LTags,
|
||||||
nsfw,
|
nsfw,
|
||||||
repost,
|
repost,
|
||||||
originalAuthor
|
originalAuthor
|
||||||
}: BodyProps) => {
|
}: BodyProps) => {
|
||||||
|
const COLLAPSED_MAX_SIZE = 250
|
||||||
const postBodyRef = useRef<HTMLDivElement>(null)
|
const postBodyRef = useRef<HTMLDivElement>(null)
|
||||||
const viewFullPostBtnRef = useRef<HTMLDivElement>(null)
|
const viewFullPostBtnRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const [lightBoxController, setLightBoxController] = useState({
|
const [lightBoxController, setLightBoxController] = useState({
|
||||||
toggler: false,
|
toggler: false,
|
||||||
slide: 1
|
slide: 1
|
||||||
@ -456,6 +461,14 @@ const Body = ({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (postBodyRef.current) {
|
||||||
|
if (postBodyRef.current.scrollHeight <= COLLAPSED_MAX_SIZE) {
|
||||||
|
viewFullPost()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const viewFullPost = () => {
|
const viewFullPost = () => {
|
||||||
if (postBodyRef.current && viewFullPostBtnRef.current) {
|
if (postBodyRef.current && viewFullPostBtnRef.current) {
|
||||||
postBodyRef.current.style.maxHeight = 'unset'
|
postBodyRef.current.style.maxHeight = 'unset'
|
||||||
@ -464,12 +477,6 @@ const Body = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
content: body,
|
|
||||||
extensions: [StarterKit, Link],
|
|
||||||
editable: false
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='IBMSMSMBSSPost'>
|
<div className='IBMSMSMBSSPost'>
|
||||||
@ -486,9 +493,12 @@ const Body = ({
|
|||||||
<div
|
<div
|
||||||
ref={postBodyRef}
|
ref={postBodyRef}
|
||||||
className='IBMSMSMBSSPostBody'
|
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 ref={viewFullPostBtnRef} className='IBMSMSMBSSPostBodyHide'>
|
||||||
<div className='IBMSMSMBSSPostBodyHideText'>
|
<div className='IBMSMSMBSSPostBodyHideText'>
|
||||||
<p onClick={viewFullPost}>Read Full</p>
|
<p onClick={viewFullPost}>Read Full</p>
|
||||||
@ -532,6 +542,50 @@ const Body = ({
|
|||||||
{tag}
|
{tag}
|
||||||
</a>
|
</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>></p>
|
||||||
|
</div>,
|
||||||
|
curr
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
|
||||||
|
{categories}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -28,7 +28,7 @@ export const modRouteLoader =
|
|||||||
const { naddr } = params
|
const { naddr } = params
|
||||||
if (!naddr) {
|
if (!naddr) {
|
||||||
log(true, LogType.Error, 'Required naddr.')
|
log(true, LogType.Error, 'Required naddr.')
|
||||||
return redirect(appRoutes.blogs)
|
return redirect(appRoutes.mods)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode from naddr
|
// Decode from naddr
|
||||||
@ -42,7 +42,7 @@ export const modRouteLoader =
|
|||||||
pubkey = decoded.data.pubkey
|
pubkey = decoded.data.pubkey
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(true, LogType.Error, `Failed to decode naddr: ${naddr}`, 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
|
const userState = store.getState().user
|
||||||
@ -80,7 +80,7 @@ export const modRouteLoader =
|
|||||||
latestFilter['#L'] = ['content-warning']
|
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([
|
const settled = await Promise.allSettled([
|
||||||
ndkContext.fetchEvent(modFilter),
|
ndkContext.fetchEvent(modFilter),
|
||||||
ndkContext.fetchEvents(latestFilter),
|
ndkContext.fetchEvents(latestFilter),
|
||||||
@ -106,7 +106,7 @@ export const modRouteLoader =
|
|||||||
log(
|
log(
|
||||||
true,
|
true,
|
||||||
LogType.Error,
|
LogType.Error,
|
||||||
'Unable to fetch the blog event.',
|
'Unable to fetch the mod event.',
|
||||||
fetchEventResult.reason
|
fetchEventResult.reason
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'
|
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'
|
||||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
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 { kinds, UnsignedEvent, Event } from 'nostr-tools'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
@ -19,6 +25,13 @@ export const PreferencesSetting = () => {
|
|||||||
const [wotLevel, setWotLevel] = useState(userWotLevel)
|
const [wotLevel, setWotLevel] = useState(userWotLevel)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (user?.pubkey) {
|
if (user?.pubkey) {
|
||||||
const hexPubkey = user.pubkey as string
|
const hexPubkey = user.pubkey as string
|
||||||
@ -191,6 +204,14 @@ export const PreferencesSetting = () => {
|
|||||||
type='checkbox'
|
type='checkbox'
|
||||||
className='CheckboxMain'
|
className='CheckboxMain'
|
||||||
name='NSFWPreference'
|
name='NSFWPreference'
|
||||||
|
checked={nsfw}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.currentTarget.checked && !confirmNsfw) {
|
||||||
|
setShowNsfwPopup(true)
|
||||||
|
} else {
|
||||||
|
setNsfw(e.currentTarget.checked)
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -238,6 +259,12 @@ export const PreferencesSetting = () => {
|
|||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{showNsfwPopup && (
|
||||||
|
<NsfwAlertPopup
|
||||||
|
handleConfirm={handleNsfwConfirm}
|
||||||
|
handleClose={() => setShowNsfwPopup(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
238
src/pages/submitMod/action.ts
Normal file
238
src/pages/submitMod/action.ts
Normal 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
|
||||||
|
}
|
39
src/pages/submitMod/index.tsx
Normal file
39
src/pages/submitMod/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -10,7 +10,6 @@ import {
|
|||||||
now,
|
now,
|
||||||
parseFormData
|
parseFormData
|
||||||
} from 'utils'
|
} from 'utils'
|
||||||
import TurndownService from 'turndown'
|
|
||||||
import { kinds, UnsignedEvent, Event, nip19 } from 'nostr-tools'
|
import { kinds, UnsignedEvent, Event, nip19 } from 'nostr-tools'
|
||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||||
@ -57,9 +56,8 @@ export const writeRouteAction =
|
|||||||
// Return earily if there are any errors
|
// Return earily if there are any errors
|
||||||
if (Object.keys(formErrors).length) return formErrors
|
if (Object.keys(formErrors).length) return formErrors
|
||||||
|
|
||||||
// Get the markdown from the html
|
// Get the markdown from formData
|
||||||
const turndownService = new TurndownService()
|
const content = decodeURIComponent(formSubmit.content!)
|
||||||
const content = turndownService.turndown(formSubmit.content!)
|
|
||||||
|
|
||||||
// Check if we are editing or this is a new blog
|
// Check if we are editing or this is a new blog
|
||||||
const { naddr } = params
|
const { naddr } = params
|
||||||
@ -154,11 +152,7 @@ const validateFormData = async (
|
|||||||
errors.title = 'Title field can not be empty'
|
errors.title = 'Title field can not be empty'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!formData.content || formData.content.trim() === '') {
|
||||||
!formData.content ||
|
|
||||||
formData.content.trim() === '' ||
|
|
||||||
formData.content.trim() === '<p></p>'
|
|
||||||
) {
|
|
||||||
errors.content = 'Content field can not be empty'
|
errors.content = 'Content field can not be empty'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
useActionData,
|
useActionData,
|
||||||
@ -8,8 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CheckboxFieldUncontrolled,
|
CheckboxFieldUncontrolled,
|
||||||
InputError,
|
InputError,
|
||||||
InputFieldUncontrolled,
|
InputFieldUncontrolled
|
||||||
MenuBar
|
|
||||||
} from '../../components/Inputs'
|
} from '../../components/Inputs'
|
||||||
import { ProfileSection } from '../../components/ProfileSection'
|
import { ProfileSection } from '../../components/ProfileSection'
|
||||||
import { useAppSelector } from '../../hooks'
|
import { useAppSelector } from '../../hooks'
|
||||||
@ -18,12 +17,8 @@ import '../../styles/innerPage.css'
|
|||||||
import '../../styles/styles.css'
|
import '../../styles/styles.css'
|
||||||
import '../../styles/write.css'
|
import '../../styles/write.css'
|
||||||
import { LoadingSpinner } from 'components/LoadingSpinner'
|
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||||
import { marked } from 'marked'
|
import { AlertPopup } from 'components/AlertPopup'
|
||||||
import DOMPurify from 'dompurify'
|
import { Editor, EditorRef } from 'components/Markdown/Editor'
|
||||||
import { EditorContent, useEditor } from '@tiptap/react'
|
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
|
||||||
import Link from '@tiptap/extension-link'
|
|
||||||
import Image from '@tiptap/extension-image'
|
|
||||||
|
|
||||||
export const WritePage = () => {
|
export const WritePage = () => {
|
||||||
const userState = useAppSelector((state) => state.user)
|
const userState = useAppSelector((state) => state.user)
|
||||||
@ -33,25 +28,26 @@ export const WritePage = () => {
|
|||||||
|
|
||||||
const blog = data?.blog
|
const blog = data?.blog
|
||||||
const title = data?.blog ? 'Edit blog post' : 'Submit a blog post'
|
const title = data?.blog ? 'Edit blog post' : 'Submit a blog post'
|
||||||
const html = marked.parse(blog?.content || '', { async: false })
|
const [content, setContent] = useState(blog?.content || '')
|
||||||
const sanitized = DOMPurify.sanitize(html)
|
const formRef = useRef<HTMLFormElement>(null)
|
||||||
const [content, setContent] = useState<string>(sanitized)
|
const editorRef = useRef<EditorRef>(null)
|
||||||
const editor = useEditor({
|
const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false)
|
||||||
content: content,
|
const handleReset = () => {
|
||||||
extensions: [
|
setShowConfirmPopup(true)
|
||||||
StarterKit,
|
}
|
||||||
Link,
|
const handleResetConfirm = (confirm: boolean) => {
|
||||||
Image.configure({
|
setShowConfirmPopup(false)
|
||||||
inline: true,
|
|
||||||
HTMLAttributes: {
|
// Cancel if not confirmed
|
||||||
class: 'IBMSMSMBSSPostImg'
|
if (!confirm) return
|
||||||
}
|
|
||||||
})
|
// Reset editor
|
||||||
],
|
if (blog?.content) {
|
||||||
onUpdate: ({ editor }) => {
|
editorRef.current?.setMarkdown(blog?.content)
|
||||||
setContent(editor.getHTML())
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
formRef.current?.reset()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='InnerBodyMain'>
|
<div className='InnerBodyMain'>
|
||||||
@ -68,26 +64,39 @@ export const WritePage = () => {
|
|||||||
{navigation.state === 'submitting' && (
|
{navigation.state === 'submitting' && (
|
||||||
<LoadingSpinner desc='Publishing blog to relays' />
|
<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
|
<InputFieldUncontrolled
|
||||||
label='Title'
|
label='Title'
|
||||||
name='title'
|
name='title'
|
||||||
defaultValue={blog?.title}
|
defaultValue={blog?.title}
|
||||||
error={formErrors?.title}
|
error={formErrors?.title}
|
||||||
/>
|
/>
|
||||||
{editor && (
|
<div className='inputLabelWrapperMain'>
|
||||||
<div className='inputLabelWrapperMain'>
|
<label className='form-label labelMain'>Content</label>
|
||||||
<label className='form-label labelMain'>Content</label>
|
<div className='inputMain'>
|
||||||
<div className='inputMain'>
|
<Editor
|
||||||
<MenuBar editor={editor} />
|
ref={editorRef}
|
||||||
<EditorContent editor={editor} />
|
markdown={content}
|
||||||
</div>
|
onChange={(md) => {
|
||||||
{typeof formErrors?.content !== 'undefined' && (
|
setContent(md)
|
||||||
<InputError message={formErrors?.content} />
|
}}
|
||||||
)}
|
/>
|
||||||
<input name='content' hidden value={content} readOnly />
|
|
||||||
</div>
|
</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
|
<InputFieldUncontrolled
|
||||||
label='Featured Image URL'
|
label='Featured Image URL'
|
||||||
name='image'
|
name='image'
|
||||||
@ -130,6 +139,17 @@ export const WritePage = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className='IBMSMSMBS_WriteAction'>
|
<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
|
<button
|
||||||
className='btn btnMain'
|
className='btn btnMain'
|
||||||
type='submit'
|
type='submit'
|
||||||
@ -143,6 +163,18 @@ export const WritePage = () => {
|
|||||||
: 'Publish'}
|
: 'Publish'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
{userState.auth && userState.user?.pubkey && (
|
{userState.auth && userState.user?.pubkey && (
|
||||||
|
@ -16,6 +16,7 @@ import { profileRouteLoader } from 'pages/profile/loader'
|
|||||||
import { SettingsPage } from '../pages/settings'
|
import { SettingsPage } from '../pages/settings'
|
||||||
import { GamePage } from '../pages/game'
|
import { GamePage } from '../pages/game'
|
||||||
import { NotFoundPage } from '../pages/404'
|
import { NotFoundPage } from '../pages/404'
|
||||||
|
import { submitModRouteAction } from 'pages/submitMod/action'
|
||||||
import { FeedLayout } from '../layout/feed'
|
import { FeedLayout } from '../layout/feed'
|
||||||
import { FeedPage } from '../pages/feed'
|
import { FeedPage } from '../pages/feed'
|
||||||
import { NotificationsPage } from '../pages/notifications'
|
import { NotificationsPage } from '../pages/notifications'
|
||||||
@ -29,7 +30,6 @@ import { blogRouteAction } from '../pages/blog/action'
|
|||||||
import { reportRouteAction } from '../actions/report'
|
import { reportRouteAction } from '../actions/report'
|
||||||
|
|
||||||
export const appRoutes = {
|
export const appRoutes = {
|
||||||
index: '/',
|
|
||||||
home: '/',
|
home: '/',
|
||||||
games: '/games',
|
games: '/games',
|
||||||
game: '/game/:name',
|
game: '/game/:name',
|
||||||
@ -75,7 +75,7 @@ export const routerWithNdkContext = (context: NDKContextType) =>
|
|||||||
element: <Layout />,
|
element: <Layout />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: appRoutes.index,
|
path: appRoutes.home,
|
||||||
element: <HomePage />
|
element: <HomePage />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -131,11 +131,16 @@ export const routerWithNdkContext = (context: NDKContextType) =>
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.submitMod,
|
path: appRoutes.submitMod,
|
||||||
element: <SubmitModPage />
|
action: submitModRouteAction(context),
|
||||||
|
element: <SubmitModPage key='submit' />,
|
||||||
|
errorElement: <NotFoundPage title={'Something went wrong.'} />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.editMod,
|
path: appRoutes.editMod,
|
||||||
element: <SubmitModPage />
|
loader: modRouteLoader(context),
|
||||||
|
action: submitModRouteAction(context),
|
||||||
|
element: <SubmitModPage key='edit' />,
|
||||||
|
errorElement: <NotFoundPage title={'Something went wrong.'} />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.write,
|
path: appRoutes.write,
|
||||||
|
@ -5,12 +5,17 @@
|
|||||||
grid-gap: 10px;
|
grid-gap: 10px;
|
||||||
text-decoration: unset;
|
text-decoration: unset;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border-radius: 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardGameMainWrapperLink:hover {
|
.cardGameMainWrapperLink:hover {
|
||||||
transition: ease 0.4s;
|
transition: ease 0.4s;
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
text-decoration: unset;
|
text-decoration: unset;
|
||||||
|
background: rgb(225,225,225,0.05);
|
||||||
|
box-shadow: 0 0 8px 0 rgb(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardGameMainWrapperLink:active {
|
.cardGameMainWrapperLink:active {
|
||||||
@ -24,7 +29,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cardGameMain {
|
.cardGameMain {
|
||||||
border-radius: 15px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
|
box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.1);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
object-fit: cover; /* Ensures the image covers the container like a background image */
|
object-fit: cover; /* Ensures the image covers the container like a background image */
|
||||||
@ -36,7 +41,7 @@
|
|||||||
.cardGameMainTitle {
|
.cardGameMainTitle {
|
||||||
transition: ease 0.4s;
|
transition: ease 0.4s;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
padding: 0 15px;
|
padding: 0 10px 10px 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
|
154
src/styles/mdxEditor.scss
Normal file
154
src/styles/mdxEditor.scss
Normal 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);
|
||||||
|
}
|
@ -271,16 +271,16 @@ h6 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* the 4 classes below here are a temp fix for the games dropdown stylings */
|
/* the 4 classes below here are a temp fix for the games dropdown stylings */
|
||||||
|
/* add an exception (not category) for normal dropdown - due !important */
|
||||||
.dropdownMainMenu.dropdownMainMenuAlt {
|
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) {
|
||||||
max-height: unset !important;
|
max-height: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownMainMenu.dropdownMainMenuAlt > div {
|
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div {
|
||||||
height: unset !important;
|
height: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownMainMenu.dropdownMainMenuAlt > div > div {
|
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div > div {
|
||||||
height: unset !important;
|
height: unset !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -291,14 +291,14 @@ h6 {
|
|||||||
padding: 5px;
|
padding: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownMainMenu.dropdownMainMenuAlt > div > div > div {
|
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div > div > div {
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
left: unset !important;
|
left: unset !important;
|
||||||
top: unset !important;
|
top: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownMainMenuItem {
|
.dropdownMainMenuItem {
|
||||||
transition: ease 0.4s;
|
transition: background ease 0.4s, color ease 0.4s;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
rgba(255, 255, 255, 0.03),
|
rgba(255, 255, 255, 0.03),
|
||||||
rgba(255, 255, 255, 0.03)
|
rgba(255, 255, 255, 0.03)
|
||||||
@ -318,8 +318,16 @@ h6 {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdownMainMenuItem.dropdownMainMenuItemCategory {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.dropdownMainMenuItemCategory.dropdownMainMenuItemCategoryAlt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdownMainMenuItem:hover {
|
.dropdownMainMenuItem:hover {
|
||||||
transition: ease 0.4s;
|
transition: background ease 0.4s, color ease 0.4s;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
rgba(255, 255, 255, 0.05),
|
rgba(255, 255, 255, 0.05),
|
||||||
rgba(255, 255, 255, 0.05)
|
rgba(255, 255, 255, 0.05)
|
||||||
@ -385,6 +393,10 @@ h6 {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.labelMain.labelMainCategory {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.inputWrapperMain {
|
.inputWrapperMain {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -530,6 +542,15 @@ h6 {
|
|||||||
align-items: center;
|
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 {
|
.CheckboxMain:checked::before {
|
||||||
transition: ease 0.2s;
|
transition: ease 0.2s;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
@ -632,6 +653,9 @@ a:hover {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
border-radius: unset;
|
border-radius: unset;
|
||||||
}
|
}
|
||||||
|
.btnMainInsideField + .btnMainInsideField {
|
||||||
|
right: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
.inputMain.inputMainWithBtn {
|
.inputMain.inputMainWithBtn {
|
||||||
padding-right: 50px;
|
padding-right: 50px;
|
||||||
|
@ -53,4 +53,42 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
box-shadow: unset;
|
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;
|
||||||
}
|
}
|
@ -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;
|
|
||||||
}
|
|
@ -11,5 +11,5 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
12
src/types/category.ts
Normal file
12
src/types/category.ts
Normal 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[]
|
||||||
|
}
|
@ -4,3 +4,5 @@ export * from './nostr'
|
|||||||
export * from './user'
|
export * from './user'
|
||||||
export * from './zap'
|
export * from './zap'
|
||||||
export * from './blog'
|
export * from './blog'
|
||||||
|
export * from './category'
|
||||||
|
export * from './popup'
|
||||||
|
@ -31,7 +31,12 @@ export interface ModFormState {
|
|||||||
originalAuthor?: string
|
originalAuthor?: string
|
||||||
screenshotsUrls: string[]
|
screenshotsUrls: string[]
|
||||||
tags: string
|
tags: string
|
||||||
|
/** Hierarchical labels */
|
||||||
|
LTags: string[]
|
||||||
|
/** Category labels for category search */
|
||||||
|
lTags: string[]
|
||||||
downloadUrls: DownloadUrl[]
|
downloadUrls: DownloadUrl[]
|
||||||
|
published_at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadUrl {
|
export interface DownloadUrl {
|
||||||
@ -45,7 +50,6 @@ export interface DownloadUrl {
|
|||||||
|
|
||||||
export interface ModDetails extends Omit<ModFormState, 'tags'> {
|
export interface ModDetails extends Omit<ModFormState, 'tags'> {
|
||||||
id: string
|
id: string
|
||||||
published_at: number
|
|
||||||
edited_at: number
|
edited_at: number
|
||||||
author: string
|
author: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
@ -63,3 +67,17 @@ export interface ModPageLoaderResult {
|
|||||||
isBlocked: boolean
|
isBlocked: boolean
|
||||||
isRepost: 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
9
src/types/popup.ts
Normal 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
84
src/utils/category.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -4,6 +4,8 @@ export * from './url'
|
|||||||
export * from './utils'
|
export * from './utils'
|
||||||
export * from './zap'
|
export * from './zap'
|
||||||
export * from './localStorage'
|
export * from './localStorage'
|
||||||
|
export * from './sessionStorage'
|
||||||
export * from './consts'
|
export * from './consts'
|
||||||
export * from './blog'
|
export * from './blog'
|
||||||
export * from './curationSets'
|
export * from './curationSets'
|
||||||
|
export * from './category'
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
import { NDKEvent } from '@nostr-dev-kit/ndk'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { ModDetails, ModFormState } from '../types'
|
import { ModDetails, ModFormState } from '../types'
|
||||||
import { getTagValue } from './nostr'
|
import { getTagValue, getTagValues } from './nostr'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts and normalizes mod data from an event.
|
* Extracts and normalizes mod data from an event.
|
||||||
@ -43,6 +43,12 @@ export const extractModData = (event: Event | NDKEvent): ModDetails => {
|
|||||||
originalAuthor: getFirstTagValue('originalAuthor'),
|
originalAuthor: getFirstTagValue('originalAuthor'),
|
||||||
screenshotsUrls: getTagValue(event, 'screenshotsUrls') || [],
|
screenshotsUrls: getTagValue(event, 'screenshotsUrls') || [],
|
||||||
tags: getTagValue(event, 'tags') || [],
|
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) =>
|
downloadUrls: (getTagValue(event, 'downloadUrls') || []).map((item) =>
|
||||||
JSON.parse(item)
|
JSON.parse(item)
|
||||||
)
|
)
|
||||||
@ -113,6 +119,7 @@ export const initializeFormState = (
|
|||||||
): ModFormState => ({
|
): ModFormState => ({
|
||||||
dTag: existingModData?.dTag || '',
|
dTag: existingModData?.dTag || '',
|
||||||
aTag: existingModData?.aTag || '',
|
aTag: existingModData?.aTag || '',
|
||||||
|
published_at: existingModData?.published_at || 0,
|
||||||
rTag: existingModData?.rTag || window.location.host,
|
rTag: existingModData?.rTag || window.location.host,
|
||||||
game: existingModData?.game || '',
|
game: existingModData?.game || '',
|
||||||
title: existingModData?.title || '',
|
title: existingModData?.title || '',
|
||||||
@ -124,6 +131,8 @@ export const initializeFormState = (
|
|||||||
originalAuthor: existingModData?.originalAuthor || undefined,
|
originalAuthor: existingModData?.originalAuthor || undefined,
|
||||||
screenshotsUrls: existingModData?.screenshotsUrls || [''],
|
screenshotsUrls: existingModData?.screenshotsUrls || [''],
|
||||||
tags: existingModData?.tags.join(',') || '',
|
tags: existingModData?.tags.join(',') || '',
|
||||||
|
lTags: existingModData?.lTags || [],
|
||||||
|
LTags: existingModData?.LTags || [],
|
||||||
downloadUrls: existingModData?.downloadUrls || [
|
downloadUrls: existingModData?.downloadUrls || [
|
||||||
{
|
{
|
||||||
url: '',
|
url: '',
|
||||||
|
32
src/utils/sessionStorage.ts
Normal file
32
src/utils/sessionStorage.ts
Normal 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 }))
|
||||||
|
}
|
@ -156,3 +156,7 @@ export const parseFormData = <T>(formData: FormData) => {
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const capitalizeEachWord = (str: string): string => {
|
||||||
|
return str.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user