mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-24 19:31:03 +00:00
feat: adding reviews to profile
This commit is contained in:
@@ -279,7 +279,11 @@ export function App() {
|
|||||||
<article className="container">
|
<article className="container">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<section ref={contentRef} id="scrollableDiv" className="container__content">
|
<section
|
||||||
|
ref={contentRef}
|
||||||
|
id="scrollableDiv"
|
||||||
|
className="container__content"
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
176
src/renderer/src/pages/profile/profile-content/library-tab.tsx
Normal file
176
src/renderer/src/pages/profile/profile-content/library-tab.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TelescopeIcon } from "@primer/octicons-react";
|
||||||
|
import InfiniteScroll from "react-infinite-scroll-component";
|
||||||
|
import { useFormat } from "@renderer/hooks";
|
||||||
|
import type { UserGame } from "@types";
|
||||||
|
import { SortOptions } from "./sort-options";
|
||||||
|
import { UserLibraryGameCard } from "./user-library-game-card";
|
||||||
|
import "./profile-content.scss";
|
||||||
|
|
||||||
|
type SortOption = "playtime" | "achievementCount" | "playedRecently";
|
||||||
|
|
||||||
|
interface LibraryTabProps {
|
||||||
|
sortBy: SortOption;
|
||||||
|
onSortChange: (sortBy: SortOption) => void;
|
||||||
|
pinnedGames: UserGame[];
|
||||||
|
libraryGames: UserGame[];
|
||||||
|
hasMoreLibraryGames: boolean;
|
||||||
|
isLoadingLibraryGames: boolean;
|
||||||
|
statsIndex: number;
|
||||||
|
userStats: { libraryCount: number } | null;
|
||||||
|
animatedGameIdsRef: React.MutableRefObject<Set<string>>;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
onMouseEnter: () => void;
|
||||||
|
onMouseLeave: () => void;
|
||||||
|
isMe: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryTab({
|
||||||
|
sortBy,
|
||||||
|
onSortChange,
|
||||||
|
pinnedGames,
|
||||||
|
libraryGames,
|
||||||
|
hasMoreLibraryGames,
|
||||||
|
isLoadingLibraryGames,
|
||||||
|
statsIndex,
|
||||||
|
userStats,
|
||||||
|
animatedGameIdsRef,
|
||||||
|
onLoadMore,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
isMe,
|
||||||
|
}: Readonly<LibraryTabProps>) {
|
||||||
|
const { t } = useTranslation("user_profile");
|
||||||
|
const { numberFormatter } = useFormat();
|
||||||
|
|
||||||
|
const hasGames = libraryGames.length > 0;
|
||||||
|
const hasPinnedGames = pinnedGames.length > 0;
|
||||||
|
const hasAnyGames = hasGames || hasPinnedGames;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={onSortChange} />}
|
||||||
|
|
||||||
|
{!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">
|
||||||
|
<h2>{t("pinned")}</h2>
|
||||||
|
<span className="profile-content__section-badge">
|
||||||
|
{pinnedGames.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="profile-content__games-grid">
|
||||||
|
{pinnedGames?.map((game) => (
|
||||||
|
<li key={game.objectId} style={{ listStyle: "none" }}>
|
||||||
|
<UserLibraryGameCard
|
||||||
|
game={game}
|
||||||
|
statIndex={statsIndex}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
sortBy={sortBy}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<InfiniteScroll
|
||||||
|
dataLength={libraryGames.length}
|
||||||
|
next={onLoadMore}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserLibraryGameCard
|
||||||
|
game={game}
|
||||||
|
statIndex={statsIndex}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
sortBy={sortBy}
|
||||||
|
/>
|
||||||
|
</motion.li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</InfiniteScroll>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,14 +8,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { ProfileHero } from "../profile-hero/profile-hero";
|
import { ProfileHero } from "../profile-hero/profile-hero";
|
||||||
import {
|
import { useAppDispatch, useFormat, useUserDetails } from "@renderer/hooks";
|
||||||
useAppDispatch,
|
|
||||||
useFormat,
|
|
||||||
useDate,
|
|
||||||
useUserDetails,
|
|
||||||
} from "@renderer/hooks";
|
|
||||||
import { setHeaderTitle } from "@renderer/features";
|
import { setHeaderTitle } from "@renderer/features";
|
||||||
import { TelescopeIcon } from "@primer/octicons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LockedProfile } from "./locked-profile";
|
import { LockedProfile } from "./locked-profile";
|
||||||
import { ReportProfile } from "../report-profile/report-profile";
|
import { ReportProfile } from "../report-profile/report-profile";
|
||||||
@@ -23,19 +17,13 @@ import { FriendsBox } from "./friends-box";
|
|||||||
import { RecentGamesBox } from "./recent-games-box";
|
import { RecentGamesBox } from "./recent-games-box";
|
||||||
import { UserStatsBox } from "./user-stats-box";
|
import { UserStatsBox } from "./user-stats-box";
|
||||||
import { UserKarmaBox } from "./user-karma-box";
|
import { UserKarmaBox } from "./user-karma-box";
|
||||||
import { UserLibraryGameCard } from "./user-library-game-card";
|
|
||||||
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 { 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 { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||||
import InfiniteScroll from "react-infinite-scroll-component";
|
import { ProfileTabs } from "./profile-tabs";
|
||||||
|
import { LibraryTab } from "./library-tab";
|
||||||
|
import { ReviewsTab } from "./reviews-tab";
|
||||||
|
import { AnimatePresence } from "framer-motion";
|
||||||
import "./profile-content.scss";
|
import "./profile-content.scss";
|
||||||
|
|
||||||
type SortOption = "playtime" | "achievementCount" | "playedRecently";
|
type SortOption = "playtime" | "achievementCount" | "playedRecently";
|
||||||
@@ -58,7 +46,7 @@ interface UserReview {
|
|||||||
title: string;
|
title: string;
|
||||||
iconUrl: string;
|
iconUrl: string;
|
||||||
objectId: string;
|
objectId: string;
|
||||||
shop: GameShop;
|
shop: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +85,6 @@ export function ProfileContent() {
|
|||||||
isLoadingLibraryGames,
|
isLoadingLibraryGames,
|
||||||
} = useContext(userProfileContext);
|
} = useContext(userProfileContext);
|
||||||
const { userDetails } = useUserDetails();
|
const { userDetails } = useUserDetails();
|
||||||
const { formatDistance } = useDate();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [statsIndex, setStatsIndex] = useState(0);
|
const [statsIndex, setStatsIndex] = useState(0);
|
||||||
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
|
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
|
||||||
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
|
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
|
||||||
@@ -117,7 +103,6 @@ export function ProfileContent() {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const { t } = useTranslation("user_profile");
|
const { t } = useTranslation("user_profile");
|
||||||
const { t: tGameDetails } = useTranslation("game_details");
|
|
||||||
const { numberFormatter } = useFormat();
|
const { numberFormatter } = useFormat();
|
||||||
|
|
||||||
const formatPlayTime = (playTimeInSeconds: number) => {
|
const formatPlayTime = (playTimeInSeconds: number) => {
|
||||||
@@ -197,7 +182,7 @@ export function ProfileContent() {
|
|||||||
setReviews(response.reviews);
|
setReviews(response.reviews);
|
||||||
setReviewsTotalCount(response.totalCount);
|
setReviewsTotalCount(response.totalCount);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch user reviews:", error);
|
// Error handling for fetching reviews
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingReviews(false);
|
setIsLoadingReviews(false);
|
||||||
}
|
}
|
||||||
@@ -392,383 +377,43 @@ export function ProfileContent() {
|
|||||||
return (
|
return (
|
||||||
<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">
|
<ProfileTabs
|
||||||
<div className="profile-content__tab-wrapper">
|
activeTab={activeTab}
|
||||||
<button
|
reviewsTotalCount={reviewsTotalCount}
|
||||||
type="button"
|
onTabChange={setActiveTab}
|
||||||
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")}
|
|
||||||
{reviewsTotalCount > 0 && (
|
|
||||||
<span className="profile-content__tab-badge">
|
|
||||||
{reviewsTotalCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</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-panels">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{activeTab === "library" && (
|
{activeTab === "library" && (
|
||||||
<motion.div
|
<LibraryTab
|
||||||
key="library"
|
sortBy={sortBy}
|
||||||
className="profile-content__tab-panel"
|
onSortChange={setSortBy}
|
||||||
initial={{ opacity: 0, x: -10 }}
|
pinnedGames={pinnedGames}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
libraryGames={libraryGames}
|
||||||
exit={{ opacity: 0, x: 10 }}
|
hasMoreLibraryGames={hasMoreLibraryGames}
|
||||||
transition={{ duration: 0.2 }}
|
isLoadingLibraryGames={isLoadingLibraryGames}
|
||||||
aria-hidden={false}
|
statsIndex={statsIndex}
|
||||||
>
|
userStats={userStats}
|
||||||
{hasAnyGames && (
|
animatedGameIdsRef={animatedGameIdsRef}
|
||||||
<SortOptions sortBy={sortBy} onSortChange={setSortBy} />
|
onLoadMore={handleLoadMore}
|
||||||
)}
|
onMouseEnter={handleOnMouseEnterGameCard}
|
||||||
|
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||||
{!hasAnyGames && (
|
isMe={isMe}
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
|
|
||||||
<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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UserLibraryGameCard
|
|
||||||
game={game}
|
|
||||||
statIndex={statsIndex}
|
|
||||||
onMouseEnter={handleOnMouseEnterGameCard}
|
|
||||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
|
||||||
sortBy={sortBy}
|
|
||||||
/>
|
|
||||||
</motion.li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</InfiniteScroll>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "reviews" && (
|
{activeTab === "reviews" && (
|
||||||
<motion.div
|
<ReviewsTab
|
||||||
key="reviews"
|
reviews={reviews}
|
||||||
className="profile-content__tab-panel"
|
isLoadingReviews={isLoadingReviews}
|
||||||
initial={{ opacity: 0, x: -10 }}
|
votingReviews={votingReviews}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
userDetailsId={userDetails?.id}
|
||||||
exit={{ opacity: 0, x: 10 }}
|
formatPlayTime={formatPlayTime}
|
||||||
transition={{ duration: 0.2 }}
|
getRatingText={getRatingText}
|
||||||
aria-hidden={false}
|
onVote={handleVoteReview}
|
||||||
>
|
onDelete={handleDeleteClick}
|
||||||
{/* 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-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),
|
|
||||||
new Date(),
|
|
||||||
{ addSuffix: true }
|
|
||||||
)}
|
|
||||||
</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 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>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ClockIcon } from "@primer/octicons-react";
|
||||||
|
import { Star, ThumbsUp, ThumbsDown, TrashIcon } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useDate } from "@renderer/hooks";
|
||||||
|
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
|
import "./profile-content.scss";
|
||||||
|
|
||||||
|
interface UserReview {
|
||||||
|
id: string;
|
||||||
|
reviewHtml: string;
|
||||||
|
score: number;
|
||||||
|
playTimeInSeconds?: number;
|
||||||
|
upvotes: number;
|
||||||
|
downvotes: number;
|
||||||
|
hasUpvoted: boolean;
|
||||||
|
hasDownvoted: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
game: {
|
||||||
|
title: string;
|
||||||
|
iconUrl: string;
|
||||||
|
objectId: string;
|
||||||
|
shop: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileReviewItemProps {
|
||||||
|
review: UserReview;
|
||||||
|
isOwnReview: boolean;
|
||||||
|
isVoting: boolean;
|
||||||
|
formatPlayTime: (playTimeInSeconds: number) => string;
|
||||||
|
getRatingText: (score: number, t: (key: string) => string) => string;
|
||||||
|
onVote: (reviewId: string, isUpvote: boolean) => void;
|
||||||
|
onDelete: (reviewId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileReviewItem({
|
||||||
|
review,
|
||||||
|
isOwnReview,
|
||||||
|
isVoting,
|
||||||
|
formatPlayTime,
|
||||||
|
getRatingText,
|
||||||
|
onVote,
|
||||||
|
onDelete,
|
||||||
|
}: Readonly<ProfileReviewItemProps>) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { formatDistance } = useDate();
|
||||||
|
const { t } = useTranslation("user_profile");
|
||||||
|
const { t: tGameDetails } = useTranslation("game_details");
|
||||||
|
|
||||||
|
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-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), new Date(), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</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 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={() => onVote(review.id, true)}
|
||||||
|
disabled={isVoting}
|
||||||
|
style={{
|
||||||
|
opacity: isVoting ? 0.5 : 1,
|
||||||
|
cursor: isVoting ? "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={() => onVote(review.id, false)}
|
||||||
|
disabled={isVoting}
|
||||||
|
style={{
|
||||||
|
opacity: isVoting ? 0.5 : 1,
|
||||||
|
cursor: isVoting ? "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={() => onDelete(review.id)}
|
||||||
|
title={t("delete_review")}
|
||||||
|
>
|
||||||
|
<TrashIcon size={14} />
|
||||||
|
<span>{t("delete_review")}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import "./profile-content.scss";
|
||||||
|
|
||||||
|
interface ProfileTabsProps {
|
||||||
|
activeTab: "library" | "reviews";
|
||||||
|
reviewsTotalCount: number;
|
||||||
|
onTabChange: (tab: "library" | "reviews") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileTabs({
|
||||||
|
activeTab,
|
||||||
|
reviewsTotalCount,
|
||||||
|
onTabChange,
|
||||||
|
}: Readonly<ProfileTabsProps>) {
|
||||||
|
const { t } = useTranslation("user_profile");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profile-content__tabs">
|
||||||
|
<div className="profile-content__tab-wrapper">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`profile-content__tab ${activeTab === "library" ? "profile-content__tab--active" : ""}`}
|
||||||
|
onClick={() => onTabChange("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={() => onTabChange("reviews")}
|
||||||
|
>
|
||||||
|
{t("user_reviews")}
|
||||||
|
{reviewsTotalCount > 0 && (
|
||||||
|
<span className="profile-content__tab-badge">
|
||||||
|
{reviewsTotalCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{activeTab === "reviews" && (
|
||||||
|
<motion.div
|
||||||
|
className="profile-content__tab-underline"
|
||||||
|
layoutId="tab-underline"
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ProfileReviewItem } from "./profile-review-item";
|
||||||
|
import "./profile-content.scss";
|
||||||
|
|
||||||
|
interface UserReview {
|
||||||
|
id: string;
|
||||||
|
reviewHtml: string;
|
||||||
|
score: number;
|
||||||
|
playTimeInSeconds?: number;
|
||||||
|
upvotes: number;
|
||||||
|
downvotes: number;
|
||||||
|
hasUpvoted: boolean;
|
||||||
|
hasDownvoted: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
game: {
|
||||||
|
title: string;
|
||||||
|
iconUrl: string;
|
||||||
|
objectId: string;
|
||||||
|
shop: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewsTabProps {
|
||||||
|
reviews: UserReview[];
|
||||||
|
isLoadingReviews: boolean;
|
||||||
|
votingReviews: Set<string>;
|
||||||
|
userDetailsId?: string;
|
||||||
|
formatPlayTime: (playTimeInSeconds: number) => string;
|
||||||
|
getRatingText: (score: number, t: (key: string) => string) => string;
|
||||||
|
onVote: (reviewId: string, isUpvote: boolean) => void;
|
||||||
|
onDelete: (reviewId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewsTab({
|
||||||
|
reviews,
|
||||||
|
isLoadingReviews,
|
||||||
|
votingReviews,
|
||||||
|
userDetailsId,
|
||||||
|
formatPlayTime,
|
||||||
|
getRatingText,
|
||||||
|
onVote,
|
||||||
|
onDelete,
|
||||||
|
}: Readonly<ReviewsTabProps>) {
|
||||||
|
const { t } = useTranslation("user_profile");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
{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 = userDetailsId === review.user.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileReviewItem
|
||||||
|
key={review.id}
|
||||||
|
review={review}
|
||||||
|
isOwnReview={isOwnReview}
|
||||||
|
isVoting={votingReviews.has(review.id)}
|
||||||
|
formatPlayTime={formatPlayTime}
|
||||||
|
getRatingText={getRatingText}
|
||||||
|
onVote={onVote}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user