feat: improving animations

This commit is contained in:
Chubby Granny Chaser
2025-11-02 17:30:45 +00:00
parent 6ebf7766aa
commit bf387aef3f
5 changed files with 542 additions and 292 deletions

View File

@@ -14,12 +14,15 @@ export interface UserProfileContext {
isMe: boolean;
userStats: UserStats | null;
getUserProfile: () => Promise<void>;
getUserLibraryGames: (sortBy?: string) => Promise<void>;
getUserLibraryGames: (sortBy?: string, reset?: boolean) => Promise<void>;
loadMoreLibraryGames: (sortBy?: string) => Promise<boolean>;
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
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<UserProfileContext>({
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<boolean> => {
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}

View File

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

View File

@@ -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<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(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() {
<section className="profile-content__section">
<div className="profile-content__main">
<div className="profile-content__tabs">
<button
type="button"
className={`profile-content__tab ${activeTab === "library" ? "profile-content__tab--active" : ""}`}
onClick={() => setActiveTab("library")}
>
{t("library")}
</button>
<button
type="button"
className={`profile-content__tab ${activeTab === "reviews" ? "profile-content__tab--active" : ""}`}
onClick={() => setActiveTab("reviews")}
>
{t("user_reviews")}
</button>
<div className="profile-content__tab-wrapper">
<button
type="button"
className={`profile-content__tab ${activeTab === "library" ? "profile-content__tab--active" : ""}`}
onClick={() => setActiveTab("library")}
>
{t("library")}
</button>
{activeTab === "library" && (
<motion.div
className="profile-content__tab-underline"
layoutId="tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
<div className="profile-content__tab-wrapper">
<button
type="button"
className={`profile-content__tab ${activeTab === "reviews" ? "profile-content__tab--active" : ""}`}
onClick={() => setActiveTab("reviews")}
>
{t("user_reviews")}
</button>
{activeTab === "reviews" && (
<motion.div
className="profile-content__tab-underline"
layoutId="tab-underline"
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>
)}
</div>
</div>
<div className="profile-content__tab-panels">
<div
className="profile-content__tab-panel"
hidden={activeTab !== "library"}
aria-hidden={activeTab !== "library"}
>
{hasAnyGames && (
<SortOptions sortBy={sortBy} onSortChange={setSortBy} />
)}
<AnimatePresence mode="wait">
{activeTab === "library" && (
<motion.div
key="library"
className="profile-content__tab-panel"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
aria-hidden={false}
>
{hasAnyGames && (
<SortOptions sortBy={sortBy} onSortChange={setSortBy} />
)}
{!hasAnyGames && (
<div className="profile-content__no-games">
<div className="profile-content__telescope-icon">
<TelescopeIcon size={24} />
</div>
<h2>{t("no_recent_activity_title")}</h2>
{isMe && <p>{t("no_recent_activity_description")}</p>}
</div>
)}
{hasAnyGames && (
<div>
{hasPinnedGames && (
<div style={{ marginBottom: "2rem" }}>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
{/* removed collapse button */}
<h2>{t("pinned")}</h2>
<span className="profile-content__section-badge">
{pinnedGames.length}
</span>
</div>
{!hasAnyGames && (
<div className="profile-content__no-games">
<div className="profile-content__telescope-icon">
<TelescopeIcon size={24} />
</div>
{/* render pinned games unconditionally */}
<ul className="profile-content__games-grid">
{pinnedGames?.map((game) => (
<li key={game.objectId} style={{ listStyle: "none" }}>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
sortBy={sortBy}
/>
</li>
))}
</ul>
<h2>{t("no_recent_activity_title")}</h2>
{isMe && <p>{t("no_recent_activity_description")}</p>}
</div>
)}
{hasGames && (
{hasAnyGames && (
<div>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
<h2>{t("library")}</h2>
{userStats && (
<span className="profile-content__section-badge">
{numberFormatter.format(userStats.libraryCount)}
</span>
)}
</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>
</div>
)}
</div>
)}
</div>
<div
className="profile-content__tab-panel"
hidden={activeTab !== "reviews"}
aria-hidden={activeTab !== "reviews"}
>
<div style={{ marginBottom: "2rem" }}>
<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">
{t("loading_reviews")}
</div>
)}
{!isLoadingReviews && reviews.length === 0 && (
<div className="user-reviews__empty">
<p>{t("no_reviews", "No reviews yet")}</p>
</div>
)}
{!isLoadingReviews && reviews.length > 0 && (
<div className="user-reviews__list">
{reviews.map((review) => {
const isOwnReview = userDetails?.id === review.user.id;
return (
<motion.div
key={review.id}
className="user-reviews__review-item"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="user-reviews__review-header">
<div className="user-reviews__review-date">
{formatDistance(
new Date(review.createdAt),
new Date(),
{ 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>
))}
{hasPinnedGames && (
<div style={{ marginBottom: "2rem" }}>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
{/* removed collapse button */}
<h2>{t("pinned")}</h2>
<span className="profile-content__section-badge">
{pinnedGames.length}
</span>
</div>
</div>
<div
className="user-reviews__review-content"
dangerouslySetInnerHTML={{
__html: review.reviewHtml,
}}
/>
{/* render pinned games unconditionally */}
<ul className="profile-content__games-grid">
{pinnedGames?.map((game) => (
<li
key={game.objectId}
style={{ listStyle: "none" }}
>
<UserLibraryGameCard
game={game}
statIndex={statsIndex}
onMouseEnter={handleOnMouseEnterGameCard}
onMouseLeave={handleOnMouseLeaveGameCard}
sortBy={sortBy}
/>
</li>
))}
</ul>
</div>
)}
<div className="user-reviews__review-footer">
<div className="user-reviews__review-game">
<div className="user-reviews__game-info">
<div className="user-reviews__game-details">
<img
src={review.game.iconUrl}
alt={review.game.title}
className="user-reviews__game-icon"
/>
<button
className="user-reviews__game-title user-reviews__game-title--clickable"
onClick={() =>
navigate(
buildGameDetailsPath(review.game)
)
}
{hasGames && (
<div>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
<h2>{t("library")}</h2>
{userStats && (
<span className="profile-content__section-badge">
{numberFormatter.format(
userStats.libraryCount
)}
</span>
)}
</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}`}
style={{ listStyle: "none" }}
>
{review.game.title}
</button>
<Skeleton
height={240}
style={{
borderRadius: "4px",
boxShadow:
"0 8px 10px -2px rgba(0, 0, 0, 0.5)",
}}
/>
</li>
))}
</ul>
</SkeletonTheme>
)}
</div>
)}
</div>
)}
</motion.div>
)}
{activeTab === "reviews" && (
<motion.div
key="reviews"
className="profile-content__tab-panel"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
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">
{t("loading_reviews")}
</div>
)}
{!isLoadingReviews && reviews.length === 0 && (
<div className="user-reviews__empty">
<p>{t("no_reviews", "No reviews yet")}</p>
</div>
)}
{!isLoadingReviews && reviews.length > 0 && (
<div className="user-reviews__list">
{reviews.map((review) => {
const isOwnReview = userDetails?.id === review.user.id;
return (
<motion.div
key={review.id}
className="user-reviews__review-item"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="user-reviews__review-header">
<div className="user-reviews__review-date">
{formatDistance(
new Date(review.createdAt),
new Date(),
{ 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
className="user-reviews__review-content"
dangerouslySetInnerHTML={{
__html: review.reviewHtml,
}}
/>
<div className="user-reviews__review-footer">
<div className="user-reviews__review-game">
<div className="user-reviews__game-info">
<div className="user-reviews__game-details">
<img
src={review.game.iconUrl}
alt={review.game.title}
className="user-reviews__game-icon"
/>
<button
className="user-reviews__game-title user-reviews__game-title--clickable"
onClick={() =>
navigate(
buildGameDetailsPath(review.game)
)
}
>
{review.game.title}
</button>
</div>
</div>
</div>
</div>
</div>
<div className="user-reviews__review-actions">
<div className="user-reviews__review-votes">
<motion.button
className={`user-reviews__vote-button ${review.hasUpvoted ? "user-reviews__vote-button--active" : ""}`}
onClick={() =>
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 }}
>
<ThumbsUp size={14} />
<AnimatePresence mode="wait">
<motion.span
key={review.upvotes}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
>
{review.upvotes}
</motion.span>
</AnimatePresence>
</motion.button>
<div className="user-reviews__review-actions">
<div className="user-reviews__review-votes">
<motion.button
className={`user-reviews__vote-button ${review.hasUpvoted ? "user-reviews__vote-button--active" : ""}`}
onClick={() =>
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 }}
>
<ThumbsUp size={14} />
<AnimatePresence mode="wait">
<motion.span
key={review.upvotes}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
>
{review.upvotes}
</motion.span>
</AnimatePresence>
</motion.button>
<motion.button
className={`user-reviews__vote-button ${review.hasDownvoted ? "user-reviews__vote-button--active" : ""}`}
onClick={() =>
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 }}
>
<ThumbsDown size={14} />
<AnimatePresence mode="wait">
<motion.span
key={review.downvotes}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
>
{review.downvotes}
</motion.span>
</AnimatePresence>
</motion.button>
<motion.button
className={`user-reviews__vote-button ${review.hasDownvoted ? "user-reviews__vote-button--active" : ""}`}
onClick={() =>
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 }}
>
<ThumbsDown size={14} />
<AnimatePresence mode="wait">
<motion.span
key={review.downvotes}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
>
{review.downvotes}
</motion.span>
</AnimatePresence>
</motion.button>
</div>
{isOwnReview && (
<button
className="user-reviews__delete-review-button"
onClick={() => handleDeleteClick(review.id)}
title={t("delete_review")}
>
<TrashIcon size={14} />
<span>{t("delete_review")}</span>
</button>
)}
</div>
{isOwnReview && (
<button
className="user-reviews__delete-review-button"
onClick={() => handleDeleteClick(review.id)}
title={t("delete_review")}
>
<TrashIcon size={14} />
<span>{t("delete_review")}</span>
</button>
)}
</div>
</motion.div>
);
})}
</div>
)}
</div>
</div>
</motion.div>
);
})}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>

View File

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

View File

@@ -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({
)}
</div>
<img
src={game.coverImageUrl ?? undefined}
alt={game.title}
className="user-library-game__game-image"
/>
{imageError || !game.coverImageUrl ? (
<div className="user-library-game__cover-placeholder">
<ImageIcon size={48} />
</div>
) : (
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
onError={() => setImageError(true)}
/>
)}
</button>
</li>
<Tooltip