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:
Moyasee
2025-10-05 20:32:41 +03:00
parent 8653e62dce
commit 6667e00c91
16 changed files with 448 additions and 158 deletions

View File

@@ -222,12 +222,22 @@
"sort_most_voted": "Most Voted",
"rating": "Rating",
"rating_stats": "Rating",
"select_rating": "Select Rating",
"rating_very_negative": "Very Negative",
"rating_negative": "Negative",
"rating_neutral": "Neutral",
"rating_positive": "Positive",
"rating_very_positive": "Very Positive",
"submit_review": "Submit Review",
"submitting": "Submitting...",
"review_submitted_successfully": "Review submitted successfully!",
"review_submission_failed": "Failed to submit review. Please try again.",
"review_cannot_be_empty": "Review text field cannot be empty.",
"review_deleted_successfully": "Review deleted successfully.",
"review_deletion_failed": "Failed to delete review. Please try again.",
"loading_reviews": "Loading reviews...",
"loading_more_reviews": "Loading more reviews...",
"load_more_reviews": "Load More Reviews",
"you_seemed_to_enjoy_this_game": "You've seemed to enjoy this game",
"would_you_recommend_this_game": "Would you like to leave a review to this game?",
"yes": "Yes",
"maybe_later": "Maybe Later",
@@ -329,6 +339,7 @@
"filter_by_source": "Filter by source",
"no_repacks_found": "No sources found for this game",
"delete_review": "Delete review",
"remove_review": "Remove Review",
"delete_review_modal_title": "Are you sure you want to delete your review?",
"delete_review_modal_description": "This action cannot be undone.",
"delete_review_modal_delete_button": "Delete",
@@ -548,7 +559,8 @@
"game_card": {
"available_one": "Available",
"available_other": "Available",
"no_downloads": "No downloads available"
"no_downloads": "No downloads available",
"calculating": "Calculating"
},
"binary_not_found_modal": {
"title": "Programs not installed",
@@ -654,7 +666,7 @@
"game_added_to_pinned": "Game added to pinned",
"karma": "Karma",
"karma_count": "karma",
"karma_description": "Earned from positive likes on your reviews"
"karma_description": "Earned from positive likes on reviews"
},
"achievement": {
"achievement_unlocked": "Achievement unlocked",

View File

@@ -189,10 +189,14 @@
"refuse_nsfw_content": "Назад",
"stats": "Статистика",
"player_count": "Активные игроки",
"rating_count": "Рейтинг",
"warning": "Внимание:",
"hydra_needs_to_remain_open": "Для этой загрузки Hydra должна оставаться открытой до завершения. Если Hydra закроется до завершения, вы потеряете прогресс.",
"achievements": "Достижения",
"achievements_count": "Достижения {{unlockedCount}}/{{achievementsCount}}",
"show_more": "Показать больше",
"reviews": "Отзывы",
"leave_a_review": "Оставить отзыв",
"cloud_save": "Облачное сохранение",
"cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве",
"backups": "Резервные копии",
@@ -271,7 +275,41 @@
"backup_unfrozen": "Резервная копия откреплена",
"backup_freeze_failed": "Не удалось закрепить резервную копию",
"backup_freeze_failed_description": "Вы должны оставить как минимум один свободный слот для автоматических резервных копий",
"manual_playtime_tooltip": "Это время игры было обновлено вручную"
"manual_playtime_tooltip": "Это время игры было обновлено вручную",
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
"sort_newest": "Новые",
"no_reviews_yet": "Пока нет отзывов",
"be_first_to_review": "Будьте первым, кто поделится своими мыслями об этой игре!",
"sort_oldest": "Старые",
"sort_highest_score": "Высший балл",
"sort_lowest_score": "Низший балл",
"sort_most_voted": "Самые популярные",
"rating": "Рейтинг",
"rating_stats": "Рейтинг",
"rating_very_negative": "Очень негативный",
"rating_negative": "Негативный",
"rating_neutral": "Нейтральный",
"rating_positive": "Позитивный",
"rating_very_positive": "Очень позитивный",
"submit_review": "Отправить отзыв",
"submitting": "Отправка...",
"remove_review": "Удалить отзыв",
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
"delete_review_modal_description": "Это действие нельзя отменить.",
"delete_review_modal_delete_button": "Удалить",
"delete_review_modal_cancel_button": "Отмена",
"review_submitted_successfully": "Отзыв успешно отправлен!",
"review_submission_failed": "Не удалось отправить отзыв. Попробуйте еще раз.",
"review_cannot_be_empty": "Поле отзыва не может быть пустым.",
"review_deleted_successfully": "Отзыв успешно удален.",
"review_deletion_failed": "Не удалось удалить отзыв. Попробуйте еще раз.",
"loading_reviews": "Загрузка отзывов...",
"loading_more_reviews": "Загрузка дополнительных отзывов...",
"load_more_reviews": "Загрузить больше отзывов",
"you_seemed_to_enjoy_this_game": "Похоже, вам понравилась эта игра",
"would_you_recommend_this_game": "Хотели бы вы оставить отзыв об этой игре?",
"yes": "Да",
"maybe_later": "Может быть позже"
},
"activation": {
"title": "Активировать Hydra",
@@ -475,7 +513,8 @@
"game_card": {
"available_one": "Доступный",
"available_other": "Доступный",
"no_downloads": "Нет доступных источников"
"no_downloads": "Нет доступных источников",
"calculating": "Вычисление"
},
"binary_not_found_modal": {
"title": "Программы не установлены",
@@ -572,7 +611,12 @@
"show_achievements_on_profile": "Покажите свои достижения в профиле",
"show_points_on_profile": "Показывать заработанные очки в своем профиле",
"error_adding_friend": "Не удалось отправить запрос в друзья. Пожалуйста, проверьте код друга",
"friend_code_length_error": "Код друга должен содержать 8 символов"
"friend_code_length_error": "Код друга должен содержать 8 символов",
"game_removed_from_pinned": "Игра удалена из закрепленных",
"game_added_to_pinned": "Игра добавлена в закрепленные",
"karma": "Карма",
"karma_count": "карма",
"karma_description": "Заработано от положительных лайков на отзывах"
},
"achievement": {
"achievement_unlocked": "Достижение разблокировано",

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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";

View File

@@ -0,0 +1 @@
export * from "./star-rating";

View 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;
}
}
}

View 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>
);
}

View File

@@ -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 &&

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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&apos;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")}

View File

@@ -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";

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -203,6 +203,7 @@ export interface UserProfile {
currentGame: UserProfileCurrentGame | null;
bio: string;
hasActiveSubscription: boolean;
karma: number;
quirks: {
backupsPerGameLimit: number;
};