feat: added reviews in profile and tabs

This commit is contained in:
Moyasee
2025-10-22 21:13:05 +03:00
parent 0d60ec8801
commit 362d6b634e
4 changed files with 610 additions and 105 deletions

View File

@@ -676,7 +676,10 @@
"game_added_to_pinned": "Game added to pinned",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Earned from positive likes on reviews"
"karma_description": "Earned from positive likes on reviews",
"user_reviews": "User's Reviews",
"delete_review": "Delete Review",
"loading_reviews": "Loading reviews..."
},
"achievement": {
"achievement_unlocked": "Achievement unlocked",

View File

@@ -3,12 +3,14 @@ import { useState, useCallback } from "react";
interface SectionCollapseState {
pinned: boolean;
library: boolean;
reviews: boolean;
}
export function useSectionCollapse() {
const [collapseState, setCollapseState] = useState<SectionCollapseState>({
pinned: false,
library: false,
reviews: false,
});
const toggleSection = useCallback((section: keyof SectionCollapseState) => {
@@ -23,5 +25,6 @@ export function useSectionCollapse() {
toggleSection,
isPinnedCollapsed: collapseState.pinned,
isLibraryCollapsed: collapseState.library,
isReviewsCollapsed: collapseState.reviews,
};
}

View File

@@ -120,7 +120,7 @@
&--active {
color: white;
border-bottom-color: #c9aa71;
border-bottom-color: white;
}
}
@@ -177,3 +177,157 @@
}
}
}
// Reviews minimal styles
.user-reviews__loading {
padding: calc(globals.$spacing-unit * 2);
color: rgba(255, 255, 255, 0.8);
}
.user-reviews__empty {
text-align: center;
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2);
color: rgba(255, 255, 255, 0.6);
}
.user-reviews__list {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
.user-reviews__review-item {
border-radius: 8px;
padding: calc(globals.$spacing-unit * 2);
}
.user-reviews__review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
.user-reviews__review-game {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
}
.user-reviews__game-icon {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: cover;
}
.user-reviews__game-info {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.25);
}
.user-reviews__game-title {
background: none;
border: none;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
cursor: pointer;
text-align: left;
&--clickable:hover {
text-decoration: underline;
}
}
.user-reviews__review-date {
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
}
.user-reviews__review-score-stars {
display: flex;
gap: calc(globals.$spacing-unit * 0.5);
}
.user-reviews__review-star-container {
display: flex;
align-items: center;
}
.user-reviews__review-content {
color: rgba(255, 255, 255, 0.85);
line-height: 1.5;
}
.user-reviews__review-actions {
margin-top: calc(globals.$spacing-unit * 2);
padding-top: calc(globals.$spacing-unit);
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
.user-reviews__review-votes {
display: flex;
gap: calc(globals.$spacing-unit);
}
.user-reviews__vote-button {
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 6px 12px;
color: #ccc;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
&--active {
color: #ffffff;
border-color: rgba(255, 255, 255, 0.3);
svg {
fill: white;
}
}
}
.user-reviews__delete-review-button {
display: flex;
align-items: center;
gap: 6px;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 6px;
padding: 6px 10px;
color: #f44336;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(244, 67, 54, 0.2);
border-color: rgba(244, 67, 54, 0.4);
color: #ff7961;
}
}
.profile-content {
&__tab-panels {
display: block;
}
&__tab-panel[hidden] {
display: none;
}
}

View File

@@ -1,9 +1,9 @@
import { userProfileContext } from "@renderer/context";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { ProfileHero } from "../profile-hero/profile-hero";
import { useAppDispatch, useFormat } from "@renderer/hooks";
import { useAppDispatch, useFormat, useDate, useUserDetails } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react";
import { TelescopeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile";
@@ -13,17 +13,56 @@ import { UserStatsBox } from "./user-stats-box";
import { UserKarmaBox } from "./user-karma-box";
import { UserLibraryGameCard } from "./user-library-game-card";
import { SortOptions } from "./sort-options";
import { useSectionCollapse } from "@renderer/hooks/use-section-collapse";
import { motion, AnimatePresence } from "framer-motion";
import { useNavigate } from "react-router-dom";
import { buildGameDetailsPath } from "@renderer/helpers";
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 {
sectionVariants,
chevronVariants,
// removed: sectionVariants,
// removed: chevronVariants,
GAME_STATS_ANIMATION_DURATION_IN_MS,
} from "./profile-animations";
import "./profile-content.scss";
type SortOption = "playtime" | "achievementCount" | "playedRecently";
interface UserReview {
id: string;
reviewHtml: string;
score: number;
upvotes: number;
downvotes: number;
hasUpvoted: boolean;
hasDownvoted: boolean;
createdAt: string;
updatedAt: string;
user: {
id: string;
};
game: {
title: string;
iconUrl: string;
objectId: string;
shop: GameShop;
};
}
interface UserReviewsResponse {
totalCount: number;
reviews: UserReview[];
}
const getScoreColorClass = (score: number) => {
if (score >= 1 && score <= 2) return "game-details__review-score--red";
if (score === 3) return "game-details__review-score--yellow";
if (score >= 4 && score <= 5) return "game-details__review-score--green";
return "";
};
export function ProfileContent() {
const {
userProfile,
@@ -33,11 +72,23 @@ export function ProfileContent() {
pinnedGames,
getUserLibraryGames,
} = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const { formatDistance } = useDate();
const navigate = useNavigate();
const [statsIndex, setStatsIndex] = useState(0);
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
const statsAnimation = useRef(-1);
const { toggleSection, isPinnedCollapsed } = useSectionCollapse();
const [activeTab, setActiveTab] = useState<"library" | "reviews">("library");
// User reviews state
const [reviews, setReviews] = useState<UserReview[]>([]);
const [reviewsTotalCount, setReviewsTotalCount] = useState(0);
const [isLoadingReviews, setIsLoadingReviews] = useState(false);
const [votingReviews, setVotingReviews] = useState<Set<string>>(new Set());
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [reviewToDelete, setReviewToDelete] = useState<string | null>(null);
const dispatch = useAppDispatch();
@@ -57,6 +108,152 @@ export function ProfileContent() {
}
}, [sortBy, getUserLibraryGames, userProfile]);
useEffect(() => {
if (userProfile?.id) {
fetchUserReviews();
}
}, [userProfile?.id]);
const fetchUserReviews = async () => {
if (!userProfile?.id) return;
setIsLoadingReviews(true);
try {
const response = await window.electron.hydraApi.get<UserReviewsResponse>(
`/users/${userProfile.id}/reviews`,
{ needsAuth: true }
);
setReviews(response.reviews);
setReviewsTotalCount(response.totalCount);
} catch (error) {
console.error("Failed to fetch user reviews:", error);
} finally {
setIsLoadingReviews(false);
}
};
const handleDeleteReview = async (reviewId: string) => {
try {
const reviewToDeleteObj = reviews.find(review => review.id === reviewId);
if (!reviewToDeleteObj) return;
await window.electron.hydraApi.delete(
`/games/${reviewToDeleteObj.game.shop}/${reviewToDeleteObj.game.objectId}/reviews/${reviewId}`
);
// Remove the review from the local state
setReviews(prev => prev.filter(review => review.id !== reviewId));
setReviewsTotalCount(prev => prev - 1);
} catch (error) {
console.error("Failed to delete review:", error);
}
};
const handleDeleteClick = (reviewId: string) => {
setReviewToDelete(reviewId);
setDeleteModalVisible(true);
};
const handleDeleteConfirm = () => {
if (reviewToDelete) {
handleDeleteReview(reviewToDelete);
setReviewToDelete(null);
}
};
const handleDeleteCancel = () => {
setDeleteModalVisible(false);
setReviewToDelete(null);
};
const handleVoteReview = async (reviewId: string, isUpvote: boolean) => {
if (votingReviews.has(reviewId)) return;
setVotingReviews(prev => new Set(prev).add(reviewId));
const review = reviews.find(r => r.id === reviewId);
if (!review) return;
const wasUpvoted = review.hasUpvoted;
const wasDownvoted = review.hasDownvoted;
// Optimistic update
setReviews(prev => prev.map(r => {
if (r.id !== reviewId) return r;
let newUpvotes = r.upvotes;
let newDownvotes = r.downvotes;
let newHasUpvoted = r.hasUpvoted;
let newHasDownvoted = r.hasDownvoted;
if (isUpvote) {
if (wasUpvoted) {
// Remove upvote
newUpvotes--;
newHasUpvoted = false;
} else {
// Add upvote
newUpvotes++;
newHasUpvoted = true;
if (wasDownvoted) {
// Remove downvote if it was downvoted
newDownvotes--;
newHasDownvoted = false;
}
}
} else {
if (wasDownvoted) {
// Remove downvote
newDownvotes--;
newHasDownvoted = false;
} else {
// Add downvote
newDownvotes++;
newHasDownvoted = true;
if (wasUpvoted) {
// Remove upvote if it was upvoted
newUpvotes--;
newHasUpvoted = false;
}
}
}
return {
...r,
upvotes: newUpvotes,
downvotes: newDownvotes,
hasUpvoted: newHasUpvoted,
hasDownvoted: newHasDownvoted,
};
}));
try {
const endpoint = isUpvote ? 'upvote' : 'downvote';
await window.electron.hydraApi.put(
`/games/${review.game.shop}/${review.game.objectId}/reviews/${reviewId}/${endpoint}`
);
} catch (error) {
console.error("Failed to vote on review:", error);
// Rollback optimistic update on error
setReviews(prev => prev.map(r => {
if (r.id !== reviewId) return r;
return {
...r,
upvotes: review.upvotes,
downvotes: review.downvotes,
hasUpvoted: review.hasUpvoted,
hasDownvoted: review.hasDownvoted,
};
}));
} finally {
setVotingReviews(prev => {
const newSet = new Set(prev);
newSet.delete(reviewId);
return newSet;
});
}
};
const handleOnMouseEnterGameCard = () => {
setIsAnimationRunning(false);
};
@@ -113,112 +310,253 @@ export function ProfileContent() {
return (
<section className="profile-content__section">
<div className="profile-content__main">
{hasAnyGames && (
<SortOptions sortBy={sortBy} onSortChange={setSortBy} />
)}
<div className="profile-content__tabs">
<button
type="button"
className={`profile-content__tab ${activeTab === "library" ? "profile-content__tab--active" : ""}`}
onClick={() => setActiveTab("library")}
>
{t("library")}
</button>
<button
type="button"
className={`profile-content__tab ${activeTab === "reviews" ? "profile-content__tab--active" : ""}`}
onClick={() => setActiveTab("reviews")}
>
{t("user_reviews")}
</button>
</div>
{!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>
)}
<div className="profile-content__tab-panels">
<div
className="profile-content__tab-panel"
hidden={activeTab !== "library"}
aria-hidden={activeTab !== "library"}
>
{hasAnyGames && (
<SortOptions sortBy={sortBy} onSortChange={setSortBy} />
)}
{hasAnyGames && (
<div>
{hasPinnedGames && (
<div style={{ marginBottom: "2rem" }}>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
<button
type="button"
className="profile-content__collapse-button"
onClick={() => toggleSection("pinned")}
aria-label={
isPinnedCollapsed
? "Expand pinned section"
: "Collapse pinned section"
}
>
<motion.div
variants={chevronVariants}
animate={isPinnedCollapsed ? "collapsed" : "expanded"}
>
<ChevronRightIcon size={16} />
</motion.div>
</button>
<h2>{t("pinned")}</h2>
<span className="profile-content__section-badge">
{pinnedGames.length}
</span>
</div>
{!hasAnyGames && (
<div className="profile-content__no-games">
<div className="profile-content__telescope-icon">
<TelescopeIcon size={24} />
</div>
<AnimatePresence initial={true} mode="wait">
{!isPinnedCollapsed && (
<motion.div
key="pinned-content"
variants={sectionVariants}
initial="collapsed"
animate="expanded"
exit="collapsed"
layout
>
<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>
</motion.div>
)}
</AnimatePresence>
<h2>{t("no_recent_activity_title")}</h2>
{isMe && <p>{t("no_recent_activity_description")}</p>}
</div>
)}
{hasGames && (
{hasAnyGames && (
<div>
<div className="profile-content__section-header">
<div className="profile-content__section-title-group">
<h2>{t("library")}</h2>
{userStats && (
<span className="profile-content__section-badge">
{numberFormatter.format(userStats.libraryCount)}
</span>
)}
</div>
</div>
{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>
<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>
{/* 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>
<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>
) : reviews.length === 0 ? (
<div className="user-reviews__empty">
<p>{t("no_reviews", "No reviews yet")}</p>
</div>
) : (
<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-game">
<img
src={review.game.iconUrl}
alt={review.game.title}
className="user-reviews__game-icon"
/>
<div className="user-reviews__game-info">
<button
className="user-reviews__game-title user-reviews__game-title--clickable"
onClick={() => navigate(buildGameDetailsPath(review.game))}
>
{review.game.title}
</button>
<div className="user-reviews__review-date">
{formatDistance(new Date(review.createdAt), new Date(), { addSuffix: true })}
</div>
</div>
</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-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)}
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)}
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>
)}
</div>
</div>
</div>
</div>
{shouldShowRightContent && (
@@ -230,6 +568,12 @@ export function ProfileContent() {
<ReportProfile />
</div>
)}
<DeleteReviewModal
visible={deleteModalVisible}
onClose={handleDeleteCancel}
onConfirm={handleDeleteConfirm}
/>
</section>
);
}, [
@@ -242,9 +586,10 @@ export function ProfileContent() {
statsIndex,
libraryGames,
pinnedGames,
isPinnedCollapsed,
toggleSection,
// removed isPinnedCollapsed,
// removed toggleSection,
sortBy,
activeTab,
]);
return (