feat: game page search, cached filters #105
@ -1,16 +1,20 @@
|
||||
import { useAppSelector } from 'hooks'
|
||||
import { useAppSelector, useLocalStorage } from 'hooks'
|
||||
import React from 'react'
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
import { FilterOptions, ModeratedFilter, NSFWFilter, SortBy } from 'types'
|
||||
import { DEFAULT_FILTER_OPTIONS } from 'utils'
|
||||
|
||||
type Props = {
|
||||
filterOptions: FilterOptions
|
||||
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
|
||||
author?: string | undefined
|
||||
filterKey?: string | undefined
|
||||
}
|
||||
|
||||
export const ModFilter = React.memo(
|
||||
({ filterOptions, setFilterOptions }: Props) => {
|
||||
({ author, filterKey = 'filter' }: Props) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||
filterKey,
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
@ -62,9 +66,9 @@ export const ModFilter = React.memo(
|
||||
import.meta.env.VITE_REPORTING_NPUB
|
||||
|
||||
const isOwnProfile =
|
||||
filterOptions.author &&
|
||||
author &&
|
||||
userState.auth &&
|
||||
userState.user?.pubkey === filterOptions.author
|
||||
userState.user?.pubkey === author
|
||||
|
||||
if (!(isAdmin || isOwnProfile)) return null
|
||||
}
|
||||
|
@ -116,14 +116,20 @@ export const Profile = ({ pubkey }: ProfileProps) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Try to encode
|
||||
let profileRoute = appRoutes.home
|
||||
const hexPubkey = npubToHex(pubkey)
|
||||
if (hexPubkey) {
|
||||
profileRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: hexPubkey
|
||||
})
|
||||
)
|
||||
let nprofile: string | undefined
|
||||
try {
|
||||
const hexPubkey = npubToHex(pubkey)
|
||||
nprofile = hexPubkey
|
||||
? nip19.nprofileEncode({
|
||||
pubkey: hexPubkey
|
||||
})
|
||||
: undefined
|
||||
profileRoute = nprofile ? getProfilePageRoute(nprofile) : appRoutes.home
|
||||
} catch (error) {
|
||||
// Silently ignore and redirect to home
|
||||
log(true, LogType.Error, 'Failed to encode profile.', error)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -148,7 +154,8 @@ export const Profile = ({ pubkey }: ProfileProps) => {
|
||||
<div className='IBMSMSMSSS_Author_Top_Left_InsideDetails'>
|
||||
<div className='IBMSMSMSSS_Author_TopWrapper'>
|
||||
<p className='IBMSMSMSSS_Author_Top_Name'>{displayName}</p>
|
||||
{nip05 && (
|
||||
{/* Nip05 can sometimes be an empty object '{}' which causes the error */}
|
||||
{typeof nip05 === 'string' && (
|
||||
<p className='IBMSMSMSSS_Author_Top_Handle'>{nip05}</p>
|
||||
)}
|
||||
</div>
|
||||
@ -181,8 +188,12 @@ export const Profile = ({ pubkey }: ProfileProps) => {
|
||||
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
<ProfileQRButtonWithPopUp pubkey={pubkey} />
|
||||
{lud16 && <ZapButtonWithPopUp pubkey={pubkey} />}
|
||||
{typeof nprofile !== 'undefined' && (
|
||||
<ProfileQRButtonWithPopUp nprofile={nprofile} />
|
||||
)}
|
||||
{typeof lud16 !== 'undefined' && (
|
||||
<ZapButtonWithPopUp pubkey={pubkey} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -227,20 +238,16 @@ const posts: Post[] = [
|
||||
]
|
||||
|
||||
type QRButtonWithPopUpProps = {
|
||||
pubkey: string
|
||||
nprofile: string
|
||||
}
|
||||
|
||||
export const ProfileQRButtonWithPopUp = ({
|
||||
pubkey
|
||||
nprofile
|
||||
}: QRButtonWithPopUpProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useBodyScrollDisable(isOpen)
|
||||
|
||||
const nprofile = nip19.nprofileEncode({
|
||||
pubkey
|
||||
})
|
||||
|
||||
const onQrCodeClicked = async () => {
|
||||
const href = `https://njump.me/${nprofile}`
|
||||
const a = document.createElement('a')
|
||||
|
39
src/components/SearchInput.tsx
Normal file
39
src/components/SearchInput.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
interface SearchInputProps {
|
||||
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
handleSearch: () => void
|
||||
}
|
||||
|
||||
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
({ handleKeyDown, handleSearch }, ref) => (
|
||||
<div className='SearchMain'>
|
||||
<div className='SearchMainInside'>
|
||||
<div className='SearchMainInsideWrapper'>
|
||||
<input
|
||||
type='text'
|
||||
className='SMIWInput'
|
||||
ref={ref}
|
||||
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>
|
||||
)
|
||||
)
|
@ -7,3 +7,4 @@ export * from './useNSFWList'
|
||||
export * from './useReactions'
|
||||
export * from './useNDKContext'
|
||||
export * from './useScrollDisable'
|
||||
export * from './useLocalStorage'
|
||||
|
@ -18,7 +18,8 @@ export const useFilteredMods = (
|
||||
muteLists: {
|
||||
admin: MuteLists
|
||||
user: MuteLists
|
||||
}
|
||||
},
|
||||
author?: string | undefined
|
||||
) => {
|
||||
return useMemo(() => {
|
||||
const nsfwFilter = (mods: ModDetails[]) => {
|
||||
@ -50,7 +51,7 @@ export const useFilteredMods = (
|
||||
const isAdmin = userState.user?.npub === import.meta.env.VITE_REPORTING_NPUB
|
||||
const isOwner =
|
||||
userState.user?.npub &&
|
||||
npubToHex(userState.user.npub as string) === filterOptions.author
|
||||
npubToHex(userState.user.npub as string) === author
|
||||
const isUnmoderatedFully =
|
||||
filterOptions.moderated === ModeratedFilter.Unmoderated_Fully
|
||||
|
||||
@ -84,7 +85,7 @@ export const useFilteredMods = (
|
||||
filterOptions.sort,
|
||||
filterOptions.moderated,
|
||||
filterOptions.nsfw,
|
||||
filterOptions.author,
|
||||
author,
|
||||
mods,
|
||||
muteLists,
|
||||
nsfwList
|
||||
|
50
src/hooks/useLocalStorage.tsx
Normal file
50
src/hooks/useLocalStorage.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
getLocalStorageItem,
|
||||
removeLocalStorageItem,
|
||||
setLocalStorageItem
|
||||
} from 'utils'
|
||||
|
||||
const useLocalStorageSubscribe = (callback: () => void) => {
|
||||
window.addEventListener('storage', callback)
|
||||
return () => window.removeEventListener('storage', callback)
|
||||
}
|
||||
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
const getSnapshot = () => getLocalStorageItem(key, initialValue)
|
||||
|
||||
const data = React.useSyncExternalStore(useLocalStorageSubscribe, getSnapshot)
|
||||
|
||||
const setState: React.Dispatch<React.SetStateAction<T>> = React.useCallback(
|
||||
(v: React.SetStateAction<T>) => {
|
||||
try {
|
||||
const nextState =
|
||||
typeof v === 'function'
|
||||
? (v as (prevState: T) => T)(JSON.parse(data))
|
||||
: v
|
||||
|
||||
if (nextState === undefined || nextState === null) {
|
||||
removeLocalStorageItem(key)
|
||||
} else {
|
||||
setLocalStorageItem(key, JSON.stringify(nextState))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
}
|
||||
},
|
||||
[key, data]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
// Set local storage only when it's empty
|
||||
const data = window.localStorage.getItem(key)
|
||||
if (data === null) {
|
||||
setLocalStorageItem(key, JSON.stringify(initialValue))
|
||||
}
|
||||
}, [key, initialValue])
|
||||
|
||||
return [JSON.parse(data) as T, setState]
|
||||
}
|
@ -6,24 +6,25 @@ import {
|
||||
import { ModCard } from 'components/ModCard'
|
||||
import { ModFilter } from 'components/ModsFilter'
|
||||
import { PaginationWithPageNumbers } from 'components/Pagination'
|
||||
import { SearchInput } from 'components/SearchInput'
|
||||
import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts'
|
||||
import {
|
||||
useAppSelector,
|
||||
useFilteredMods,
|
||||
useLocalStorage,
|
||||
useMuteLists,
|
||||
useNDKContext,
|
||||
useNSFWList
|
||||
} from 'hooks'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { FilterOptions, ModDetails } from 'types'
|
||||
import {
|
||||
FilterOptions,
|
||||
ModDetails,
|
||||
ModeratedFilter,
|
||||
NSFWFilter,
|
||||
SortBy
|
||||
} from 'types'
|
||||
import { extractModData, isModDataComplete, scrollIntoView } from 'utils'
|
||||
DEFAULT_FILTER_OPTIONS,
|
||||
extractModData,
|
||||
isModDataComplete,
|
||||
scrollIntoView
|
||||
} from 'utils'
|
||||
|
||||
export const GamePage = () => {
|
||||
const scrollTargetRef = useRef<HTMLDivElement>(null)
|
||||
@ -33,20 +34,70 @@ export const GamePage = () => {
|
||||
const muteLists = useMuteLists()
|
||||
const nsfwList = useNSFWList()
|
||||
|
||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||
sort: SortBy.Latest,
|
||||
nsfw: NSFWFilter.Hide_NSFW,
|
||||
source: window.location.host,
|
||||
moderated: ModeratedFilter.Moderated
|
||||
})
|
||||
const [filterOptions] = useLocalStorage<FilterOptions>(
|
||||
'filter',
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
const [mods, setMods] = useState<ModDetails[]>([])
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
|
||||
const filteredMods = useFilteredMods(
|
||||
mods,
|
||||
// Search
|
||||
const searchTermRef = useRef<HTMLInputElement>(null)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '')
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// Handle "Enter" key press inside the input
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
const filteredMods = useMemo(() => {
|
||||
const filterSourceFn = (mod: ModDetails) => {
|
||||
if (filterOptions.source === window.location.host) {
|
||||
return mod.rTag === filterOptions.source
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// If search term is missing, only filter by sources
|
||||
if (searchTerm === '') return mods.filter(filterSourceFn)
|
||||
|
||||
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).filter(filterSourceFn)
|
||||
}, [filterOptions.source, mods, searchTerm])
|
||||
|
||||
const filteredModList = useFilteredMods(
|
||||
filteredMods,
|
||||
userState,
|
||||
filterOptions,
|
||||
nsfwList,
|
||||
@ -54,11 +105,11 @@ export const GamePage = () => {
|
||||
)
|
||||
|
||||
// Pagination logic
|
||||
const totalGames = filteredMods.length
|
||||
const totalGames = filteredModList.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 currentMods = filteredModList.slice(startIndex, endIndex)
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
@ -116,14 +167,24 @@ export const GamePage = () => {
|
||||
<span className='IBMSMTitleMainHeadingSpan'>
|
||||
{gameName}
|
||||
</span>
|
||||
{searchTerm !== '' && (
|
||||
<>
|
||||
—
|
||||
<span className='IBMSMTitleMainHeadingSpan'>
|
||||
{searchTerm}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<SearchInput
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleSearch={handleSearch}
|
||||
ref={searchTermRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ModFilter
|
||||
filterOptions={filterOptions}
|
||||
setFilterOptions={setFilterOptions}
|
||||
/>
|
||||
<ModFilter />
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMList'>
|
||||
{currentMods.map((mod) => (
|
||||
|
@ -9,6 +9,7 @@ import '../styles/styles.css'
|
||||
import { createSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { appRoutes } from 'routes'
|
||||
import { scrollIntoView } from 'utils'
|
||||
import { SearchInput } from 'components/SearchInput'
|
||||
|
||||
export const GamesPage = () => {
|
||||
const scrollTargetRef = useRef<HTMLDivElement>(null)
|
||||
@ -74,8 +75,8 @@ export const GamesPage = () => {
|
||||
const value = searchTermRef.current?.value || '' // Access the input value from the ref
|
||||
if (value !== '') {
|
||||
const searchParams = createSearchParams({
|
||||
searchTerm: value,
|
||||
searching: 'Games'
|
||||
q: value,
|
||||
kind: 'Games'
|
||||
})
|
||||
navigate({ pathname: appRoutes.search, search: `?${searchParams}` })
|
||||
}
|
||||
@ -100,34 +101,11 @@ export const GamesPage = () => {
|
||||
<div className='IBMSMTitleMain'>
|
||||
<h2 className='IBMSMTitleMainHeading'>Games</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>
|
||||
<SearchInput
|
||||
ref={searchTermRef}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleSearch={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
|
@ -8,6 +8,7 @@ import { MOD_FILTER_LIMIT } from '../constants'
|
||||
import {
|
||||
useAppSelector,
|
||||
useFilteredMods,
|
||||
useLocalStorage,
|
||||
useMuteLists,
|
||||
useNDKContext,
|
||||
useNSFWList
|
||||
@ -17,26 +18,20 @@ import '../styles/filters.css'
|
||||
import '../styles/pagination.css'
|
||||
import '../styles/search.css'
|
||||
import '../styles/styles.css'
|
||||
import {
|
||||
FilterOptions,
|
||||
ModDetails,
|
||||
ModeratedFilter,
|
||||
NSFWFilter,
|
||||
SortBy
|
||||
} from '../types'
|
||||
import { scrollIntoView } from 'utils'
|
||||
import { FilterOptions, ModDetails } from '../types'
|
||||
import { DEFAULT_FILTER_OPTIONS, scrollIntoView } from 'utils'
|
||||
import { SearchInput } from 'components/SearchInput'
|
||||
|
||||
export const ModsPage = () => {
|
||||
const scrollTargetRef = useRef<HTMLDivElement>(null)
|
||||
const { fetchMods } = useNDKContext()
|
||||
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 [filterOptions] = useLocalStorage<FilterOptions>(
|
||||
'filter',
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
const muteLists = useMuteLists()
|
||||
const nsfwList = useNSFWList()
|
||||
|
||||
@ -112,10 +107,7 @@ export const ModsPage = () => {
|
||||
ref={scrollTargetRef}
|
||||
>
|
||||
<PageTitleRow />
|
||||
<ModFilter
|
||||
filterOptions={filterOptions}
|
||||
setFilterOptions={setFilterOptions}
|
||||
/>
|
||||
<ModFilter />
|
||||
|
||||
<div className='IBMSecMain IBMSMListWrapper'>
|
||||
<div className='IBMSMList'>
|
||||
@ -146,8 +138,8 @@ const PageTitleRow = React.memo(() => {
|
||||
const value = searchTermRef.current?.value || '' // Access the input value from the ref
|
||||
if (value !== '') {
|
||||
const searchParams = createSearchParams({
|
||||
searchTerm: value,
|
||||
searching: 'Mods'
|
||||
q: value,
|
||||
kind: 'Mods'
|
||||
})
|
||||
navigate({ pathname: appRoutes.search, search: `?${searchParams}` })
|
||||
}
|
||||
@ -166,35 +158,11 @@ const PageTitleRow = React.memo(() => {
|
||||
<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>
|
||||
<SearchInput
|
||||
ref={searchTermRef}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleSearch={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -9,6 +9,7 @@ import { MOD_FILTER_LIMIT } from '../constants'
|
||||
import {
|
||||
useAppSelector,
|
||||
useFilteredMods,
|
||||
useLocalStorage,
|
||||
useMuteLists,
|
||||
useNDKContext,
|
||||
useNSFWList
|
||||
@ -18,16 +19,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useParams, Navigate, Link } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { appRoutes, getProfilePageRoute } from 'routes'
|
||||
import {
|
||||
FilterOptions,
|
||||
ModDetails,
|
||||
ModeratedFilter,
|
||||
NSFWFilter,
|
||||
SortBy,
|
||||
UserRelaysType
|
||||
} from 'types'
|
||||
import { FilterOptions, ModDetails, UserRelaysType } from 'types'
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
DEFAULT_FILTER_OPTIONS,
|
||||
log,
|
||||
LogType,
|
||||
now,
|
||||
npubToHex,
|
||||
scrollIntoView,
|
||||
@ -47,8 +44,8 @@ export const ProfilePage = () => {
|
||||
: undefined
|
||||
profilePubkey = value?.data.pubkey
|
||||
} catch (error) {
|
||||
// Failed to decode the nprofile
|
||||
// Silently ignore and redirect to home or logged in user
|
||||
log(true, LogType.Error, 'Failed to decode nprofile.', error)
|
||||
}
|
||||
|
||||
const scrollTargetRef = useRef<HTMLDivElement>(null)
|
||||
@ -230,12 +227,9 @@ export const ProfilePage = () => {
|
||||
|
||||
// Mods
|
||||
const [mods, setMods] = useState<ModDetails[]>([])
|
||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||
sort: SortBy.Latest,
|
||||
nsfw: NSFWFilter.Hide_NSFW,
|
||||
source: window.location.host,
|
||||
moderated: ModeratedFilter.Moderated,
|
||||
author: profilePubkey
|
||||
const filterKey = 'filter-profile'
|
||||
const [filterOptions] = useLocalStorage<FilterOptions>(filterKey, {
|
||||
...DEFAULT_FILTER_OPTIONS
|
||||
})
|
||||
const muteLists = useMuteLists()
|
||||
const nsfwList = useNSFWList()
|
||||
@ -304,7 +298,8 @@ export const ProfilePage = () => {
|
||||
userState,
|
||||
filterOptions,
|
||||
nsfwList,
|
||||
muteLists
|
||||
muteLists,
|
||||
profilePubkey
|
||||
)
|
||||
|
||||
// Redirect route
|
||||
@ -470,10 +465,7 @@ export const ProfilePage = () => {
|
||||
{/* Tabs Content */}
|
||||
{tab === 0 && (
|
||||
<>
|
||||
<ModFilter
|
||||
filterOptions={filterOptions}
|
||||
setFilterOptions={setFilterOptions}
|
||||
/>
|
||||
<ModFilter filterKey={filterKey} author={profilePubkey} />
|
||||
|
||||
<div className='IBMSMList IBMSMListAlt'>
|
||||
{filteredModList.map((mod) => (
|
||||
|
@ -13,6 +13,7 @@ import { ModCard } from 'components/ModCard'
|
||||
import { ModFilter } from 'components/ModsFilter'
|
||||
import { Pagination } from 'components/Pagination'
|
||||
import { Profile } from 'components/ProfileSection'
|
||||
import { SearchInput } from 'components/SearchInput'
|
||||
import {
|
||||
MAX_GAMES_PER_PAGE,
|
||||
MAX_MODS_PER_PAGE,
|
||||
@ -22,33 +23,22 @@ import {
|
||||
useAppSelector,
|
||||
useFilteredMods,
|
||||
useGames,
|
||||
useLocalStorage,
|
||||
useMuteLists,
|
||||
useNDKContext,
|
||||
useNSFWList
|
||||
} from 'hooks'
|
||||
import React, {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { FilterOptions, ModDetails, ModeratedFilter, MuteLists } from 'types'
|
||||
import {
|
||||
FilterOptions,
|
||||
ModDetails,
|
||||
ModeratedFilter,
|
||||
MuteLists,
|
||||
NSFWFilter,
|
||||
SortBy
|
||||
} from 'types'
|
||||
import {
|
||||
DEFAULT_FILTER_OPTIONS,
|
||||
extractModData,
|
||||
isModDataComplete,
|
||||
log,
|
||||
LogType,
|
||||
scrollIntoView
|
||||
scrollIntoView,
|
||||
timeout
|
||||
} from 'utils'
|
||||
|
||||
enum SearchKindEnum {
|
||||
@ -59,30 +49,35 @@ enum SearchKindEnum {
|
||||
|
||||
export const SearchPage = () => {
|
||||
const scrollTargetRef = useRef<HTMLDivElement>(null)
|
||||
const [searchParams] = useSearchParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
const muteLists = useMuteLists()
|
||||
const nsfwList = useNSFWList()
|
||||
const searchTermRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [searchKind, setSearchKind] = useState(
|
||||
(searchParams.get('searching') as SearchKindEnum) || SearchKindEnum.Mods
|
||||
const searchKind =
|
||||
(searchParams.get('kind') as SearchKindEnum) || SearchKindEnum.Mods
|
||||
|
||||
const [filterOptions] = useLocalStorage<FilterOptions>(
|
||||
'filter',
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
|
||||
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
|
||||
sort: SortBy.Latest,
|
||||
nsfw: NSFWFilter.Hide_NSFW,
|
||||
source: window.location.host,
|
||||
moderated: ModeratedFilter.Moderated
|
||||
})
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(
|
||||
searchParams.get('searchTerm') || ''
|
||||
)
|
||||
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '')
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// Handle "Enter" key press inside the input
|
||||
@ -109,42 +104,14 @@ export const SearchPage = () => {
|
||||
</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>
|
||||
<SearchInput
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleSearch={handleSearch}
|
||||
ref={searchTermRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Filters
|
||||
filterOptions={filterOptions}
|
||||
setFilterOptions={setFilterOptions}
|
||||
searchKind={searchKind}
|
||||
setSearchKind={setSearchKind}
|
||||
/>
|
||||
<Filters />
|
||||
{searchKind === SearchKindEnum.Mods && (
|
||||
<ModsResult
|
||||
searchTerm={searchTerm}
|
||||
@ -170,73 +137,29 @@ export const SearchPage = () => {
|
||||
)
|
||||
}
|
||||
|
||||
type FiltersProps = {
|
||||
filterOptions: FilterOptions
|
||||
setFilterOptions: Dispatch<SetStateAction<FilterOptions>>
|
||||
searchKind: SearchKindEnum
|
||||
setSearchKind: Dispatch<SetStateAction<SearchKindEnum>>
|
||||
}
|
||||
const Filters = React.memo(() => {
|
||||
const [filterOptions, setFilterOptions] = useLocalStorage<FilterOptions>(
|
||||
'filter',
|
||||
DEFAULT_FILTER_OPTIONS
|
||||
)
|
||||
|
||||
const Filters = React.memo(
|
||||
({
|
||||
filterOptions,
|
||||
setFilterOptions,
|
||||
searchKind,
|
||||
setSearchKind
|
||||
}: FiltersProps) => {
|
||||
const userState = useAppSelector((state) => state.user)
|
||||
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
|
||||
filterOptions={filterOptions}
|
||||
setFilterOptions={setFilterOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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'>
|
||||
{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>
|
||||
)}
|
||||
return (
|
||||
<div className='IBMSecMain'>
|
||||
<div className='FiltersMain'>
|
||||
{searchKind === SearchKindEnum.Mods && <ModFilter />}
|
||||
|
||||
{searchKind === SearchKindEnum.Users && (
|
||||
<div className='FiltersMainElement'>
|
||||
<div className='dropdown dropdownMain'>
|
||||
<button
|
||||
@ -245,26 +168,65 @@ const Filters = React.memo(
|
||||
data-bs-toggle='dropdown'
|
||||
type='button'
|
||||
>
|
||||
Searching: {searchKind}
|
||||
{filterOptions.moderated}
|
||||
</button>
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
{Object.values(SearchKindEnum).map((item, index) => (
|
||||
<div
|
||||
key={`searchingFilterItem-${index}`}
|
||||
className='dropdown-item dropdownMainMenuItem'
|
||||
onClick={() => setSearchKind(item)}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
{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'
|
||||
>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
type ModsResultProps = {
|
||||
filterOptions: FilterOptions
|
||||
@ -324,6 +286,7 @@ const ModsResult = ({
|
||||
}, [searchTerm])
|
||||
|
||||
const filteredMods = useMemo(() => {
|
||||
// Search page requires search term
|
||||
if (searchTerm === '') return []
|
||||
|
||||
const lowerCaseSearchTerm = searchTerm.toLowerCase()
|
||||
@ -338,6 +301,7 @@ const ModsResult = ({
|
||||
) > -1
|
||||
|
||||
const filterSourceFn = (mod: ModDetails) => {
|
||||
// Filter by source if selected
|
||||
if (filterOptions.source === window.location.host) {
|
||||
return mod.rTag === filterOptions.source
|
||||
}
|
||||
@ -410,29 +374,38 @@ const UsersResult = ({
|
||||
if (searchTerm === '') {
|
||||
setProfiles([])
|
||||
} else {
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Metadata],
|
||||
search: searchTerm
|
||||
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)
|
||||
}
|
||||
|
||||
setIsFetching(true)
|
||||
fetchEvents(filter)
|
||||
.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)
|
||||
})
|
||||
fetchProfiles()
|
||||
}
|
||||
}, [searchTerm, fetchEvents])
|
||||
}, [fetchEvents, searchTerm])
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
let filtered = [...profiles]
|
||||
|
@ -98,15 +98,15 @@ export const ProfileSettings = () => {
|
||||
|
||||
// In case user is not logged in clicking on profile link will navigate to homepage
|
||||
let profileRoute = appRoutes.home
|
||||
let nprofile: string | undefined
|
||||
if (userState.auth && userState.user) {
|
||||
const hexPubkey = npubToHex(userState.user.npub as string)
|
||||
|
||||
if (hexPubkey) {
|
||||
profileRoute = getProfilePageRoute(
|
||||
nip19.nprofileEncode({
|
||||
pubkey: hexPubkey
|
||||
})
|
||||
)
|
||||
nprofile = nip19.nprofileEncode({
|
||||
pubkey: hexPubkey
|
||||
})
|
||||
profileRoute = getProfilePageRoute(nprofile)
|
||||
}
|
||||
}
|
||||
|
||||
@ -247,10 +247,8 @@ export const ProfileSettings = () => {
|
||||
<path d='M384 96L384 0h-112c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48H464c26.51 0 48-21.49 48-48V128h-95.1C398.4 128 384 113.6 384 96zM416 0v96h96L416 0zM192 352V128h-144c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h192c26.51 0 48-21.49 48-48L288 416h-32C220.7 416 192 387.3 192 352z'></path>
|
||||
</svg>
|
||||
</div>
|
||||
{typeof userState.user?.pubkey === 'string' && (
|
||||
<ProfileQRButtonWithPopUp
|
||||
pubkey={userState.user.pubkey}
|
||||
/>
|
||||
{typeof nprofile !== 'undefined' && (
|
||||
<ProfileQRButtonWithPopUp nprofile={nprofile} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -22,5 +22,4 @@ export interface FilterOptions {
|
||||
nsfw: NSFWFilter
|
||||
source: string
|
||||
moderated: ModeratedFilter
|
||||
author?: string
|
||||
}
|
||||
|
8
src/utils/consts.ts
Normal file
8
src/utils/consts.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { FilterOptions, SortBy, NSFWFilter, ModeratedFilter } from 'types'
|
||||
|
||||
export const DEFAULT_FILTER_OPTIONS: FilterOptions = {
|
||||
sort: SortBy.Latest,
|
||||
nsfw: NSFWFilter.Hide_NSFW,
|
||||
source: window.location.host,
|
||||
moderated: ModeratedFilter.Moderated
|
||||
}
|
@ -3,3 +3,5 @@ export * from './nostr'
|
||||
export * from './url'
|
||||
export * from './utils'
|
||||
export * from './zap'
|
||||
export * from './localStorage'
|
||||
export * from './consts'
|
||||
|
32
src/utils/localStorage.ts
Normal file
32
src/utils/localStorage.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export function getLocalStorageItem<T>(key: string, defaultValue: T): string {
|
||||
try {
|
||||
const data = window.localStorage.getItem(key)
|
||||
if (data === null) return JSON.stringify(defaultValue)
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error(`Error while fetching local storage value: `, err)
|
||||
return JSON.stringify(defaultValue)
|
||||
}
|
||||
}
|
||||
|
||||
export function setLocalStorageItem(key: string, value: string) {
|
||||
try {
|
||||
window.localStorage.setItem(key, value)
|
||||
dispatchLocalStorageEvent(key, value)
|
||||
} catch (err) {
|
||||
console.error(`Error while saving local storage value: `, err)
|
||||
}
|
||||
}
|
||||
|
||||
export function removeLocalStorageItem(key: string) {
|
||||
try {
|
||||
window.localStorage.removeItem(key)
|
||||
dispatchLocalStorageEvent(key, null)
|
||||
} catch (err) {
|
||||
console.error(`Error while deleting local storage value: `, err)
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchLocalStorageEvent(key: string, newValue: string | null) {
|
||||
window.dispatchEvent(new StorageEvent('storage', { key, newValue }))
|
||||
}
|
Loading…
Reference in New Issue
Block a user