From 4dc65b92f708ed64b2b4faeec3bb100fcdb6c833 Mon Sep 17 00:00:00 2001 From: daniyal Date: Wed, 18 Sep 2024 08:20:32 +0500 Subject: [PATCH] feat: implemented search page --- src/pages/search.tsx | 653 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 651 insertions(+), 2 deletions(-) diff --git a/src/pages/search.tsx b/src/pages/search.tsx index ea66cd8..ad1ada3 100644 --- a/src/pages/search.tsx +++ b/src/pages/search.tsx @@ -1,3 +1,652 @@ -export const SearchPage = () => { - return

WIP

+import { NDKEvent, NDKUserProfile, profileFromEvent } from '@nostr-dev-kit/ndk' +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(null) + const [filterOptions, setFilterOptions] = useState({ + 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) => { + if (event.key === 'Enter') { + handleSearch() + } + } + + return ( +
+
+
+
+
+
+

+ Search:  + + {searchTerm} + +

+
+
+
+
+ + +
+
+
+
+
+ + {filterOptions.searching === SearchingFilterEnum.Mods && ( + + )} + {filterOptions.searching === SearchingFilterEnum.Users && ( + + )} + {filterOptions.searching === SearchingFilterEnum.Games && ( + + )} +
+
+
+ ) +} + +type FiltersProps = { + filterOptions: FilterOptions + setFilterOptions: Dispatch> +} + +const Filters = React.memo( + ({ filterOptions, setFilterOptions }: FiltersProps) => { + const userState = useAppSelector((state) => state.user) + + return ( +
+
+
+
+ + +
+ {Object.values(SortByEnum).map((item, index) => ( +
+ setFilterOptions((prev) => ({ + ...prev, + sort: item + })) + } + > + {item} +
+ ))} +
+
+
+
+
+ +
+ {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 ( +
+ setFilterOptions((prev) => ({ + ...prev, + moderated: item + })) + } + > + {item} +
+ ) + })} +
+
+
+
+
+ +
+ {Object.values(SearchingFilterEnum).map((item, index) => ( +
+ setFilterOptions((prev) => ({ + ...prev, + searching: item + })) + } + > + {item} +
+ ))} +
+
+
+
+
+ ) + } +) + +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([]) + 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 && ( + + )} +
+
+ {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 ( + + ) + })} +
+
+ + + ) +} + +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([]) + + 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 && } +
+
+ {filteredProfiles.map((profile) => { + if (profile.pubkey) { + return ( + + + + ) + } + + return null + })} +
+
+ + ) +} + +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([]) + 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(csvText, { + worker: true, + header: true, + complete: (results) => { + const uniqueGames: Game[] = [] + const gameNames = new Set() + + // 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 && } +
+
+ {filteredGames + .slice((page - 1) * MAX_GAMES_PER_PAGE, page * MAX_GAMES_PER_PAGE) + .map((game) => ( + + ))} +
+
+ + + ) }