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"