diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 305747c3..1ab76381 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -279,7 +279,11 @@ export function App() {
-
+
diff --git a/src/renderer/src/pages/profile/profile-content/library-tab.tsx b/src/renderer/src/pages/profile/profile-content/library-tab.tsx new file mode 100644 index 00000000..bd461862 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/library-tab.tsx @@ -0,0 +1,176 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import { TelescopeIcon } from "@primer/octicons-react"; +import InfiniteScroll from "react-infinite-scroll-component"; +import { useFormat } from "@renderer/hooks"; +import type { UserGame } from "@types"; +import { SortOptions } from "./sort-options"; +import { UserLibraryGameCard } from "./user-library-game-card"; +import "./profile-content.scss"; + +type SortOption = "playtime" | "achievementCount" | "playedRecently"; + +interface LibraryTabProps { + sortBy: SortOption; + onSortChange: (sortBy: SortOption) => void; + pinnedGames: UserGame[]; + libraryGames: UserGame[]; + hasMoreLibraryGames: boolean; + isLoadingLibraryGames: boolean; + statsIndex: number; + userStats: { libraryCount: number } | null; + animatedGameIdsRef: React.MutableRefObject>; + onLoadMore: () => void; + onMouseEnter: () => void; + onMouseLeave: () => void; + isMe: boolean; +} + +export function LibraryTab({ + sortBy, + onSortChange, + pinnedGames, + libraryGames, + hasMoreLibraryGames, + isLoadingLibraryGames, + statsIndex, + userStats, + animatedGameIdsRef, + onLoadMore, + onMouseEnter, + onMouseLeave, + isMe, +}: Readonly) { + const { t } = useTranslation("user_profile"); + const { numberFormatter } = useFormat(); + + const hasGames = libraryGames.length > 0; + const hasPinnedGames = pinnedGames.length > 0; + const hasAnyGames = hasGames || hasPinnedGames; + + return ( + + {hasAnyGames && } + + {!hasAnyGames && ( +
+
+ +
+

{t("no_recent_activity_title")}

+ {isMe &&

{t("no_recent_activity_description")}

} +
+ )} + + {hasAnyGames && ( +
+ {hasPinnedGames && ( +
+
+
+

{t("pinned")}

+ + {pinnedGames.length} + +
+
+ +
    + {pinnedGames?.map((game) => ( +
  • + +
  • + ))} +
+
+ )} + + {hasGames && ( +
+
+
+

{t("library")}

+ {userStats && ( + + {numberFormatter.format(userStats.libraryCount)} + + )} +
+
+ + +
    + {libraryGames?.map((game, index) => { + const hasAnimated = + animatedGameIdsRef.current.has(game.objectId); + const isNewGame = !hasAnimated && !isLoadingLibraryGames; + + return ( + { + if (isNewGame) { + animatedGameIdsRef.current.add(game.objectId); + } + }} + > + + + ); + })} +
+
+
+ )} +
+ )} +
+ ); +} + diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 7ef486d4..ab9fdf01 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -8,14 +8,8 @@ import { useState, } from "react"; import { ProfileHero } from "../profile-hero/profile-hero"; -import { - useAppDispatch, - useFormat, - useDate, - useUserDetails, -} from "@renderer/hooks"; +import { useAppDispatch, useFormat, useUserDetails } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; -import { TelescopeIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; import { LockedProfile } from "./locked-profile"; import { ReportProfile } from "../report-profile/report-profile"; @@ -23,19 +17,13 @@ import { FriendsBox } from "./friends-box"; import { RecentGamesBox } from "./recent-games-box"; import { UserStatsBox } from "./user-stats-box"; import { UserKarmaBox } from "./user-karma-box"; -import { UserLibraryGameCard } from "./user-library-game-card"; -import { SortOptions } from "./sort-options"; - -import { motion, AnimatePresence } from "framer-motion"; -import { useNavigate } from "react-router-dom"; -import { buildGameDetailsPath } from "@renderer/helpers"; -import { ClockIcon } from "@primer/octicons-react"; -import { Star, ThumbsUp, ThumbsDown, TrashIcon } from "lucide-react"; -import type { GameShop } from "@types"; import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; -import InfiniteScroll from "react-infinite-scroll-component"; +import { ProfileTabs } from "./profile-tabs"; +import { LibraryTab } from "./library-tab"; +import { ReviewsTab } from "./reviews-tab"; +import { AnimatePresence } from "framer-motion"; import "./profile-content.scss"; type SortOption = "playtime" | "achievementCount" | "playedRecently"; @@ -58,7 +46,7 @@ interface UserReview { title: string; iconUrl: string; objectId: string; - shop: GameShop; + shop: string; }; } @@ -97,8 +85,6 @@ export function ProfileContent() { isLoadingLibraryGames, } = useContext(userProfileContext); const { userDetails } = useUserDetails(); - const { formatDistance } = useDate(); - const navigate = useNavigate(); const [statsIndex, setStatsIndex] = useState(0); const [isAnimationRunning, setIsAnimationRunning] = useState(true); const [sortBy, setSortBy] = useState("playedRecently"); @@ -117,7 +103,6 @@ export function ProfileContent() { const dispatch = useAppDispatch(); const { t } = useTranslation("user_profile"); - const { t: tGameDetails } = useTranslation("game_details"); const { numberFormatter } = useFormat(); const formatPlayTime = (playTimeInSeconds: number) => { @@ -197,7 +182,7 @@ export function ProfileContent() { setReviews(response.reviews); setReviewsTotalCount(response.totalCount); } catch (error) { - console.error("Failed to fetch user reviews:", error); + // Error handling for fetching reviews } finally { setIsLoadingReviews(false); } @@ -392,383 +377,43 @@ export function ProfileContent() { return (
-
-
- - {activeTab === "library" && ( - - )} -
-
- - {activeTab === "reviews" && ( - - )} -
-
+
{activeTab === "library" && ( - - {hasAnyGames && ( - - )} - - {!hasAnyGames && ( -
-
- -
-

{t("no_recent_activity_title")}

- {isMe &&

{t("no_recent_activity_description")}

} -
- )} - - {hasAnyGames && ( -
- {hasPinnedGames && ( -
-
-
- {/* removed collapse button */} -

{t("pinned")}

- - {pinnedGames.length} - -
-
- - {/* render pinned games unconditionally */} -
    - {pinnedGames?.map((game) => ( -
  • - -
  • - ))} -
-
- )} - - {hasGames && ( -
-
-
-

{t("library")}

- {userStats && ( - - {numberFormatter.format( - userStats.libraryCount - )} - - )} -
-
- - -
    - {libraryGames?.map((game, index) => { - const hasAnimated = - animatedGameIdsRef.current.has(game.objectId); - const isNewGame = - !hasAnimated && !isLoadingLibraryGames; - - return ( - { - if (isNewGame) { - animatedGameIdsRef.current.add( - game.objectId - ); - } - }} - > - - - ); - })} -
-
-
- )} -
- )} -
+ )} {activeTab === "reviews" && ( - - {/* render reviews content unconditionally */} - {isLoadingReviews && ( -
- {t("loading_reviews")} -
- )} - {!isLoadingReviews && reviews.length === 0 && ( -
-

{t("no_reviews", "No reviews yet")}

-
- )} - {!isLoadingReviews && reviews.length > 0 && ( -
- {reviews.map((review) => { - const isOwnReview = userDetails?.id === review.user.id; - - return ( - -
-
-
- - - {review.score}/5 - -
- {Boolean( - review.playTimeInSeconds && - review.playTimeInSeconds > 0 - ) && ( -
- - - {tGameDetails("review_played_for")}{" "} - {formatPlayTime( - review.playTimeInSeconds || 0 - )} - -
- )} -
-
- {formatDistance( - new Date(review.createdAt), - new Date(), - { addSuffix: true } - )} -
-
- -
- -
-
-
-
- {review.game.title} - -
-
-
-
- -
-
- - handleVoteReview(review.id, true) - } - disabled={votingReviews.has(review.id)} - style={{ - opacity: votingReviews.has(review.id) - ? 0.5 - : 1, - cursor: votingReviews.has(review.id) - ? "not-allowed" - : "pointer", - }} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.upvotes} - - - - - - handleVoteReview(review.id, false) - } - disabled={votingReviews.has(review.id)} - style={{ - opacity: votingReviews.has(review.id) - ? 0.5 - : 1, - cursor: votingReviews.has(review.id) - ? "not-allowed" - : "pointer", - }} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.downvotes} - - - -
- - {isOwnReview && ( - - )} -
- - ); - })} -
- )} -
+ )}
diff --git a/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx b/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx new file mode 100644 index 00000000..79e0440e --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx @@ -0,0 +1,191 @@ +import { motion, AnimatePresence } from "framer-motion"; +import { useNavigate } from "react-router-dom"; +import { ClockIcon } from "@primer/octicons-react"; +import { Star, ThumbsUp, ThumbsDown, TrashIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useDate } from "@renderer/hooks"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import "./profile-content.scss"; + +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: string; + }; +} + +interface ProfileReviewItemProps { + review: UserReview; + isOwnReview: boolean; + isVoting: boolean; + formatPlayTime: (playTimeInSeconds: number) => string; + getRatingText: (score: number, t: (key: string) => string) => string; + onVote: (reviewId: string, isUpvote: boolean) => void; + onDelete: (reviewId: string) => void; +} + +export function ProfileReviewItem({ + review, + isOwnReview, + isVoting, + formatPlayTime, + getRatingText, + onVote, + onDelete, +}: Readonly) { + const navigate = useNavigate(); + const { formatDistance } = useDate(); + const { t } = useTranslation("user_profile"); + const { t: tGameDetails } = useTranslation("game_details"); + + return ( + +
+
+
+ + + {review.score}/5 + +
+ {Boolean( + review.playTimeInSeconds && review.playTimeInSeconds > 0 + ) && ( +
+ + + {tGameDetails("review_played_for")}{" "} + {formatPlayTime(review.playTimeInSeconds || 0)} + +
+ )} +
+
+ {formatDistance(new Date(review.createdAt), new Date(), { + addSuffix: true, + })} +
+
+ +
+ +
+
+
+
+ {review.game.title} + +
+
+
+
+ +
+
+ onVote(review.id, true)} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.upvotes} + + + + + onVote(review.id, false)} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.downvotes} + + + +
+ + {isOwnReview && ( + + )} +
+ + ); +} + diff --git a/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx new file mode 100644 index 00000000..bc76f40c --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx @@ -0,0 +1,67 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import "./profile-content.scss"; + +interface ProfileTabsProps { + activeTab: "library" | "reviews"; + reviewsTotalCount: number; + onTabChange: (tab: "library" | "reviews") => void; +} + +export function ProfileTabs({ + activeTab, + reviewsTotalCount, + onTabChange, +}: Readonly) { + const { t } = useTranslation("user_profile"); + + return ( +
+
+ + {activeTab === "library" && ( + + )} +
+
+ + {activeTab === "reviews" && ( + + )} +
+
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx new file mode 100644 index 00000000..97924040 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx @@ -0,0 +1,92 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import { ProfileReviewItem } from "./profile-review-item"; +import "./profile-content.scss"; + +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: string; + }; +} + +interface ReviewsTabProps { + reviews: UserReview[]; + isLoadingReviews: boolean; + votingReviews: Set; + userDetailsId?: string; + formatPlayTime: (playTimeInSeconds: number) => string; + getRatingText: (score: number, t: (key: string) => string) => string; + onVote: (reviewId: string, isUpvote: boolean) => void; + onDelete: (reviewId: string) => void; +} + +export function ReviewsTab({ + reviews, + isLoadingReviews, + votingReviews, + userDetailsId, + formatPlayTime, + getRatingText, + onVote, + onDelete, +}: Readonly) { + const { t } = useTranslation("user_profile"); + + return ( + + {isLoadingReviews && ( +
{t("loading_reviews")}
+ )} + {!isLoadingReviews && reviews.length === 0 && ( +
+

{t("no_reviews", "No reviews yet")}

+
+ )} + {!isLoadingReviews && reviews.length > 0 && ( +
+ {reviews.map((review) => { + const isOwnReview = userDetailsId === review.user.id; + + return ( + + ); + })} +
+ )} +
+ ); +} +