diff --git a/src/components/activity/ActivityFeed.tsx b/src/components/activity/ActivityFeed.tsx index 4d9ec34..39a45d6 100644 --- a/src/components/activity/ActivityFeed.tsx +++ b/src/components/activity/ActivityFeed.tsx @@ -1,35 +1,93 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import { useInfiniteActivities } from "@/components/activity/hooks/useInfiniteActivities"; -import { Stack } from "@mantine/core"; +import { Skeleton, Stack } from "@mantine/core"; import { Activity } from "@/wrapper/server"; import type = Activity.type; -import ReviewActivityItem from "@/components/activity/ReviewActivityItem"; +import ReviewActivityItem from "@/components/activity/item/ReviewActivityItem"; +import CenteredLoading from "@/components/general/CenteredLoading"; +import CollectionEntryActivityItem from "@/components/activity/item/CollectionEntryActivityItem"; +import CenteredErrorMessage from "@/components/general/CenteredErrorMessage"; +import UserFollowActivityItem from "@/components/activity/item/UserFollowActivityItem"; +import { useIntersection } from "@mantine/hooks"; +import ActivityList from "@/components/activity/ActivityList"; interface Props { criteria: "following" | "all"; } const ActivityFeed = ({ criteria }: Props) => { + const { ref, entry } = useIntersection({ + threshold: 1, + }); const activityQuery = useInfiniteActivities({ criteria, limit: 10, }); + const items = useMemo(() => { if (!activityQuery.data) return undefined; - return activityQuery.data.pages.flatMap((page) => { - return page.data.map((activity) => { - if (activity.type === type.REVIEW) { - return ( - - ); - } - }); - }); + return activityQuery.data.pages?.flatMap((page) => page.data); }, [activityQuery.data]); - return {items}; + + const isLoading = activityQuery.isLoading; + const isError = activityQuery.isError; + const isSuccess = activityQuery.isSuccess; + const isFetching = activityQuery.isFetching; + + const isEmpty = + activityQuery.data != undefined && + activityQuery.data?.pages.some((page) => { + return page.pagination.totalItems === 0; + }); + + const buildSkeletons = useCallback(() => { + return new Array(4).fill(0).map((_, i) => { + return ; + }); + }, []); + + useEffect(() => { + const lastElement = + activityQuery.data?.pages[activityQuery.data?.pages.length - 1]; + const hasNextPage = + lastElement != undefined && + lastElement.data.length > 0 && + lastElement.pagination.hasNextPage; + + const canFetchNextPage = !isFetching && !isLoading && hasNextPage; + + // Minimum amount of time (ms) since document creation for + // intersection to be considered valid + const minimumIntersectionTime = 3000; + + if ( + canFetchNextPage && + entry?.isIntersecting && + entry.time > minimumIntersectionTime + ) { + activityQuery.fetchNextPage({ cancelRefetch: false }); + } + }, [activityQuery, entry, isFetching, isLoading]); + + return ( + + {activityQuery.isLoading && buildSkeletons()} + {!isLoading && isEmpty && ( + + )} + {isError && ( + + )} + +
+
+ ); }; export default ActivityFeed; diff --git a/src/components/activity/ActivityFeedLayout.tsx b/src/components/activity/ActivityFeedLayout.tsx index 1ad7f00..02a8cfb 100644 --- a/src/components/activity/ActivityFeedLayout.tsx +++ b/src/components/activity/ActivityFeedLayout.tsx @@ -26,7 +26,7 @@ const ActivityFeedLayout = ({ children, currentTab }: Props) => { - {children} + {children} ); }; diff --git a/src/components/activity/ActivityList.tsx b/src/components/activity/ActivityList.tsx new file mode 100644 index 0000000..8114c1e --- /dev/null +++ b/src/components/activity/ActivityList.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { Activity } from "@/wrapper/server"; +import ReviewActivityItem from "@/components/activity/item/ReviewActivityItem"; +import CollectionEntryActivityItem from "@/components/activity/item/CollectionEntryActivityItem"; +import UserFollowActivityItem from "@/components/activity/item/UserFollowActivityItem"; +import type = Activity.type; + +interface Props { + items: Activity[] | undefined; +} + +const ActivityList = ({ items }: Props) => { + if (!items) return null; + return items.map((activity) => { + switch (activity.type) { + case type.REVIEW: + return ( + + ); + case type.COLLECTION_ENTRY: + return ( + + ); + case type.FOLLOW: + return ( + + ); + } + }); +}; + +export default ActivityList; diff --git a/src/components/activity/RecentActivitiesList.tsx b/src/components/activity/RecentActivitiesList.tsx new file mode 100644 index 0000000..b91824f --- /dev/null +++ b/src/components/activity/RecentActivitiesList.tsx @@ -0,0 +1,58 @@ +import React, { useMemo } from "react"; +import useUserProfile from "@/components/profile/hooks/useUserProfile"; +import { useLatestActivities } from "@/components/activity/hooks/useLatestActivities"; +import ReviewActivityItem from "@/components/activity/item/ReviewActivityItem"; +import CollectionEntryActivityItem from "@/components/activity/item/CollectionEntryActivityItem"; +import UserFollowActivityItem from "@/components/activity/item/UserFollowActivityItem"; +import { Stack } from "@mantine/core"; +import CenteredLoading from "@/components/general/CenteredLoading"; +import { Activity } from "@/wrapper/server"; +import type = Activity.type; + +interface Props { + userId?: string; + limit?: number; + offset?: number; +} + +const RecentActivitiesList = ({ userId, offset = 0, limit = 5 }: Props) => { + const activitiesQuery = useLatestActivities(userId, offset, limit); + + const items = useMemo(() => { + if (!activitiesQuery.data) return null; + return activitiesQuery.data.data.map((activity) => { + switch (activity.type) { + case type.REVIEW: + return ( + + ); + case type.COLLECTION_ENTRY: + return ( + + ); + case type.FOLLOW: + return ( + + ); + } + }); + }, [activitiesQuery.data]); + + return ( + + {activitiesQuery.isLoading && } + {items} + + ); +}; + +export default RecentActivitiesList; diff --git a/src/components/activity/hooks/useLatestActivities.ts b/src/components/activity/hooks/useLatestActivities.ts new file mode 100644 index 0000000..196a980 --- /dev/null +++ b/src/components/activity/hooks/useLatestActivities.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import { ActivitiesService } from "@/wrapper/server"; + +export function useLatestActivities( + userId: string | undefined, + offset = 0, + limit = 10, +) { + return useQuery({ + queryKey: ["activities", "latest", userId, offset, limit], + queryFn: async () => { + return ActivitiesService.activitiesRepositoryControllerFindLatest( + userId, + offset, + limit, + ); + }, + retry: 1, + }); +} diff --git a/src/components/activity/input/ActivityItemLikes.tsx b/src/components/activity/input/ActivityItemLikes.tsx index a43c739..7b92db7 100644 --- a/src/components/activity/input/ActivityItemLikes.tsx +++ b/src/components/activity/input/ActivityItemLikes.tsx @@ -6,6 +6,7 @@ import sourceType = StatisticsActionDto.sourceType; import { ActionIcon, Group, Text } from "@mantine/core"; import { redirectToAuth } from "supertokens-auth-react"; import { IconThumbUp } from "@tabler/icons-react"; +import useOnMobile from "@/components/general/hooks/useOnMobile"; interface Props { activityId: string; @@ -19,24 +20,22 @@ const ActivityItemLikes = ({ activityId }: Props) => { sourceType: sourceType.ACTIVITY, }); return ( - - { - if (!userId) { - redirectToAuth(); - return; - } - toggleLike(); - }} - variant={isLiked ? "filled" : "subtle"} - size={"xl"} - color={isLiked ? "brand" : "white"} - data-disabled={!userId} - > - - {likesCount} - - + { + if (!userId) { + await redirectToAuth(); + return; + } + toggleLike(); + }} + variant={isLiked ? "filled" : "transparent"} + size={"lg"} + color={isLiked ? "brand" : "white"} + data-disabled={!userId} + > + + {likesCount} + ); }; diff --git a/src/components/activity/item/CollectionEntryActivityItem.tsx b/src/components/activity/item/CollectionEntryActivityItem.tsx new file mode 100644 index 0000000..8657452 --- /dev/null +++ b/src/components/activity/item/CollectionEntryActivityItem.tsx @@ -0,0 +1,126 @@ +import React from "react"; +import { Activity } from "@/wrapper/server"; +import useOnMobile from "@/components/general/hooks/useOnMobile"; +import { useReview } from "@/components/review/hooks/useReview"; +import { useGame } from "@/components/game/hooks/useGame"; +import { + getSizedImageUrl, + ImageSize, +} from "@/components/game/util/getSizedImageUrl"; +import { Box, Group, Overlay, Stack, Text, Title } from "@mantine/core"; +import UserAvatarWithUsername from "@/components/general/input/UserAvatarWithUsername"; +import GameRating from "@/components/general/input/GameRating"; +import ActivityItemLikes from "@/components/activity/input/ActivityItemLikes"; +import { useCollectionEntry } from "@/components/collection/collection-entry/hooks/useCollectionEntry"; +import { useCollection } from "@/components/collection/hooks/useCollection"; +import Link from "next/link"; +import TitleLink from "@/components/general/TitleLink"; +import TextLink from "@/components/general/TextLink"; +import getTimeSinceString from "@/util/getTimeSinceString"; +import { UserAvatar } from "@/components/general/input/UserAvatar"; +import { UserAvatarGroup } from "@/components/general/input/UserAvatarGroup"; + +interface Props { + activity: Activity; +} + +const CollectionEntryActivityItem = ({ activity }: Props) => { + const onMobile = useOnMobile(); + const collectionEntryQuery = useCollectionEntry( + activity.collectionEntryId!, + ); + const collectionQuery = useCollection(activity.collectionId!); + const gameId = collectionEntryQuery.data?.gameId; + const gameQuery = useGame(gameId, { + relations: { + cover: true, + }, + }); + const imageUrl = getSizedImageUrl( + gameQuery.data?.cover?.url, + onMobile ? ImageSize.SCREENSHOT_MED : ImageSize.SCREENSHOT_BIG, + ); + const isError = collectionQuery.isError || collectionEntryQuery.isError; + if (isError) { + return null; + } + + const collectionEntryCreateDate = collectionEntryQuery.data + ? new Date(collectionEntryQuery.data.createdAt) + : new Date(); + const timeSince = getTimeSinceString(collectionEntryCreateDate); + return ( + + + + + + + + + + + {gameQuery.data?.name} + + + + Added to collection + + + + + + + {timeSince} ago + + + + {collectionQuery.data?.name} + + + + + + + + + + ); +}; + +export default CollectionEntryActivityItem; diff --git a/src/components/activity/ReviewActivityItem.tsx b/src/components/activity/item/ReviewActivityItem.tsx similarity index 50% rename from src/components/activity/ReviewActivityItem.tsx rename to src/components/activity/item/ReviewActivityItem.tsx index d4f1463..3bbc963 100644 --- a/src/components/activity/ReviewActivityItem.tsx +++ b/src/components/activity/item/ReviewActivityItem.tsx @@ -13,6 +13,9 @@ import { IconThumbUp } from "@tabler/icons-react"; import ActivityItemLikes from "@/components/activity/input/ActivityItemLikes"; import GameRating from "@/components/general/input/GameRating"; import UserAvatarWithUsername from "@/components/general/input/UserAvatarWithUsername"; +import Link from "next/link"; +import getTimeSinceString from "@/util/getTimeSinceString"; +import { UserAvatarGroup } from "@/components/general/input/UserAvatarGroup"; interface Props { activity: Activity; @@ -31,6 +34,12 @@ const ReviewActivityItem = ({ activity }: Props) => { gameQuery.data?.cover?.url, onMobile ? ImageSize.SCREENSHOT_MED : ImageSize.SCREENSHOT_BIG, ); + const reviewCreateDate = reviewQuery.data + ? new Date(reviewQuery.data.createdAt) + : new Date(); + + const timeSince = getTimeSinceString(reviewCreateDate); + return ( { backgroundSize: "cover", backgroundRepeat: "no-repeat", }} - className={"relative w-full h-[120px] rounded-md"} + className={"relative w-full mih-[160px] rounded-md"} > - + - - + - - - {gameQuery.data?.name} - - + + + + {gameQuery.data?.name} + + + Reviewed @@ -65,18 +91,19 @@ const ReviewActivityItem = ({ activity }: Props) => { - - - - - - + + {timeSince} ago + + + + + diff --git a/src/components/activity/item/UserFollowActivityItem.tsx b/src/components/activity/item/UserFollowActivityItem.tsx new file mode 100644 index 0000000..e5121c5 --- /dev/null +++ b/src/components/activity/item/UserFollowActivityItem.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { Activity } from "@/wrapper/server"; +import { useUserFollow } from "@/components/follow/hooks/useUserFollow"; +import useUserProfile from "@/components/profile/hooks/useUserProfile"; +import { Box, Group, Paper, Text, Title } from "@mantine/core"; +import UserAvatarWithUsername from "@/components/general/input/UserAvatarWithUsername"; +import useOnMobile from "@/components/general/hooks/useOnMobile"; +import Link from "next/link"; +import TextLink from "@/components/general/TextLink"; +import { UserAvatarGroup } from "@/components/general/input/UserAvatarGroup"; + +interface Props { + activity: Activity; +} + +const UserFollowActivityItem = ({ activity }: Props) => { + const onMobile = useOnMobile(); + const userFollowQuery = useUserFollow(activity.userFollowId!); + + const followerUserId = userFollowQuery.data?.followerUserId; + const followedUserId = userFollowQuery.data?.followedUserId; + + const followerUserProfile = useUserProfile(followerUserId); + const followedUserProfile = useUserProfile(followedUserId); + + if (!followerUserId || !followedUserId) return null; + + return ( + + + + + + + + + {followerUserProfile.data?.username} + {" "} + has started following{" "} + + {followedUserProfile.data?.username} + + + + + + + + + ); +}; + +export default UserFollowActivityItem; diff --git a/src/components/collection/collection-entry/hooks/useCollectionEntry.ts b/src/components/collection/collection-entry/hooks/useCollectionEntry.ts new file mode 100644 index 0000000..dc3db0d --- /dev/null +++ b/src/components/collection/collection-entry/hooks/useCollectionEntry.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { CollectionsEntriesService } from "@/wrapper/server"; + +export function useCollectionEntry(collectionEntryId: string) { + return useQuery({ + queryKey: ["collection-entries", collectionEntryId], + queryFn: async () => { + return CollectionsEntriesService.collectionsEntriesControllerFindEntryById( + collectionEntryId, + ); + }, + }); +} diff --git a/src/components/explore/ExploreScreenFilters.tsx b/src/components/explore/ExploreScreenFilters.tsx index 0769401..1bbf05a 100644 --- a/src/components/explore/ExploreScreenFilters.tsx +++ b/src/components/explore/ExploreScreenFilters.tsx @@ -1,4 +1,10 @@ -import React, { Dispatch, SetStateAction, useEffect, useRef } from "react"; +import React, { + Dispatch, + SetStateAction, + useEffect, + useLayoutEffect, + useRef, +} from "react"; import { z } from "zod"; import { FindStatisticsTrendingGamesDto, @@ -130,7 +136,11 @@ const ExploreScreenFilters = ({ drawerUtils.close(); }; - useEffect(() => { + /** + * useLayoutEffect executes BEFORE the screen is updated, so this avoids screen "blinks" when a screen is in + * an unfinished state + */ + useLayoutEffect(() => { const query = router.query; if (router.isReady && !hasLoadedQueryParams) { const dto = exploreScreenUrlQueryToDto(query); diff --git a/src/components/follow/hooks/useUserFollow.ts b/src/components/follow/hooks/useUserFollow.ts new file mode 100644 index 0000000..2088b26 --- /dev/null +++ b/src/components/follow/hooks/useUserFollow.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { FollowService } from "@/wrapper/server"; + +/** + * Returns a UserFollow entity using it's id. + * @param id + */ +export function useUserFollow(id: number) { + return useQuery({ + queryKey: ["follow", "entity", id], + queryFn: async () => { + return FollowService.followControllerGetUserFollowById(id); + }, + }); +} diff --git a/src/components/game/info/playtime/GameInfoPlaytime.tsx b/src/components/game/info/playtime/GameInfoPlaytime.tsx index 0848750..523ded3 100644 --- a/src/components/game/info/playtime/GameInfoPlaytime.tsx +++ b/src/components/game/info/playtime/GameInfoPlaytime.tsx @@ -47,7 +47,6 @@ const GameInfoPlaytime = ({ gameId }: Props) => { isLoading={playtimeQuery.isLoading} value={playtime?.time100} /> - {playtimeQuery.isLoading && } Data provided by HLTB diff --git a/src/components/game/info/playtime/GameInfoPlaytimeItem.tsx b/src/components/game/info/playtime/GameInfoPlaytimeItem.tsx index 37ee851..8165cf6 100644 --- a/src/components/game/info/playtime/GameInfoPlaytimeItem.tsx +++ b/src/components/game/info/playtime/GameInfoPlaytimeItem.tsx @@ -20,14 +20,17 @@ const GameInfoPlaytimeItem = ({ name, value, isLoading }: Props) => { > {name} - - {valueHours} Hours - - {isLoading && } + {isLoading ? ( + + ) : ( + + {valueHours === 0 ? "Not Available" : `${valueHours} Hours`} + + )} ); }; diff --git a/src/components/game/trending/TrendingGamesList.tsx b/src/components/game/trending/TrendingGamesList.tsx index 2342ff9..1aa59f5 100644 --- a/src/components/game/trending/TrendingGamesList.tsx +++ b/src/components/game/trending/TrendingGamesList.tsx @@ -43,7 +43,13 @@ const TrendingGamesList = () => { }, []); return ( - + {isLoading && elementsSkeletons} {gamesQuery.data?.map((game) => { diff --git a/src/components/general/input/GameRating.tsx b/src/components/general/input/GameRating.tsx index 6163bd9..5fff537 100644 --- a/src/components/general/input/GameRating.tsx +++ b/src/components/general/input/GameRating.tsx @@ -13,7 +13,7 @@ const GameRating = (props: Props) => { return ( diff --git a/src/components/general/input/UserAvatarWithUsername.tsx b/src/components/general/input/UserAvatarWithUsername.tsx index 09473e3..45405b7 100644 --- a/src/components/general/input/UserAvatarWithUsername.tsx +++ b/src/components/general/input/UserAvatarWithUsername.tsx @@ -51,16 +51,16 @@ const UserAvatarWithUsername = ({ userId, ...avatarProps }: Props) => { "linear-gradient(180deg, rgba(30,30,30,0.7973390039609594) 0%, rgba(30,30,30,0.772128919927346) 100%)" } backgroundOpacity={0.6} - className={"z-10"} + className={"z-10 rounded"} /> )} {overlayVisible && ( - + {profileQuery.isLoading && ( )} {username && ( - + {username} )} diff --git a/src/components/general/shell/GlobalShellNavbar/GlobalShellNavbar.tsx b/src/components/general/shell/GlobalShellNavbar/GlobalShellNavbar.tsx index 8543c43..89805e9 100755 --- a/src/components/general/shell/GlobalShellNavbar/GlobalShellNavbar.tsx +++ b/src/components/general/shell/GlobalShellNavbar/GlobalShellNavbar.tsx @@ -35,6 +35,7 @@ const links: NavbarItem[] = [ { icon: IconRouteAltLeft, label: "Explore", href: "/explore" }, { icon: IconUser, label: "Library", href: "/library" }, { icon: IconCheckbox, label: "Achievements", href: "/achievements" }, + { icon: IconBulb, label: "Activity", href: "/activity" }, ]; interface IGlobalShellNavbarProps extends BaseModalChildrenProps { diff --git a/src/components/library/view/sidebar/LibraryViewSidebar.tsx b/src/components/library/view/sidebar/LibraryViewSidebar.tsx index 6dca2e1..4638b04 100644 --- a/src/components/library/view/sidebar/LibraryViewSidebar.tsx +++ b/src/components/library/view/sidebar/LibraryViewSidebar.tsx @@ -6,6 +6,7 @@ import { Box, Group, Space, Text, TextInput } from "@mantine/core"; import Link from "next/link"; import { Collection } from "@/wrapper/server"; import LibraryViewSidebarCollections from "@/components/library/view/sidebar/LibraryViewSidebarCollections"; +import useUserProfile from "@/components/profile/hooks/useUserProfile"; interface ILibraryViewSidebarProps { userId: string | undefined; @@ -20,12 +21,14 @@ const buildCollectionsItems = (collections: Collection[]) => { const LibraryViewSidebar = ({ userId }: ILibraryViewSidebarProps) => { const userLibraryQuery = useUserLibrary(userId); const userLibrary = userLibraryQuery.data; + const userProfileQuery = useUserProfile(userId); + const username = userProfileQuery.data?.username; return (