-
-
-
- {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 }
- )}
-
-
-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
-
-
- 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,
+ })}
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+
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 (
+
+ );
+ })}
+
+ )}
+
+ );
+}
+