From 2e8da53d1a459e5a71efc214155382468c3370a6 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Sun, 2 Nov 2025 20:23:12 +0000 Subject: [PATCH] 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" && (
- - {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"