feat: implemented search page
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
All checks were successful
Release to Staging / build_and_release (push) Successful in 43s
This commit is contained in:
parent
9e8aa16297
commit
4dc65b92f7
@ -1,3 +1,652 @@
|
|||||||
export const SearchPage = () => {
|
import { NDKEvent, NDKUserProfile, profileFromEvent } from '@nostr-dev-kit/ndk'
|
||||||
return <h1>WIP</h1>
|
import { ErrorBoundary } from 'components/ErrorBoundary'
|
||||||
|
import { GameCard } from 'components/GameCard'
|
||||||
|
import { LoadingSpinner } from 'components/LoadingSpinner'
|
||||||
|
import { ModCard } from 'components/ModCard'
|
||||||
|
import { Pagination } from 'components/Pagination'
|
||||||
|
import { Profile } from 'components/ProfileSection'
|
||||||
|
import { T_TAG_VALUE } from 'constants.ts'
|
||||||
|
import { MetadataController, RelayController } from 'controllers'
|
||||||
|
import { useAppSelector, useDidMount } from 'hooks'
|
||||||
|
import { Filter, kinds, nip19 } from 'nostr-tools'
|
||||||
|
import { Subscription } from 'nostr-tools/abstract-relay'
|
||||||
|
import Papa from 'papaparse'
|
||||||
|
import React, {
|
||||||
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from 'react'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { getModPageRoute } from 'routes'
|
||||||
|
import { ModDetails, MuteLists } 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'
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SearchingFilterEnum {
|
||||||
|
Mods = 'Mods',
|
||||||
|
Games = 'Games',
|
||||||
|
Users = 'Users'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterOptions {
|
||||||
|
sort: SortByEnum
|
||||||
|
moderated: ModeratedFilterEnum
|
||||||
|
searching: SearchingFilterEnum
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchPage = () => {
|
||||||
|
const searchTermRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||||
|
sort: SortByEnum.Latest,
|
||||||
|
moderated: ModeratedFilterEnum.Moderated,
|
||||||
|
searching: SearchingFilterEnum.Mods
|
||||||
|
})
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [muteLists, setMuteLists] = useState<{
|
||||||
|
admin: MuteLists
|
||||||
|
user: MuteLists
|
||||||
|
}>({
|
||||||
|
admin: {
|
||||||
|
authors: [],
|
||||||
|
replaceableEvents: []
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
authors: [],
|
||||||
|
replaceableEvents: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const userState = useAppSelector((state) => state.user)
|
||||||
|
|
||||||
|
useDidMount(async () => {
|
||||||
|
const pubkey = userState.user?.pubkey as string | undefined
|
||||||
|
|
||||||
|
const metadataController = await MetadataController.getInstance()
|
||||||
|
metadataController.getMuteLists(pubkey).then((lists) => {
|
||||||
|
setMuteLists(lists)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
const value = searchTermRef.current?.value || '' // Access the input value from the ref
|
||||||
|
setSearchTerm(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "Enter" key press inside the input
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='InnerBodyMain'>
|
||||||
|
<div className='ContainerMain'>
|
||||||
|
<div className='IBMSecMainGroup IBMSecMainGroupAlt'>
|
||||||
|
<div className='IBMSecMain'>
|
||||||
|
<div className='SearchMainWrapper'>
|
||||||
|
<div className='IBMSMTitleMain'>
|
||||||
|
<h2 className='IBMSMTitleMainHeading'>
|
||||||
|
Search:
|
||||||
|
<span className='IBMSMTitleMainHeadingSpan'>
|
||||||
|
{searchTerm}
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
<Filters
|
||||||
|
filterOptions={filterOptions}
|
||||||
|
setFilterOptions={setFilterOptions}
|
||||||
|
/>
|
||||||
|
{filterOptions.searching === SearchingFilterEnum.Mods && (
|
||||||
|
<ModsResult
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
filterOptions={filterOptions}
|
||||||
|
muteLists={muteLists}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filterOptions.searching === SearchingFilterEnum.Users && (
|
||||||
|
<UsersResult
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
muteLists={muteLists}
|
||||||
|
moderationFilter={filterOptions.moderated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filterOptions.searching === SearchingFilterEnum.Games && (
|
||||||
|
<GamesResult searchTerm={searchTerm} />
|
||||||
|
)}
|
||||||
|
</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 className='FiltersMainElement'>
|
||||||
|
<div className='dropdown dropdownMain'>
|
||||||
|
<button
|
||||||
|
className='btn dropdown-toggle btnMain btnMainDropdown'
|
||||||
|
aria-expanded='false'
|
||||||
|
data-bs-toggle='dropdown'
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
Searching: {filterOptions.searching}
|
||||||
|
</button>
|
||||||
|
<div className='dropdown-menu dropdownMainMenu'>
|
||||||
|
{Object.values(SearchingFilterEnum).map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={`searchingFilterItem-${index}`}
|
||||||
|
className='dropdown-item dropdownMainMenuItem'
|
||||||
|
onClick={() =>
|
||||||
|
setFilterOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
searching: item
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const MAX_MODS_PER_PAGE = 10
|
||||||
|
|
||||||
|
type ModsResultProps = {
|
||||||
|
filterOptions: FilterOptions
|
||||||
|
searchTerm: string
|
||||||
|
muteLists: {
|
||||||
|
admin: MuteLists
|
||||||
|
user: MuteLists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModsResult = ({
|
||||||
|
filterOptions,
|
||||||
|
searchTerm,
|
||||||
|
muteLists
|
||||||
|
}: ModsResultProps) => {
|
||||||
|
const hasEffectRun = useRef(false)
|
||||||
|
const [isSubscribing, setIsSubscribing] = useState(false)
|
||||||
|
const [mods, setMods] = useState<ModDetails[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const userState = useAppSelector((state) => state.user)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
}, [searchTerm])
|
||||||
|
|
||||||
|
const filteredMods = useMemo(() => {
|
||||||
|
if (searchTerm === '') return []
|
||||||
|
|
||||||
|
const lowerCaseSearchTerm = searchTerm.toLowerCase()
|
||||||
|
|
||||||
|
const filterFn = (mod: ModDetails) =>
|
||||||
|
mod.title.toLowerCase().includes(lowerCaseSearchTerm) ||
|
||||||
|
mod.game.toLowerCase().includes(lowerCaseSearchTerm) ||
|
||||||
|
mod.summary.toLowerCase().includes(lowerCaseSearchTerm) ||
|
||||||
|
mod.body.toLowerCase().includes(lowerCaseSearchTerm) ||
|
||||||
|
mod.tags.findIndex((tag) =>
|
||||||
|
tag.toLowerCase().includes(lowerCaseSearchTerm)
|
||||||
|
) > -1
|
||||||
|
|
||||||
|
return mods.filter(filterFn)
|
||||||
|
}, [mods, searchTerm])
|
||||||
|
|
||||||
|
const filteredModList = useMemo(() => {
|
||||||
|
let filtered: ModDetails[] = [...filteredMods]
|
||||||
|
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
|
||||||
|
}, [
|
||||||
|
filteredMods,
|
||||||
|
userState.user?.npub,
|
||||||
|
filterOptions.sort,
|
||||||
|
filterOptions.moderated,
|
||||||
|
muteLists
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
setPage((prev) => prev + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
setPage((prev) => prev - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isSubscribing && (
|
||||||
|
<LoadingSpinner desc='Subscribing to relays for mods' />
|
||||||
|
)}
|
||||||
|
<div className='IBMSecMain IBMSMListWrapper'>
|
||||||
|
<div className='IBMSMList'>
|
||||||
|
{filteredModList
|
||||||
|
.slice((page - 1) * MAX_MODS_PER_PAGE, page * MAX_MODS_PER_PAGE)
|
||||||
|
.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={filteredModList.length <= page * MAX_MODS_PER_PAGE}
|
||||||
|
handlePrev={handlePrev}
|
||||||
|
handleNext={handleNext}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsersResultProps = {
|
||||||
|
searchTerm: string
|
||||||
|
moderationFilter: ModeratedFilterEnum
|
||||||
|
muteLists: {
|
||||||
|
admin: MuteLists
|
||||||
|
user: MuteLists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UsersResult = ({
|
||||||
|
searchTerm,
|
||||||
|
moderationFilter,
|
||||||
|
muteLists
|
||||||
|
}: UsersResultProps) => {
|
||||||
|
const [isFetching, setIsFetching] = useState(false)
|
||||||
|
const [profiles, setProfiles] = useState<NDKUserProfile[]>([])
|
||||||
|
|
||||||
|
const userState = useAppSelector((state) => state.user)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTerm === '') {
|
||||||
|
setProfiles([])
|
||||||
|
} else {
|
||||||
|
const filter: Filter = {
|
||||||
|
kinds: [kinds.Metadata],
|
||||||
|
search: searchTerm
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFetching(true)
|
||||||
|
RelayController.getInstance()
|
||||||
|
.fetchEvents(filter, ['wss://purplepag.es', 'wss://user.kindpag.es'])
|
||||||
|
.then((events) => {
|
||||||
|
const results = events.map((event) => {
|
||||||
|
const ndkEvent = new NDKEvent(undefined, event)
|
||||||
|
const profile = profileFromEvent(ndkEvent)
|
||||||
|
return profile
|
||||||
|
})
|
||||||
|
setProfiles(results)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log(true, LogType.Error, 'An error occurred in fetching users', err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsFetching(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [searchTerm])
|
||||||
|
|
||||||
|
const filteredProfiles = useMemo(() => {
|
||||||
|
let filtered = [...profiles]
|
||||||
|
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||||
|
const isUnmoderatedFully =
|
||||||
|
moderationFilter === 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(
|
||||||
|
(profile) => !muteLists.admin.authors.includes(profile.pubkey as string)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moderationFilter === ModeratedFilterEnum.Moderated) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(profile) => !muteLists.user.authors.includes(profile.pubkey as string)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}, [userState.user?.npub, moderationFilter, profiles, muteLists])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isFetching && <LoadingSpinner desc='Fetching Profiles' />}
|
||||||
|
<div className='IBMSecMain IBMSMListWrapper'>
|
||||||
|
<div className='IBMSMList'>
|
||||||
|
{filteredProfiles.map((profile) => {
|
||||||
|
if (profile.pubkey) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary key={profile.pubkey}>
|
||||||
|
<Profile profile={profile} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Game = {
|
||||||
|
'Game Name': string
|
||||||
|
'16 by 9 image': string
|
||||||
|
'Boxart image': string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_GAMES_PER_PAGE = 10
|
||||||
|
|
||||||
|
type GamesResultProps = {
|
||||||
|
searchTerm: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const GamesResult = ({ searchTerm }: GamesResultProps) => {
|
||||||
|
const hasProcessedCSV = useRef(false)
|
||||||
|
const [isProcessingCSVFile, setIsProcessingCSVFile] = useState(false)
|
||||||
|
const [games, setGames] = useState<Game[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasProcessedCSV.current) return
|
||||||
|
hasProcessedCSV.current = true
|
||||||
|
|
||||||
|
setIsProcessingCSVFile(true)
|
||||||
|
|
||||||
|
// Fetch the CSV file from the public folder
|
||||||
|
fetch('/assets/games.csv')
|
||||||
|
.then((response) => response.text())
|
||||||
|
.then((csvText) => {
|
||||||
|
// Parse the CSV text using PapaParse
|
||||||
|
Papa.parse<Game>(csvText, {
|
||||||
|
worker: true,
|
||||||
|
header: true,
|
||||||
|
complete: (results) => {
|
||||||
|
const uniqueGames: Game[] = []
|
||||||
|
const gameNames = new Set<string>()
|
||||||
|
|
||||||
|
// Remove duplicate games based on 'Game Name'
|
||||||
|
results.data.forEach((game) => {
|
||||||
|
if (!gameNames.has(game['Game Name'])) {
|
||||||
|
gameNames.add(game['Game Name'])
|
||||||
|
uniqueGames.push(game)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set the unique games list
|
||||||
|
setGames(uniqueGames)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log(true, LogType.Error, 'Error occurred in processing csv file', err)
|
||||||
|
toast.error(err.message || err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsProcessingCSVFile(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Reset the page to 1 whenever searchTerm changes
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
}, [searchTerm])
|
||||||
|
|
||||||
|
const filteredGames = useMemo(() => {
|
||||||
|
if (searchTerm === '') return []
|
||||||
|
|
||||||
|
const lowerCaseSearchTerm = searchTerm.toLowerCase()
|
||||||
|
|
||||||
|
return games.filter((game) =>
|
||||||
|
game['Game Name'].toLowerCase().includes(lowerCaseSearchTerm)
|
||||||
|
)
|
||||||
|
}, [searchTerm, games])
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
setPage((prev) => prev + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
setPage((prev) => prev - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isProcessingCSVFile && <LoadingSpinner desc='Processing games file' />}
|
||||||
|
<div className='IBMSecMain IBMSMListWrapper'>
|
||||||
|
<div className='IBMSMList'>
|
||||||
|
{filteredGames
|
||||||
|
.slice((page - 1) * MAX_GAMES_PER_PAGE, page * MAX_GAMES_PER_PAGE)
|
||||||
|
.map((game) => (
|
||||||
|
<GameCard
|
||||||
|
key={game['Game Name']}
|
||||||
|
title={game['Game Name']}
|
||||||
|
imageUrl={game['Boxart image']}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
disabledNext={filteredGames.length <= page * MAX_GAMES_PER_PAGE}
|
||||||
|
handlePrev={handlePrev}
|
||||||
|
handleNext={handleNext}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user