feat: added review functionality

This commit is contained in:
Moyasee
2025-10-02 00:43:49 +03:00
parent 79498abdb5
commit f08ad361ed
21 changed files with 2003 additions and 6 deletions

View File

@@ -198,6 +198,29 @@
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util it's completed. If Hydra closes before completing, you will lose your progress.",
"achievements": "Achievements",
"achievements_count": "Achievements {{unlockedCount}}/{{achievementsCount}}",
"show_more": "Show more",
"show_less": "Show less",
"reviews": "Reviews",
"leave_a_review": "Leave a Review",
"write_review_placeholder": "Share your thoughts about this game...",
"sort_newest": "Newest",
"sort_by": "Sort by",
"no_reviews_yet": "No reviews yet",
"be_first_to_review": "Be the first to share your thoughts about this game!",
"sort_oldest": "Oldest",
"sort_highest_score": "Highest Score",
"sort_lowest_score": "Lowest Score",
"sort_most_voted": "Most Voted",
"rating": "Rating",
"submit_review": "Submit Review",
"submitting": "Submitting...",
"loading_reviews": "Loading reviews...",
"loading_more_reviews": "Loading more reviews...",
"load_more_reviews": "Load More Reviews",
"youve_played_for_hours": "You've played for {{hours}} hours",
"would_you_recommend_this_game": "Would you like to leave a review to this game?",
"yes": "Yes",
"maybe_later": "Maybe Later",
"cloud_save": "Cloud save",
"cloud_save_description": "Save your progress in the cloud and continue playing on any device",
"backups": "Backups",

View File

@@ -0,0 +1,17 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const checkGameReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
return HydraApi.get(
`/games/${shop}/${objectId}/reviews/check`,
null,
{ needsAuth: true }
);
};
registerEvent("checkGameReview", checkGameReview);

View File

@@ -0,0 +1,18 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const createGameReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
reviewHtml: string,
score: number
) => {
return HydraApi.post(`/games/${shop}/${objectId}/reviews`, {
reviewHtml,
score,
});
};
registerEvent("createGameReview", createGameReview);

View File

@@ -0,0 +1,14 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const deleteReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
reviewId: string
) => {
return HydraApi.delete(`/games/${shop}/${objectId}/reviews/${reviewId}`);
};
registerEvent("deleteReview", deleteReview);

View File

@@ -0,0 +1,26 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const getGameReviews = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
take: number = 20,
skip: number = 0,
sortBy: string = "newest"
) => {
const params = new URLSearchParams({
take: take.toString(),
skip: skip.toString(),
sortBy,
});
return HydraApi.get(
`/games/${shop}/${objectId}/reviews?${params.toString()}`,
null,
{ needsAuth: false }
);
};
registerEvent("getGameReviews", getGameReviews);

View File

@@ -0,0 +1,15 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { GameShop } from "@types";
const voteReview = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
reviewId: string,
voteType: 'upvote' | 'downvote'
) => {
return HydraApi.put(`/games/${shop}/${objectId}/reviews/${reviewId}/${voteType}`, {});
};
registerEvent("voteReview", voteReview);

View File

@@ -11,6 +11,11 @@ import "./catalogue/get-game-stats";
import "./catalogue/get-trending-games";
import "./catalogue/get-publishers";
import "./catalogue/get-developers";
import "./catalogue/create-game-review";
import "./catalogue/get-game-reviews";
import "./catalogue/vote-review";
import "./catalogue/delete-review";
import "./catalogue/check-game-review";
import "./hardware/get-disk-free-space";
import "./hardware/check-folder-write-permission";
import "./library/add-game-to-library";

View File

@@ -77,6 +77,32 @@ contextBridge.exposeInMainWorld("electron", {
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
createGameReview: (
shop: GameShop,
objectId: string,
reviewHtml: string,
score: number
) => ipcRenderer.invoke("createGameReview", shop, objectId, reviewHtml, score),
getGameReviews: (
shop: GameShop,
objectId: string,
take?: number,
skip?: number,
sortBy?: string
) => ipcRenderer.invoke("getGameReviews", shop, objectId, take, skip, sortBy),
voteReview: (
shop: GameShop,
objectId: string,
reviewId: string,
voteType: "upvote" | "downvote"
) => ipcRenderer.invoke("voteReview", shop, objectId, reviewId, voteType),
deleteReview: (
shop: GameShop,
objectId: string,
reviewId: string
) => ipcRenderer.invoke("deleteReview", shop, objectId, reviewId),
checkGameReview: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("checkGameReview", shop, objectId),
onUpdateAchievements: (
objectId: string,
shop: GameShop,

View File

@@ -1,4 +1,4 @@
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
import { DownloadIcon, PeopleIcon, StarIcon } from "@primer/octicons-react";
import type { GameStats } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
@@ -107,6 +107,14 @@ export function GameCard({ game, ...props }: GameCardProps) {
{stats ? numberFormatter.format(stats.playerCount) : "…"}
</span>
</div>
{stats?.averageScore && (
<div className="game-card__specifics-item">
<StarIcon />
<span>
{stats.averageScore.toFixed(1)}
</span>
</div>
)}
</div>
</div>
</div>

View File

@@ -93,6 +93,34 @@ declare global {
) => Promise<HowLongToBeatCategory[] | null>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getTrendingGames: () => Promise<TrendingGame[]>;
createGameReview: (
shop: GameShop,
objectId: string,
reviewHtml: string,
score: number
) => Promise<void>;
getGameReviews: (
shop: GameShop,
objectId: string,
take?: number,
skip?: number,
sortBy?: string
) => Promise<any[]>;
voteReview: (
shop: GameShop,
objectId: string,
reviewId: string,
voteType: 'upvote' | 'downvote'
) => Promise<void>;
deleteReview: (
shop: GameShop,
objectId: string,
reviewId: string
) => Promise<void>;
checkGameReview: (
shop: GameShop,
objectId: string
) => Promise<{ hasReviewed: boolean }>;
onUpdateAchievements: (
objectId: string,
shop: GameShop,

View File

@@ -65,7 +65,7 @@
&__preview {
width: 100%;
padding: globals.$spacing-unit 0;
height: 100%;
height: auto;
display: flex;
position: relative;
overflow-x: auto;

View File

@@ -1,33 +1,45 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { PencilIcon } from "@primer/octicons-react";
import { PencilIcon, TrashIcon, ClockIcon } from "@primer/octicons-react";
import { ThumbsUp, ThumbsDown } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Bold from '@tiptap/extension-bold';
import Italic from '@tiptap/extension-italic';
import Underline from '@tiptap/extension-underline';
import type { GameReview } from "@types";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
import { GallerySlider } from "./gallery-slider/gallery-slider";
import { Sidebar } from "./sidebar/sidebar";
import { EditGameModal } from "./modals";
import { ReviewSortOptions } from "./review-sort-options";
import { ReviewPromptBanner } from "./review-prompt-banner";
import { useTranslation } from "react-i18next";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { AuthPage } from "@shared";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails, useLibrary } from "@renderer/hooks";
import { useUserDetails, useLibrary, useDate } from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./game-details.scss";
export function GameDetailsContent() {
const heroRef = useRef<HTMLDivElement | null>(null);
const navigate = useNavigate();
const { t } = useTranslation("game_details");
const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame } =
const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame, shop } =
useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription();
const { userDetails, hasActiveSubscription } = useUserDetails();
const { updateLibrary } = useLibrary();
const { formatDistance } = useDate();
const { setShowCloudSyncModal, getGameArtifacts } =
useContext(cloudSyncContext);
@@ -80,6 +92,41 @@ export function GameDetailsContent() {
const [backdropOpacity, setBackdropOpacity] = useState(1);
const [showEditGameModal, setShowEditGameModal] = useState(false);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
// Reviews state management
const [reviews, setReviews] = useState<GameReview[]>([]);
const [reviewsLoading, setReviewsLoading] = useState(false);
const [reviewScore, setReviewScore] = useState(5);
const [submittingReview, setSubmittingReview] = useState(false);
const [reviewsSortBy, setReviewsSortBy] = useState("newest");
const [reviewsPage, setReviewsPage] = useState(0);
const [hasMoreReviews, setHasMoreReviews] = useState(true);
const [visibleBlockedReviews, setVisibleBlockedReviews] = useState<Set<string>>(new Set());
const [totalReviewCount, setTotalReviewCount] = useState(0);
const [showReviewForm, setShowReviewForm] = useState(false);
// Review prompt banner state
const [showReviewPrompt, setShowReviewPrompt] = useState(false);
const [hasUserReviewed, setHasUserReviewed] = useState(false);
const [reviewCheckLoading, setReviewCheckLoading] = useState(false);
// Tiptap editor for review input
const editor = useEditor({
extensions: [
StarterKit,
Bold,
Italic,
Underline,
],
content: '',
editorProps: {
attributes: {
class: 'game-details__review-editor',
'data-placeholder': t("write_review_placeholder"),
},
},
});
useEffect(() => {
setBackdropOpacity(1);
@@ -114,6 +161,188 @@ export function GameDetailsContent() {
const isCustomGame = game?.shop === "custom";
// Reviews functions
const checkUserReview = async () => {
if (!objectId || !userDetails) return;
setReviewCheckLoading(true);
try {
const response = await window.electron.checkGameReview(shop, objectId);
const hasReviewed = (response as any)?.hasReviewed || false;
setHasUserReviewed(hasReviewed);
// Show prompt only if user hasn't reviewed and has played the game
if (!hasReviewed && game?.playTimeInMilliseconds && game.playTimeInMilliseconds > 0) {
setShowReviewPrompt(true);
}
} catch (error) {
console.error("Failed to check user review:", error);
} finally {
setReviewCheckLoading(false);
}
};
const loadReviews = async (reset = false) => {
if (!objectId) return;
setReviewsLoading(true);
try {
const skip = reset ? 0 : reviewsPage * 20;
const response = await window.electron.getGameReviews(
shop,
objectId,
20,
skip,
reviewsSortBy
);
// Handle the response structure: { totalCount: number, reviews: Review[] }
const reviewsData = (response as any)?.reviews || [];
const reviewCount = (response as any)?.totalCount || 0;
if (reset) {
setReviews(reviewsData);
setReviewsPage(0);
setTotalReviewCount(reviewCount);
} else {
setReviews(prev => [...prev, ...reviewsData]);
}
setHasMoreReviews(reviewsData.length === 20);
} catch (error) {
console.error("Failed to load reviews:", error);
} finally {
setReviewsLoading(false);
}
};
const handleVoteReview = async (reviewId: string, voteType: 'upvote' | 'downvote') => {
if (!objectId) return;
try {
await window.electron.voteReview(shop, objectId, reviewId, voteType);
// Reload reviews to get updated vote counts
loadReviews(true);
} catch (error) {
console.error(`Failed to ${voteType} review:`, error);
}
};
const handleDeleteReview = async (reviewId: string) => {
if (!objectId) return;
try {
await window.electron.deleteReview(shop, objectId, reviewId);
// Reload reviews after deletion
loadReviews(true);
} catch (error) {
console.error('Failed to delete review:', error);
}
};
const handleSubmitReview = async () => {
console.log("handleSubmitReview called");
console.log("game:", game);
console.log("objectId:", objectId);
const reviewHtml = editor?.getHTML() || '';
console.log("reviewHtml:", reviewHtml);
console.log("reviewScore:", reviewScore);
console.log("submittingReview:", submittingReview);
if (!objectId || !reviewHtml.trim() || submittingReview) {
console.log("Early return - validation failed");
return;
}
console.log("Starting review submission...");
setSubmittingReview(true);
try {
console.log("Calling window.electron.createGameReview...");
await window.electron.createGameReview(
shop,
objectId,
reviewHtml,
reviewScore
);
console.log("Review submitted successfully");
editor?.commands.clearContent();
setReviewScore(5);
await loadReviews(true); // Reload reviews after submission
setShowReviewForm(false); // Hide the review form after successful submission
setShowReviewPrompt(false); // Hide the prompt banner
setHasUserReviewed(true); // Update the review status
} catch (error) {
console.error("Failed to submit review:", error);
} finally {
setSubmittingReview(false);
console.log("Review submission completed");
}
};
// Review prompt banner handlers
const handleReviewPromptYes = () => {
setShowReviewPrompt(false);
setShowReviewForm(true);
// Scroll to review form
setTimeout(() => {
const reviewFormElement = document.querySelector('.game-details__review-form');
if (reviewFormElement) {
reviewFormElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}, 100);
};
const handleReviewPromptLater = () => {
setShowReviewPrompt(false);
};
const handleSortChange = (newSortBy: string) => {
setReviewsSortBy(newSortBy);
setReviewsPage(0);
setHasMoreReviews(true);
loadReviews(true);
};
const toggleBlockedReview = (reviewId: string) => {
setVisibleBlockedReviews(prev => {
const newSet = new Set(prev);
if (newSet.has(reviewId)) {
newSet.delete(reviewId);
} else {
newSet.add(reviewId);
}
return newSet;
});
};
const loadMoreReviews = () => {
if (!reviewsLoading && hasMoreReviews) {
setReviewsPage(prev => prev + 1);
loadReviews(false);
}
};
// Load reviews when component mounts or sort changes
useEffect(() => {
if (objectId && (game || shop)) {
loadReviews(true);
checkUserReview(); // Check if user has reviewed this game
}
}, [game, shop, objectId, reviewsSortBy, userDetails]);
// Load more reviews when page changes
useEffect(() => {
if (reviewsPage > 0) {
loadReviews(false);
}
}, [reviewsPage]);
// Helper function to get image with custom asset priority
const getImageWithCustomPriority = (
customUrl: string | null | undefined,
@@ -227,6 +456,14 @@ export function GameDetailsContent() {
<div className="game-details__description-container">
<div className="game-details__description-content">
{/* Review Prompt Banner */}
{showReviewPrompt && userDetails && game?.playTimeInMilliseconds && !hasUserReviewed && !reviewCheckLoading && (
<ReviewPromptBanner
onYesClick={handleReviewPromptYes}
onLaterClick={handleReviewPromptLater}
/>
)}
<DescriptionHeader />
<GallerySlider />
@@ -234,8 +471,237 @@ export function GameDetailsContent() {
dangerouslySetInnerHTML={{
__html: aboutTheGame,
}}
className="game-details__description"
className={`game-details__description ${
isDescriptionExpanded ? 'game-details__description--expanded' : 'game-details__description--collapsed'
}`}
/>
{aboutTheGame && aboutTheGame.length > 500 && (
<button
type="button"
className="game-details__description-toggle"
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
>
{isDescriptionExpanded ? t("show_less") : t("show_more")}
</button>
)}
<div className="game-details__reviews-section">
{showReviewForm && (
<>
<div className="game-details__reviews-header">
<h3 className="game-details__reviews-title">{t("leave_a_review")}</h3>
</div>
<div className="game-details__review-form">
<div className="game-details__review-input-container">
<EditorContent
editor={editor}
className="game-details__review-input"
/>
<div className="game-details__review-input-bottom">
<div className="game-details__review-editor-toolbar">
<button
type="button"
onClick={() => editor?.chain().focus().toggleBold().run()}
className={`game-details__editor-button ${editor?.isActive('bold') ? 'is-active' : ''}`}
disabled={!editor}
>
<strong>B</strong>
</button>
<button
type="button"
onClick={() => editor?.chain().focus().toggleItalic().run()}
className={`game-details__editor-button ${editor?.isActive('italic') ? 'is-active' : ''}`}
disabled={!editor}
>
<em>I</em>
</button>
<button
type="button"
onClick={() => editor?.chain().focus().toggleUnderline().run()}
className={`game-details__editor-button ${editor?.isActive('underline') ? 'is-active' : ''}`}
disabled={!editor}
>
<u>U</u>
</button>
</div>
<button
className="game-details__review-submit-button"
onClick={handleSubmitReview}
disabled={!editor?.getHTML().trim() || submittingReview}
>
{submittingReview ? t("submitting") : t("submit_review")}
</button>
</div>
</div>
<div className="game-details__review-form-bottom">
<div className="game-details__review-score-container">
<label className="game-details__review-score-label">
{t("rating")}
</label>
<select
className="game-details__review-score-select"
value={reviewScore}
onChange={(e) => setReviewScore(Number(e.target.value))}
>
<option value={1}>1/10</option>
<option value={2}>2/10</option>
<option value={3}>3/10</option>
<option value={4}>4/10</option>
<option value={5}>5/10</option>
<option value={6}>6/10</option>
<option value={7}>7/10</option>
<option value={8}>8/10</option>
<option value={9}>9/10</option>
<option value={10}>10/10</option>
</select>
</div>
</div>
</div>
</>
)}
{showReviewForm && (
<div className="game-details__reviews-separator"></div>
)}
<div className="game-details__reviews-list">
<div className="game-details__reviews-list-header">
<div className="game-details__reviews-title-group">
<h3 className="game-details__reviews-title">
{t("reviews")}
</h3>
<span className="game-details__reviews-badge">
{totalReviewCount}
</span>
</div>
<ReviewSortOptions
sortBy={reviewsSortBy as any}
onSortChange={handleSortChange}
/>
</div>
{reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-loading">
{t("loading_reviews")}
</div>
)}
{!reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-empty">
<div className="game-details__reviews-empty-icon">📝</div>
<h4 className="game-details__reviews-empty-title">
{t("no_reviews_yet")}
</h4>
<p className="game-details__reviews-empty-message">
{t("be_first_to_review")}
</p>
</div>
)}
{reviews.map((review, index) => (
<div key={index} className="game-details__review-item">
{review.isBlocked && !visibleBlockedReviews.has(review.id) ? (
<div className="game-details__blocked-review-simple">
Review from blocked user
<button
className="game-details__blocked-review-show-link"
onClick={() => toggleBlockedReview(review.id)}
>
Show
</button>
</div>
) : (
<>
<div className="game-details__review-header">
<div className="game-details__review-user">
{review.user?.profileImageUrl && (
<img
src={review.user.profileImageUrl}
alt={review.user.displayName || 'User'}
className="game-details__review-avatar"
/>
)}
<div className="game-details__review-user-info">
<div
className="game-details__review-display-name game-details__review-display-name--clickable"
onClick={() => review.user?.id && navigate(`/profile/${review.user.id}`)}
>
{review.user?.displayName || 'Anonymous'}
</div>
<div className="game-details__review-date">
<ClockIcon size={12} />
{formatDistance(new Date(review.createdAt), new Date(), { addSuffix: true })}
</div>
</div>
</div>
<div className="game-details__review-score">
{review.score}/10
</div>
</div>
<div
className="game-details__review-content"
dangerouslySetInnerHTML={{ __html: review.reviewHtml }}
/>
<div className="game-details__review-actions">
<div className="game-details__review-votes">
<button
className={`game-details__vote-button game-details__vote-button--upvote ${review.hasUpvoted ? 'game-details__vote-button--active' : ''}`}
onClick={() => handleVoteReview(review.id, 'upvote')}
>
<ThumbsUp size={16} />
<span>{review.upvotes || 0}</span>
</button>
<button
className={`game-details__vote-button game-details__vote-button--downvote ${review.hasDownvoted ? 'game-details__vote-button--active' : ''}`}
onClick={() => handleVoteReview(review.id, 'downvote')}
>
<ThumbsDown size={16} />
<span>{review.downvotes || 0}</span>
</button>
</div>
{userDetails?.id === review.user?.id && (
<button
className="game-details__delete-review-button"
onClick={() => handleDeleteReview(review.id)}
title={t("delete_review")}
>
<TrashIcon size={16} />
</button>
)}
{review.isBlocked && visibleBlockedReviews.has(review.id) && (
<button
className="game-details__blocked-review-hide-link"
onClick={() => toggleBlockedReview(review.id)}
>
Hide
</button>
)}
</div>
</>
)}
</div>
))}
{hasMoreReviews && !reviewsLoading && (
<button
className="game-details__load-more-reviews"
onClick={loadMoreReviews}
>
{t("load_more_reviews")}
</button>
)}
{reviewsLoading && reviews.length > 0 && (
<div className="game-details__reviews-loading">
{t("loading_more_reviews")}
</div>
)}
</div>
</div>
</div>
{game?.shop !== "custom" && <Sidebar />}

View File

@@ -35,6 +35,7 @@ export function GameDetailsSkeleton() {
))}
<Skeleton className="game-details__hero-image-skeleton" />
<Skeleton />
<Skeleton width={120} height={36} className="game-details__description-toggle" />
</div>
</div>
<div className="content-sidebar">

View File

@@ -27,6 +27,418 @@ $hero-height: 300px;
}
}
&__review-form {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
margin-bottom: calc(globals.$spacing-unit * 3);
padding: calc(globals.$spacing-unit * 2);
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
&__review-form-controls {
display: flex;
gap: calc(globals.$spacing-unit * 2);
align-items: flex-end;
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
gap: calc(globals.$spacing-unit * 1.5);
}
}
&__review-form-bottom {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: calc(globals.$spacing-unit * 2);
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
gap: calc(globals.$spacing-unit * 1.5);
}
}
&__review-score-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.75);
min-width: 120px;
}
&__review-score-label {
display: block;
font-size: globals.$body-font-size;
color: globals.$body-color;
}
&__review-score-select {
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
border-radius: 4px;
padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1);
color: globals.$body-color;
font-size: globals.$body-font-size;
font-family: inherit;
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
&:focus {
outline: none;
background-color: rgba(255, 255, 255, 0.08);
border-color: globals.$brand-teal;
}
&:hover {
border-color: rgba(255, 255, 255, 0.15);
}
option {
background-color: globals.$dark-background-color;
color: globals.$body-color;
}
}
&__reviews-sort {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.75);
min-width: 150px;
}
&__reviews-sort-label {
display: block;
font-size: globals.$body-font-size;
color: globals.$body-color;
}
&__reviews-sort-select {
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
border-radius: 4px;
padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1);
color: globals.$body-color;
font-size: globals.$body-font-size;
font-family: inherit;
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
&:focus {
outline: none;
background-color: rgba(255, 255, 255, 0.08);
border-color: globals.$brand-teal;
}
&:hover {
border-color: rgba(255, 255, 255, 0.15);
}
option {
background-color: globals.$dark-background-color;
color: globals.$body-color;
}
}
&__review-submit-button {
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
color: globals.$body-color;
padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1.5);
border-radius: 6px;
cursor: pointer;
font-size: globals.$small-font-size;
font-family: inherit;
transition: all ease 0.2s;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
&:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
}
&:active {
opacity: 0.9;
}
&:disabled {
background: rgba(255, 255, 255, 0.1);
cursor: not-allowed;
color: rgba(255, 255, 255, 0.5);
}
}
&__reviews-list {
margin-top: calc(globals.$spacing-unit * 3);
}
&__reviews-separator {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: calc(globals.$spacing-unit * 3) 0;
width: 100%;
}
&__reviews-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 2);
padding-bottom: calc(globals.$spacing-unit * 1);
}
&__reviews-empty {
text-align: center;
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2);
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__reviews-empty-icon {
font-size: 48px;
margin-bottom: calc(globals.$spacing-unit * 2);
opacity: 0.6;
}
&__reviews-empty-title {
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
margin: 0 0 calc(globals.$spacing-unit * 1) 0;
}
&__reviews-empty-message {
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
margin: 0;
line-height: 1.4;
}
&__review-item {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: calc(globals.$spacing-unit * 2);
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
&__review-user {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
}
&__review-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(255, 255, 255, 0.1);
}
&__review-user-info {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.25);
}
&__review-display-name {
color: rgba(255, 255, 255, 0.9);
font-size: globals.$small-font-size;
font-weight: 600;
&--clickable {
cursor: pointer;
transition: color 0.2s ease;
&:hover {
text-decoration: underline;
}
}
}
&__review-actions {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
&__review-votes {
display: flex;
gap: 12px;
}
&__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);
}
&--upvote:hover {
color: #4caf50;
border-color: #4caf50;
}
&--downvote:hover {
color: #f44336;
border-color: #f44336;
}
&--active {
&.game-details__vote-button--upvote {
svg {
fill: white;
}
}
&.game-details__vote-button--downvote {
svg {
fill: white;
}
}
}
span {
font-weight: 500;
}
}
&__delete-review-button {
display: flex;
align-items: center;
justify-content: center;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 6px;
padding: 6px;
color: #f44336;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(244, 67, 54, 0.2);
border-color: #f44336;
color: #ff5722;
}
}
&__blocked-review-simple {
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
}
&__blocked-review-show-link {
background: none;
border: none;
color: #ffc107;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color 0.2s ease;
&:hover {
color: #ffeb3b;
}
}
&__blocked-review-hide-link {
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color 0.2s ease;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
}
&__review-score {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.9);
padding: calc(globals.$spacing-unit * 0.5) calc(globals.$spacing-unit * 1);
border-radius: 4px;
font-size: globals.$small-font-size;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.15);
}
&__review-date {
display: flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
}
&__review-content {
color: globals.$body-color;
line-height: 1.5;
}
&__reviews-loading {
text-align: center;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit * 2);
}
&__load-more-reviews {
background: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
color: globals.$body-color;
padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 2);
border-radius: 4px;
cursor: pointer;
font-size: globals.$body-font-size;
font-family: inherit;
transition: all 0.2s ease;
width: 100%;
margin-top: calc(globals.$spacing-unit * 2);
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: globals.$brand-teal;
}
}
&__hero {
width: 100%;
height: $hero-height;
@@ -192,6 +604,8 @@ $hero-height: 300px;
min-width: 0;
flex: 1;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
&__description {
@@ -203,6 +617,7 @@ $hero-height: 300px;
margin-right: auto;
overflow-x: auto;
min-height: auto;
transition: max-height 0.3s ease-in-out;
@media (min-width: 1280px) {
width: 60%;
@@ -212,6 +627,27 @@ $hero-height: 300px;
width: 50%;
}
&--collapsed {
max-height: 300px;
overflow: hidden;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: linear-gradient(transparent, globals.$background-color);
pointer-events: none;
}
}
&--expanded {
max-height: none;
}
img,
video {
border-radius: 5px;
@@ -237,6 +673,24 @@ $hero-height: 300px;
}
}
&__description-toggle {
background: none;
border: 1px solid globals.$border-color;
color: globals.$body-color;
padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1.5);
border-radius: 4px;
cursor: pointer;
font-size: globals.$body-font-size;
margin-top: calc(globals.$spacing-unit * 1.5);
transition: all 0.2s ease;
align-self: center;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: globals.$brand-teal;
}
}
&__description-skeleton {
display: flex;
flex-direction: column;
@@ -367,4 +821,206 @@ $hero-height: 300px;
flex: 1;
transition: opacity 0.2s ease;
}
&__reviews-section {
margin-top: calc(globals.$spacing-unit * 3);
padding-top: calc(globals.$spacing-unit * 3);
border-top: 1px solid rgba(255, 255, 255, 0.1);
width: 100%;
margin-left: auto;
margin-right: auto;
@media (min-width: 1280px) {
width: 60%;
}
@media (min-width: 1536px) {
width: 50%;
}
}
&__reviews-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit * 2);
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit * 1.5);
}
}
&__reviews-title {
font-size: 1.25rem;
font-weight: 600;
color: globals.$muted-color;
margin: 0;
}
&__reviews-title-group {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex: 1;
}
&__reviews-badge {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
flex-shrink: 0;
}
&__leave-review-cta {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1.5);
background: linear-gradient(135deg, globals.$brand-teal, globals.$brand-blue);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: calc(globals.$spacing-unit);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(globals.$brand-teal, 0.3);
}
&:active {
transform: translateY(0);
}
svg {
flex-shrink: 0;
}
}
&__review-input-container {
width: 100%;
position: relative;
}
&__review-input-bottom {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
z-index: 10;
}
&__review-editor-toolbar {
display: flex;
gap: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 4px;
backdrop-filter: blur(10px);
background: rgba(0, 0, 0, 0.3);
}
&__editor-button {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 3px;
color: globals.$body-color;
padding: 4px 6px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.2s ease;
min-width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.is-active {
background: globals.$brand-teal;
border-color: globals.$brand-teal;
color: white;
}
}
&__review-input {
width: 100%;
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
border-radius: 6px;
padding: calc(globals.$spacing-unit * 1.5);
padding-bottom: calc(globals.$spacing-unit * 3.5);
color: globals.$body-color;
font-size: globals.$body-font-size;
font-family: inherit;
line-height: 1.5;
min-height: 100px;
transition: border-color 0.2s ease, background-color 0.2s ease;
&:focus-within {
outline: none;
background-color: rgba(255, 255, 255, 0.08);
border-color: globals.$brand-teal;
}
&:hover {
border-color: rgba(255, 255, 255, 0.15);
}
.ProseMirror {
outline: none;
min-height: 80px;
&:empty:before {
content: attr(data-placeholder);
color: rgba(208, 209, 215, 0.6);
pointer-events: none;
}
p {
margin: 0 0 8px 0;
&:last-child {
margin-bottom: 0;
}
}
strong {
font-weight: 700;
}
em {
font-style: italic;
}
u {
text-decoration: underline;
}
}
}
}

View File

@@ -0,0 +1,46 @@
@use "../../scss/globals.scss";
.review-prompt-banner {
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
padding: calc(globals.$spacing-unit * 2);
margin-bottom: calc(globals.$spacing-unit * 3);
border: 1px solid rgba(255, 255, 255, 0.05);
&__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(globals.$spacing-unit * 2.5);
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit * 2);
}
}
&__text {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.5);
}
&__playtime {
font-size: globals.$body-font-size;
color: globals.$body-color;
font-weight: 600;
}
&__question {
font-size: globals.$small-font-size;
color: globals.$muted-color;
font-weight: 400;
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
}
}

View File

@@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components";
import "./review-prompt-banner.scss";
interface ReviewPromptBannerProps {
onYesClick: () => void;
onLaterClick: () => void;
}
export function ReviewPromptBanner({
onYesClick,
onLaterClick,
}: ReviewPromptBannerProps) {
const { t } = useTranslation("game_details");
return (
<div className="review-prompt-banner">
<div className="review-prompt-banner__content">
<div className="review-prompt-banner__text">
<span className="review-prompt-banner__playtime">
You've seemed to enjoy this game
</span>
<span className="review-prompt-banner__question">
{t("would_you_recommend_this_game")}
</span>
</div>
<div className="review-prompt-banner__actions">
<Button
theme="primary"
onClick={onYesClick}
>
{t("yes")}
</Button>
<Button
theme="outline"
onClick={onLaterClick}
>
{t("maybe_later")}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
@use "../../scss/globals.scss";
.review-sort-options {
&__container {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit);
}
&__label {
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 400;
}
&__options {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
font-size: 14px;
flex-wrap: wrap;
@media (max-width: 768px) {
gap: calc(globals.$spacing-unit * 0.75);
}
}
&__option {
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
padding: 4px 0;
font-size: 14px;
font-weight: 300;
transition: all ease 0.2s;
display: flex;
align-items: center;
gap: 6px;
&:hover:not(:disabled) {
color: rgba(255, 255, 255, 0.6);
}
&.active {
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
span {
display: inline-block;
@media (max-width: 480px) {
display: none;
}
}
@media (max-width: 480px) {
gap: 0;
}
}
&__separator {
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
@media (max-width: 480px) {
display: none;
}
}
}

View File

@@ -0,0 +1,60 @@
import { CalendarIcon, StarIcon, ThumbsupIcon, ClockIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./review-sort-options.scss";
type ReviewSortOption = "newest" | "oldest" | "score_high" | "score_low" | "most_voted";
interface ReviewSortOptionsProps {
sortBy: ReviewSortOption;
onSortChange: (sortBy: ReviewSortOption) => void;
}
export function ReviewSortOptions({ sortBy, onSortChange }: ReviewSortOptionsProps) {
const { t } = useTranslation("game_details");
return (
<div className="review-sort-options__container">
<div className="review-sort-options__options">
<button
className={`review-sort-options__option ${sortBy === "newest" ? "active" : ""}`}
onClick={() => onSortChange("newest")}
>
<CalendarIcon size={16} />
<span>{t("sort_newest")}</span>
</button>
<span className="review-sort-options__separator">|</span>
<button
className={`review-sort-options__option ${sortBy === "oldest" ? "active" : ""}`}
onClick={() => onSortChange("oldest")}
>
<ClockIcon size={16} />
<span>{t("sort_oldest")}</span>
</button>
<span className="review-sort-options__separator">|</span>
<button
className={`review-sort-options__option ${sortBy === "score_high" ? "active" : ""}`}
onClick={() => onSortChange("score_high")}
>
<StarIcon size={16} />
<span>{t("sort_highest_score")}</span>
</button>
<span className="review-sort-options__separator">|</span>
<button
className={`review-sort-options__option ${sortBy === "score_low" ? "active" : ""}`}
onClick={() => onSortChange("score_low")}
>
<StarIcon size={16} />
<span>{t("sort_lowest_score")}</span>
</button>
<span className="review-sort-options__separator">|</span>
<button
className={`review-sort-options__option ${sortBy === "most_voted" ? "active" : ""}`}
onClick={() => onSortChange("most_voted")}
>
<ThumbsupIcon size={16} />
<span>{t("sort_most_voted")}</span>
</button>
</div>
</div>
);
}

View File

@@ -234,6 +234,25 @@ export interface GameStats {
downloadCount: number;
playerCount: number;
assets: ShopAssets | null;
averageScore: number | null;
}
export interface GameReview {
id: string;
reviewHtml: string;
score: number;
createdAt: string;
updatedAt: string;
upvotes: number;
downvotes: number;
isBlocked: boolean;
hasUpvoted: boolean;
hasDownvoted: boolean;
user: {
id: string;
displayName: string;
profileImageUrl: string | null;
} | null;
}
export interface TrendingGame extends ShopAssets {