feat: adding infinite scroll

This commit is contained in:
Chubby Granny Chaser
2025-11-02 20:23:12 +00:00
parent 8794fbc742
commit 2e8da53d1a
12 changed files with 258 additions and 153 deletions

View File

@@ -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",

View File

@@ -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..."
},

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "Достижение разблокировано",

View File

@@ -279,7 +279,7 @@ export function App() {
<article className="container">
<Header />
<section ref={contentRef} className="container__content">
<section ref={contentRef} id="scrollableDiv" className="container__content">
<Outlet />
</section>
</article>

View File

@@ -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;

View File

@@ -175,7 +175,6 @@ export function ReviewItem({
</div>
<div className="game-details__review-right">
<div className="game-details__review-date">
<ClockIcon size={12} />
{formatDistance(new Date(review.createdAt), new Date(), {
addSuffix: true,
})}

View File

@@ -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 {

View File

@@ -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<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const animatedGameIdsRef = useRef<Set<string>>(new Set());
const currentSortByRef = useRef<SortOption>(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 && (
<span className="profile-content__tab-badge">
{reviewsTotalCount}
</span>
)}
</button>
{activeTab === "reviews" && (
<motion.div
@@ -513,55 +515,65 @@ export function ProfileContent() {
</div>
</div>
<ul className="profile-content__games-grid">
{libraryGames?.map((game) => (
<li
key={game.objectId}
style={{ listStyle: "none" }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
sortBy={sortBy}
/>
</li>
))}
</ul>
{hasMoreLibraryGames && (
<div
ref={loadMoreRef}
style={{
height: "20px",
width: "100%",
}}
/>
)}
{isLoadingLibraryGames && (
<SkeletonTheme
baseColor="#1c1c1c"
highlightColor="#444"
>
<ul className="profile-content__games-grid">
{Array.from({ length: 12 }).map((_, i) => (
<li
key={`skeleton-${i}`}
<InfiniteScroll
dataLength={libraryGames.length}
next={handleLoadMore}
hasMore={hasMoreLibraryGames}
loader={null}
scrollThreshold={0.9}
style={{ overflow: "visible" }}
scrollableTarget="scrollableDiv"
>
<ul className="profile-content__games-grid">
{libraryGames?.map((game, index) => {
const hasAnimated =
animatedGameIdsRef.current.has(game.objectId);
const isNewGame =
!hasAnimated && !isLoadingLibraryGames;
return (
<motion.li
key={`${sortBy}-${game.objectId}`}
style={{ listStyle: "none" }}
initial={
isNewGame
? { opacity: 0.5, y: 15, scale: 0.96 }
: false
}
animate={
isNewGame
? { opacity: 1, y: 0, scale: 1 }
: false
}
transition={
isNewGame
? {
duration: 0.15,
ease: "easeOut",
delay: index * 0.01,
}
: undefined
}
onAnimationComplete={() => {
if (isNewGame) {
animatedGameIdsRef.current.add(
game.objectId
);
}
}}
>
<Skeleton
height={240}
style={{
borderRadius: "4px",
boxShadow:
"0 8px 10px -2px rgba(0, 0, 0, 0.5)",
}}
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
sortBy={sortBy}
/>
</li>
))}
</ul>
</SkeletonTheme>
)}
</motion.li>
);
})}
</ul>
</InfiniteScroll>
</div>
)}
</div>
@@ -579,18 +591,6 @@ export function ProfileContent() {
transition={{ duration: 0.2 }}
aria-hidden={false}
>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
{/* removed collapse button */}
<h2>{t("user_reviews")}</h2>
{reviewsTotalCount > 0 && (
<span className="profile-content__section-badge">
{reviewsTotalCount}
</span>
)}
</div>
</div>
{/* render reviews content unconditionally */}
{isLoadingReviews && (
<div className="user-reviews__loading">
@@ -616,6 +616,37 @@ export function ProfileContent() {
transition={{ duration: 0.3 }}
>
<div className="user-reviews__review-header">
<div className="user-reviews__review-meta-row">
<div
className="user-reviews__review-score-stars"
title={getRatingText(
review.score,
tGameDetails
)}
>
<Star
size={12}
className="user-reviews__review-star user-reviews__review-star--filled"
/>
<span className="user-reviews__review-score-text">
{review.score}/5
</span>
</div>
{Boolean(
review.playTimeInSeconds &&
review.playTimeInSeconds > 0
) && (
<div className="user-reviews__review-playtime">
<ClockIcon size={12} />
<span>
{tGameDetails("review_played_for")}{" "}
{formatPlayTime(
review.playTimeInSeconds || 0
)}
</span>
</div>
)}
</div>
<div className="user-reviews__review-date">
{formatDistance(
new Date(review.createdAt),
@@ -623,29 +654,6 @@ export function ProfileContent() {
{ addSuffix: true }
)}
</div>
<div className="user-reviews__review-score-stars">
{Array.from({ length: 5 }, (_, index) => (
<div
key={index}
className="user-reviews__review-star-container"
>
<Star
size={24}
fill={
index < review.score
? "currentColor"
: "none"
}
className={`user-reviews__review-star ${
index < review.score
? `user-reviews__review-star--filled game-details__review-star--filled ${getScoreColorClass(review.score)}`
: "user-reviews__review-star--empty game-details__review-star--empty"
}`}
/>
</div>
))}
</div>
</div>
<div

View File

@@ -7538,6 +7538,13 @@ react-i18next@^14.1.0:
"@babel/runtime" "^7.23.9"
html-parse-stringify "^3.0.1"
react-infinite-scroll-component@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz#7e511e7aa0f728ac3e51f64a38a6079ac522407f"
integrity sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==
dependencies:
throttle-debounce "^2.1.0"
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@@ -8540,6 +8547,11 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
throttle-debounce@^2.1.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2"
integrity sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==
"through@>=2.2.7 <3":
version "2.3.8"
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"