mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-28 13:21:02 +00:00
feat: improving animations
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user