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.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss
index c3c71d9a..958fe52d 100644
--- a/src/renderer/src/pages/profile/profile-content/profile-content.scss
+++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss
@@ -101,6 +101,11 @@
gap: calc(globals.$spacing-unit);
margin-bottom: calc(globals.$spacing-unit * 2);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ position: relative;
+ }
+
+ &__tab-wrapper {
+ position: relative;
}
&__tab {
@@ -111,19 +116,40 @@
cursor: pointer;
font-size: 14px;
font-weight: 500;
- border-bottom: 2px solid transparent;
- transition: all ease 0.2s;
-
- &:hover {
- color: rgba(255, 255, 255, 0.8);
- }
+ transition: color ease 0.2s;
+ display: flex;
+ align-items: center;
+ gap: calc(globals.$spacing-unit * 0.5);
&--active {
color: white;
- border-bottom-color: #c9aa71;
}
}
+ &__tab-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 6px;
+ background-color: rgba(255, 255, 255, 0.15);
+ border-radius: 9px;
+ font-size: 11px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.9);
+ line-height: 1;
+ }
+
+ &__tab-underline {
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: white;
+ }
+
&__games-grid {
list-style: none;
margin: 0;
@@ -175,5 +201,245 @@
backdrop-filter: blur(10px);
}
}
+
+ &__tab-panels {
+ display: block;
+ }
+ }
+}
+
+// Reviews minimal styles
+.user-reviews__loading {
+ padding: calc(globals.$spacing-unit * 2);
+ color: rgba(255, 255, 255, 0.8);
+ text-align: center;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.user-reviews__empty {
+ text-align: center;
+ padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2);
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.user-reviews__list {
+ display: flex;
+ flex-direction: column;
+ gap: calc(globals.$spacing-unit * 4);
+}
+
+.user-reviews__review-item {
+ border-radius: 8px;
+}
+
+.user-reviews__review-header {
+ display: flex;
+ flex-direction: column;
+ gap: calc(globals.$spacing-unit * 1);
+ margin-bottom: calc(globals.$spacing-unit * 1.5);
+}
+
+.user-reviews__review-header-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+}
+
+.user-reviews__review-header-bottom {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.user-reviews__review-meta-row {
+ display: flex;
+ align-items: center;
+ gap: calc(globals.$spacing-unit * 0.75);
+}
+
+.user-reviews__review-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: calc(globals.$spacing-unit * 1.5);
+ margin-bottom: calc(globals.$spacing-unit * 1.5);
+}
+
+.user-reviews__review-game {
+ display: flex;
+ gap: calc(globals.$spacing-unit);
+}
+
+.user-reviews__game-icon {
+ width: 24px;
+ height: 24px;
+ object-fit: cover;
+}
+
+.user-reviews__game-info {
+ display: flex;
+ flex-direction: column;
+}
+
+.user-reviews__game-details {
+ display: flex;
+ align-items: center;
+ gap: calc(globals.$spacing-unit * 0.75);
+}
+
+.user-reviews__game-title {
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.9);
+ font-weight: 600;
+ cursor: pointer;
+ text-align: left;
+
+ &--clickable:hover {
+ text-decoration: underline;
+ }
+}
+
+.user-reviews__review-date {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: rgba(255, 255, 255, 0.6);
+ font-size: globals.$small-font-size;
+}
+
+.user-reviews__review-score-stars {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 2px 6px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ font-size: 11px;
+ font-weight: 500;
+}
+
+.user-reviews__review-star {
+ color: rgba(255, 255, 255, 0.7);
+ transition: color 0.2s ease;
+
+ &--filled {
+ color: rgba(255, 255, 255, 0.7);
+
+ svg {
+ fill: currentColor;
+ }
+ }
+}
+
+.user-reviews__review-score-text {
+ font-weight: 500;
+}
+
+.user-reviews__review-playtime {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 11px;
+ font-weight: 500;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 2px 6px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.user-reviews__review-content {
+ color: rgba(255, 255, 255, 0.85);
+ line-height: 1.5;
+ word-wrap: break-word;
+ word-break: break-word;
+ overflow-wrap: break-word;
+ white-space: pre-wrap;
+ max-width: 100%;
+}
+
+.user-reviews__review-translation-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: calc(globals.$spacing-unit * 1);
+ margin-top: calc(globals.$spacing-unit * 1.5);
+ padding: 0;
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 0.875rem;
+ cursor: pointer;
+ text-decoration: none;
+ transition: all 0.2s ease;
+
+ &:hover {
+ text-decoration: underline;
+ color: rgba(255, 255, 255, 0.9);
+ }
+}
+
+.user-reviews__review-actions {
+ margin-top: calc(globals.$spacing-unit * 2);
+ padding-top: calc(globals.$spacing-unit);
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.user-reviews__review-votes {
+ display: flex;
+ gap: calc(globals.$spacing-unit);
+}
+
+.user-reviews__vote-button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 6px;
+ padding: 6px 12px;
+ color: #ccc;
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.2);
+ color: #ffffff;
+ }
+
+ &--active {
+ color: #ffffff;
+ border-color: rgba(255, 255, 255, 0.3);
+
+ svg {
+ fill: white;
+ }
+ }
+}
+
+.user-reviews__delete-review-button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: rgba(244, 67, 54, 0.1);
+ border: 1px solid rgba(244, 67, 54, 0.3);
+ border-radius: 6px;
+ padding: 6px 10px;
+ color: #f44336;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: rgba(244, 67, 54, 0.2);
+ border-color: rgba(244, 67, 54, 0.4);
+ color: #ff7961;
}
}
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 d2f1f074..8176bace 100644
--- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx
+++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx
@@ -1,29 +1,82 @@
import { userProfileContext } from "@renderer/context";
-import { useContext, useEffect, useMemo, useRef, useState } from "react";
+import {
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { ProfileHero } from "../profile-hero/profile-hero";
-import { useAppDispatch, useFormat } from "@renderer/hooks";
+import { useAppDispatch, useFormat, useUserDetails } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
-import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
+import type { GameShop } from "@types";
import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile";
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 { useSectionCollapse } from "@renderer/hooks/use-section-collapse";
-import { motion, AnimatePresence } from "framer-motion";
-import {
- sectionVariants,
- chevronVariants,
- GAME_STATS_ANIMATION_DURATION_IN_MS,
-} from "./profile-animations";
+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 { 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";
+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: GameShop;
+ };
+ translations: {
+ [key: string]: string;
+ };
+ detectedLanguage: string | null;
+}
+
+interface UserReviewsResponse {
+ totalCount: number;
+ reviews: UserReview[];
+}
+
+const getRatingText = (score: number, t: (key: string) => string): string => {
+ switch (score) {
+ case 1:
+ return t("rating_very_negative");
+ case 2:
+ return t("rating_negative");
+ case 3:
+ return t("rating_neutral");
+ case 4:
+ return t("rating_positive");
+ case 5:
+ return t("rating_very_positive");
+ default:
+ return "";
+ }
+};
+
export function ProfileContent() {
const {
userProfile,
@@ -32,16 +85,43 @@ export function ProfileContent() {
libraryGames,
pinnedGames,
getUserLibraryGames,
+ loadMoreLibraryGames,
+ hasMoreLibraryGames,
+ isLoadingLibraryGames,
} = useContext(userProfileContext);
+ const { userDetails } = useUserDetails();
const [statsIndex, setStatsIndex] = useState(0);
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
const [sortBy, setSortBy] = useState("playedRecently");
const statsAnimation = useRef(-1);
- const { toggleSection, isPinnedCollapsed } = useSectionCollapse();
+
+ const [activeTab, setActiveTab] = useState<"library" | "reviews">("library");
+
+ // User reviews state
+ const [reviews, setReviews] = useState([]);
+ const [reviewsTotalCount, setReviewsTotalCount] = useState(0);
+ const [isLoadingReviews, setIsLoadingReviews] = useState(false);
+ const [votingReviews, setVotingReviews] = useState>(new Set());
+ const [deleteModalVisible, setDeleteModalVisible] = useState(false);
+ const [reviewToDelete, setReviewToDelete] = useState(null);
const dispatch = useAppDispatch();
const { t } = useTranslation("user_profile");
+ const { numberFormatter } = useFormat();
+
+ const formatPlayTime = (playTimeInSeconds: number) => {
+ const minutes = playTimeInSeconds / 60;
+
+ if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
+ return t("amount_minutes", {
+ amount: minutes.toFixed(0),
+ });
+ }
+
+ const hours = minutes / 60;
+ return t("amount_hours", { amount: numberFormatter.format(hours) });
+ };
useEffect(() => {
dispatch(setHeaderTitle(""));
@@ -53,10 +133,201 @@ export function ProfileContent() {
useEffect(() => {
if (userProfile) {
- getUserLibraryGames(sortBy);
+ // When sortBy changes, clear animated games so all games animate in
+ if (currentSortByRef.current !== sortBy) {
+ animatedGameIdsRef.current.clear();
+ currentSortByRef.current = sortBy;
+ }
+ getUserLibraryGames(sortBy, true);
}
}, [sortBy, getUserLibraryGames, userProfile]);
+ const animatedGameIdsRef = useRef>(new Set());
+ const currentSortByRef = useRef(sortBy);
+
+ const handleLoadMore = useCallback(() => {
+ if (
+ activeTab === "library" &&
+ hasMoreLibraryGames &&
+ !isLoadingLibraryGames
+ ) {
+ loadMoreLibraryGames(sortBy);
+ }
+ }, [
+ activeTab,
+ hasMoreLibraryGames,
+ isLoadingLibraryGames,
+ loadMoreLibraryGames,
+ sortBy,
+ ]);
+
+ // Clear reviews state and reset tab when switching users
+ useEffect(() => {
+ setReviews([]);
+ setReviewsTotalCount(0);
+ setIsLoadingReviews(false);
+ setActiveTab("library");
+ }, [userProfile?.id]);
+
+ useEffect(() => {
+ if (userProfile?.id) {
+ fetchUserReviews();
+ }
+ }, [userProfile?.id]);
+
+ const fetchUserReviews = async () => {
+ if (!userProfile?.id) return;
+
+ setIsLoadingReviews(true);
+ try {
+ const response = await window.electron.hydraApi.get(
+ `/users/${userProfile.id}/reviews`,
+ { needsAuth: true }
+ );
+ setReviews(response.reviews);
+ setReviewsTotalCount(response.totalCount);
+ } catch (error) {
+ // Error handling for fetching reviews
+ } finally {
+ setIsLoadingReviews(false);
+ }
+ };
+
+ const handleDeleteReview = async (reviewId: string) => {
+ try {
+ const reviewToDeleteObj = reviews.find(
+ (review) => review.id === reviewId
+ );
+ if (!reviewToDeleteObj) return;
+
+ await window.electron.hydraApi.delete(
+ `/games/${reviewToDeleteObj.game.shop}/${reviewToDeleteObj.game.objectId}/reviews/${reviewId}`
+ );
+ // Remove the review from the local state
+ setReviews((prev) => prev.filter((review) => review.id !== reviewId));
+ setReviewsTotalCount((prev) => prev - 1);
+ } catch (error) {
+ console.error("Failed to delete review:", error);
+ }
+ };
+
+ const handleDeleteClick = (reviewId: string) => {
+ setReviewToDelete(reviewId);
+ setDeleteModalVisible(true);
+ };
+
+ const handleDeleteConfirm = () => {
+ if (reviewToDelete) {
+ handleDeleteReview(reviewToDelete);
+ setReviewToDelete(null);
+ }
+ };
+
+ const handleDeleteCancel = () => {
+ setDeleteModalVisible(false);
+ setReviewToDelete(null);
+ };
+
+ const handleVoteReview = async (reviewId: string, isUpvote: boolean) => {
+ if (votingReviews.has(reviewId)) return;
+
+ setVotingReviews((prev) => new Set(prev).add(reviewId));
+
+ const review = reviews.find((r) => r.id === reviewId);
+ if (!review) {
+ setVotingReviews((prev) => {
+ const next = new Set(prev);
+ next.delete(reviewId);
+ return next;
+ });
+ return;
+ }
+
+ const wasUpvoted = review.hasUpvoted;
+ const wasDownvoted = review.hasDownvoted;
+
+ // Optimistic update
+ setReviews((prev) =>
+ prev.map((r) => {
+ if (r.id !== reviewId) return r;
+
+ let newUpvotes = r.upvotes;
+ let newDownvotes = r.downvotes;
+ let newHasUpvoted = r.hasUpvoted;
+ let newHasDownvoted = r.hasDownvoted;
+
+ if (isUpvote) {
+ if (wasUpvoted) {
+ // Remove upvote
+ newUpvotes--;
+ newHasUpvoted = false;
+ } else {
+ // Add upvote
+ newUpvotes++;
+ newHasUpvoted = true;
+ if (wasDownvoted) {
+ // Remove downvote if it was downvoted
+ newDownvotes--;
+ newHasDownvoted = false;
+ }
+ }
+ } else if (wasDownvoted) {
+ // Remove downvote
+ newDownvotes--;
+ newHasDownvoted = false;
+ } else {
+ // Add downvote
+ newDownvotes++;
+ newHasDownvoted = true;
+ if (wasUpvoted) {
+ // Remove upvote if it was upvoted
+ newUpvotes--;
+ newHasUpvoted = false;
+ }
+ }
+
+ return {
+ ...r,
+ upvotes: newUpvotes,
+ downvotes: newDownvotes,
+ hasUpvoted: newHasUpvoted,
+ hasDownvoted: newHasDownvoted,
+ };
+ })
+ );
+
+ try {
+ const endpoint = isUpvote ? "upvote" : "downvote";
+ await window.electron.hydraApi.put(
+ `/games/${review.game.shop}/${review.game.objectId}/reviews/${reviewId}/${endpoint}`
+ );
+ } catch (error) {
+ console.error("Failed to vote on review:", error);
+
+ // Rollback optimistic update on error
+ setReviews((prev) =>
+ prev.map((r) => {
+ if (r.id !== reviewId) return r;
+ return {
+ ...r,
+ upvotes: review.upvotes,
+ downvotes: review.downvotes,
+ hasUpvoted: review.hasUpvoted,
+ hasDownvoted: review.hasDownvoted,
+ };
+ })
+ );
+ } finally {
+ setTimeout(() => {
+ setVotingReviews((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(reviewId);
+ return newSet;
+ });
+ }, 500);
+ }
+ };
+
const handleOnMouseEnterGameCard = () => {
setIsAnimationRunning(false);
};
@@ -86,8 +357,6 @@ export function ProfileContent() {
};
}, [setStatsIndex, isAnimationRunning]);
- const { numberFormatter } = useFormat();
-
const usersAreFriends = useMemo(() => {
return userProfile?.relation?.status === "ACCEPTED";
}, [userProfile]);
@@ -113,112 +382,46 @@ export function ProfileContent() {
return (
- {hasAnyGames && (
-
- )}
+
- {!hasAnyGames && (
-
-
-
-
-
{t("no_recent_activity_title")}
- {isMe &&
{t("no_recent_activity_description")}
}
-
- )}
-
- {hasAnyGames && (
-
- {hasPinnedGames && (
-
-
-
-
-
{t("pinned")}
-
- {pinnedGames.length}
-
-
-
-
-
- {!isPinnedCollapsed && (
-
-
- {pinnedGames?.map((game) => (
- -
-
-
- ))}
-
-
- )}
-
-
+
+
+ {activeTab === "library" && (
+
)}
- {hasGames && (
-
-
-
-
{t("library")}
- {userStats && (
-
- {numberFormatter.format(userStats.libraryCount)}
-
- )}
-
-
-
-
- {libraryGames?.map((game) => (
- -
-
-
- ))}
-
-
+ {activeTab === "reviews" && (
+
)}
-
- )}
+
+
{shouldShowRightContent && (
@@ -230,6 +433,12 @@ export function ProfileContent() {
)}
+
+
);
}, [
@@ -242,9 +451,15 @@ export function ProfileContent() {
statsIndex,
libraryGames,
pinnedGames,
- isPinnedCollapsed,
- toggleSection,
+
sortBy,
+ activeTab,
+ // ensure reviews UI updates correctly
+ reviews,
+ reviewsTotalCount,
+ isLoadingReviews,
+ votingReviews,
+ deleteModalVisible,
]);
return (
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..bea569e7
--- /dev/null
+++ b/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx
@@ -0,0 +1,252 @@
+import { motion, AnimatePresence } from "framer-motion";
+import { useNavigate } from "react-router-dom";
+import { ClockIcon } from "@primer/octicons-react";
+import { Star, ThumbsUp, ThumbsDown, TrashIcon, Languages } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { useState } from "react";
+import type { GameShop } from "@types";
+import { sanitizeHtml } from "@shared";
+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: GameShop;
+ };
+ translations: {
+ [key: string]: string;
+ };
+ detectedLanguage: string | null;
+}
+
+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, i18n } = useTranslation("game_details");
+ const [showOriginal, setShowOriginal] = useState(false);
+
+ const getBaseLanguage = (lang: string | null) => lang?.split("-")[0] || "";
+
+ const isDifferentLanguage =
+ getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language);
+
+ const needsTranslation =
+ !isOwnReview && isDifferentLanguage && review.translations[i18n.language];
+
+ const getLanguageName = (languageCode: string | null) => {
+ if (!languageCode) return "";
+ try {
+ const displayNames = new Intl.DisplayNames([i18n.language], {
+ type: "language",
+ });
+ return displayNames.of(languageCode) || languageCode.toUpperCase();
+ } catch {
+ return languageCode.toUpperCase();
+ }
+ };
+
+ const displayContent = needsTranslation
+ ? review.translations[i18n.language]
+ : review.reviewHtml;
+
+ return (
+
+
+
+
+
+
+

+
+
+
+
+
+ {formatDistance(new Date(review.createdAt), new Date(), {
+ addSuffix: true,
+ })}
+
+
+
+
+
+
+
+ {review.score}/5
+
+
+ {Boolean(
+ review.playTimeInSeconds && review.playTimeInSeconds > 0
+ ) && (
+
+
+
+ {tGameDetails("review_played_for")}{" "}
+ {formatPlayTime(review.playTimeInSeconds || 0)}
+
+
+ )}
+
+
+
+
+
+
+ {needsTranslation && (
+ <>
+
+ {showOriginal && (
+
+ )}
+ >
+ )}
+
+
+
+
+
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..afcc417b
--- /dev/null
+++ b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx
@@ -0,0 +1,96 @@
+import { motion } from "framer-motion";
+import { useTranslation } from "react-i18next";
+import type { GameShop } from "@types";
+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: GameShop;
+ };
+ translations: {
+ [key: string]: string;
+ };
+ detectedLanguage: string | null;
+}
+
+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 (
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss
index 61640536..76bd25a9 100644
--- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss
+++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss
@@ -36,6 +36,7 @@
box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5);
width: 100%;
position: relative;
+ overflow: hidden;
&:before {
content: "";
@@ -193,8 +194,28 @@
border-radius: 4px;
width: 100%;
height: 100%;
- min-width: 100%;
- min-height: 100%;
+ display: block;
+ }
+
+ &__cover-placeholder {
+ position: relative;
+ width: 100%;
+ padding-bottom: 150%;
+ background: linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0.08) 0%,
+ rgba(255, 255, 255, 0.04) 50%,
+ rgba(255, 255, 255, 0.08) 100%
+ );
+ border-radius: 4px;
+ color: rgba(255, 255, 255, 0.3);
+
+ svg {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ }
}
&__achievements-progress {
diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx
index 72b48a8c..81db6334 100644
--- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx
+++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx
@@ -2,7 +2,7 @@ import { UserGame } from "@types";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useFormat, useToast } from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
-import { useCallback, useContext, useState } from "react";
+import { useCallback, useContext, useEffect, useState } from "react";
import {
buildGameAchievementPath,
buildGameDetailsPath,
@@ -15,6 +15,7 @@ import {
AlertFillIcon,
PinIcon,
PinSlashIcon,
+ ImageIcon,
} from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { Tooltip } from "react-tooltip";
@@ -44,6 +45,11 @@ export function UserLibraryGameCard({
const navigate = useNavigate();
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
const [isPinning, setIsPinning] = useState(false);
+ const [imageError, setImageError] = useState(false);
+
+ useEffect(() => {
+ setImageError(false);
+ }, [game.coverImageUrl]);
const getStatsItemCount = useCallback(() => {
let statsCount = 1;
@@ -233,11 +239,18 @@ export function UserLibraryGameCard({
)}