mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-29 13:51:02 +00:00
Feat: added rating showing in game card in categories, fixed maybe later button, changed empty state, fixed copy issue, added karma showing, added remove review text, added empty state for games with no reviews, fixed sorting buttons, fixed shift in the page
This commit is contained in:
@@ -72,7 +72,12 @@
|
||||
display: flex;
|
||||
color: globals.$muted-color;
|
||||
font-size: 12px;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
|
||||
// Ensure star rating is properly aligned
|
||||
.star-rating {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-container {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DownloadIcon, PeopleIcon, StarIcon } from "@primer/octicons-react";
|
||||
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
||||
import type { GameStats } from "@types";
|
||||
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
@@ -7,6 +7,7 @@ import "./game-card.scss";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "../badge/badge";
|
||||
import { StarRating } from "../star-rating/star-rating";
|
||||
import { useCallback, useState, useMemo } from "react";
|
||||
import { useFormat, useRepacks } from "@renderer/hooks";
|
||||
|
||||
@@ -107,12 +108,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 className="game-card__specifics-item">
|
||||
<StarRating
|
||||
rating={stats?.averageScore || null}
|
||||
size={14}
|
||||
showCalculating={!!(stats && stats.averageScore === null)}
|
||||
calculatingText={t("calculating")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,3 +18,4 @@ export * from "./debrid-badge/debrid-badge";
|
||||
export * from "./context-menu/context-menu";
|
||||
export * from "./game-context-menu/game-context-menu";
|
||||
export * from "./game-context-menu/use-game-actions";
|
||||
export * from "./star-rating/star-rating";
|
||||
|
||||
1
src/renderer/src/components/star-rating/index.ts
Normal file
1
src/renderer/src/components/star-rating/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./star-rating";
|
||||
54
src/renderer/src/components/star-rating/star-rating.scss
Normal file
54
src/renderer/src/components/star-rating/star-rating.scss
Normal file
@@ -0,0 +1,54 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.star-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
&__star {
|
||||
color: globals.$muted-color;
|
||||
transition: color ease 0.2s;
|
||||
|
||||
&--filled {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&--empty {
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&--half {
|
||||
color: #ffffff;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
clip-path: polygon(0 0, 50% 0, 50% 100%, 0 100%);
|
||||
}
|
||||
}
|
||||
|
||||
&__half-star {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&__value {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: globals.$muted-color;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__calculating-text,
|
||||
&__no-rating-text {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&--calculating,
|
||||
&--no-rating {
|
||||
.star-rating__star {
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/renderer/src/components/star-rating/star-rating.tsx
Normal file
64
src/renderer/src/components/star-rating/star-rating.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { StarIcon, StarFillIcon } from "@primer/octicons-react";
|
||||
import "./star-rating.scss";
|
||||
|
||||
export interface StarRatingProps {
|
||||
rating: number | null;
|
||||
maxStars?: number;
|
||||
size?: number;
|
||||
showCalculating?: boolean;
|
||||
calculatingText?: string;
|
||||
}
|
||||
|
||||
export function StarRating({
|
||||
rating,
|
||||
maxStars = 5,
|
||||
size = 12,
|
||||
showCalculating = false,
|
||||
calculatingText = "Calculating"
|
||||
}: StarRatingProps) {
|
||||
if (rating === null && showCalculating) {
|
||||
return (
|
||||
<div className="star-rating star-rating--calculating">
|
||||
<StarIcon size={size} />
|
||||
<span className="star-rating__calculating-text">{calculatingText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rating === null || rating === undefined) {
|
||||
return (
|
||||
<div className="star-rating star-rating--no-rating">
|
||||
<StarIcon size={size} />
|
||||
<span className="star-rating__no-rating-text">…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filledStars = Math.floor(rating);
|
||||
const hasHalfStar = rating % 1 >= 0.5;
|
||||
const emptyStars = maxStars - filledStars - (hasHalfStar ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="star-rating">
|
||||
|
||||
{Array.from({ length: filledStars }, (_, index) => (
|
||||
<StarFillIcon key={`filled-${index}`} size={size} className="star-rating__star star-rating__star--filled" />
|
||||
))}
|
||||
|
||||
|
||||
{hasHalfStar && (
|
||||
<div className="star-rating__half-star" key="half-star">
|
||||
<StarIcon size={size} className="star-rating__star star-rating__star--empty" />
|
||||
<StarFillIcon size={size} className="star-rating__star star-rating__star--half" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{Array.from({ length: emptyStars }, (_, index) => (
|
||||
<StarIcon key={`empty-${index}`} size={size} className="star-rating__star star-rating__star--empty" />
|
||||
))}
|
||||
|
||||
<span className="star-rating__value">{rating.toFixed(1)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PencilIcon, TrashIcon, ClockIcon } from "@primer/octicons-react";
|
||||
import { ThumbsUp, ThumbsDown } from "lucide-react";
|
||||
import { PencilIcon, TrashIcon, ClockIcon, NoteIcon } from "@primer/octicons-react";
|
||||
import { ThumbsUp, ThumbsDown, Star } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
@@ -20,28 +20,24 @@ import { useTranslation } from "react-i18next";
|
||||
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
|
||||
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
|
||||
import { useUserDetails, useLibrary, useDate } from "@renderer/hooks";
|
||||
import { useUserDetails, useLibrary, useDate, useToast } from "@renderer/hooks";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import "./game-details.scss";
|
||||
|
||||
// Helper function to get score color class
|
||||
const getScoreColorClass = (score: number): string => {
|
||||
if (score >= 0 && score <= 3) return "game-details__review-score--red";
|
||||
if (score >= 4 && score <= 6) return "game-details__review-score--yellow";
|
||||
if (score >= 7 && score <= 10) return "game-details__review-score--green";
|
||||
if (score >= 1 && score <= 2) return "game-details__review-score--red";
|
||||
if (score >= 3 && score <= 3) return "game-details__review-score--yellow";
|
||||
if (score >= 4 && score <= 5) return "game-details__review-score--green";
|
||||
return "";
|
||||
};
|
||||
|
||||
// Helper function to process media elements for responsive display
|
||||
const processMediaElements = (document: Document) => {
|
||||
const $images = Array.from(document.querySelectorAll("img"));
|
||||
$images.forEach(($image) => {
|
||||
$image.loading = "lazy";
|
||||
// Remove any inline width/height styles that might cause overflow
|
||||
$image.removeAttribute("width");
|
||||
$image.removeAttribute("height");
|
||||
$image.removeAttribute("style");
|
||||
// Set max-width to prevent overflow
|
||||
$image.style.maxWidth = "100%";
|
||||
$image.style.width = "auto";
|
||||
$image.style.height = "auto";
|
||||
@@ -51,11 +47,9 @@ const processMediaElements = (document: Document) => {
|
||||
// Handle videos the same way
|
||||
const $videos = Array.from(document.querySelectorAll("video"));
|
||||
$videos.forEach(($video) => {
|
||||
// Remove any inline width/height styles that might cause overflow
|
||||
$video.removeAttribute("width");
|
||||
$video.removeAttribute("height");
|
||||
$video.removeAttribute("style");
|
||||
// Set max-width to prevent overflow
|
||||
$video.style.maxWidth = "100%";
|
||||
$video.style.width = "auto";
|
||||
$video.style.height = "auto";
|
||||
@@ -63,16 +57,26 @@ const processMediaElements = (document: Document) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to get score color class for select element
|
||||
const getSelectScoreColorClass = (score: number): string => {
|
||||
if (score >= 0 && score <= 3) return "game-details__review-score-select--red";
|
||||
if (score >= 4 && score <= 7)
|
||||
if (score >= 1 && score <= 2) return "game-details__review-score-select--red";
|
||||
if (score >= 3 && score <= 3)
|
||||
return "game-details__review-score-select--yellow";
|
||||
if (score >= 8 && score <= 10)
|
||||
if (score >= 4 && score <= 5)
|
||||
return "game-details__review-score-select--green";
|
||||
return "";
|
||||
};
|
||||
|
||||
const getRatingText = (score: number, t: (key: string) => string): string => {
|
||||
switch (score) {
|
||||
case 1: return t("rating_very_negative");
|
||||
case 2: return t("rating_negative");
|
||||
case 3: return t("rating_neutral");
|
||||
case 4: return t("rating_positive");
|
||||
case 5: return t("rating_very_positive");
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
export function GameDetailsContent() {
|
||||
const heroRef = useRef<HTMLDivElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -93,6 +97,7 @@ export function GameDetailsContent() {
|
||||
const { userDetails, hasActiveSubscription } = useUserDetails();
|
||||
const { updateLibrary } = useLibrary();
|
||||
const { formatDistance } = useDate();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const { setShowCloudSyncModal, getGameArtifacts } =
|
||||
useContext(cloudSyncContext);
|
||||
@@ -139,16 +144,13 @@ export function GameDetailsContent() {
|
||||
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.configure({
|
||||
// Disable link extension to prevent automatic link rendering and XSS
|
||||
link: false,
|
||||
}),
|
||||
],
|
||||
@@ -159,14 +161,26 @@ export function GameDetailsContent() {
|
||||
"data-placeholder": t("write_review_placeholder"),
|
||||
},
|
||||
handlePaste: (view, event) => {
|
||||
// Strip formatting from pasted content to prevent overflow issues
|
||||
const text = event.clipboardData?.getData("text/plain") || "";
|
||||
const htmlContent = event.clipboardData?.getData("text/html") || "";
|
||||
const plainText = event.clipboardData?.getData("text/plain") || "";
|
||||
|
||||
const currentText = view.state.doc.textContent;
|
||||
const remainingChars = MAX_REVIEW_CHARS - currentText.length;
|
||||
|
||||
if (text && remainingChars > 0) {
|
||||
if ((htmlContent || plainText) && remainingChars > 0) {
|
||||
event.preventDefault();
|
||||
const truncatedText = text.slice(0, remainingChars);
|
||||
|
||||
if (htmlContent) {
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
const textLength = tempDiv.textContent?.length || 0;
|
||||
|
||||
if (textLength <= remainingChars) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const truncatedText = plainText.slice(0, remainingChars);
|
||||
view.dispatch(view.state.tr.insertText(truncatedText));
|
||||
return true;
|
||||
}
|
||||
@@ -177,7 +191,6 @@ export function GameDetailsContent() {
|
||||
const text = editor.getText();
|
||||
setReviewCharCount(text.length);
|
||||
|
||||
// Prevent typing beyond character limit
|
||||
if (text.length > MAX_REVIEW_CHARS) {
|
||||
const truncatedContent = text.slice(0, MAX_REVIEW_CHARS);
|
||||
editor.commands.setContent(truncatedContent);
|
||||
@@ -219,7 +232,6 @@ export function GameDetailsContent() {
|
||||
|
||||
const isCustomGame = game?.shop === "custom";
|
||||
|
||||
// Reviews functions
|
||||
const checkUserReview = async () => {
|
||||
if (!objectId || !userDetails) return;
|
||||
|
||||
@@ -229,11 +241,9 @@ export function GameDetailsContent() {
|
||||
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
|
||||
!sessionStorage.getItem(`reviewPromptDismissed_${objectId}`)
|
||||
) {
|
||||
setShowReviewPrompt(true);
|
||||
}
|
||||
@@ -258,7 +268,6 @@ export function GameDetailsContent() {
|
||||
reviewsSortBy
|
||||
);
|
||||
|
||||
// Handle the response structure: { totalCount: number, reviews: Review[] }
|
||||
const reviewsData = (response as any)?.reviews || [];
|
||||
const reviewCount = (response as any)?.totalCount || 0;
|
||||
|
||||
@@ -286,7 +295,6 @@ export function GameDetailsContent() {
|
||||
|
||||
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);
|
||||
@@ -303,40 +311,40 @@ export function GameDetailsContent() {
|
||||
|
||||
try {
|
||||
await window.electron.deleteReview(shop, objectId, reviewToDelete);
|
||||
// Reload reviews after deletion
|
||||
loadReviews(true);
|
||||
setShowDeleteReviewModal(false);
|
||||
setReviewToDelete(null);
|
||||
showSuccessToast(t("review_deleted_successfully"));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete review:", error);
|
||||
showErrorToast(t("review_deletion_failed"));
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
const reviewText = editor?.getText() || "";
|
||||
|
||||
if (
|
||||
!objectId ||
|
||||
!reviewHtml.trim() ||
|
||||
reviewScore === null ||
|
||||
submittingReview ||
|
||||
reviewCharCount > MAX_REVIEW_CHARS
|
||||
) {
|
||||
console.log("Early return - validation failed");
|
||||
if (!objectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reviewText.trim()) {
|
||||
showErrorToast(t("review_cannot_be_empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (submittingReview || reviewCharCount > MAX_REVIEW_CHARS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reviewScore === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Starting review submission...");
|
||||
setSubmittingReview(true);
|
||||
|
||||
try {
|
||||
console.log("Calling window.electron.createGameReview...");
|
||||
await window.electron.createGameReview(
|
||||
shop,
|
||||
objectId,
|
||||
@@ -344,27 +352,25 @@ export function GameDetailsContent() {
|
||||
reviewScore
|
||||
);
|
||||
|
||||
console.log("Review submitted successfully");
|
||||
editor?.commands.clearContent();
|
||||
setReviewScore(null);
|
||||
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
|
||||
showSuccessToast(t("review_submitted_successfully"));
|
||||
|
||||
await loadReviews(true);
|
||||
setShowReviewForm(false);
|
||||
setShowReviewPrompt(false);
|
||||
setHasUserReviewed(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to submit review:", error);
|
||||
showErrorToast(t("review_submission_failed"));
|
||||
} 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"
|
||||
@@ -380,13 +386,18 @@ export function GameDetailsContent() {
|
||||
|
||||
const handleReviewPromptLater = () => {
|
||||
setShowReviewPrompt(false);
|
||||
if (objectId) {
|
||||
sessionStorage.setItem(`reviewPromptDismissed_${objectId}`, 'true');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortChange = (newSortBy: string) => {
|
||||
setReviewsSortBy(newSortBy);
|
||||
setReviewsPage(0);
|
||||
setHasMoreReviews(true);
|
||||
loadReviews(true);
|
||||
if (newSortBy !== reviewsSortBy) {
|
||||
setReviewsSortBy(newSortBy);
|
||||
setReviewsPage(0);
|
||||
setHasMoreReviews(true);
|
||||
loadReviews(true);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBlockedReview = (reviewId: string) => {
|
||||
@@ -408,22 +419,19 @@ export function GameDetailsContent() {
|
||||
}
|
||||
};
|
||||
|
||||
// Load reviews when component mounts or sort changes
|
||||
useEffect(() => {
|
||||
if (objectId && (game || shop)) {
|
||||
loadReviews(true);
|
||||
checkUserReview(); // Check if user has reviewed this game
|
||||
checkUserReview();
|
||||
}
|
||||
}, [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,
|
||||
originalUrl: string | null | undefined,
|
||||
@@ -540,7 +548,6 @@ export function GameDetailsContent() {
|
||||
{game?.shop !== "custom" &&
|
||||
showReviewPrompt &&
|
||||
userDetails &&
|
||||
game?.playTimeInMilliseconds &&
|
||||
!hasUserReviewed &&
|
||||
!reviewCheckLoading && (
|
||||
<ReviewPromptBanner
|
||||
@@ -637,36 +644,30 @@ export function GameDetailsContent() {
|
||||
|
||||
<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 ${
|
||||
reviewScore
|
||||
? getSelectScoreColorClass(reviewScore)
|
||||
: ""
|
||||
}`}
|
||||
value={reviewScore || ""}
|
||||
onChange={(e) =>
|
||||
setReviewScore(
|
||||
e.target.value ? Number(e.target.value) : null
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("select_rating")}
|
||||
</option>
|
||||
<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 className="game-details__star-rating">
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<button
|
||||
key={starValue}
|
||||
type="button"
|
||||
className={`game-details__star ${
|
||||
reviewScore && starValue <= reviewScore
|
||||
? "game-details__star--filled"
|
||||
: "game-details__star--empty"
|
||||
} ${
|
||||
reviewScore && starValue <= reviewScore
|
||||
? getSelectScoreColorClass(reviewScore)
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setReviewScore(starValue)}
|
||||
title={getRatingText(starValue, t)}
|
||||
>
|
||||
<Star
|
||||
size={24}
|
||||
fill={reviewScore && starValue <= reviewScore ? "currentColor" : "none"}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -683,6 +684,7 @@ export function GameDetailsContent() {
|
||||
? t("submitting")
|
||||
: t("submit_review")}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -716,7 +718,9 @@ export function GameDetailsContent() {
|
||||
|
||||
{!reviewsLoading && reviews.length === 0 && (
|
||||
<div className="game-details__reviews-empty">
|
||||
<div className="game-details__reviews-empty-icon">📝</div>
|
||||
<div className="game-details__reviews-empty-icon">
|
||||
<NoteIcon size={48} />
|
||||
</div>
|
||||
<h4 className="game-details__reviews-empty-title">
|
||||
{t("no_reviews_yet")}
|
||||
</h4>
|
||||
@@ -770,10 +774,23 @@ export function GameDetailsContent() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`game-details__review-score ${getScoreColorClass(review.score)}`}
|
||||
>
|
||||
{review.score}/10
|
||||
<div className="game-details__review-score-stars" title={getRatingText(review.score, t)}>
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<Star
|
||||
key={starValue}
|
||||
size={20}
|
||||
fill={starValue <= review.score ? "currentColor" : "none"}
|
||||
className={`game-details__review-star ${
|
||||
starValue <= review.score
|
||||
? "game-details__review-star--filled"
|
||||
: "game-details__review-star--empty"
|
||||
} ${
|
||||
starValue <= review.score
|
||||
? getScoreColorClass(review.score)
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -842,6 +859,7 @@ export function GameDetailsContent() {
|
||||
title={t("delete_review")}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
<span>{t("remove_review")}</span>
|
||||
</button>
|
||||
)}
|
||||
{review.isBlocked &&
|
||||
|
||||
@@ -55,10 +55,31 @@ $hero-height: 300px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__review-message {
|
||||
padding: calc(globals.$spacing-unit * 1);
|
||||
border-radius: 4px;
|
||||
font-size: globals.$small-font-size;
|
||||
font-weight: 500;
|
||||
margin-top: calc(globals.$spacing-unit * 1);
|
||||
border: 1px solid;
|
||||
|
||||
&--success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #86efac;
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #fca5a5;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&__review-score-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__review-score-label {
|
||||
@@ -104,6 +125,59 @@ $hero-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
&__star-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__star {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&--filled {
|
||||
color: #ffffff;
|
||||
|
||||
&.game-details__review-score-select--red {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
&.game-details__review-score-select--yellow {
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
&.game-details__review-score-select--green {
|
||||
color: #27ae60;
|
||||
}
|
||||
}
|
||||
|
||||
&--empty {
|
||||
color: #666666;
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
&__reviews-sort {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -191,16 +265,13 @@ $hero-height: 300px;
|
||||
&__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;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
&__reviews-empty-title {
|
||||
@@ -341,6 +412,7 @@ $hero-height: 300px;
|
||||
color: #f44336;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
gap: 6px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
@@ -387,32 +459,39 @@ $hero-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
&__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-score-stars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
// Color variants based on score
|
||||
&--red {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #fca5a5;
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
&__review-star {
|
||||
color: #666666;
|
||||
transition: color 0.2s ease;
|
||||
cursor: default;
|
||||
|
||||
&--filled {
|
||||
color: #ffffff;
|
||||
|
||||
&.game-details__review-score--red {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
&.game-details__review-score--yellow {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
&.game-details__review-score--green {
|
||||
color: #86efac;
|
||||
}
|
||||
}
|
||||
|
||||
&--yellow {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #fcd34d;
|
||||
border-color: rgba(245, 158, 11, 0.4);
|
||||
&--empty {
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #86efac;
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 8px;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
margin-bottom: calc(globals.$spacing-unit * 3);
|
||||
margin-bottom: calc(globals.$spacing-unit * 1.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
||||
&__content {
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ReviewPromptBanner({
|
||||
<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
|
||||
{t("you_seemed_to_enjoy_this_game")}
|
||||
</span>
|
||||
<span className="review-prompt-banner__question">
|
||||
{t("would_you_recommend_this_game")}
|
||||
|
||||
@@ -35,7 +35,9 @@ export function ReviewSortOptions({
|
||||
};
|
||||
|
||||
const handleMostVotedClick = () => {
|
||||
onSortChange("most_voted");
|
||||
if (sortBy !== "most_voted") {
|
||||
onSortChange("most_voted");
|
||||
}
|
||||
};
|
||||
|
||||
const isDateActive = sortBy === "newest" || sortBy === "oldest";
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
UserAchievement,
|
||||
} from "@types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Link } from "@renderer/components";
|
||||
import { Button, Link, StarRating } from "@renderer/components";
|
||||
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { useDate, useFormat, useUserDetails } from "@renderer/hooks";
|
||||
@@ -227,15 +227,18 @@ export function Sidebar() {
|
||||
<p>{numberFormatter.format(stats?.playerCount)}</p>
|
||||
</div>
|
||||
|
||||
{stats?.averageScore && (
|
||||
<div className="stats__category">
|
||||
<p className="stats__category-title">
|
||||
<StarIcon size={18} />
|
||||
{t("rating_count")}
|
||||
</p>
|
||||
<p>{stats.averageScore.toFixed(1)}/10</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="stats__category">
|
||||
<p className="stats__category-title">
|
||||
<StarIcon size={18} />
|
||||
{t("rating_count")}
|
||||
</p>
|
||||
<StarRating
|
||||
rating={stats?.averageScore || 0}
|
||||
size={16}
|
||||
showCalculating={!!(stats && stats.averageScore === null)}
|
||||
calculatingText={t("calculating", { ns: "game_card" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
@@ -6,13 +6,16 @@ import { Award } from "lucide-react";
|
||||
import "./user-karma-box.scss";
|
||||
|
||||
export function UserKarmaBox() {
|
||||
const { isMe } = useContext(userProfileContext);
|
||||
const { isMe, userProfile } = useContext(userProfileContext);
|
||||
const { userDetails } = useUserDetails();
|
||||
const { t } = useTranslation("user_profile");
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
// Only show karma for the current user (me)
|
||||
if (!isMe || !userDetails) return null;
|
||||
// Get karma from userDetails (for current user) or userProfile (for other users)
|
||||
const karma = isMe ? userDetails?.karma : userProfile?.karma;
|
||||
|
||||
// Don't show if karma is not available
|
||||
if (karma === undefined || karma === null) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -24,7 +27,7 @@ export function UserKarmaBox() {
|
||||
<div className="user-karma__content">
|
||||
<div className="user-karma__stats-row">
|
||||
<p className="user-karma__description">
|
||||
<Award size={20} /> {numberFormatter.format(userDetails.karma)}{" "}
|
||||
<Award size={20} /> {numberFormatter.format(karma)}{" "}
|
||||
{t("karma_count")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user