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; isMe: boolean;
userStats: UserStats | null; userStats: UserStats | null;
getUserProfile: () => Promise<void>; 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>>; setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
backgroundImage: string; backgroundImage: string;
badges: Badge[]; badges: Badge[];
libraryGames: UserGame[]; libraryGames: UserGame[];
pinnedGames: UserGame[]; pinnedGames: UserGame[];
hasMoreLibraryGames: boolean;
isLoadingLibraryGames: boolean;
} }
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3"; export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
@@ -30,12 +33,15 @@ export const userProfileContext = createContext<UserProfileContext>({
isMe: false, isMe: false,
userStats: null, userStats: null,
getUserProfile: async () => {}, getUserProfile: async () => {},
getUserLibraryGames: async (_sortBy?: string) => {}, getUserLibraryGames: async (_sortBy?: string, _reset?: boolean) => {},
loadMoreLibraryGames: async (_sortBy?: string) => false,
setSelectedBackgroundImage: () => {}, setSelectedBackgroundImage: () => {},
backgroundImage: "", backgroundImage: "",
badges: [], badges: [],
libraryGames: [], libraryGames: [],
pinnedGames: [], pinnedGames: [],
hasMoreLibraryGames: false,
isLoadingLibraryGames: false,
}); });
const { Provider } = userProfileContext; const { Provider } = userProfileContext;
@@ -62,6 +68,9 @@ export function UserProfileContextProvider({
DEFAULT_USER_PROFILE_BACKGROUND DEFAULT_USER_PROFILE_BACKGROUND
); );
const [selectedBackgroundImage, setSelectedBackgroundImage] = useState(""); 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; const isMe = userDetails?.id === userProfile?.id;
@@ -93,7 +102,13 @@ export function UserProfileContextProvider({
}, [userId]); }, [userId]);
const getUserLibraryGames = useCallback( const getUserLibraryGames = useCallback(
async (sortBy?: string) => { async (sortBy?: string, reset = true) => {
if (reset) {
setLibraryPage(0);
setHasMoreLibraryGames(true);
setIsLoadingLibraryGames(true);
}
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("take", "12"); params.append("take", "12");
@@ -115,18 +130,68 @@ export function UserProfileContextProvider({
if (response) { if (response) {
setLibraryGames(response.library); setLibraryGames(response.library);
setPinnedGames(response.pinnedGames); setPinnedGames(response.pinnedGames);
setHasMoreLibraryGames(response.library.length === 12);
} else { } else {
setLibraryGames([]); setLibraryGames([]);
setPinnedGames([]); setPinnedGames([]);
setHasMoreLibraryGames(false);
} }
} catch (error) { } catch (error) {
setLibraryGames([]); setLibraryGames([]);
setPinnedGames([]); setPinnedGames([]);
setHasMoreLibraryGames(false);
} finally {
setIsLoadingLibraryGames(false);
} }
}, },
[userId] [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 () => { const getUserProfile = useCallback(async () => {
getUserStats(); getUserStats();
getUserLibraryGames(); getUserLibraryGames();
@@ -164,6 +229,8 @@ export function UserProfileContextProvider({
setLibraryGames([]); setLibraryGames([]);
setPinnedGames([]); setPinnedGames([]);
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND); setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
setLibraryPage(0);
setHasMoreLibraryGames(true);
getUserProfile(); getUserProfile();
getBadges(); getBadges();
@@ -177,12 +244,15 @@ export function UserProfileContextProvider({
isMe, isMe,
getUserProfile, getUserProfile,
getUserLibraryGames, getUserLibraryGames,
loadMoreLibraryGames,
setSelectedBackgroundImage, setSelectedBackgroundImage,
backgroundImage: getBackgroundImageUrl(), backgroundImage: getBackgroundImageUrl(),
userStats, userStats,
badges, badges,
libraryGames, libraryGames,
pinnedGames, pinnedGames,
hasMoreLibraryGames,
isLoadingLibraryGames,
}} }}
> >
{children} {children}

View File

@@ -101,6 +101,11 @@
gap: calc(globals.$spacing-unit); gap: calc(globals.$spacing-unit);
margin-bottom: calc(globals.$spacing-unit * 2); margin-bottom: calc(globals.$spacing-unit * 2);
border-bottom: 1px solid rgba(255, 255, 255, 0.1); border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
}
&__tab-wrapper {
position: relative;
} }
&__tab { &__tab {
@@ -111,19 +116,22 @@
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
border-bottom: 2px solid transparent; transition: color ease 0.2s;
transition: all ease 0.2s;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
&--active { &--active {
color: white; color: white;
border-bottom-color: white;
} }
} }
&__tab-underline {
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: white;
}
&__games-grid { &__games-grid {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -179,10 +187,6 @@
&__tab-panels { &__tab-panels {
display: block; display: block;
} }
&__tab-panel[hidden] {
display: none;
}
} }
} }
@@ -210,7 +214,6 @@
.user-reviews__review-item { .user-reviews__review-item {
border-radius: 8px; border-radius: 8px;
padding: calc(globals.$spacing-unit * 2);
} }
.user-reviews__review-header { .user-reviews__review-header {

View File

@@ -26,6 +26,8 @@ import { Star, ThumbsUp, ThumbsDown, TrashIcon } from "lucide-react";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; 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 Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import "./profile-content.scss"; import "./profile-content.scss";
type SortOption = "playtime" | "achievementCount" | "playedRecently"; type SortOption = "playtime" | "achievementCount" | "playedRecently";
@@ -71,6 +73,9 @@ export function ProfileContent() {
libraryGames, libraryGames,
pinnedGames, pinnedGames,
getUserLibraryGames, getUserLibraryGames,
loadMoreLibraryGames,
hasMoreLibraryGames,
isLoadingLibraryGames,
} = useContext(userProfileContext); } = useContext(userProfileContext);
const { userDetails } = useUserDetails(); const { userDetails } = useUserDetails();
const { formatDistance } = useDate(); const { formatDistance } = useDate();
@@ -104,10 +109,69 @@ export function ProfileContent() {
useEffect(() => { useEffect(() => {
if (userProfile) { if (userProfile) {
getUserLibraryGames(sortBy); getUserLibraryGames(sortBy, true);
} }
}, [sortBy, getUserLibraryGames, userProfile]); }, [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 // Clear reviews state and reset tab when switching users
useEffect(() => { useEffect(() => {
setReviews([]); setReviews([]);
@@ -332,294 +396,373 @@ export function ProfileContent() {
<section className="profile-content__section"> <section className="profile-content__section">
<div className="profile-content__main"> <div className="profile-content__main">
<div className="profile-content__tabs"> <div className="profile-content__tabs">
<button <div className="profile-content__tab-wrapper">
type="button" <button
className={`profile-content__tab ${activeTab === "library" ? "profile-content__tab--active" : ""}`} type="button"
onClick={() => setActiveTab("library")} className={`profile-content__tab ${activeTab === "library" ? "profile-content__tab--active" : ""}`}
> onClick={() => setActiveTab("library")}
{t("library")} >
</button> {t("library")}
<button </button>
type="button" {activeTab === "library" && (
className={`profile-content__tab ${activeTab === "reviews" ? "profile-content__tab--active" : ""}`} <motion.div
onClick={() => setActiveTab("reviews")} className="profile-content__tab-underline"
> layoutId="tab-underline"
{t("user_reviews")} transition={{
</button> 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>
<div className="profile-content__tab-panels"> <div className="profile-content__tab-panels">
<div <AnimatePresence mode="wait">
className="profile-content__tab-panel" {activeTab === "library" && (
hidden={activeTab !== "library"} <motion.div
aria-hidden={activeTab !== "library"} key="library"
> className="profile-content__tab-panel"
{hasAnyGames && ( initial={{ opacity: 0, x: -10 }}
<SortOptions sortBy={sortBy} onSortChange={setSortBy} /> animate={{ opacity: 1, x: 0 }}
)} exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
aria-hidden={false}
>
{hasAnyGames && (
<SortOptions sortBy={sortBy} onSortChange={setSortBy} />
)}
{!hasAnyGames && ( {!hasAnyGames && (
<div className="profile-content__no-games"> <div className="profile-content__no-games">
<div className="profile-content__telescope-icon"> <div className="profile-content__telescope-icon">
<TelescopeIcon size={24} /> <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>
</div> </div>
<h2>{t("no_recent_activity_title")}</h2>
{/* render pinned games unconditionally */} {isMe && <p>{t("no_recent_activity_description")}</p>}
<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>
)} )}
{hasGames && ( {hasAnyGames && (
<div> <div>
<div className="profile-content__section-header"> {hasPinnedGames && (
<div className="profile-content__section-title-group"> <div style={{ marginBottom: "2rem" }}>
<h2>{t("library")}</h2> <div className="profile-content__section-header">
{userStats && ( <div className="profile-content__section-title-group">
<span className="profile-content__section-badge"> {/* removed collapse button */}
{numberFormatter.format(userStats.libraryCount)} <h2>{t("pinned")}</h2>
</span> <span className="profile-content__section-badge">
)} {pinnedGames.length}
</div> </span>
</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>
))}
</div> </div>
</div> </div>
<div {/* render pinned games unconditionally */}
className="user-reviews__review-content" <ul className="profile-content__games-grid">
dangerouslySetInnerHTML={{ {pinnedGames?.map((game) => (
__html: review.reviewHtml, <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"> {hasGames && (
<div className="user-reviews__review-game"> <div>
<div className="user-reviews__game-info"> <div className="profile-content__section-header">
<div className="user-reviews__game-details"> <div className="profile-content__section-title-group">
<img <h2>{t("library")}</h2>
src={review.game.iconUrl} {userStats && (
alt={review.game.title} <span className="profile-content__section-badge">
className="user-reviews__game-icon" {numberFormatter.format(
/> userStats.libraryCount
<button )}
className="user-reviews__game-title user-reviews__game-title--clickable" </span>
onClick={() => )}
navigate( </div>
buildGameDetailsPath(review.game) </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} <Skeleton
</button> 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> </div>
</div>
<div className="user-reviews__review-actions"> <div className="user-reviews__review-actions">
<div className="user-reviews__review-votes"> <div className="user-reviews__review-votes">
<motion.button <motion.button
className={`user-reviews__vote-button ${review.hasUpvoted ? "user-reviews__vote-button--active" : ""}`} className={`user-reviews__vote-button ${review.hasUpvoted ? "user-reviews__vote-button--active" : ""}`}
onClick={() => onClick={() =>
handleVoteReview(review.id, true) handleVoteReview(review.id, true)
} }
disabled={votingReviews.has(review.id)} disabled={votingReviews.has(review.id)}
style={{ style={{
opacity: votingReviews.has(review.id) opacity: votingReviews.has(review.id)
? 0.5 ? 0.5
: 1, : 1,
cursor: votingReviews.has(review.id) cursor: votingReviews.has(review.id)
? "not-allowed" ? "not-allowed"
: "pointer", : "pointer",
}} }}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
> >
<ThumbsUp size={14} /> <ThumbsUp size={14} />
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.span <motion.span
key={review.upvotes} key={review.upvotes}
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }} exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
{review.upvotes} {review.upvotes}
</motion.span> </motion.span>
</AnimatePresence> </AnimatePresence>
</motion.button> </motion.button>
<motion.button <motion.button
className={`user-reviews__vote-button ${review.hasDownvoted ? "user-reviews__vote-button--active" : ""}`} className={`user-reviews__vote-button ${review.hasDownvoted ? "user-reviews__vote-button--active" : ""}`}
onClick={() => onClick={() =>
handleVoteReview(review.id, false) handleVoteReview(review.id, false)
} }
disabled={votingReviews.has(review.id)} disabled={votingReviews.has(review.id)}
style={{ style={{
opacity: votingReviews.has(review.id) opacity: votingReviews.has(review.id)
? 0.5 ? 0.5
: 1, : 1,
cursor: votingReviews.has(review.id) cursor: votingReviews.has(review.id)
? "not-allowed" ? "not-allowed"
: "pointer", : "pointer",
}} }}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
> >
<ThumbsDown size={14} /> <ThumbsDown size={14} />
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.span <motion.span
key={review.downvotes} key={review.downvotes}
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }} exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
{review.downvotes} {review.downvotes}
</motion.span> </motion.span>
</AnimatePresence> </AnimatePresence>
</motion.button> </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> </div>
</motion.div>
{isOwnReview && ( );
<button })}
className="user-reviews__delete-review-button" </div>
onClick={() => handleDeleteClick(review.id)} )}
title={t("delete_review")} </motion.div>
> )}
<TrashIcon size={14} /> </AnimatePresence>
<span>{t("delete_review")}</span>
</button>
)}
</div>
</motion.div>
);
})}
</div>
)}
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -36,6 +36,7 @@
box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5); box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5);
width: 100%; width: 100%;
position: relative; position: relative;
overflow: hidden;
&:before { &:before {
content: ""; content: "";
@@ -193,8 +194,28 @@
border-radius: 4px; border-radius: 4px;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-width: 100%; display: block;
min-height: 100%; }
&__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 { &__achievements-progress {

View File

@@ -2,7 +2,7 @@ import { UserGame } from "@types";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useFormat, useToast } from "@renderer/hooks"; import { useFormat, useToast } from "@renderer/hooks";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useCallback, useContext, useState } from "react"; import { useCallback, useContext, useEffect, useState } from "react";
import { import {
buildGameAchievementPath, buildGameAchievementPath,
buildGameDetailsPath, buildGameDetailsPath,
@@ -15,6 +15,7 @@ import {
AlertFillIcon, AlertFillIcon,
PinIcon, PinIcon,
PinSlashIcon, PinSlashIcon,
ImageIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { Tooltip } from "react-tooltip"; import { Tooltip } from "react-tooltip";
@@ -44,6 +45,11 @@ export function UserLibraryGameCard({
const navigate = useNavigate(); const navigate = useNavigate();
const [isTooltipHovered, setIsTooltipHovered] = useState(false); const [isTooltipHovered, setIsTooltipHovered] = useState(false);
const [isPinning, setIsPinning] = useState(false); const [isPinning, setIsPinning] = useState(false);
const [imageError, setImageError] = useState(false);
useEffect(() => {
setImageError(false);
}, [game.coverImageUrl]);
const getStatsItemCount = useCallback(() => { const getStatsItemCount = useCallback(() => {
let statsCount = 1; let statsCount = 1;
@@ -233,11 +239,18 @@ export function UserLibraryGameCard({
)} )}
</div> </div>
<img {imageError || !game.coverImageUrl ? (
src={game.coverImageUrl ?? undefined} <div className="user-library-game__cover-placeholder">
alt={game.title} <ImageIcon size={48} />
className="user-library-game__game-image" </div>
/> ) : (
<img
src={game.coverImageUrl}
alt={game.title}
className="user-library-game__game-image"
onError={() => setImageError(true)}
/>
)}
</button> </button>
</li> </li>
<Tooltip <Tooltip