Multi-file games lists, clicking a game leads to mod search for that game, search redirects, pagination optimizations #38
299
src/pages/game.tsx
Normal file
299
src/pages/game.tsx
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||||
|
import { ModCard } from 'components/ModCard'
|
||||||
|
import { PaginationWithPageNumbers } from 'components/Pagination'
|
||||||
|
import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts'
|
||||||
|
import { RelayController } from 'controllers'
|
||||||
|
import { useAppSelector, useMuteLists } from 'hooks'
|
||||||
|
import { Filter, kinds, nip19 } from 'nostr-tools'
|
||||||
|
import { Subscription } from 'nostr-tools/abstract-relay'
|
||||||
|
import React, {
|
||||||
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { getModPageRoute } from 'routes'
|
||||||
|
import { ModDetails } from 'types'
|
||||||
|
import { extractModData, isModDataComplete, log, LogType } from 'utils'
|
||||||
|
|
||||||
|
enum SortByEnum {
|
||||||
|
Latest = 'Latest',
|
||||||
|
Oldest = 'Oldest',
|
||||||
|
Best_Rated = 'Best Rated',
|
||||||
|
Worst_Rated = 'Worst Rated'
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ModeratedFilterEnum {
|
||||||
|
Moderated = 'Moderated',
|
||||||
|
Unmoderated = 'Unmoderated',
|
||||||
|
Unmoderated_Fully = 'Unmoderated Fully'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterOptions {
|
||||||
|
sort: SortByEnum
|
||||||
|
moderated: ModeratedFilterEnum
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GamePage = () => {
|
||||||
|
const params = useParams()
|
||||||
|
const { name: gameName } = params
|
||||||
|
const muteLists = useMuteLists()
|
||||||
|
|
||||||
|
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||||
|
sort: SortByEnum.Latest,
|
||||||
|
moderated: ModeratedFilterEnum.Moderated
|
||||||
|
})
|
||||||
|
const [mods, setMods] = useState<ModDetails[]>([])
|
||||||
|
|
||||||
|
const hasEffectRun = useRef(false)
|
||||||
|
const [isSubscribing, setIsSubscribing] = useState(false)
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
|
||||||
|
const userState = useAppSelector((state) => state.user)
|
||||||
|
|
||||||
|
const filteredMods = useMemo(() => {
|
||||||
|
let filtered: ModDetails[] = [...mods]
|
||||||
|
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||||
|
const isUnmoderatedFully =
|
||||||
|
filterOptions.moderated === ModeratedFilterEnum.Unmoderated_Fully
|
||||||
|
|
||||||
|
// Only apply filtering if the user is not an admin or the admin has not selected "Unmoderated Fully"
|
||||||
|
if (!(isAdmin && isUnmoderatedFully)) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(mod) =>
|
||||||
|
!muteLists.admin.authors.includes(mod.author) &&
|
||||||
|
!muteLists.admin.replaceableEvents.includes(mod.aTag)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterOptions.moderated === ModeratedFilterEnum.Moderated) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(mod) =>
|
||||||
|
!muteLists.user.authors.includes(mod.author) &&
|
||||||
|
!muteLists.user.replaceableEvents.includes(mod.aTag)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterOptions.sort === SortByEnum.Latest) {
|
||||||
|
filtered.sort((a, b) => b.published_at - a.published_at)
|
||||||
|
} else if (filterOptions.sort === SortByEnum.Oldest) {
|
||||||
|
filtered.sort((a, b) => a.published_at - b.published_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}, [
|
||||||
|
mods,
|
||||||
|
userState.user?.npub,
|
||||||
|
filterOptions.sort,
|
||||||
|
filterOptions.moderated,
|
||||||
|
muteLists
|
||||||
|
])
|
||||||
|
|
||||||
|
// Pagination logic
|
||||||
|
const totalGames = filteredMods.length
|
||||||
|
const totalPages = Math.ceil(totalGames / MAX_MODS_PER_PAGE)
|
||||||
|
const startIndex = (currentPage - 1) * MAX_MODS_PER_PAGE
|
||||||
|
const endIndex = startIndex + MAX_MODS_PER_PAGE
|
||||||
|
const currentMods = filteredMods.slice(startIndex, endIndex)
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
if (page >= 1 && page <= totalPages) {
|
||||||
|
setCurrentPage(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasEffectRun.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasEffectRun.current = true // Set it so the effect doesn't run again
|
||||||
|
|
||||||
|
const filter: Filter = {
|
||||||
|
kinds: [kinds.ClassifiedListing],
|
||||||
|
'#t': [T_TAG_VALUE]
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubscribing(true)
|
||||||
|
|
||||||
|
let subscriptions: Subscription[] = []
|
||||||
|
|
||||||
|
RelayController.getInstance()
|
||||||
|
.subscribeForEvents(filter, [], (event) => {
|
||||||
|
if (isModDataComplete(event)) {
|
||||||
|
const mod = extractModData(event)
|
||||||
|
if (mod.game === gameName) setMods((prev) => [...prev, mod])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((subs) => {
|
||||||
|
subscriptions = subs
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log(
|
||||||
|
true,
|
||||||
|
LogType.Error,
|
||||||
|
'An error occurred in subscribing to relays.',
|
||||||
|
err
|
||||||
|
)
|
||||||
|
toast.error(err.message || err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsSubscribing(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cleanup function to stop all subscriptions
|
||||||
|
return () => {
|
||||||
|
subscriptions.forEach((sub) => sub.close()) // close each subscription
|
||||||
|
}
|
||||||
|
}, [gameName])
|
||||||
|
|
||||||
|
if (!gameName) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isSubscribing && (
|
||||||
|
<LoadingSpinner desc='Subscribing to relays for mods' />
|
||||||
|
)}
|
||||||
|
<div className='InnerBodyMain'>
|
||||||
|
<div className='ContainerMain'>
|
||||||
|
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
||||||
|
<div className='IBMSecMain'>
|
||||||
|
<div className='SearchMainWrapper'>
|
||||||
|
<div className='IBMSMTitleMain'>
|
||||||
|
<h2 className='IBMSMTitleMainHeading'>
|
||||||
|
Game:
|
||||||
|
<span className='IBMSMTitleMainHeadingSpan'>
|
||||||
|
{gameName}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Filters
|
||||||
|
filterOptions={filterOptions}
|
||||||
|
setFilterOptions={setFilterOptions}
|
||||||
|
/>
|
||||||
|
<div className='IBMSecMain IBMSMListWrapper'>
|
||||||
|
<div className='IBMSMList'>
|
||||||
|
{currentMods.map((mod) => {
|
||||||
|
const route = getModPageRoute(
|
||||||
|
nip19.naddrEncode({
|
||||||
|
identifier: mod.aTag,
|
||||||
|
pubkey: mod.author,
|
||||||
|
kind: kinds.ClassifiedListing
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModCard
|
||||||
|
key={mod.id}
|
||||||
|
title={mod.title}
|
||||||
|
gameName={mod.game}
|
||||||
|
summary={mod.summary}
|
||||||
|
imageUrl={mod.featuredImageUrl}
|
||||||
|
route={route}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PaginationWithPageNumbers
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
handlePageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FiltersProps = {
|
||||||
|
filterOptions: FilterOptions
|
||||||
|
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Filters = React.memo(
|
||||||
|
({ filterOptions, setFilterOptions }: FiltersProps) => {
|
||||||
|
const userState = useAppSelector((state) => state.user)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='IBMSecMain'>
|
||||||
|
<div className='FiltersMain'>
|
||||||
|
<div className='FiltersMainElement'>
|
||||||
|
<div className='dropdown dropdownMain'>
|
||||||
|
<button
|
||||||
|
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||||
|
aria-expanded='false'
|
||||||
|
data-bs-toggle='dropdown'
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
{filterOptions.sort}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className='dropdown-menu dropdownMainMenu'>
|
||||||
|
{Object.values(SortByEnum).map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={`sortByItem-${index}`}
|
||||||
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
|
onClick={() =>
|
||||||
|
setFilterOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sort: item
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='FiltersMainElement'>
|
||||||
|
<div className='dropdown dropdownMain'>
|
||||||
|
<button
|
||||||
|
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||||
|
aria-expanded='false'
|
||||||
|
data-bs-toggle='dropdown'
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
{filterOptions.moderated}
|
||||||
|
</button>
|
||||||
|
<div className='dropdown-menu dropdownMainMenu'>
|
||||||
|
{Object.values(ModeratedFilterEnum).map((item, index) => {
|
||||||
|
if (item === ModeratedFilterEnum.Unmoderated_Fully) {
|
||||||
|
const isAdmin =
|
||||||
|
userState.user?.npub ===
|
||||||
|
import.meta.env.VITE_REPORTING_NPUB
|
||||||
|
|
||||||
|
if (!isAdmin) return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`moderatedFilterItem-${index}`}
|
||||||
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
|
onClick={() =>
|
||||||
|
setFilterOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
moderated: item
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
@ -9,11 +9,13 @@ import { ProfilePage } from '../pages/profile'
|
|||||||
import { SettingsPage } from '../pages/settings'
|
import { SettingsPage } from '../pages/settings'
|
||||||
import { SubmitModPage } from '../pages/submitMod'
|
import { SubmitModPage } from '../pages/submitMod'
|
||||||
import { WritePage } from '../pages/write'
|
import { WritePage } from '../pages/write'
|
||||||
|
import { GamePage } from 'pages/game'
|
||||||
|
|
||||||
export const appRoutes = {
|
export const appRoutes = {
|
||||||
index: '/',
|
index: '/',
|
||||||
home: '/home',
|
home: '/home',
|
||||||
games: '/games',
|
games: '/games',
|
||||||
|
game: '/game/:name',
|
||||||
mods: '/mods',
|
mods: '/mods',
|
||||||
mod: '/mod/:naddr',
|
mod: '/mod/:naddr',
|
||||||
about: '/about',
|
about: '/about',
|
||||||
@ -29,6 +31,9 @@ export const appRoutes = {
|
|||||||
profile: '/profile/:nprofile'
|
profile: '/profile/:nprofile'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getGamePageRoute = (name: string) =>
|
||||||
|
appRoutes.game.replace(':name', name)
|
||||||
|
|
||||||
export const getModPageRoute = (eventId: string) =>
|
export const getModPageRoute = (eventId: string) =>
|
||||||
appRoutes.mod.replace(':naddr', eventId)
|
appRoutes.mod.replace(':naddr', eventId)
|
||||||
|
|
||||||
@ -51,6 +56,10 @@ export const routes = [
|
|||||||
path: appRoutes.games,
|
path: appRoutes.games,
|
||||||
element: <GamesPage />
|
element: <GamesPage />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: appRoutes.game,
|
||||||
|
element: <GamePage />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: appRoutes.mods,
|
path: appRoutes.mods,
|
||||||
element: <ModsPage />
|
element: <ModsPage />
|
||||||
|
Loading…
Reference in New Issue
Block a user