degmods.com/src/pages/search.tsx

508 lines
14 KiB
TypeScript
Raw Normal View History

2024-10-14 19:20:43 +05:00
import {
NDKEvent,
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage,
NDKUserProfile,
profileFromEvent
} from '@nostr-dev-kit/ndk'
2024-09-18 08:20:32 +05:00
import { ErrorBoundary } from 'components/ErrorBoundary'
import { GameCard } from 'components/GameCard'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ModCard } from 'components/ModCard'
2024-10-07 15:45:21 +05:00
import { ModFilter } from 'components/ModsFilter'
2024-09-18 08:20:32 +05:00
import { Pagination } from 'components/Pagination'
import { Profile } from 'components/ProfileSection'
import { SearchInput } from 'components/SearchInput'
2024-09-18 21:40:34 +05:00
import {
MAX_GAMES_PER_PAGE,
MAX_MODS_PER_PAGE,
T_TAG_VALUE
} from 'constants.ts'
2024-10-07 15:45:21 +05:00
import {
useAppSelector,
useFilteredMods,
useGames,
useLocalStorage,
2024-10-07 15:45:21 +05:00
useMuteLists,
2024-10-14 19:20:43 +05:00
useNDKContext,
2024-10-07 15:45:21 +05:00
useNSFWList
} from 'hooks'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { FilterOptions, ModDetails, ModeratedFilter, MuteLists } from 'types'
2024-10-07 15:45:21 +05:00
import {
DEFAULT_FILTER_OPTIONS,
extractModData,
isModDataComplete,
log,
LogType,
2024-10-29 15:44:41 +01:00
scrollIntoView,
timeout
} from 'utils'
2024-09-18 08:20:32 +05:00
2024-10-07 15:45:21 +05:00
enum SearchKindEnum {
2024-09-18 08:20:32 +05:00
Mods = 'Mods',
Games = 'Games',
Users = 'Users'
}
export const SearchPage = () => {
const scrollTargetRef = useRef<HTMLDivElement>(null)
const [searchParams, setSearchParams] = useSearchParams()
2024-09-18 21:40:34 +05:00
const muteLists = useMuteLists()
2024-10-07 15:45:21 +05:00
const nsfwList = useNSFWList()
2024-09-18 08:20:32 +05:00
const searchTermRef = useRef<HTMLInputElement>(null)
2024-10-07 15:45:21 +05:00
const searchKind =
(searchParams.get('kind') as SearchKindEnum) || SearchKindEnum.Mods
2024-10-07 15:45:21 +05:00
const [filterOptions] = useLocalStorage<FilterOptions>(
'filter',
DEFAULT_FILTER_OPTIONS
)
2024-10-07 15:45:21 +05:00
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '')
2024-09-18 08:20:32 +05:00
const handleSearch = () => {
const value = searchTermRef.current?.value || '' // Access the input value from the ref
setSearchTerm(value)
if (value) {
searchParams.set('q', value)
} else {
searchParams.delete('q')
}
setSearchParams(searchParams, {
replace: true
})
2024-09-18 08:20:32 +05:00
}
// 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'
ref={scrollTargetRef}
>
2024-09-18 08:20:32 +05:00
<div className='IBMSecMain'>
<div className='SearchMainWrapper'>
<div className='IBMSMTitleMain'>
<h2 className='IBMSMTitleMainHeading'>
Search:&nbsp;
<span className='IBMSMTitleMainHeadingSpan'>
{searchTerm}
</span>
</h2>
</div>
<SearchInput
handleKeyDown={handleKeyDown}
handleSearch={handleSearch}
ref={searchTermRef}
/>
2024-09-18 08:20:32 +05:00
</div>
</div>
<Filters />
2024-10-07 15:45:21 +05:00
{searchKind === SearchKindEnum.Mods && (
2024-09-18 08:20:32 +05:00
<ModsResult
searchTerm={searchTerm}
filterOptions={filterOptions}
muteLists={muteLists}
2024-10-07 15:45:21 +05:00
nsfwList={nsfwList}
el={scrollTargetRef.current}
2024-09-18 08:20:32 +05:00
/>
)}
2024-10-07 15:45:21 +05:00
{searchKind === SearchKindEnum.Users && (
2024-09-18 08:20:32 +05:00
<UsersResult
searchTerm={searchTerm}
muteLists={muteLists}
moderationFilter={filterOptions.moderated}
/>
)}
2024-10-07 15:45:21 +05:00
{searchKind === SearchKindEnum.Games && (
2024-09-18 08:20:32 +05:00
<GamesResult searchTerm={searchTerm} />
)}
</div>
</div>
</div>
)
}
const Filters = React.memo(() => {
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
'filter',
DEFAULT_FILTER_OPTIONS
)
const userState = useAppSelector((state) => state.user)
const [searchParams, setSearchParams] = useSearchParams()
const searchKind =
(searchParams.get('kind') as SearchKindEnum) || SearchKindEnum.Mods
const handleChangeSearchKind = (kind: SearchKindEnum) => {
searchParams.set('kind', kind)
setSearchParams(searchParams, {
replace: true
})
}
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>
{searchKind === SearchKindEnum.Mods && <ModFilter />}
{searchKind === SearchKindEnum.Users && (
2024-09-18 08:20:32 +05:00
<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}
2024-09-18 08:20:32 +05:00
</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>
)
})}
2024-09-18 08:20:32 +05:00
</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: {searchKind}
</button>
<div className='dropdown-menu dropdownMainMenu'>
{Object.values(SearchKindEnum).map((item, index) => (
<div
key={`searchingFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
onClick={() => handleChangeSearchKind(item)}
>
{item}
</div>
))}
</div>
</div>
2024-09-18 08:20:32 +05:00
</div>
</div>
</div>
)
})
2024-09-18 08:20:32 +05:00
type ModsResultProps = {
filterOptions: FilterOptions
searchTerm: string
muteLists: {
admin: MuteLists
user: MuteLists
}
2024-10-07 15:45:21 +05:00
nsfwList: string[]
el: HTMLElement | null
2024-09-18 08:20:32 +05:00
}
const ModsResult = ({
filterOptions,
searchTerm,
2024-10-07 15:45:21 +05:00
muteLists,
nsfwList,
el
2024-09-18 08:20:32 +05:00
}: ModsResultProps) => {
2024-10-14 19:20:43 +05:00
const { ndk } = useNDKContext()
2024-09-18 08:20:32 +05:00
const [mods, setMods] = useState<ModDetails[]>([])
const [page, setPage] = useState(1)
const userState = useAppSelector((state) => state.user)
useEffect(() => {
2024-10-14 19:20:43 +05:00
const filter: NDKFilter = {
kinds: [NDKKind.Classified],
'#t': [T_TAG_VALUE]
2024-09-18 08:20:32 +05:00
}
2024-10-14 19:20:43 +05:00
const subscription = ndk.subscribe(filter, {
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
closeOnEose: true
})
2024-09-18 08:20:32 +05:00
2024-10-14 19:20:43 +05:00
subscription.on('event', (ndkEvent) => {
if (isModDataComplete(ndkEvent)) {
const mod = extractModData(ndkEvent)
setMods((prev) => {
if (prev.find((e) => e.aTag === mod.aTag)) return [...prev]
2024-09-18 08:20:32 +05:00
2024-10-14 19:20:43 +05:00
return [...prev, mod]
})
}
})
2024-09-18 08:20:32 +05:00
// Cleanup function to stop all subscriptions
return () => {
2024-10-14 19:20:43 +05:00
subscription.stop()
2024-09-18 08:20:32 +05:00
}
2024-10-14 19:20:43 +05:00
}, [ndk])
2024-09-18 08:20:32 +05:00
useEffect(() => {
scrollIntoView(el)
2024-09-18 08:20:32 +05:00
setPage(1)
// eslint-disable-next-line react-hooks/exhaustive-deps
2024-09-18 08:20:32 +05:00
}, [searchTerm])
const filteredMods = useMemo(() => {
// Search page requires search term
2024-09-18 08:20:32 +05:00
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
const filterSourceFn = (mod: ModDetails) => {
// Filter by source if selected
if (filterOptions.source === window.location.host) {
return mod.rTag === filterOptions.source
}
return true
}
return mods.filter(filterFn).filter(filterSourceFn)
}, [filterOptions.source, mods, searchTerm])
2024-09-18 08:20:32 +05:00
2024-10-07 15:45:21 +05:00
const filteredModList = useFilteredMods(
2024-09-18 08:20:32 +05:00
filteredMods,
2024-10-07 15:45:21 +05:00
userState,
filterOptions,
nsfwList,
2024-09-18 08:20:32 +05:00
muteLists
2024-10-07 15:45:21 +05:00
)
2024-09-18 08:20:32 +05:00
const handleNext = () => {
scrollIntoView(el)
2024-09-18 08:20:32 +05:00
setPage((prev) => prev + 1)
}
const handlePrev = () => {
scrollIntoView(el)
2024-09-18 08:20:32 +05:00
setPage((prev) => prev - 1)
}
return (
<>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{filteredModList
.slice((page - 1) * MAX_MODS_PER_PAGE, page * MAX_MODS_PER_PAGE)
.map((mod) => (
<ModCard key={mod.id} {...mod} />
))}
2024-09-18 08:20:32 +05:00
</div>
</div>
<Pagination
page={page}
disabledNext={filteredModList.length <= page * MAX_MODS_PER_PAGE}
handlePrev={handlePrev}
handleNext={handleNext}
/>
</>
)
}
type UsersResultProps = {
searchTerm: string
2024-10-07 15:45:21 +05:00
moderationFilter: ModeratedFilter
2024-09-18 08:20:32 +05:00
muteLists: {
admin: MuteLists
user: MuteLists
}
}
const UsersResult = ({
searchTerm,
moderationFilter,
muteLists
}: UsersResultProps) => {
2024-10-14 19:20:43 +05:00
const { fetchEvents } = useNDKContext()
2024-09-18 08:20:32 +05:00
const [isFetching, setIsFetching] = useState(false)
const [profiles, setProfiles] = useState<NDKUserProfile[]>([])
const userState = useAppSelector((state) => state.user)
useEffect(() => {
if (searchTerm === '') {
setProfiles([])
} else {
2024-10-29 15:44:41 +01:00
const fetchProfiles = async () => {
setIsFetching(true)
const filter: NDKFilter = {
kinds: [NDKKind.Metadata],
search: searchTerm
}
const profiles = await Promise.race([
fetchEvents(filter),
timeout(10 * 1000)
])
.then((events) => {
const results = events.map((event) => {
const ndkEvent = new NDKEvent(undefined, event)
const profile = profileFromEvent(ndkEvent)
return profile
})
return results
})
.catch((err) => {
log(true, LogType.Error, 'An error occurred in fetching users', err)
return []
})
setProfiles(profiles)
setIsFetching(false)
2024-09-18 08:20:32 +05:00
}
2024-10-29 15:44:41 +01:00
fetchProfiles()
2024-09-18 08:20:32 +05:00
}
2024-10-29 15:44:41 +01:00
}, [fetchEvents, searchTerm])
2024-09-18 08:20:32 +05:00
const filteredProfiles = useMemo(() => {
let filtered = [...profiles]
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isUnmoderatedFully =
2024-10-07 15:45:21 +05:00
moderationFilter === ModeratedFilter.Unmoderated_Fully
2024-09-18 08:20:32 +05:00
// 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)
)
}
2024-10-07 15:45:21 +05:00
if (moderationFilter === ModeratedFilter.Moderated) {
2024-09-18 08:20:32 +05:00
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 pubkey={profile.pubkey as string} />
2024-09-18 08:20:32 +05:00
</ErrorBoundary>
)
}
return null
})}
</div>
</div>
</>
)
}
type GamesResultProps = {
searchTerm: string
}
const GamesResult = ({ searchTerm }: GamesResultProps) => {
2024-09-18 21:40:34 +05:00
const games = useGames()
2024-09-18 08:20:32 +05:00
const [page, setPage] = useState(1)
// 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 (
<>
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList IBMSMListFeaturedAlt'>
2024-09-18 08:20:32 +05:00
{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}
/>
</>
)
}