443 lines
13 KiB
TypeScript
443 lines
13 KiB
TypeScript
import { Pagination } from 'components/Pagination'
|
|
import { kinds, nip19 } from 'nostr-tools'
|
|
import React, {
|
|
Dispatch,
|
|
SetStateAction,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState
|
|
} from 'react'
|
|
import { createSearchParams, useNavigate } from 'react-router-dom'
|
|
import { LoadingSpinner } from '../components/LoadingSpinner'
|
|
import { ModCard } from '../components/ModCard'
|
|
import { MOD_FILTER_LIMIT } from '../constants'
|
|
import { MetadataController } from '../controllers'
|
|
import { useAppSelector, useDidMount, useMuteLists } from '../hooks'
|
|
import { appRoutes, getModPageRoute } from '../routes'
|
|
import '../styles/filters.css'
|
|
import '../styles/pagination.css'
|
|
import '../styles/search.css'
|
|
import '../styles/styles.css'
|
|
import { ModDetails } from '../types'
|
|
import { fetchMods } from '../utils'
|
|
|
|
enum SortBy {
|
|
Latest = 'Latest',
|
|
Oldest = 'Oldest',
|
|
Best_Rated = 'Best Rated',
|
|
Worst_Rated = 'Worst Rated'
|
|
}
|
|
|
|
enum NSFWFilter {
|
|
Hide_NSFW = 'Hide NSFW',
|
|
Show_NSFW = 'Show NSFW',
|
|
Only_NSFW = 'Only NSFW'
|
|
}
|
|
|
|
enum ModeratedFilter {
|
|
Moderated = 'Moderated',
|
|
Unmoderated = 'Unmoderated',
|
|
Unmoderated_Fully = 'Unmoderated Fully'
|
|
}
|
|
|
|
interface FilterOptions {
|
|
sort: SortBy
|
|
nsfw: NSFWFilter
|
|
source: string
|
|
moderated: ModeratedFilter
|
|
}
|
|
|
|
export const ModsPage = () => {
|
|
const [isFetching, setIsFetching] = useState(false)
|
|
const [mods, setMods] = useState<ModDetails[]>([])
|
|
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
|
sort: SortBy.Latest,
|
|
nsfw: NSFWFilter.Hide_NSFW,
|
|
source: window.location.host,
|
|
moderated: ModeratedFilter.Moderated
|
|
})
|
|
const muteLists = useMuteLists()
|
|
|
|
const [nsfwList, setNSFWList] = useState<string[]>([])
|
|
|
|
const [page, setPage] = useState(1)
|
|
|
|
const userState = useAppSelector((state) => state.user)
|
|
|
|
useDidMount(async () => {
|
|
const metadataController = await MetadataController.getInstance()
|
|
|
|
metadataController.getNSFWList().then((list) => {
|
|
setNSFWList(list)
|
|
})
|
|
})
|
|
|
|
useEffect(() => {
|
|
setIsFetching(true)
|
|
fetchMods({ source: filterOptions.source })
|
|
.then((res) => {
|
|
setMods(res)
|
|
})
|
|
.finally(() => {
|
|
setIsFetching(false)
|
|
})
|
|
}, [filterOptions.source])
|
|
|
|
const handleNext = useCallback(() => {
|
|
setIsFetching(true)
|
|
|
|
const until =
|
|
mods.length > 0 ? mods[mods.length - 1].published_at - 1 : undefined
|
|
|
|
fetchMods({
|
|
source: filterOptions.source,
|
|
until
|
|
})
|
|
.then((res) => {
|
|
setMods(res)
|
|
setPage((prev) => prev + 1)
|
|
})
|
|
.finally(() => {
|
|
setIsFetching(false)
|
|
})
|
|
}, [filterOptions.source, mods])
|
|
|
|
const handlePrev = useCallback(() => {
|
|
setIsFetching(true)
|
|
|
|
const since = mods.length > 0 ? mods[0].published_at + 1 : undefined
|
|
|
|
fetchMods({
|
|
source: filterOptions.source,
|
|
since
|
|
})
|
|
.then((res) => {
|
|
setMods(res)
|
|
setPage((prev) => prev - 1)
|
|
})
|
|
.finally(() => {
|
|
setIsFetching(false)
|
|
})
|
|
}, [filterOptions.source, mods])
|
|
|
|
const filteredModList = useMemo(() => {
|
|
const nsfwFilter = (mods: ModDetails[]) => {
|
|
// Determine the filtering logic based on the NSFW filter option
|
|
switch (filterOptions.nsfw) {
|
|
case NSFWFilter.Hide_NSFW:
|
|
// If 'Hide_NSFW' is selected, filter out NSFW mods
|
|
return mods.filter((mod) => !mod.nsfw && !nsfwList.includes(mod.aTag))
|
|
case NSFWFilter.Show_NSFW:
|
|
// If 'Show_NSFW' is selected, return all mods (no filtering)
|
|
return mods
|
|
case NSFWFilter.Only_NSFW:
|
|
// If 'Only_NSFW' is selected, filter to show only NSFW mods
|
|
return mods.filter((mod) => mod.nsfw || nsfwList.includes(mod.aTag))
|
|
}
|
|
}
|
|
|
|
let filtered = nsfwFilter(mods)
|
|
|
|
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
|
const isUnmoderatedFully =
|
|
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
|
|
|
|
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
|
|
if (!(isAdmin && isUnmoderatedFully)) {
|
|
filtered = filtered.filter(
|
|
(mod) =>
|
|
!muteLists.admin.authors.includes(mod.author) &&
|
|
!muteLists.admin.replaceableEvents.includes(mod.aTag)
|
|
)
|
|
}
|
|
|
|
if (filterOptions.moderated === ModeratedFilter.Moderated) {
|
|
filtered = filtered.filter(
|
|
(mod) =>
|
|
!muteLists.user.authors.includes(mod.author) &&
|
|
!muteLists.user.replaceableEvents.includes(mod.aTag)
|
|
)
|
|
}
|
|
|
|
if (filterOptions.sort === SortBy.Latest) {
|
|
filtered.sort((a, b) => b.published_at - a.published_at)
|
|
} else if (filterOptions.sort === SortBy.Oldest) {
|
|
filtered.sort((a, b) => a.published_at - b.published_at)
|
|
}
|
|
|
|
return filtered
|
|
}, [
|
|
userState.user?.npub,
|
|
filterOptions.sort,
|
|
filterOptions.moderated,
|
|
filterOptions.nsfw,
|
|
mods,
|
|
muteLists,
|
|
nsfwList
|
|
])
|
|
|
|
return (
|
|
<>
|
|
{isFetching && <LoadingSpinner desc='Fetching mod details from relays' />}
|
|
<div className='InnerBodyMain'>
|
|
<div className='ContainerMain'>
|
|
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
|
<PageTitleRow />
|
|
<Filters
|
|
filterOptions={filterOptions}
|
|
setFilterOptions={setFilterOptions}
|
|
/>
|
|
|
|
<div className='IBMSecMain IBMSMListWrapper'>
|
|
<div className='IBMSMList'>
|
|
{filteredModList.map((mod) => {
|
|
const route = getModPageRoute(
|
|
nip19.naddrEncode({
|
|
identifier: mod.aTag,
|
|
pubkey: mod.author,
|
|
kind: kinds.ClassifiedListing
|
|
})
|
|
)
|
|
|
|
return (
|
|
<ModCard
|
|
key={mod.id}
|
|
title={mod.title}
|
|
gameName={mod.game}
|
|
summary={mod.summary}
|
|
imageUrl={mod.featuredImageUrl}
|
|
route={route}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<Pagination
|
|
page={page}
|
|
disabledNext={mods.length < MOD_FILTER_LIMIT}
|
|
handlePrev={handlePrev}
|
|
handleNext={handleNext}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const PageTitleRow = React.memo(() => {
|
|
const navigate = useNavigate()
|
|
const searchTermRef = useRef<HTMLInputElement>(null)
|
|
|
|
const handleSearch = () => {
|
|
const value = searchTermRef.current?.value || '' // Access the input value from the ref
|
|
if (value !== '') {
|
|
const searchParams = createSearchParams({
|
|
searchTerm: value,
|
|
searching: 'Mods'
|
|
})
|
|
navigate({ pathname: appRoutes.search, search: `?${searchParams}` })
|
|
}
|
|
}
|
|
|
|
// Handle "Enter" key press inside the input
|
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (event.key === 'Enter') {
|
|
handleSearch()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className='IBMSecMain'>
|
|
<div className='SearchMainWrapper'>
|
|
<div className='IBMSMTitleMain'>
|
|
<h2 className='IBMSMTitleMainHeading'>Mods</h2>
|
|
</div>
|
|
<div className='SearchMain'>
|
|
<div className='SearchMainInside'>
|
|
<div className='SearchMainInsideWrapper'>
|
|
<input
|
|
type='text'
|
|
className='SMIWInput'
|
|
ref={searchTermRef}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder='Enter search term'
|
|
/>
|
|
|
|
<button
|
|
className='btn btnMain SMIWButton'
|
|
type='button'
|
|
onClick={handleSearch}
|
|
>
|
|
<svg
|
|
xmlns='http://www.w3.org/2000/svg'
|
|
viewBox='0 0 512 512'
|
|
width='1em'
|
|
height='1em'
|
|
fill='currentColor'
|
|
>
|
|
<path d='M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z'></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
type FiltersProps = {
|
|
filterOptions: FilterOptions
|
|
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
|
|
}
|
|
|
|
const Filters = React.memo(
|
|
({ filterOptions, setFilterOptions }: FiltersProps) => {
|
|
const userState = useAppSelector((state) => state.user)
|
|
|
|
return (
|
|
<div className='IBMSecMain'>
|
|
<div className='FiltersMain'>
|
|
<div className='FiltersMainElement'>
|
|
<div className='dropdown dropdownMain'>
|
|
<button
|
|
className='btn dropdown-toggle btnMain btnMainDropdown'
|
|
aria-expanded='false'
|
|
data-bs-toggle='dropdown'
|
|
type='button'
|
|
>
|
|
{filterOptions.sort}
|
|
</button>
|
|
|
|
<div className='dropdown-menu dropdownMainMenu'>
|
|
{Object.values(SortBy).map((item, index) => (
|
|
<div
|
|
key={`sortByItem-${index}`}
|
|
className='dropdown-item dropdownMainMenuItem'
|
|
onClick={() =>
|
|
setFilterOptions((prev) => ({
|
|
...prev,
|
|
sort: item
|
|
}))
|
|
}
|
|
>
|
|
{item}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className='FiltersMainElement'>
|
|
<div className='dropdown dropdownMain'>
|
|
<button
|
|
className='btn dropdown-toggle btnMain btnMainDropdown'
|
|
aria-expanded='false'
|
|
data-bs-toggle='dropdown'
|
|
type='button'
|
|
>
|
|
{filterOptions.moderated}
|
|
</button>
|
|
<div className='dropdown-menu dropdownMainMenu'>
|
|
{Object.values(ModeratedFilter).map((item, index) => {
|
|
if (item === ModeratedFilter.Unmoderated_Fully) {
|
|
const isAdmin =
|
|
userState.user?.npub ===
|
|
import.meta.env.VITE_REPORTING_NPUB
|
|
|
|
if (!isAdmin) return null
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={`moderatedFilterItem-${index}`}
|
|
className='dropdown-item dropdownMainMenuItem'
|
|
onClick={() =>
|
|
setFilterOptions((prev) => ({
|
|
...prev,
|
|
moderated: item
|
|
}))
|
|
}
|
|
>
|
|
{item}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className='FiltersMainElement'>
|
|
<div className='dropdown dropdownMain'>
|
|
<button
|
|
className='btn dropdown-toggle btnMain btnMainDropdown'
|
|
aria-expanded='false'
|
|
data-bs-toggle='dropdown'
|
|
type='button'
|
|
>
|
|
{filterOptions.nsfw}
|
|
</button>
|
|
<div className='dropdown-menu dropdownMainMenu'>
|
|
{Object.values(NSFWFilter).map((item, index) => (
|
|
<div
|
|
key={`nsfwFilterItem-${index}`}
|
|
className='dropdown-item dropdownMainMenuItem'
|
|
onClick={() =>
|
|
setFilterOptions((prev) => ({
|
|
...prev,
|
|
nsfw: item
|
|
}))
|
|
}
|
|
>
|
|
{item}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className='FiltersMainElement'>
|
|
<div className='dropdown dropdownMain'>
|
|
<button
|
|
className='btn dropdown-toggle btnMain btnMainDropdown'
|
|
aria-expanded='false'
|
|
data-bs-toggle='dropdown'
|
|
type='button'
|
|
>
|
|
{filterOptions.source === window.location.host
|
|
? `Show From: ${filterOptions.source}`
|
|
: 'Show All'}
|
|
</button>
|
|
<div className='dropdown-menu dropdownMainMenu'>
|
|
<div
|
|
className='dropdown-item dropdownMainMenuItem'
|
|
onClick={() =>
|
|
setFilterOptions((prev) => ({
|
|
...prev,
|
|
source: window.location.host
|
|
}))
|
|
}
|
|
>
|
|
Show From: {window.location.host}
|
|
</div>
|
|
<div
|
|
className='dropdown-item dropdownMainMenuItem'
|
|
onClick={() =>
|
|
setFilterOptions((prev) => ({
|
|
...prev,
|
|
source: 'Show All'
|
|
}))
|
|
}
|
|
>
|
|
Show All
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
)
|