degmods.com/src/pages/search.tsx

522 lines
15 KiB
TypeScript
Raw Normal View History

2024-10-14 14:20:43 +00:00
import {
NDKEvent,
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage,
NDKUserProfile,
profileFromEvent
} from '@nostr-dev-kit/ndk'
2024-09-18 03:20:32 +00:00
import { ErrorBoundary } from 'components/ErrorBoundary'
import { GameCard } from 'components/GameCard'
import { LoadingSpinner } from 'components/LoadingSpinner'
import { ModCard } from 'components/ModCard'
2024-10-07 10:45:21 +00:00
import { ModFilter } from 'components/ModsFilter'
2024-09-18 03:20:32 +00:00
import { Pagination } from 'components/Pagination'
import { Profile } from 'components/ProfileSection'
2024-09-18 16:40:34 +00:00
import {
MAX_GAMES_PER_PAGE,
MAX_MODS_PER_PAGE,
T_TAG_VALUE
} from 'constants.ts'
2024-10-07 10:45:21 +00:00
import {
useAppSelector,
useFilteredMods,
useGames,
useMuteLists,
2024-10-14 14:20:43 +00:00
useNDKContext,
2024-10-07 10:45:21 +00:00
useNSFWList
} from 'hooks'
2024-09-18 03:20:32 +00:00
import React, {
Dispatch,
SetStateAction,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { useSearchParams } from 'react-router-dom'
2024-10-07 10:45:21 +00:00
import {
FilterOptions,
ModDetails,
ModeratedFilter,
MuteLists,
NSFWFilter,
SortBy
} from 'types'
2024-09-18 03:20:32 +00:00
import { extractModData, isModDataComplete, log, LogType } from 'utils'
2024-10-07 10:45:21 +00:00
enum SearchKindEnum {
2024-09-18 03:20:32 +00:00
Mods = 'Mods',
Games = 'Games',
Users = 'Users'
}
export const SearchPage = () => {
const [searchParams] = useSearchParams()
2024-09-18 16:40:34 +00:00
const muteLists = useMuteLists()
2024-10-07 10:45:21 +00:00
const nsfwList = useNSFWList()
2024-09-18 03:20:32 +00:00
const searchTermRef = useRef<HTMLInputElement>(null)
2024-10-07 10:45:21 +00:00
const [searchKind, setSearchKind] = useState(
(searchParams.get('searching') as SearchKindEnum) || SearchKindEnum.Mods
)
2024-09-18 03:20:32 +00:00
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
2024-10-07 10:45:21 +00:00
sort: SortBy.Latest,
nsfw: NSFWFilter.Hide_NSFW,
source: window.location.host,
2024-10-07 10:45:21 +00:00
moderated: ModeratedFilter.Moderated
2024-09-18 03:20:32 +00:00
})
2024-10-07 10:45:21 +00:00
const [searchTerm, setSearchTerm] = useState(
searchParams.get('searchTerm') || ''
)
2024-09-18 03:20:32 +00:00
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:&nbsp;
<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}
2024-10-07 10:45:21 +00:00
searchKind={searchKind}
setSearchKind={setSearchKind}
2024-09-18 03:20:32 +00:00
/>
2024-10-07 10:45:21 +00:00
{searchKind === SearchKindEnum.Mods && (
2024-09-18 03:20:32 +00:00
<ModsResult
searchTerm={searchTerm}
filterOptions={filterOptions}
muteLists={muteLists}
2024-10-07 10:45:21 +00:00
nsfwList={nsfwList}
2024-09-18 03:20:32 +00:00
/>
)}
2024-10-07 10:45:21 +00:00
{searchKind === SearchKindEnum.Users && (
2024-09-18 03:20:32 +00:00
<UsersResult
searchTerm={searchTerm}
muteLists={muteLists}
moderationFilter={filterOptions.moderated}
/>
)}
2024-10-07 10:45:21 +00:00
{searchKind === SearchKindEnum.Games && (
2024-09-18 03:20:32 +00:00
<GamesResult searchTerm={searchTerm} />
)}
</div>
</div>
</div>
)
}
type FiltersProps = {
filterOptions: FilterOptions
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
2024-10-07 10:45:21 +00:00
searchKind: SearchKindEnum
setSearchKind: Dispatch<SetStateAction<SearchKindEnum>>
2024-09-18 03:20:32 +00:00
}
const Filters = React.memo(
2024-10-07 10:45:21 +00:00
({
filterOptions,
setFilterOptions,
searchKind,
setSearchKind
}: FiltersProps) => {
2024-09-18 03:20:32 +00:00
const userState = useAppSelector((state) => state.user)
return (
<div className='IBMSecMain'>
<div className='FiltersMain'>
2024-10-07 10:45:21 +00:00
{searchKind === SearchKindEnum.Mods && (
<ModFilter
filterOptions={filterOptions}
setFilterOptions={setFilterOptions}
/>
)}
2024-09-18 03:20:32 +00:00
2024-10-07 10:45:21 +00:00
{searchKind === SearchKindEnum.Users && (
<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'>
2024-10-07 10:45:21 +00:00
{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>
)}
2024-09-18 03:20:32 +00:00
<div className='FiltersMainElement'>
<div className='dropdown dropdownMain'>
<button
className='btn dropdown-toggle btnMain btnMainDropdown'
aria-expanded='false'
data-bs-toggle='dropdown'
type='button'
>
2024-10-07 10:45:21 +00:00
Searching: {searchKind}
2024-09-18 03:20:32 +00:00
</button>
<div className='dropdown-menu dropdownMainMenu'>
2024-10-07 10:45:21 +00:00
{Object.values(SearchKindEnum).map((item, index) => (
2024-09-18 03:20:32 +00:00
<div
key={`searchingFilterItem-${index}`}
className='dropdown-item dropdownMainMenuItem'
2024-10-07 10:45:21 +00:00
onClick={() => setSearchKind(item)}
2024-09-18 03:20:32 +00:00
>
{item}
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}
)
type ModsResultProps = {
filterOptions: FilterOptions
searchTerm: string
muteLists: {
admin: MuteLists
user: MuteLists
}
2024-10-07 10:45:21 +00:00
nsfwList: string[]
2024-09-18 03:20:32 +00:00
}
const ModsResult = ({
filterOptions,
searchTerm,
2024-10-07 10:45:21 +00:00
muteLists,
nsfwList
2024-09-18 03:20:32 +00:00
}: ModsResultProps) => {
2024-10-14 14:20:43 +00:00
const { ndk } = useNDKContext()
2024-09-18 03:20:32 +00:00
const [mods, setMods] = useState<ModDetails[]>([])
const [page, setPage] = useState(1)
const userState = useAppSelector((state) => state.user)
useEffect(() => {
2024-10-14 14:20:43 +00:00
const filter: NDKFilter = {
kinds: [NDKKind.Classified],
'#t': [T_TAG_VALUE]
2024-09-18 03:20:32 +00:00
}
2024-10-14 14:20:43 +00:00
const subscription = ndk.subscribe(filter, {
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
closeOnEose: true
})
2024-09-18 03:20:32 +00:00
2024-10-14 14:20:43 +00: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 03:20:32 +00:00
2024-10-14 14:20:43 +00:00
return [...prev, mod]
})
}
})
2024-09-18 03:20:32 +00:00
// Cleanup function to stop all subscriptions
return () => {
2024-10-14 14:20:43 +00:00
subscription.stop()
2024-09-18 03:20:32 +00:00
}
2024-10-14 14:20:43 +00:00
}, [ndk])
2024-09-18 03:20:32 +00:00
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])
2024-10-07 10:45:21 +00:00
const filteredModList = useFilteredMods(
2024-09-18 03:20:32 +00:00
filteredMods,
2024-10-07 10:45:21 +00:00
userState,
filterOptions,
nsfwList,
2024-09-18 03:20:32 +00:00
muteLists
2024-10-07 10:45:21 +00:00
)
2024-09-18 03:20:32 +00:00
const handleNext = () => {
setPage((prev) => prev + 1)
}
const handlePrev = () => {
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 03:20:32 +00: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 10:45:21 +00:00
moderationFilter: ModeratedFilter
2024-09-18 03:20:32 +00:00
muteLists: {
admin: MuteLists
user: MuteLists
}
}
const UsersResult = ({
searchTerm,
moderationFilter,
muteLists
}: UsersResultProps) => {
2024-10-14 14:20:43 +00:00
const { fetchEvents } = useNDKContext()
2024-09-18 03:20:32 +00:00
const [isFetching, setIsFetching] = useState(false)
const [profiles, setProfiles] = useState<NDKUserProfile[]>([])
const userState = useAppSelector((state) => state.user)
useEffect(() => {
if (searchTerm === '') {
setProfiles([])
} else {
2024-10-14 14:20:43 +00:00
const filter: NDKFilter = {
kinds: [NDKKind.Metadata],
2024-09-18 03:20:32 +00:00
search: searchTerm
}
setIsFetching(true)
fetchEvents(filter)
2024-09-18 03:20:32 +00:00
.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)
})
}
2024-10-14 14:20:43 +00:00
}, [searchTerm, fetchEvents])
2024-09-18 03:20:32 +00:00
const filteredProfiles = useMemo(() => {
let filtered = [...profiles]
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
const isUnmoderatedFully =
2024-10-07 10:45:21 +00:00
moderationFilter === ModeratedFilter.Unmoderated_Fully
2024-09-18 03:20:32 +00: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 10:45:21 +00:00
if (moderationFilter === ModeratedFilter.Moderated) {
2024-09-18 03:20:32 +00: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) {
const displayName =
profile?.displayName || profile?.name || '[name not set up]'
const about = profile?.bio || profile?.about || '[bio not set up]'
2024-09-18 03:20:32 +00:00
return (
<ErrorBoundary key={profile.pubkey}>
<Profile
pubkey={profile.pubkey as string}
displayName={displayName}
about={about}
image={profile?.image}
nip05={profile?.nip05}
lud16={profile?.lud16}
/>
2024-09-18 03:20:32 +00:00
</ErrorBoundary>
)
}
return null
})}
</div>
</div>
</>
)
}
type GamesResultProps = {
searchTerm: string
}
const GamesResult = ({ searchTerm }: GamesResultProps) => {
2024-09-18 16:40:34 +00:00
const games = useGames()
2024-09-18 03:20:32 +00: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 03:20:32 +00: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}
/>
</>
)
}