diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index b54fe2fb..1af953fe 100755
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -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",
diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json
index 8992a4a0..45177eaf 100644
--- a/src/locales/ru/translation.json
+++ b/src/locales/ru/translation.json
@@ -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": "Достижение разблокировано",
diff --git a/src/renderer/src/components/game-card/game-card.scss b/src/renderer/src/components/game-card/game-card.scss
index ee4a22b1..99aa866e 100644
--- a/src/renderer/src/components/game-card/game-card.scss
+++ b/src/renderer/src/components/game-card/game-card.scss
@@ -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 {
diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx
index 15b5439b..1aa58ba7 100644
--- a/src/renderer/src/components/game-card/game-card.tsx
+++ b/src/renderer/src/components/game-card/game-card.tsx
@@ -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) : "…"}
- {stats?.averageScore && (
-
-
- {stats.averageScore.toFixed(1)}
-
- )}
+
+
+
diff --git a/src/renderer/src/components/index.ts b/src/renderer/src/components/index.ts
index 9970be42..89dccdbc 100644
--- a/src/renderer/src/components/index.ts
+++ b/src/renderer/src/components/index.ts
@@ -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";
diff --git a/src/renderer/src/components/star-rating/index.ts b/src/renderer/src/components/star-rating/index.ts
new file mode 100644
index 00000000..0f153ca4
--- /dev/null
+++ b/src/renderer/src/components/star-rating/index.ts
@@ -0,0 +1 @@
+export * from "./star-rating";
\ No newline at end of file
diff --git a/src/renderer/src/components/star-rating/star-rating.scss b/src/renderer/src/components/star-rating/star-rating.scss
new file mode 100644
index 00000000..4fa7ba2a
--- /dev/null
+++ b/src/renderer/src/components/star-rating/star-rating.scss
@@ -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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/renderer/src/components/star-rating/star-rating.tsx b/src/renderer/src/components/star-rating/star-rating.tsx
new file mode 100644
index 00000000..5aa2a5ee
--- /dev/null
+++ b/src/renderer/src/components/star-rating/star-rating.tsx
@@ -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 (
+
+
+ {calculatingText}
+
+ );
+ }
+
+ if (rating === null || rating === undefined) {
+ return (
+
+
+ …
+
+ );
+ }
+
+ const filledStars = Math.floor(rating);
+ const hasHalfStar = rating % 1 >= 0.5;
+ const emptyStars = maxStars - filledStars - (hasHalfStar ? 1 : 0);
+
+ return (
+
+
+ {Array.from({ length: filledStars }, (_, index) => (
+
+ ))}
+
+
+ {hasHalfStar && (
+
+
+
+
+ )}
+
+
+ {Array.from({ length: emptyStars }, (_, index) => (
+
+ ))}
+
+
{rating.toFixed(1)}
+
+ );
+}
\ No newline at end of file
diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx
index 9da59d4c..2b6de1d8 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -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(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 && (
-
-
+
+ {[1, 2, 3, 4, 5].map((starValue) => (
+
+ ))}
+