mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-20 09:43:57 +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:
@@ -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",
|
||||
|
||||
@@ -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": "Достижение разблокировано",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -203,6 +203,7 @@ export interface UserProfile {
|
||||
currentGame: UserProfileCurrentGame | null;
|
||||
bio: string;
|
||||
hasActiveSubscription: boolean;
|
||||
karma: number;
|
||||
quirks: {
|
||||
backupsPerGameLimit: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user