From 362d6b634e74b1577d0d8cdb8e04788a109c0f19 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Wed, 22 Oct 2025 21:13:05 +0300 Subject: [PATCH 01/19] feat: added reviews in profile and tabs --- src/locales/en/translation.json | 5 +- .../src/hooks/use-section-collapse.ts | 3 + .../profile-content/profile-content.scss | 156 ++++- .../profile-content/profile-content.tsx | 551 ++++++++++++++---- 4 files changed, 610 insertions(+), 105 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 46bdb28c..e00501c0 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -676,7 +676,10 @@ "game_added_to_pinned": "Game added to pinned", "karma": "Karma", "karma_count": "karma", - "karma_description": "Earned from positive likes on reviews" + "karma_description": "Earned from positive likes on reviews", + "user_reviews": "User's Reviews", + "delete_review": "Delete Review", + "loading_reviews": "Loading reviews..." }, "achievement": { "achievement_unlocked": "Achievement unlocked", diff --git a/src/renderer/src/hooks/use-section-collapse.ts b/src/renderer/src/hooks/use-section-collapse.ts index 7cd22224..59ffd6af 100644 --- a/src/renderer/src/hooks/use-section-collapse.ts +++ b/src/renderer/src/hooks/use-section-collapse.ts @@ -3,12 +3,14 @@ import { useState, useCallback } from "react"; interface SectionCollapseState { pinned: boolean; library: boolean; + reviews: boolean; } export function useSectionCollapse() { const [collapseState, setCollapseState] = useState({ pinned: false, library: false, + reviews: false, }); const toggleSection = useCallback((section: keyof SectionCollapseState) => { @@ -23,5 +25,6 @@ export function useSectionCollapse() { toggleSection, isPinnedCollapsed: collapseState.pinned, isLibraryCollapsed: collapseState.library, + isReviewsCollapsed: collapseState.reviews, }; } 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..a9aad402 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -120,7 +120,7 @@ &--active { color: white; - border-bottom-color: #c9aa71; + border-bottom-color: white; } } @@ -177,3 +177,157 @@ } } } + + // Reviews minimal styles + .user-reviews__loading { + padding: calc(globals.$spacing-unit * 2); + color: rgba(255, 255, 255, 0.8); + } + + .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 * 2); + } + + .user-reviews__review-item { + border-radius: 8px; + padding: calc(globals.$spacing-unit * 2); + } + + .user-reviews__review-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: calc(globals.$spacing-unit * 1.5); + } + + .user-reviews__review-game { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + .user-reviews__game-icon { + width: 40px; + height: 40px; + border-radius: 8px; + object-fit: cover; + } + + .user-reviews__game-info { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 0.25); + } + + .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 { + color: rgba(255, 255, 255, 0.6); + font-size: globals.$small-font-size; + } + + .user-reviews__review-score-stars { + display: flex; + gap: calc(globals.$spacing-unit * 0.5); + } + + .user-reviews__review-star-container { + display: flex; + align-items: center; + } + + .user-reviews__review-content { + color: rgba(255, 255, 255, 0.85); + line-height: 1.5; + } + + .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; + } + } + + .profile-content { + &__tab-panels { + display: block; + } + + &__tab-panel[hidden] { + display: none; + } + } 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..bb4e0025 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -1,9 +1,9 @@ import { userProfileContext } from "@renderer/context"; import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { ProfileHero } from "../profile-hero/profile-hero"; -import { useAppDispatch, useFormat } from "@renderer/hooks"; +import { useAppDispatch, useFormat, useDate, useUserDetails } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; -import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react"; +import { TelescopeIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; import { LockedProfile } from "./locked-profile"; import { ReportProfile } from "../report-profile/report-profile"; @@ -13,17 +13,56 @@ 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 { useNavigate } from "react-router-dom"; +import { buildGameDetailsPath } from "@renderer/helpers"; +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 { - sectionVariants, - chevronVariants, + // removed: sectionVariants, + // removed: chevronVariants, GAME_STATS_ANIMATION_DURATION_IN_MS, } from "./profile-animations"; import "./profile-content.scss"; + type SortOption = "playtime" | "achievementCount" | "playedRecently"; +interface UserReview { + id: string; + reviewHtml: string; + score: 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; + }; +} + +interface UserReviewsResponse { + totalCount: number; + reviews: UserReview[]; +} + +const getScoreColorClass = (score: number) => { + if (score >= 1 && score <= 2) return "game-details__review-score--red"; + if (score === 3) return "game-details__review-score--yellow"; + if (score >= 4 && score <= 5) return "game-details__review-score--green"; + return ""; +}; + export function ProfileContent() { const { userProfile, @@ -33,11 +72,23 @@ export function ProfileContent() { pinnedGames, getUserLibraryGames, } = 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"); 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(); @@ -57,6 +108,152 @@ export function ProfileContent() { } }, [sortBy, getUserLibraryGames, userProfile]); + 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) { + console.error("Failed to fetch user reviews:", error); + } 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) 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 { + setVotingReviews(prev => { + const newSet = new Set(prev); + newSet.delete(reviewId); + return newSet; + }); + } + }; + const handleOnMouseEnterGameCard = () => { setIsAnimationRunning(false); }; @@ -113,112 +310,253 @@ export function ProfileContent() { return (
- {hasAnyGames && ( - - )} +
+ + +
- {!hasAnyGames && ( -
-
- -
-

{t("no_recent_activity_title")}

- {isMe &&

{t("no_recent_activity_description")}

} -
- )} +
+
); }, [ @@ -242,9 +586,10 @@ export function ProfileContent() { statsIndex, libraryGames, pinnedGames, - isPinnedCollapsed, - toggleSection, + // removed isPinnedCollapsed, + // removed toggleSection, sortBy, + activeTab, ]); return ( From 035f6e8d24c478d6a6df1df35f669ccb1b6957c4 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Wed, 22 Oct 2025 22:05:05 +0300 Subject: [PATCH 02/19] ci: formatting --- .../profile-content/profile-content.scss | 288 +++++++++--------- .../profile-content/profile-content.tsx | 178 ++++++----- 2 files changed, 247 insertions(+), 219 deletions(-) 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 a9aad402..4cdea61b 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -178,156 +178,156 @@ } } - // Reviews minimal styles - .user-reviews__loading { - padding: calc(globals.$spacing-unit * 2); - color: rgba(255, 255, 255, 0.8); +// Reviews minimal styles +.user-reviews__loading { + padding: calc(globals.$spacing-unit * 2); + color: rgba(255, 255, 255, 0.8); +} + +.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 * 2); +} + +.user-reviews__review-item { + border-radius: 8px; + padding: calc(globals.$spacing-unit * 2); +} + +.user-reviews__review-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: calc(globals.$spacing-unit * 1.5); +} + +.user-reviews__review-game { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); +} + +.user-reviews__game-icon { + width: 40px; + height: 40px; + border-radius: 8px; + object-fit: cover; +} + +.user-reviews__game-info { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 0.25); +} + +.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 { + color: rgba(255, 255, 255, 0.6); + font-size: globals.$small-font-size; +} + +.user-reviews__review-score-stars { + display: flex; + gap: calc(globals.$spacing-unit * 0.5); +} + +.user-reviews__review-star-container { + display: flex; + align-items: center; +} + +.user-reviews__review-content { + color: rgba(255, 255, 255, 0.85); + line-height: 1.5; +} + +.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; } - .user-reviews__empty { - text-align: center; - padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2); - color: rgba(255, 255, 255, 0.6); - } + &--active { + color: #ffffff; + border-color: rgba(255, 255, 255, 0.3); - .user-reviews__list { - display: flex; - flex-direction: column; - gap: calc(globals.$spacing-unit * 2); - } - - .user-reviews__review-item { - border-radius: 8px; - padding: calc(globals.$spacing-unit * 2); - } - - .user-reviews__review-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: calc(globals.$spacing-unit * 1.5); - } - - .user-reviews__review-game { - display: flex; - align-items: center; - gap: calc(globals.$spacing-unit); - } - - .user-reviews__game-icon { - width: 40px; - height: 40px; - border-radius: 8px; - object-fit: cover; - } - - .user-reviews__game-info { - display: flex; - flex-direction: column; - gap: calc(globals.$spacing-unit * 0.25); - } - - .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; + svg { + fill: white; } } +} - .user-reviews__review-date { - color: rgba(255, 255, 255, 0.6); - font-size: globals.$small-font-size; +.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; + } +} + +.profile-content { + &__tab-panels { + display: block; } - .user-reviews__review-score-stars { - display: flex; - gap: calc(globals.$spacing-unit * 0.5); - } - - .user-reviews__review-star-container { - display: flex; - align-items: center; - } - - .user-reviews__review-content { - color: rgba(255, 255, 255, 0.85); - line-height: 1.5; - } - - .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; - } - } - - .profile-content { - &__tab-panels { - display: block; - } - - &__tab-panel[hidden] { - display: none; - } + &__tab-panel[hidden] { + display: none; } +} 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 bb4e0025..d245aa5e 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -1,7 +1,12 @@ import { userProfileContext } from "@renderer/context"; import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { ProfileHero } from "../profile-hero/profile-hero"; -import { useAppDispatch, useFormat, useDate, useUserDetails } from "@renderer/hooks"; +import { + useAppDispatch, + useFormat, + useDate, + useUserDetails, +} from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; import { TelescopeIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; @@ -27,7 +32,6 @@ import { } from "./profile-animations"; import "./profile-content.scss"; - type SortOption = "playtime" | "achievementCount" | "playedRecently"; interface UserReview { @@ -79,7 +83,7 @@ export function ProfileContent() { const [isAnimationRunning, setIsAnimationRunning] = useState(true); const [sortBy, setSortBy] = useState("playedRecently"); const statsAnimation = useRef(-1); - + const [activeTab, setActiveTab] = useState<"library" | "reviews">("library"); // User reviews state @@ -116,7 +120,7 @@ export function ProfileContent() { const fetchUserReviews = async () => { if (!userProfile?.id) return; - + setIsLoadingReviews(true); try { const response = await window.electron.hydraApi.get( @@ -134,15 +138,17 @@ export function ProfileContent() { const handleDeleteReview = async (reviewId: string) => { try { - const reviewToDeleteObj = reviews.find(review => review.id === reviewId); + 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); + setReviews((prev) => prev.filter((review) => review.id !== reviewId)); + setReviewsTotalCount((prev) => prev - 1); } catch (error) { console.error("Failed to delete review:", error); } @@ -168,85 +174,89 @@ export function ProfileContent() { const handleVoteReview = async (reviewId: string, isUpvote: boolean) => { if (votingReviews.has(reviewId)) return; - setVotingReviews(prev => new Set(prev).add(reviewId)); + setVotingReviews((prev) => new Set(prev).add(reviewId)); - const review = reviews.find(r => r.id === reviewId); + const review = reviews.find((r) => r.id === reviewId); if (!review) return; const wasUpvoted = review.hasUpvoted; const wasDownvoted = review.hasDownvoted; // Optimistic update - setReviews(prev => prev.map(r => { - if (r.id !== reviewId) return r; + 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; + 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 (isUpvote) { if (wasUpvoted) { - // Remove upvote if it was upvoted + // 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, - }; - })); + return { + ...r, + upvotes: newUpvotes, + downvotes: newDownvotes, + hasUpvoted: newHasUpvoted, + hasDownvoted: newHasDownvoted, + }; + }) + ); try { - const endpoint = isUpvote ? 'upvote' : 'downvote'; + 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, - }; - })); + 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 { - setVotingReviews(prev => { + setVotingReviews((prev) => { const newSet = new Set(prev); newSet.delete(reviewId); return newSet; @@ -364,10 +374,7 @@ export function ProfileContent() { {/* render pinned games unconditionally */}
    {pinnedGames?.map((game) => ( -
  • +
  • {t("loading_reviews")} +
    + {t("loading_reviews")} +
    ) : reviews.length === 0 ? (

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

    @@ -461,22 +470,35 @@ export function ProfileContent() {
    - {formatDistance(new Date(review.createdAt), new Date(), { addSuffix: true })} + {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )}
    {Array.from({ length: 5 }, (_, index) => ( -
    +
    handleVoteReview(review.id, true)} + onClick={() => + handleVoteReview(review.id, true) + } disabled={votingReviews.has(review.id)} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} @@ -518,7 +544,9 @@ export function ProfileContent() { handleVoteReview(review.id, false)} + onClick={() => + handleVoteReview(review.id, false) + } disabled={votingReviews.has(review.id)} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} From acf8f340dd171c64b819c95c37045acf1fd314ae Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 10:33:29 +0300 Subject: [PATCH 03/19] ci: review message ui change and fix loading reviews positioning --- .../profile-content/profile-content.scss | 22 +++++++- .../profile-content/profile-content.tsx | 51 ++++++++++--------- 2 files changed, 48 insertions(+), 25 deletions(-) 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 4cdea61b..21acfa47 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -182,6 +182,10 @@ .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 { @@ -208,6 +212,14 @@ margin-bottom: calc(globals.$spacing-unit * 1.5); } +.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; align-items: center; @@ -215,8 +227,8 @@ } .user-reviews__game-icon { - width: 40px; - height: 40px; + width: 24px; + height: 24px; border-radius: 8px; object-fit: cover; } @@ -227,6 +239,12 @@ gap: calc(globals.$spacing-unit * 0.25); } +.user-reviews__game-details { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.5); +} + .user-reviews__game-title { background: none; border: none; 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 d245aa5e..97d9b1a9 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -461,29 +461,12 @@ export function ProfileContent() { transition={{ duration: 0.3 }} >
    -
    - {review.game.title} -
    - -
    - {formatDistance( - new Date(review.createdAt), - new Date(), - { addSuffix: true } - )} -
    -
    +
    + {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )}
    @@ -517,6 +500,28 @@ export function ProfileContent() { }} /> +
    +
    +
    +
    + {review.game.title} + +
    +
    +
    +
    +
    Date: Thu, 23 Oct 2025 10:34:15 +0300 Subject: [PATCH 04/19] ci: formatting --- .../src/pages/profile/profile-content/profile-content.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 97d9b1a9..b37f7517 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -512,7 +512,9 @@ export function ProfileContent() {
    {/* render reviews content unconditionally */} - {isLoadingReviews ? ( -
    - {t("loading_reviews")} -
    - ) : reviews.length === 0 ? ( -
    -

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

    -
    - ) : ( -
    - {reviews.map((review) => { + {(() => { + if (isLoadingReviews) { + return ( +
    + {t("loading_reviews")} +
    + ); + } + + if (reviews.length === 0) { + return ( +
    +

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

    +
    + ); + } + + return ( +
    + {reviews.map((review) => { const isOwnReview = userDetails?.id === review.user.id; return ( @@ -586,7 +595,8 @@ export function ProfileContent() { ); })}
    - )} + ); + })()}
    From f5399774316da2250c7049c3049f2900406f4e58 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 11:53:35 +0300 Subject: [PATCH 08/19] fix: refactoring functions to prevent nesting more than 4 lvls --- .../profile-content/profile-content.tsx | 294 +++++++++--------- 1 file changed, 143 insertions(+), 151 deletions(-) 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 679556db..f5511a1e 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -437,166 +437,158 @@ export function ProfileContent() {
    {/* render reviews content unconditionally */} - {(() => { - if (isLoadingReviews) { - return ( -
    - {t("loading_reviews")} -
    - ); - } - - if (reviews.length === 0) { - return ( -
    -

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

    -
    - ); - } - - return ( -
    - {reviews.map((review) => { - const isOwnReview = userDetails?.id === review.user.id; + {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 ( - -
    -
    - {formatDistance( - new Date(review.createdAt), - new Date(), - { addSuffix: true } - )} -
    + return ( + +
    +
    + {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )} +
    -
    - {Array.from({ length: 5 }, (_, index) => ( -
    - -
    - ))} -
    -
    - -
    - -
    -
    -
    -
    - {review.game.title} - + +
    + ))} +
    +
    + +
    + +
    +
    +
    +
    + {review.game.title} + +
    -
    -
    -
    - - handleVoteReview(review.id, true) - } - disabled={votingReviews.has(review.id)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.upvotes} - - - +
    +
    + + handleVoteReview(review.id, true) + } + disabled={votingReviews.has(review.id)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.upvotes} + + + - - handleVoteReview(review.id, false) - } - disabled={votingReviews.has(review.id)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.downvotes} - - - + + handleVoteReview(review.id, false) + } + disabled={votingReviews.has(review.id)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.downvotes} + + + +
    + + {isOwnReview && ( + + )}
    - - {isOwnReview && ( - - )} -
    - - ); - })} -
    - ); - })()} + + ); + })} +
    + )}
    From d21ec52814e17d657360bfba75443aa1cc3bae45 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 12:06:23 +0300 Subject: [PATCH 09/19] ci: deleted comments --- .../profile-content/profile-content.tsx | 264 +++++++++--------- 1 file changed, 131 insertions(+), 133 deletions(-) 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 f5511a1e..dd09ed0b 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -26,8 +26,7 @@ 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 { - // removed: sectionVariants, - // removed: chevronVariants, + GAME_STATS_ANIMATION_DURATION_IN_MS, } from "./profile-animations"; import "./profile-content.scss"; @@ -450,145 +449,145 @@ export function ProfileContent() { {!isLoadingReviews && reviews.length > 0 && (
    {reviews.map((review) => { - const isOwnReview = userDetails?.id === review.user.id; + const isOwnReview = userDetails?.id === review.user.id; - return ( - -
    -
    - {formatDistance( - new Date(review.createdAt), - new Date(), - { addSuffix: true } - )} -
    - -
    - {Array.from({ length: 5 }, (_, index) => ( -
    - -
    - ))} -
    + return ( + +
    +
    + {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )}
    -
    +
    + {Array.from({ length: 5 }, (_, index) => ( +
    + +
    + ))} +
    +
    -
    -
    -
    -
    - {review.game.title} - -
    +
    + +
    +
    +
    +
    + {review.game.title} +
    +
    -
    -
    - - handleVoteReview(review.id, true) - } - disabled={votingReviews.has(review.id)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.upvotes} - - - +
    +
    + + handleVoteReview(review.id, true) + } + disabled={votingReviews.has(review.id)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.upvotes} + + + - - handleVoteReview(review.id, false) - } - disabled={votingReviews.has(review.id)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.downvotes} - - - -
    - - {isOwnReview && ( - - )} + + handleVoteReview(review.id, false) + } + disabled={votingReviews.has(review.id)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.downvotes} + + +
    - - ); - })} -
    - )} + + {isOwnReview && ( + + )} +
    + + ); + })} +
    + )}
    @@ -621,8 +620,7 @@ export function ProfileContent() { statsIndex, libraryGames, pinnedGames, - // removed isPinnedCollapsed, - // removed toggleSection, + sortBy, activeTab, ]); From daf9751cf6ebf1532c600f86be3b9dde2ab05931 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 14:27:03 +0300 Subject: [PATCH 10/19] ci: import formatting --- .../src/pages/profile/profile-content/profile-content.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 dd09ed0b..cd833c6c 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -25,10 +25,7 @@ import { buildGameDetailsPath } from "@renderer/helpers"; 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 { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations"; import "./profile-content.scss"; type SortOption = "playtime" | "achievementCount" | "playedRecently"; From cc95deb709c78d0d9621ca8ad37ea0280873381b Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 14:40:02 +0300 Subject: [PATCH 11/19] fix: proreply reseting user reviews on profile changing --- .../src/pages/profile/profile-content/profile-content.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) 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 cd833c6c..9955640b 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -108,6 +108,14 @@ export function ProfileContent() { } }, [sortBy, getUserLibraryGames, userProfile]); + // Clear reviews state and reset tab when switching users + useEffect(() => { + setReviews([]); + setReviewsTotalCount(0); + setIsLoadingReviews(false); + setActiveTab("library"); + }, [userProfile?.id]); + useEffect(() => { if (userProfile?.id) { fetchUserReviews(); From 81a77411ccd325692395d79f188fee5f6d2911df Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 16:54:18 +0300 Subject: [PATCH 12/19] ci: fix gap between game image and game name in reviews --- .../src/pages/profile/profile-content/profile-content.scss | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 45a7f119..15b9de6d 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -230,27 +230,24 @@ .user-reviews__review-game { display: flex; - align-items: center; gap: calc(globals.$spacing-unit); } .user-reviews__game-icon { width: 24px; height: 24px; - border-radius: 8px; object-fit: cover; } .user-reviews__game-info { display: flex; flex-direction: column; - gap: calc(globals.$spacing-unit * 0.25); } .user-reviews__game-details { display: flex; align-items: center; - gap: calc(globals.$spacing-unit * 0.5); + gap: calc(globals.$spacing-unit * 0.75); } .user-reviews__game-title { From 29e1713824856a4f455dd06361214b5fc69786ab Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 20:06:37 +0300 Subject: [PATCH 13/19] fix: upvote/downvote button arent being disabled after click --- .../profile-content/profile-content.tsx | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) 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 9955640b..44af9c1d 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -181,7 +181,14 @@ export function ProfileContent() { setVotingReviews((prev) => new Set(prev).add(reviewId)); const review = reviews.find((r) => r.id === reviewId); - if (!review) return; + if (!review) { + setVotingReviews((prev) => { + const next = new Set(prev); + next.delete(reviewId); + return next; + }); + return; + } const wasUpvoted = review.hasUpvoted; const wasDownvoted = review.hasDownvoted; @@ -258,11 +265,13 @@ export function ProfileContent() { }) ); } finally { - setVotingReviews((prev) => { - const newSet = new Set(prev); - newSet.delete(reviewId); - return newSet; - }); + setTimeout(() => { + setVotingReviews((prev) => { + const newSet = new Set(prev); + newSet.delete(reviewId); + return newSet; + }); + }, 500); } }; @@ -536,6 +545,10 @@ export function ProfileContent() { 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 }} > @@ -559,6 +572,10 @@ export function ProfileContent() { 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 }} > @@ -628,6 +645,12 @@ export function ProfileContent() { sortBy, activeTab, + // ensure reviews UI updates correctly + reviews, + reviewsTotalCount, + isLoadingReviews, + votingReviews, + deleteModalVisible, ]); return ( From 8de6c92d28ef63d26217d85a82880292d5d90c90 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 24 Oct 2025 08:19:55 +0300 Subject: [PATCH 14/19] ci: formatting --- .../profile/profile-content/profile-content.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 44af9c1d..e284cb88 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -546,8 +546,12 @@ export function ProfileContent() { } disabled={votingReviews.has(review.id)} style={{ - opacity: votingReviews.has(review.id) ? 0.5 : 1, - cursor: votingReviews.has(review.id) ? "not-allowed" : "pointer", + opacity: votingReviews.has(review.id) + ? 0.5 + : 1, + cursor: votingReviews.has(review.id) + ? "not-allowed" + : "pointer", }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} @@ -573,8 +577,12 @@ export function ProfileContent() { } disabled={votingReviews.has(review.id)} style={{ - opacity: votingReviews.has(review.id) ? 0.5 : 1, - cursor: votingReviews.has(review.id) ? "not-allowed" : "pointer", + opacity: votingReviews.has(review.id) + ? 0.5 + : 1, + cursor: votingReviews.has(review.id) + ? "not-allowed" + : "pointer", }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} From bf387aef3f240febd3e4b6d66c3d78be698c983f Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Sun, 2 Nov 2025 17:30:45 +0000 Subject: [PATCH 15/19] feat: improving animations --- .../user-profile/user-profile.context.tsx | 76 +- .../profile-content/profile-content.scss | 27 +- .../profile-content/profile-content.tsx | 681 +++++++++++------- .../user-library-game-card.scss | 25 +- .../user-library-game-card.tsx | 25 +- 5 files changed, 542 insertions(+), 292 deletions(-) diff --git a/src/renderer/src/context/user-profile/user-profile.context.tsx b/src/renderer/src/context/user-profile/user-profile.context.tsx index 87e2a669..9f3a861d 100644 --- a/src/renderer/src/context/user-profile/user-profile.context.tsx +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -14,12 +14,15 @@ export interface UserProfileContext { isMe: boolean; userStats: UserStats | null; getUserProfile: () => Promise; - getUserLibraryGames: (sortBy?: string) => Promise; + getUserLibraryGames: (sortBy?: string, reset?: boolean) => Promise; + loadMoreLibraryGames: (sortBy?: string) => Promise; setSelectedBackgroundImage: React.Dispatch>; backgroundImage: string; badges: Badge[]; libraryGames: UserGame[]; pinnedGames: UserGame[]; + hasMoreLibraryGames: boolean; + isLoadingLibraryGames: boolean; } export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3"; @@ -30,12 +33,15 @@ export const userProfileContext = createContext({ isMe: false, userStats: null, getUserProfile: async () => {}, - getUserLibraryGames: async (_sortBy?: string) => {}, + getUserLibraryGames: async (_sortBy?: string, _reset?: boolean) => {}, + loadMoreLibraryGames: async (_sortBy?: string) => false, setSelectedBackgroundImage: () => {}, backgroundImage: "", badges: [], libraryGames: [], pinnedGames: [], + hasMoreLibraryGames: false, + isLoadingLibraryGames: false, }); const { Provider } = userProfileContext; @@ -62,6 +68,9 @@ export function UserProfileContextProvider({ DEFAULT_USER_PROFILE_BACKGROUND ); const [selectedBackgroundImage, setSelectedBackgroundImage] = useState(""); + const [libraryPage, setLibraryPage] = useState(0); + const [hasMoreLibraryGames, setHasMoreLibraryGames] = useState(true); + const [isLoadingLibraryGames, setIsLoadingLibraryGames] = useState(false); const isMe = userDetails?.id === userProfile?.id; @@ -93,7 +102,13 @@ export function UserProfileContextProvider({ }, [userId]); const getUserLibraryGames = useCallback( - async (sortBy?: string) => { + async (sortBy?: string, reset = true) => { + if (reset) { + setLibraryPage(0); + setHasMoreLibraryGames(true); + setIsLoadingLibraryGames(true); + } + try { const params = new URLSearchParams(); params.append("take", "12"); @@ -115,18 +130,68 @@ export function UserProfileContextProvider({ if (response) { setLibraryGames(response.library); setPinnedGames(response.pinnedGames); + setHasMoreLibraryGames(response.library.length === 12); } else { setLibraryGames([]); setPinnedGames([]); + setHasMoreLibraryGames(false); } } catch (error) { setLibraryGames([]); setPinnedGames([]); + setHasMoreLibraryGames(false); + } finally { + setIsLoadingLibraryGames(false); } }, [userId] ); + const loadMoreLibraryGames = useCallback( + async (sortBy?: string): Promise => { + if (isLoadingLibraryGames || !hasMoreLibraryGames) { + return false; + } + + setIsLoadingLibraryGames(true); + try { + const nextPage = libraryPage + 1; + const params = new URLSearchParams(); + params.append("take", "12"); + params.append("skip", String(nextPage * 12)); + if (sortBy) { + params.append("sortBy", sortBy); + } + + const queryString = params.toString(); + const url = queryString + ? `/users/${userId}/library?${queryString}` + : `/users/${userId}/library`; + + const response = await window.electron.hydraApi.get<{ + library: UserGame[]; + pinnedGames: UserGame[]; + }>(url); + + if (response && response.library.length > 0) { + setLibraryGames((prev) => [...prev, ...response.library]); + setLibraryPage(nextPage); + setHasMoreLibraryGames(response.library.length === 12); + return true; + } else { + setHasMoreLibraryGames(false); + return false; + } + } catch (error) { + setHasMoreLibraryGames(false); + return false; + } finally { + setIsLoadingLibraryGames(false); + } + }, + [userId, libraryPage, hasMoreLibraryGames, isLoadingLibraryGames] + ); + const getUserProfile = useCallback(async () => { getUserStats(); getUserLibraryGames(); @@ -164,6 +229,8 @@ export function UserProfileContextProvider({ setLibraryGames([]); setPinnedGames([]); setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND); + setLibraryPage(0); + setHasMoreLibraryGames(true); getUserProfile(); getBadges(); @@ -177,12 +244,15 @@ export function UserProfileContextProvider({ isMe, getUserProfile, getUserLibraryGames, + loadMoreLibraryGames, setSelectedBackgroundImage, backgroundImage: getBackgroundImageUrl(), userStats, badges, libraryGames, pinnedGames, + hasMoreLibraryGames, + isLoadingLibraryGames, }} > {children} 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 15b9de6d..ffdf6a45 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,22 @@ 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; &--active { color: white; - border-bottom-color: white; } } + &__tab-underline { + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: white; + } + &__games-grid { list-style: none; margin: 0; @@ -179,10 +187,6 @@ &__tab-panels { display: block; } - - &__tab-panel[hidden] { - display: none; - } } } @@ -210,7 +214,6 @@ .user-reviews__review-item { border-radius: 8px; - padding: calc(globals.$spacing-unit * 2); } .user-reviews__review-header { 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 e284cb88..749c7588 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -26,6 +26,8 @@ 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 Skeleton, { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; import "./profile-content.scss"; type SortOption = "playtime" | "achievementCount" | "playedRecently"; @@ -71,6 +73,9 @@ export function ProfileContent() { libraryGames, pinnedGames, getUserLibraryGames, + loadMoreLibraryGames, + hasMoreLibraryGames, + isLoadingLibraryGames, } = useContext(userProfileContext); const { userDetails } = useUserDetails(); const { formatDistance } = useDate(); @@ -104,10 +109,69 @@ export function ProfileContent() { useEffect(() => { if (userProfile) { - getUserLibraryGames(sortBy); + getUserLibraryGames(sortBy, true); } }, [sortBy, getUserLibraryGames, userProfile]); + const loadMoreRef = useRef(null); + const observerRef = useRef(null); + + useEffect(() => { + if (activeTab !== "library" || !hasMoreLibraryGames) { + return; + } + + // Clean up previous observer + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + // Use setTimeout to ensure the DOM element is available after render + const timeoutId = setTimeout(() => { + const currentRef = loadMoreRef.current; + if (!currentRef) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if ( + entry?.isIntersecting && + hasMoreLibraryGames && + !isLoadingLibraryGames + ) { + loadMoreLibraryGames(sortBy); + } + }, + { + root: null, + rootMargin: "200px", + threshold: 0.1, + } + ); + + observerRef.current = observer; + observer.observe(currentRef); + }, 100); + + return () => { + clearTimeout(timeoutId); + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + }; + }, [ + activeTab, + hasMoreLibraryGames, + isLoadingLibraryGames, + loadMoreLibraryGames, + sortBy, + libraryGames.length, + ]); + // Clear reviews state and reset tab when switching users useEffect(() => { setReviews([]); @@ -332,294 +396,373 @@ export function ProfileContent() {
    - - +
    + + {activeTab === "library" && ( + + )} +
    +
    + + {activeTab === "reviews" && ( + + )} +
    -
  • + ))} +
+ + )} + + )} + + )} + + )} + + {activeTab === "reviews" && ( + +
+
+ {/* removed collapse button */} +

{t("user_reviews")}

+ {reviewsTotalCount > 0 && ( + + {reviewsTotalCount} + + )} +
+
+ + {/* 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 ( + +
+
+ {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )} +
+ +
+ {Array.from({ length: 5 }, (_, index) => ( +
+ +
+ ))} +
+
+ +
+ +
+
+
+
+ {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, 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} - - - + + 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 && ( + + )}
- - {isOwnReview && ( - - )} -
- - ); - })} -
- )} -
- +
+ ); + })} + + )} + + )} + 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({ )} - {game.title} + {imageError || !game.coverImageUrl ? ( +
+ +
+ ) : ( + {game.title} setImageError(true)} + /> + )} Date: Sun, 2 Nov 2025 20:23:12 +0000 Subject: [PATCH 16/19] feat: adding infinite scroll --- package.json | 1 + src/locales/en/translation.json | 2 +- src/locales/es/translation.json | 7 +- src/locales/pt-BR/translation.json | 7 +- src/locales/pt-PT/translation.json | 9 +- src/locales/ru/translation.json | 7 +- src/renderer/src/app.tsx | 2 +- .../user-profile/user-profile.context.tsx | 8 +- .../src/pages/game-details/review-item.tsx | 1 - .../profile-content/profile-content.scss | 67 +++- .../profile-content/profile-content.tsx | 288 +++++++++--------- yarn.lock | 12 + 12 files changed, 258 insertions(+), 153 deletions(-) diff --git a/package.json b/package.json index ee039574..f06fef64 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-hook-form": "^7.53.0", "react-i18next": "^14.1.0", + "react-infinite-scroll-component": "^6.1.0", "react-loading-skeleton": "^3.4.0", "react-redux": "^9.1.1", "react-router-dom": "^6.22.3", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index e34c113a..2888a2e2 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -694,7 +694,7 @@ "karma": "Karma", "karma_count": "karma", "karma_description": "Earned from positive likes on reviews", - "user_reviews": "User's Reviews", + "user_reviews": "Reviews", "delete_review": "Delete Review", "loading_reviews": "Loading reviews..." }, diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index adf25e33..c7e9d13e 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -325,6 +325,7 @@ "maybe_later": "Tal vez después", "no_repacks_found": "Sin fuentes encontradas para este juego", "no_reviews_yet": "Sin reseñas aún", + "review_played_for": "Jugado por", "properties": "Propiedades", "rating": "Calificación", "rating_count": "Calificación", @@ -681,7 +682,11 @@ "karma_count": "karma", "karma_description": "Conseguido por me gustas positivos en reseñas", "sort_by": "Filtrar por:", - "game_added_to_pinned": "Juego añadido a fijados" + "game_added_to_pinned": "Juego añadido a fijados", + "user_reviews": "Reseñas", + "loading_reviews": "Cargando reseñas...", + "no_reviews": "Sin reseñas aún", + "delete_review": "Eliminar reseña" }, "achievement": { "achievement_unlocked": "Logro desbloqueado", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 42743a64..50049140 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -317,6 +317,7 @@ "sort_lowest_score": "Menor Nota", "sort_most_voted": "Mais Votadas", "no_reviews_yet": "Ainda não há avaliações", + "review_played_for": "Jogado por", "be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!", "rating": "Avaliação", "rating_stats": "Avaliação", @@ -696,7 +697,11 @@ "karma": "Karma", "karma_count": "karma", "karma_description": "Ganho a partir de curtidas positivas em avaliações", - "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente" + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", + "user_reviews": "Avaliações", + "loading_reviews": "Carregando avaliações...", + "no_reviews": "Ainda não há avaliações", + "delete_review": "Excluir avaliação" }, "achievement": { "achievement_unlocked": "Conquista desbloqueada", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 6c1963cc..c8e4586d 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -183,7 +183,8 @@ "create_start_menu_shortcut": "Criar atalho no Menu Iniciar", "review_from_blocked_user": "Avaliação de utilizador bloqueado", "show": "Mostrar", - "hide": "Ocultar" + "hide": "Ocultar", + "review_played_for": "Jogado por" }, "activation": { "title": "Ativação", @@ -469,7 +470,11 @@ "achievements_unlocked": "Conquistas desbloqueadas", "earned_points": "Pontos ganhos", "show_achievements_on_profile": "Mostre as suas conquistas no perfil", - "show_points_on_profile": "Mostre os seus pontos ganhos no perfil" + "show_points_on_profile": "Mostre os seus pontos ganhos no perfil", + "user_reviews": "Avaliações", + "loading_reviews": "A carregar avaliações...", + "no_reviews": "Ainda não há avaliações", + "delete_review": "Eliminar avaliação" }, "achievement": { "achievement_unlocked": "Conquista desbloqueada", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 6f4d4b92..2e7c1504 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -227,6 +227,7 @@ "write_review_placeholder": "Поделитесь своими мыслями об этой игре...", "sort_newest": "Сначала новые", "no_reviews_yet": "Пока нет отзывов", + "review_played_for": "Играли", "be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!", "sort_oldest": "Сначала старые", "sort_highest_score": "Высший балл", @@ -692,7 +693,11 @@ "game_added_to_pinned": "Игра добавлена в закрепленные", "karma": "Карма", "karma_count": "карма", - "karma_description": "Заработана положительными оценками отзывов" + "karma_description": "Заработана положительными оценками отзывов", + "user_reviews": "Отзывы", + "loading_reviews": "Загрузка отзывов...", + "no_reviews": "Пока нет отзывов", + "delete_review": "Удалить отзыв" }, "achievement": { "achievement_unlocked": "Достижение разблокировано", diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 168a4435..305747c3 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -279,7 +279,7 @@ export function App() {
-
+
diff --git a/src/renderer/src/context/user-profile/user-profile.context.tsx b/src/renderer/src/context/user-profile/user-profile.context.tsx index 9f3a861d..c2a6864c 100644 --- a/src/renderer/src/context/user-profile/user-profile.context.tsx +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -174,7 +174,13 @@ export function UserProfileContextProvider({ }>(url); if (response && response.library.length > 0) { - setLibraryGames((prev) => [...prev, ...response.library]); + setLibraryGames((prev) => { + const existingIds = new Set(prev.map((game) => game.objectId)); + const newGames = response.library.filter( + (game) => !existingIds.has(game.objectId) + ); + return [...prev, ...newGames]; + }); setLibraryPage(nextPage); setHasMoreLibraryGames(response.library.length === 12); return true; diff --git a/src/renderer/src/pages/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx index 6b03e7ea..bfc02cfe 100644 --- a/src/renderer/src/pages/game-details/review-item.tsx +++ b/src/renderer/src/pages/game-details/review-item.tsx @@ -175,7 +175,6 @@ export function ReviewItem({
- {formatDistance(new Date(review.createdAt), new Date(), { addSuffix: true, })} 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 ffdf6a45..d41e3530 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -117,12 +117,30 @@ font-size: 14px; font-weight: 500; transition: color ease 0.2s; + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.5); &--active { color: white; } } + &__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; @@ -209,7 +227,7 @@ .user-reviews__list { display: flex; flex-direction: column; - gap: calc(globals.$spacing-unit * 2); + gap: calc(globals.$spacing-unit * 4); } .user-reviews__review-item { @@ -219,10 +237,16 @@ .user-reviews__review-header { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; margin-bottom: calc(globals.$spacing-unit * 1.5); } +.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; @@ -267,18 +291,53 @@ } .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; - gap: calc(globals.$spacing-unit * 0.5); + 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-container { +.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 { 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 749c7588..7ef486d4 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -1,5 +1,12 @@ 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, @@ -22,12 +29,13 @@ 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 Skeleton, { SkeletonTheme } from "react-loading-skeleton"; -import "react-loading-skeleton/dist/skeleton.css"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import InfiniteScroll from "react-infinite-scroll-component"; import "./profile-content.scss"; type SortOption = "playtime" | "achievementCount" | "playedRecently"; @@ -36,6 +44,7 @@ interface UserReview { id: string; reviewHtml: string; score: number; + playTimeInSeconds?: number; upvotes: number; downvotes: number; hasUpvoted: boolean; @@ -58,11 +67,21 @@ interface UserReviewsResponse { reviews: UserReview[]; } -const getScoreColorClass = (score: number) => { - if (score >= 1 && score <= 2) return "game-details__review-score--red"; - if (score === 3) return "game-details__review-score--yellow"; - if (score >= 4 && score <= 5) return "game-details__review-score--green"; - return ""; +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() { @@ -98,6 +117,21 @@ export function ProfileContent() { const dispatch = useAppDispatch(); const { t } = useTranslation("user_profile"); + const { t: tGameDetails } = useTranslation("game_details"); + 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("")); @@ -109,67 +143,32 @@ export function ProfileContent() { useEffect(() => { if (userProfile) { + // 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 loadMoreRef = useRef(null); - const observerRef = useRef(null); + const animatedGameIdsRef = useRef>(new Set()); + const currentSortByRef = useRef(sortBy); - useEffect(() => { - if (activeTab !== "library" || !hasMoreLibraryGames) { - return; + const handleLoadMore = useCallback(() => { + if ( + activeTab === "library" && + hasMoreLibraryGames && + !isLoadingLibraryGames + ) { + loadMoreLibraryGames(sortBy); } - - // Clean up previous observer - if (observerRef.current) { - observerRef.current.disconnect(); - observerRef.current = null; - } - - // Use setTimeout to ensure the DOM element is available after render - const timeoutId = setTimeout(() => { - const currentRef = loadMoreRef.current; - if (!currentRef) { - return; - } - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if ( - entry?.isIntersecting && - hasMoreLibraryGames && - !isLoadingLibraryGames - ) { - loadMoreLibraryGames(sortBy); - } - }, - { - root: null, - rootMargin: "200px", - threshold: 0.1, - } - ); - - observerRef.current = observer; - observer.observe(currentRef); - }, 100); - - return () => { - clearTimeout(timeoutId); - if (observerRef.current) { - observerRef.current.disconnect(); - observerRef.current = null; - } - }; }, [ activeTab, hasMoreLibraryGames, isLoadingLibraryGames, loadMoreLibraryGames, sortBy, - libraryGames.length, ]); // Clear reviews state and reset tab when switching users @@ -368,8 +367,6 @@ export function ProfileContent() { }; }, [setStatsIndex, isAnimationRunning]); - const { numberFormatter } = useFormat(); - const usersAreFriends = useMemo(() => { return userProfile?.relation?.status === "ACCEPTED"; }, [userProfile]); @@ -423,6 +420,11 @@ export function ProfileContent() { onClick={() => setActiveTab("reviews")} > {t("user_reviews")} + {reviewsTotalCount > 0 && ( + + {reviewsTotalCount} + + )} {activeTab === "reviews" && (
-
    - {libraryGames?.map((game) => ( -
  • - -
  • - ))} -
- {hasMoreLibraryGames && ( -
- )} - {isLoadingLibraryGames && ( - -
    - {Array.from({ length: 12 }).map((_, i) => ( -
  • +
      + {libraryGames?.map((game, index) => { + const hasAnimated = + animatedGameIdsRef.current.has(game.objectId); + const isNewGame = + !hasAnimated && !isLoadingLibraryGames; + + return ( + { + if (isNewGame) { + animatedGameIdsRef.current.add( + game.objectId + ); + } + }} > - - - ))} -
    - - )} + + ); + })} +
+
)}
@@ -579,18 +591,6 @@ export function ProfileContent() { transition={{ duration: 0.2 }} aria-hidden={false} > -
-
- {/* removed collapse button */} -

{t("user_reviews")}

- {reviewsTotalCount > 0 && ( - - {reviewsTotalCount} - - )} -
-
- {/* render reviews content unconditionally */} {isLoadingReviews && (
@@ -616,6 +616,37 @@ export function ProfileContent() { transition={{ duration: 0.3 }} >
+
+
+ + + {review.score}/5 + +
+ {Boolean( + review.playTimeInSeconds && + review.playTimeInSeconds > 0 + ) && ( +
+ + + {tGameDetails("review_played_for")}{" "} + {formatPlayTime( + review.playTimeInSeconds || 0 + )} + +
+ )} +
{formatDistance( new Date(review.createdAt), @@ -623,29 +654,6 @@ export function ProfileContent() { { addSuffix: true } )}
- -
- {Array.from({ length: 5 }, (_, index) => ( -
- -
- ))} -
=2.2.7 <3": version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" From f0dc7478cfd08ed0b60c24ef7bcaf471146d7b63 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Sun, 2 Nov 2025 20:29:16 +0000 Subject: [PATCH 17/19] feat: adding reviews to profile --- src/renderer/src/app.tsx | 6 +- .../profile/profile-content/library-tab.tsx | 176 +++++++ .../profile-content/profile-content.tsx | 429 ++---------------- .../profile-content/profile-review-item.tsx | 191 ++++++++ .../profile/profile-content/profile-tabs.tsx | 67 +++ .../profile/profile-content/reviews-tab.tsx | 92 ++++ 6 files changed, 568 insertions(+), 393 deletions(-) create mode 100644 src/renderer/src/pages/profile/profile-content/library-tab.tsx create mode 100644 src/renderer/src/pages/profile/profile-content/profile-review-item.tsx create mode 100644 src/renderer/src/pages/profile/profile-content/profile-tabs.tsx create mode 100644 src/renderer/src/pages/profile/profile-content/reviews-tab.tsx 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 ( + + ); + })} +
+ )} +
+ ); +} + From fdc3fecd6f0ae4455067b79d512e42e255bc095b Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Sun, 2 Nov 2025 20:42:42 +0000 Subject: [PATCH 18/19] feat: adding reviews to profile --- .../src/pages/game-details/review-item.scss | 16 +- .../src/pages/game-details/review-item.tsx | 86 ++++----- .../profile/profile-content/library-tab.tsx | 10 +- .../profile-content/profile-content.scss | 39 +++- .../profile-content/profile-content.tsx | 4 + .../profile-content/profile-review-item.tsx | 169 ++++++++++++------ .../profile/profile-content/reviews-tab.tsx | 5 +- 7 files changed, 225 insertions(+), 104 deletions(-) diff --git a/src/renderer/src/pages/game-details/review-item.scss b/src/renderer/src/pages/game-details/review-item.scss index b3577a75..64657bfd 100644 --- a/src/renderer/src/pages/game-details/review-item.scss +++ b/src/renderer/src/pages/game-details/review-item.scss @@ -8,11 +8,23 @@ &__review-header { display: flex; - justify-content: space-between; - align-items: center; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1); margin-bottom: calc(globals.$spacing-unit * 1.5); } + &__review-header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + } + + &__review-header-bottom { + display: flex; + justify-content: flex-start; + align-items: center; + } + &__review-user { display: flex; align-items: center; diff --git a/src/renderer/src/pages/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx index bfc02cfe..09d91df8 100644 --- a/src/renderer/src/pages/game-details/review-item.tsx +++ b/src/renderer/src/pages/game-details/review-item.tsx @@ -126,60 +126,62 @@ export function ReviewItem({ return (
-
- -
+
+
-
-
+
- {Boolean( - review.playTimeInSeconds && review.playTimeInSeconds > 0 - ) && ( -
- - - {t("review_played_for")}{" "} - {formatPlayTime(review.playTimeInSeconds || 0)} - -
- )} + {review.user.displayName || "Anonymous"} +
-
-
{formatDistance(new Date(review.createdAt), new Date(), { addSuffix: true, })}
+
+
+
+ + + {review.score}/5 + +
+ {Boolean( + review.playTimeInSeconds && review.playTimeInSeconds > 0 + ) && ( +
+ + + {t("review_played_for")}{" "} + {formatPlayTime(review.playTimeInSeconds || 0)} + +
+ )} +
+
- {hasAnyGames && } + {hasAnyGames && ( + + )} {!hasAnyGames && (
@@ -123,8 +125,9 @@ export function LibraryTab({ >
    {libraryGames?.map((game, index) => { - const hasAnimated = - animatedGameIdsRef.current.has(game.objectId); + const hasAnimated = animatedGameIdsRef.current.has( + game.objectId + ); const isNewGame = !hasAnimated && !isLoadingLibraryGames; return ( @@ -173,4 +176,3 @@ export function LibraryTab({ ); } - 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 d41e3530..958fe52d 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -235,10 +235,22 @@ } .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; - margin-bottom: calc(globals.$spacing-unit * 1.5); +} + +.user-reviews__review-header-bottom { + display: flex; + justify-content: space-between; + align-items: center; } .user-reviews__review-meta-row { @@ -343,6 +355,31 @@ .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 { 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 ab9fdf01..bb6842d7 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -48,6 +48,10 @@ interface UserReview { objectId: string; shop: string; }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; } interface UserReviewsResponse { 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 index 79e0440e..bea569e7 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx @@ -1,8 +1,11 @@ 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 { 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"; @@ -25,8 +28,12 @@ interface UserReview { title: string; iconUrl: string; objectId: string; - shop: string; + shop: GameShop; }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; } interface ProfileReviewItemProps { @@ -51,7 +58,32 @@ export function ProfileReviewItem({ const navigate = useNavigate(); const { formatDistance } = useDate(); const { t } = useTranslation("user_profile"); - const { t: tGameDetails } = useTranslation("game_details"); + 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 (
    -
    -
    - - - {review.score}/5 - +
    +
    +
    +
    + {review.game.title} + +
    +
    - {Boolean( - review.playTimeInSeconds && review.playTimeInSeconds > 0 - ) && ( -
    - - - {tGameDetails("review_played_for")}{" "} - {formatPlayTime(review.playTimeInSeconds || 0)} +
    + {formatDistance(new Date(review.createdAt), new Date(), { + addSuffix: true, + })} +
    +
    +
    +
    +
    + + + {review.score}/5
    - )} -
    -
    - {formatDistance(new Date(review.createdAt), new Date(), { - addSuffix: true, - })} + {Boolean( + review.playTimeInSeconds && review.playTimeInSeconds > 0 + ) && ( +
    + + + {tGameDetails("review_played_for")}{" "} + {formatPlayTime(review.playTimeInSeconds || 0)} + +
    + )} +
    -
    - -
    -
    -
    -
    - {review.game.title} +
    + {needsTranslation && ( + <> + + {showOriginal && ( +
    - -
    -
    -
    + )} + + )}
    @@ -188,4 +250,3 @@ export function ProfileReviewItem({ ); } - diff --git a/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx index 97924040..9fcea37e 100644 --- a/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx +++ b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx @@ -23,6 +23,10 @@ interface UserReview { objectId: string; shop: string; }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; } interface ReviewsTabProps { @@ -89,4 +93,3 @@ export function ReviewsTab({ ); } - From 48775e57fc17f4ca755f0825516c105d045f3582 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Sun, 2 Nov 2025 20:43:59 +0000 Subject: [PATCH 19/19] feat: adding reviews to profile --- .../src/pages/profile/profile-content/profile-content.tsx | 3 ++- src/renderer/src/pages/profile/profile-content/reviews-tab.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 bb6842d7..8176bace 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -11,6 +11,7 @@ import { ProfileHero } from "../profile-hero/profile-hero"; import { useAppDispatch, useFormat, useUserDetails } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; 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"; @@ -46,7 +47,7 @@ interface UserReview { title: string; iconUrl: string; objectId: string; - shop: string; + shop: GameShop; }; translations: { [key: string]: string; diff --git a/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx index 9fcea37e..afcc417b 100644 --- a/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx +++ b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx @@ -1,5 +1,6 @@ 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"; @@ -21,7 +22,7 @@ interface UserReview { title: string; iconUrl: string; objectId: string; - shop: string; + shop: GameShop; }; translations: { [key: string]: string;