From 362d6b634e74b1577d0d8cdb8e04788a109c0f19 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Wed, 22 Oct 2025 21:13:05 +0300 Subject: [PATCH] feat: added reviews in profile and tabs --- src/locales/en/translation.json | 5 +- .../src/hooks/use-section-collapse.ts | 3 + .../profile-content/profile-content.scss | 156 ++++- .../profile-content/profile-content.tsx | 551 ++++++++++++++---- 4 files changed, 610 insertions(+), 105 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 46bdb28c..e00501c0 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", diff --git a/src/renderer/src/hooks/use-section-collapse.ts b/src/renderer/src/hooks/use-section-collapse.ts index 7cd22224..59ffd6af 100644 --- a/src/renderer/src/hooks/use-section-collapse.ts +++ b/src/renderer/src/hooks/use-section-collapse.ts @@ -3,12 +3,14 @@ import { useState, useCallback } from "react"; interface SectionCollapseState { pinned: boolean; library: boolean; + reviews: boolean; } export function useSectionCollapse() { const [collapseState, setCollapseState] = useState({ 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, }; } diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss index c3c71d9a..a9aad402 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -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; + } + } diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index d2f1f074..bb4e0025 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -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("playedRecently"); const statsAnimation = useRef(-1); - const { toggleSection, isPinnedCollapsed } = useSectionCollapse(); + + const [activeTab, setActiveTab] = useState<"library" | "reviews">("library"); + + // User reviews state + const [reviews, setReviews] = useState([]); + const [reviewsTotalCount, setReviewsTotalCount] = useState(0); + const [isLoadingReviews, setIsLoadingReviews] = useState(false); + const [votingReviews, setVotingReviews] = useState>(new Set()); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [reviewToDelete, setReviewToDelete] = useState(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( + `/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 (
- {hasAnyGames && ( - - )} +
+ + +
- {!hasAnyGames && ( -
-
- -
-

{t("no_recent_activity_title")}

- {isMe &&

{t("no_recent_activity_description")}

} -
- )} +
+
); }, [ @@ -242,9 +586,10 @@ export function ProfileContent() { statsIndex, libraryGames, pinnedGames, - isPinnedCollapsed, - toggleSection, + // removed isPinnedCollapsed, + // removed toggleSection, sortBy, + activeTab, ]); return (