diff --git a/src/pages/game.tsx b/src/pages/game.tsx new file mode 100644 index 0000000..3ab9d14 --- /dev/null +++ b/src/pages/game.tsx @@ -0,0 +1,299 @@ +import { LoadingSpinner } from 'components/LoadingSpinner' +import { ModCard } from 'components/ModCard' +import { PaginationWithPageNumbers } from 'components/Pagination' +import { MAX_MODS_PER_PAGE, T_TAG_VALUE } from 'constants.ts' +import { RelayController } from 'controllers' +import { useAppSelector, useMuteLists } from 'hooks' +import { Filter, kinds, nip19 } from 'nostr-tools' +import { Subscription } from 'nostr-tools/abstract-relay' +import React, { + Dispatch, + SetStateAction, + useEffect, + useMemo, + useRef, + useState +} from 'react' +import { useParams } from 'react-router-dom' +import { toast } from 'react-toastify' +import { getModPageRoute } from 'routes' +import { ModDetails } 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' +} + +interface FilterOptions { + sort: SortByEnum + moderated: ModeratedFilterEnum +} + +export const GamePage = () => { + const params = useParams() + const { name: gameName } = params + const muteLists = useMuteLists() + + const [filterOptions, setFilterOptions] = useState({ + sort: SortByEnum.Latest, + moderated: ModeratedFilterEnum.Moderated + }) + const [mods, setMods] = useState([]) + + const hasEffectRun = useRef(false) + const [isSubscribing, setIsSubscribing] = useState(false) + const [currentPage, setCurrentPage] = useState(1) + + const userState = useAppSelector((state) => state.user) + + const filteredMods = useMemo(() => { + let filtered: ModDetails[] = [...mods] + 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 + }, [ + mods, + userState.user?.npub, + filterOptions.sort, + filterOptions.moderated, + muteLists + ]) + + // Pagination logic + const totalGames = filteredMods.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 handlePageChange = (page: number) => { + if (page >= 1 && page <= totalPages) { + setCurrentPage(page) + } + } + + 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) + if (mod.game === gameName) 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 + } + }, [gameName]) + + if (!gameName) return null + + return ( + <> + {isSubscribing && ( + + )} +
+
+
+
+
+
+

+ Game:  + + {gameName} + +

+
+
+
+ +
+
+ {currentMods.map((mod) => { + const route = getModPageRoute( + nip19.naddrEncode({ + identifier: mod.aTag, + pubkey: mod.author, + kind: kinds.ClassifiedListing + }) + ) + + return ( + + ) + })} +
+
+ +
+
+
+ + ) +} + +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} +
+ ) + })} +
+
+
+
+
+ ) + } +) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 3cf4f53..668194b 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -9,11 +9,13 @@ import { ProfilePage } from '../pages/profile' import { SettingsPage } from '../pages/settings' import { SubmitModPage } from '../pages/submitMod' import { WritePage } from '../pages/write' +import { GamePage } from 'pages/game' export const appRoutes = { index: '/', home: '/home', games: '/games', + game: '/game/:name', mods: '/mods', mod: '/mod/:naddr', about: '/about', @@ -29,6 +31,9 @@ export const appRoutes = { profile: '/profile/:nprofile' } +export const getGamePageRoute = (name: string) => + appRoutes.game.replace(':name', name) + export const getModPageRoute = (eventId: string) => appRoutes.mod.replace(':naddr', eventId) @@ -51,6 +56,10 @@ export const routes = [ path: appRoutes.games, element: }, + { + path: appRoutes.game, + element: + }, { path: appRoutes.mods, element: