From f08ad361eda5bea57ecf561a87fdf52ffa10a877 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Thu, 2 Oct 2025 00:43:49 +0300
Subject: [PATCH 01/38] feat: added review functionality
---
package.json | 7 +
src/locales/en/translation.json | 23 +
.../events/catalogue/check-game-review.ts | 17 +
.../events/catalogue/create-game-review.ts | 18 +
src/main/events/catalogue/delete-review.ts | 14 +
src/main/events/catalogue/get-game-reviews.ts | 26 +
src/main/events/catalogue/vote-review.ts | 15 +
src/main/events/index.ts | 5 +
src/preload/index.ts | 26 +
.../src/components/game-card/game-card.tsx | 10 +-
src/renderer/src/declaration.d.ts | 28 +
.../gallery-slider/gallery-slider.scss | 2 +-
.../game-details/game-details-content.tsx | 474 ++++++++++++-
.../game-details/game-details-skeleton.tsx | 1 +
.../src/pages/game-details/game-details.scss | 656 ++++++++++++++++++
.../game-details/review-prompt-banner.scss | 46 ++
.../game-details/review-prompt-banner.tsx | 44 ++
.../game-details/review-sort-options.scss | 72 ++
.../game-details/review-sort-options.tsx | 60 ++
src/types/index.ts | 19 +
yarn.lock | 446 ++++++++++++
21 files changed, 2003 insertions(+), 6 deletions(-)
create mode 100644 src/main/events/catalogue/check-game-review.ts
create mode 100644 src/main/events/catalogue/create-game-review.ts
create mode 100644 src/main/events/catalogue/delete-review.ts
create mode 100644 src/main/events/catalogue/get-game-reviews.ts
create mode 100644 src/main/events/catalogue/vote-review.ts
create mode 100644 src/renderer/src/pages/game-details/review-prompt-banner.scss
create mode 100644 src/renderer/src/pages/game-details/review-prompt-banner.tsx
create mode 100644 src/renderer/src/pages/game-details/review-sort-options.scss
create mode 100644 src/renderer/src/pages/game-details/review-sort-options.tsx
diff --git a/package.json b/package.json
index e21c962a..e2f750bd 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,12 @@
"@primer/octicons-react": "^19.9.0",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@reduxjs/toolkit": "^2.2.3",
+ "@tiptap/extension-bold": "^3.6.2",
+ "@tiptap/extension-italic": "^3.6.2",
+ "@tiptap/extension-link": "^3.6.2",
+ "@tiptap/extension-underline": "^3.6.2",
+ "@tiptap/react": "^3.6.2",
+ "@tiptap/starter-kit": "^3.6.2",
"auto-launch": "^5.0.6",
"axios": "^1.7.9",
"axios-cookiejar-support": "^5.0.5",
@@ -63,6 +69,7 @@
"jsdom": "^24.0.0",
"jsonwebtoken": "^9.0.2",
"lodash-es": "^4.17.21",
+ "lucide-react": "^0.544.0",
"parse-torrent": "^11.0.18",
"rc-virtual-list": "^3.18.3",
"react-dnd": "^16.0.1",
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index bcf8cf54..b0fee465 100755
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -198,6 +198,29 @@
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util it's completed. If Hydra closes before completing, you will lose your progress.",
"achievements": "Achievements",
"achievements_count": "Achievements {{unlockedCount}}/{{achievementsCount}}",
+ "show_more": "Show more",
+ "show_less": "Show less",
+ "reviews": "Reviews",
+ "leave_a_review": "Leave a Review",
+ "write_review_placeholder": "Share your thoughts about this game...",
+ "sort_newest": "Newest",
+ "sort_by": "Sort by",
+ "no_reviews_yet": "No reviews yet",
+ "be_first_to_review": "Be the first to share your thoughts about this game!",
+ "sort_oldest": "Oldest",
+ "sort_highest_score": "Highest Score",
+ "sort_lowest_score": "Lowest Score",
+ "sort_most_voted": "Most Voted",
+ "rating": "Rating",
+ "submit_review": "Submit Review",
+ "submitting": "Submitting...",
+ "loading_reviews": "Loading reviews...",
+ "loading_more_reviews": "Loading more reviews...",
+ "load_more_reviews": "Load More Reviews",
+ "youve_played_for_hours": "You've played for {{hours}} hours",
+ "would_you_recommend_this_game": "Would you like to leave a review to this game?",
+ "yes": "Yes",
+ "maybe_later": "Maybe Later",
"cloud_save": "Cloud save",
"cloud_save_description": "Save your progress in the cloud and continue playing on any device",
"backups": "Backups",
diff --git a/src/main/events/catalogue/check-game-review.ts b/src/main/events/catalogue/check-game-review.ts
new file mode 100644
index 00000000..c46ede07
--- /dev/null
+++ b/src/main/events/catalogue/check-game-review.ts
@@ -0,0 +1,17 @@
+import { registerEvent } from "../register-event";
+import { HydraApi } from "@main/services";
+import type { GameShop } from "@types";
+
+const checkGameReview = async (
+ _event: Electron.IpcMainInvokeEvent,
+ shop: GameShop,
+ objectId: string
+) => {
+ return HydraApi.get(
+ `/games/${shop}/${objectId}/reviews/check`,
+ null,
+ { needsAuth: true }
+ );
+};
+
+registerEvent("checkGameReview", checkGameReview);
\ No newline at end of file
diff --git a/src/main/events/catalogue/create-game-review.ts b/src/main/events/catalogue/create-game-review.ts
new file mode 100644
index 00000000..7f29b639
--- /dev/null
+++ b/src/main/events/catalogue/create-game-review.ts
@@ -0,0 +1,18 @@
+import { registerEvent } from "../register-event";
+import { HydraApi } from "@main/services";
+import type { GameShop } from "@types";
+
+const createGameReview = async (
+ _event: Electron.IpcMainInvokeEvent,
+ shop: GameShop,
+ objectId: string,
+ reviewHtml: string,
+ score: number
+) => {
+ return HydraApi.post(`/games/${shop}/${objectId}/reviews`, {
+ reviewHtml,
+ score,
+ });
+};
+
+registerEvent("createGameReview", createGameReview);
\ No newline at end of file
diff --git a/src/main/events/catalogue/delete-review.ts b/src/main/events/catalogue/delete-review.ts
new file mode 100644
index 00000000..2048b3e7
--- /dev/null
+++ b/src/main/events/catalogue/delete-review.ts
@@ -0,0 +1,14 @@
+import { registerEvent } from "../register-event";
+import { HydraApi } from "@main/services";
+import type { GameShop } from "@types";
+
+const deleteReview = async (
+ _event: Electron.IpcMainInvokeEvent,
+ shop: GameShop,
+ objectId: string,
+ reviewId: string
+) => {
+ return HydraApi.delete(`/games/${shop}/${objectId}/reviews/${reviewId}`);
+};
+
+registerEvent("deleteReview", deleteReview);
\ No newline at end of file
diff --git a/src/main/events/catalogue/get-game-reviews.ts b/src/main/events/catalogue/get-game-reviews.ts
new file mode 100644
index 00000000..d3c31780
--- /dev/null
+++ b/src/main/events/catalogue/get-game-reviews.ts
@@ -0,0 +1,26 @@
+import { registerEvent } from "../register-event";
+import { HydraApi } from "@main/services";
+import type { GameShop } from "@types";
+
+const getGameReviews = async (
+ _event: Electron.IpcMainInvokeEvent,
+ shop: GameShop,
+ objectId: string,
+ take: number = 20,
+ skip: number = 0,
+ sortBy: string = "newest"
+) => {
+ const params = new URLSearchParams({
+ take: take.toString(),
+ skip: skip.toString(),
+ sortBy,
+ });
+
+ return HydraApi.get(
+ `/games/${shop}/${objectId}/reviews?${params.toString()}`,
+ null,
+ { needsAuth: false }
+ );
+};
+
+registerEvent("getGameReviews", getGameReviews);
\ No newline at end of file
diff --git a/src/main/events/catalogue/vote-review.ts b/src/main/events/catalogue/vote-review.ts
new file mode 100644
index 00000000..b60062c3
--- /dev/null
+++ b/src/main/events/catalogue/vote-review.ts
@@ -0,0 +1,15 @@
+import { registerEvent } from "../register-event";
+import { HydraApi } from "@main/services";
+import type { GameShop } from "@types";
+
+const voteReview = async (
+ _event: Electron.IpcMainInvokeEvent,
+ shop: GameShop,
+ objectId: string,
+ reviewId: string,
+ voteType: 'upvote' | 'downvote'
+) => {
+ return HydraApi.put(`/games/${shop}/${objectId}/reviews/${reviewId}/${voteType}`, {});
+};
+
+registerEvent("voteReview", voteReview);
\ No newline at end of file
diff --git a/src/main/events/index.ts b/src/main/events/index.ts
index d4c461f8..378a3b6e 100644
--- a/src/main/events/index.ts
+++ b/src/main/events/index.ts
@@ -11,6 +11,11 @@ import "./catalogue/get-game-stats";
import "./catalogue/get-trending-games";
import "./catalogue/get-publishers";
import "./catalogue/get-developers";
+import "./catalogue/create-game-review";
+import "./catalogue/get-game-reviews";
+import "./catalogue/vote-review";
+import "./catalogue/delete-review";
+import "./catalogue/check-game-review";
import "./hardware/get-disk-free-space";
import "./hardware/check-folder-write-permission";
import "./library/add-game-to-library";
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 17c1225f..eda43369 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -77,6 +77,32 @@ contextBridge.exposeInMainWorld("electron", {
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
+ createGameReview: (
+ shop: GameShop,
+ objectId: string,
+ reviewHtml: string,
+ score: number
+ ) => ipcRenderer.invoke("createGameReview", shop, objectId, reviewHtml, score),
+ getGameReviews: (
+ shop: GameShop,
+ objectId: string,
+ take?: number,
+ skip?: number,
+ sortBy?: string
+ ) => ipcRenderer.invoke("getGameReviews", shop, objectId, take, skip, sortBy),
+ voteReview: (
+ shop: GameShop,
+ objectId: string,
+ reviewId: string,
+ voteType: "upvote" | "downvote"
+ ) => ipcRenderer.invoke("voteReview", shop, objectId, reviewId, voteType),
+ deleteReview: (
+ shop: GameShop,
+ objectId: string,
+ reviewId: string
+ ) => ipcRenderer.invoke("deleteReview", shop, objectId, reviewId),
+ checkGameReview: (shop: GameShop, objectId: string) =>
+ ipcRenderer.invoke("checkGameReview", shop, objectId),
onUpdateAchievements: (
objectId: string,
shop: GameShop,
diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx
index 3cdefc19..cb9a060c 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 } from "@primer/octicons-react";
+import { DownloadIcon, PeopleIcon, StarIcon } from "@primer/octicons-react";
import type { GameStats } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
@@ -107,6 +107,14 @@ export function GameCard({ game, ...props }: GameCardProps) {
{stats ? numberFormatter.format(stats.playerCount) : "…"}
+ {stats?.averageScore && (
+
+
+
+ {stats.averageScore.toFixed(1)}
+
+
+ )}
diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts
index e6277888..752a1115 100644
--- a/src/renderer/src/declaration.d.ts
+++ b/src/renderer/src/declaration.d.ts
@@ -93,6 +93,34 @@ declare global {
) => Promise;
getGameStats: (objectId: string, shop: GameShop) => Promise;
getTrendingGames: () => Promise;
+ createGameReview: (
+ shop: GameShop,
+ objectId: string,
+ reviewHtml: string,
+ score: number
+ ) => Promise;
+ getGameReviews: (
+ shop: GameShop,
+ objectId: string,
+ take?: number,
+ skip?: number,
+ sortBy?: string
+ ) => Promise;
+ voteReview: (
+ shop: GameShop,
+ objectId: string,
+ reviewId: string,
+ voteType: 'upvote' | 'downvote'
+ ) => Promise;
+ deleteReview: (
+ shop: GameShop,
+ objectId: string,
+ reviewId: string
+ ) => Promise;
+ checkGameReview: (
+ shop: GameShop,
+ objectId: string
+ ) => Promise<{ hasReviewed: boolean }>;
onUpdateAchievements: (
objectId: string,
shop: GameShop,
diff --git a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss
index 9483b50e..d1ae2481 100644
--- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss
+++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss
@@ -65,7 +65,7 @@
&__preview {
width: 100%;
padding: globals.$spacing-unit 0;
- height: 100%;
+ height: auto;
display: flex;
position: relative;
overflow-x: auto;
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 ca2ca023..48228e8e 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -1,33 +1,45 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
-import { PencilIcon } from "@primer/octicons-react";
+import { PencilIcon, TrashIcon, ClockIcon } from "@primer/octicons-react";
+import { ThumbsUp, ThumbsDown } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { useEditor, EditorContent } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import Bold from '@tiptap/extension-bold';
+import Italic from '@tiptap/extension-italic';
+import Underline from '@tiptap/extension-underline';
+import type { GameReview } from "@types";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
import { GallerySlider } from "./gallery-slider/gallery-slider";
import { Sidebar } from "./sidebar/sidebar";
import { EditGameModal } from "./modals";
+import { ReviewSortOptions } from "./review-sort-options";
+import { ReviewPromptBanner } from "./review-prompt-banner";
import { useTranslation } from "react-i18next";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { AuthPage } from "@shared";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
-import { useUserDetails, useLibrary } from "@renderer/hooks";
+import { useUserDetails, useLibrary, useDate } from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./game-details.scss";
export function GameDetailsContent() {
const heroRef = useRef(null);
+ const navigate = useNavigate();
const { t } = useTranslation("game_details");
- const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame } =
+ const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame, shop } =
useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription();
const { userDetails, hasActiveSubscription } = useUserDetails();
const { updateLibrary } = useLibrary();
+ const { formatDistance } = useDate();
const { setShowCloudSyncModal, getGameArtifacts } =
useContext(cloudSyncContext);
@@ -80,6 +92,41 @@ export function GameDetailsContent() {
const [backdropOpacity, setBackdropOpacity] = useState(1);
const [showEditGameModal, setShowEditGameModal] = useState(false);
+ const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
+
+ // Reviews state management
+ const [reviews, setReviews] = useState([]);
+ const [reviewsLoading, setReviewsLoading] = useState(false);
+ const [reviewScore, setReviewScore] = useState(5);
+ const [submittingReview, setSubmittingReview] = useState(false);
+ const [reviewsSortBy, setReviewsSortBy] = useState("newest");
+ const [reviewsPage, setReviewsPage] = useState(0);
+ const [hasMoreReviews, setHasMoreReviews] = useState(true);
+ const [visibleBlockedReviews, setVisibleBlockedReviews] = useState>(new Set());
+ const [totalReviewCount, setTotalReviewCount] = useState(0);
+ const [showReviewForm, setShowReviewForm] = useState(false);
+
+ // Review prompt banner state
+ const [showReviewPrompt, setShowReviewPrompt] = useState(false);
+ const [hasUserReviewed, setHasUserReviewed] = useState(false);
+ const [reviewCheckLoading, setReviewCheckLoading] = useState(false);
+
+ // Tiptap editor for review input
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Bold,
+ Italic,
+ Underline,
+ ],
+ content: '',
+ editorProps: {
+ attributes: {
+ class: 'game-details__review-editor',
+ 'data-placeholder': t("write_review_placeholder"),
+ },
+ },
+ });
useEffect(() => {
setBackdropOpacity(1);
@@ -114,6 +161,188 @@ export function GameDetailsContent() {
const isCustomGame = game?.shop === "custom";
+ // Reviews functions
+ const checkUserReview = async () => {
+ if (!objectId || !userDetails) return;
+
+ setReviewCheckLoading(true);
+ try {
+ const response = await window.electron.checkGameReview(shop, objectId);
+ const hasReviewed = (response as any)?.hasReviewed || false;
+ setHasUserReviewed(hasReviewed);
+
+ // Show prompt only if user hasn't reviewed and has played the game
+ if (!hasReviewed && game?.playTimeInMilliseconds && game.playTimeInMilliseconds > 0) {
+ setShowReviewPrompt(true);
+ }
+ } catch (error) {
+ console.error("Failed to check user review:", error);
+ } finally {
+ setReviewCheckLoading(false);
+ }
+ };
+
+ const loadReviews = async (reset = false) => {
+ if (!objectId) return;
+
+ setReviewsLoading(true);
+ try {
+ const skip = reset ? 0 : reviewsPage * 20;
+ const response = await window.electron.getGameReviews(
+ shop,
+ objectId,
+ 20,
+ skip,
+ reviewsSortBy
+ );
+
+ // Handle the response structure: { totalCount: number, reviews: Review[] }
+ const reviewsData = (response as any)?.reviews || [];
+ const reviewCount = (response as any)?.totalCount || 0;
+
+ if (reset) {
+ setReviews(reviewsData);
+ setReviewsPage(0);
+ setTotalReviewCount(reviewCount);
+ } else {
+ setReviews(prev => [...prev, ...reviewsData]);
+ }
+
+ setHasMoreReviews(reviewsData.length === 20);
+ } catch (error) {
+ console.error("Failed to load reviews:", error);
+ } finally {
+ setReviewsLoading(false);
+ }
+ };
+
+ const handleVoteReview = async (reviewId: string, voteType: 'upvote' | 'downvote') => {
+ if (!objectId) return;
+
+ try {
+ await window.electron.voteReview(shop, objectId, reviewId, voteType);
+ // Reload reviews to get updated vote counts
+ loadReviews(true);
+ } catch (error) {
+ console.error(`Failed to ${voteType} review:`, error);
+ }
+ };
+
+ const handleDeleteReview = async (reviewId: string) => {
+ if (!objectId) return;
+
+ try {
+ await window.electron.deleteReview(shop, objectId, reviewId);
+ // Reload reviews after deletion
+ loadReviews(true);
+ } catch (error) {
+ console.error('Failed to delete review:', error);
+ }
+ };
+
+ const handleSubmitReview = async () => {
+ console.log("handleSubmitReview called");
+ console.log("game:", game);
+ console.log("objectId:", objectId);
+
+ const reviewHtml = editor?.getHTML() || '';
+ console.log("reviewHtml:", reviewHtml);
+ console.log("reviewScore:", reviewScore);
+ console.log("submittingReview:", submittingReview);
+
+ if (!objectId || !reviewHtml.trim() || submittingReview) {
+ console.log("Early return - validation failed");
+ return;
+ }
+
+ console.log("Starting review submission...");
+ setSubmittingReview(true);
+ try {
+ console.log("Calling window.electron.createGameReview...");
+ await window.electron.createGameReview(
+ shop,
+ objectId,
+ reviewHtml,
+ reviewScore
+ );
+
+ console.log("Review submitted successfully");
+ editor?.commands.clearContent();
+ setReviewScore(5);
+ await loadReviews(true); // Reload reviews after submission
+ setShowReviewForm(false); // Hide the review form after successful submission
+ setShowReviewPrompt(false); // Hide the prompt banner
+ setHasUserReviewed(true); // Update the review status
+ } catch (error) {
+ console.error("Failed to submit review:", error);
+ } finally {
+ setSubmittingReview(false);
+ console.log("Review submission completed");
+ }
+ };
+
+ // Review prompt banner handlers
+ const handleReviewPromptYes = () => {
+ setShowReviewPrompt(false);
+ setShowReviewForm(true);
+
+ // Scroll to review form
+ setTimeout(() => {
+ const reviewFormElement = document.querySelector('.game-details__review-form');
+ if (reviewFormElement) {
+ reviewFormElement.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start'
+ });
+ }
+ }, 100);
+ };
+
+ const handleReviewPromptLater = () => {
+ setShowReviewPrompt(false);
+ };
+
+ const handleSortChange = (newSortBy: string) => {
+ setReviewsSortBy(newSortBy);
+ setReviewsPage(0);
+ setHasMoreReviews(true);
+ loadReviews(true);
+ };
+
+ const toggleBlockedReview = (reviewId: string) => {
+ setVisibleBlockedReviews(prev => {
+ const newSet = new Set(prev);
+ if (newSet.has(reviewId)) {
+ newSet.delete(reviewId);
+ } else {
+ newSet.add(reviewId);
+ }
+ return newSet;
+ });
+ };
+
+ const loadMoreReviews = () => {
+ if (!reviewsLoading && hasMoreReviews) {
+ setReviewsPage(prev => prev + 1);
+ loadReviews(false);
+ }
+ };
+
+ // Load reviews when component mounts or sort changes
+ useEffect(() => {
+ if (objectId && (game || shop)) {
+ loadReviews(true);
+ checkUserReview(); // Check if user has reviewed this game
+ }
+ }, [game, shop, objectId, reviewsSortBy, userDetails]);
+
+ // Load more reviews when page changes
+ useEffect(() => {
+ if (reviewsPage > 0) {
+ loadReviews(false);
+ }
+ }, [reviewsPage]);
+
// Helper function to get image with custom asset priority
const getImageWithCustomPriority = (
customUrl: string | null | undefined,
@@ -227,6 +456,14 @@ export function GameDetailsContent() {
+ {/* Review Prompt Banner */}
+ {showReviewPrompt && userDetails && game?.playTimeInMilliseconds && !hasUserReviewed && !reviewCheckLoading && (
+
+ )}
+
@@ -234,8 +471,237 @@ export function GameDetailsContent() {
dangerouslySetInnerHTML={{
__html: aboutTheGame,
}}
- className="game-details__description"
+ className={`game-details__description ${
+ isDescriptionExpanded ? 'game-details__description--expanded' : 'game-details__description--collapsed'
+ }`}
/>
+
+ {aboutTheGame && aboutTheGame.length > 500 && (
+
+ )}
+
+
+ {showReviewForm && (
+ <>
+
+
{t("leave_a_review")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ {showReviewForm && (
+
+ )}
+
+
+
+
+
+ {t("reviews")}
+
+
+ {totalReviewCount}
+
+
+
+
+
+ {reviewsLoading && reviews.length === 0 && (
+
+ {t("loading_reviews")}
+
+ )}
+
+ {!reviewsLoading && reviews.length === 0 && (
+
+
📝
+
+ {t("no_reviews_yet")}
+
+
+ {t("be_first_to_review")}
+
+
+ )}
+
+ {reviews.map((review, index) => (
+
+ {review.isBlocked && !visibleBlockedReviews.has(review.id) ? (
+
+ Review from blocked user —
+
+
+ ) : (
+ <>
+
+
+ {review.user?.profileImageUrl && (
+

+ )}
+
+
review.user?.id && navigate(`/profile/${review.user.id}`)}
+ >
+ {review.user?.displayName || 'Anonymous'}
+
+
+
+ {formatDistance(new Date(review.createdAt), new Date(), { addSuffix: true })}
+
+
+
+
+ {review.score}/10
+
+
+
+
+
+
+
+
+ {userDetails?.id === review.user?.id && (
+
+ )}
+ {review.isBlocked && visibleBlockedReviews.has(review.id) && (
+
+ )}
+
+ >
+ )}
+
+ ))}
+
+ {hasMoreReviews && !reviewsLoading && (
+
+ )}
+
+ {reviewsLoading && reviews.length > 0 && (
+
+ {t("loading_more_reviews")}
+
+ )}
+
+
{game?.shop !== "custom" &&
}
diff --git a/src/renderer/src/pages/game-details/game-details-skeleton.tsx b/src/renderer/src/pages/game-details/game-details-skeleton.tsx
index c39da3bb..adaf4ab2 100644
--- a/src/renderer/src/pages/game-details/game-details-skeleton.tsx
+++ b/src/renderer/src/pages/game-details/game-details-skeleton.tsx
@@ -35,6 +35,7 @@ export function GameDetailsSkeleton() {
))}
+
diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss
index e1140d31..b82bd6b1 100644
--- a/src/renderer/src/pages/game-details/game-details.scss
+++ b/src/renderer/src/pages/game-details/game-details.scss
@@ -27,6 +27,418 @@ $hero-height: 300px;
}
}
+ &__review-form {
+ display: flex;
+ flex-direction: column;
+ gap: calc(globals.$spacing-unit * 2);
+ margin-bottom: calc(globals.$spacing-unit * 3);
+ padding: calc(globals.$spacing-unit * 2);
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ }
+
+ &__review-form-controls {
+ display: flex;
+ gap: calc(globals.$spacing-unit * 2);
+ align-items: flex-end;
+ flex-wrap: wrap;
+
+ @media (max-width: 768px) {
+ flex-direction: column;
+ align-items: stretch;
+ gap: calc(globals.$spacing-unit * 1.5);
+ }
+ }
+
+ &__review-form-bottom {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: calc(globals.$spacing-unit * 2);
+
+ @media (max-width: 768px) {
+ flex-direction: column;
+ align-items: stretch;
+ gap: calc(globals.$spacing-unit * 1.5);
+ }
+ }
+
+ &__review-score-container {
+ display: flex;
+ flex-direction: column;
+ gap: calc(globals.$spacing-unit * 0.75);
+ min-width: 120px;
+ }
+
+ &__review-score-label {
+ display: block;
+ font-size: globals.$body-font-size;
+ color: globals.$body-color;
+ }
+
+ &__review-score-select {
+ background-color: rgba(255, 255, 255, 0.05);
+ border: 1px solid globals.$border-color;
+ border-radius: 4px;
+ padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1);
+ color: globals.$body-color;
+ font-size: globals.$body-font-size;
+ font-family: inherit;
+ cursor: pointer;
+ transition: border-color 0.2s ease, background-color 0.2s ease;
+
+ &:focus {
+ outline: none;
+ background-color: rgba(255, 255, 255, 0.08);
+ border-color: globals.$brand-teal;
+ }
+
+ &:hover {
+ border-color: rgba(255, 255, 255, 0.15);
+ }
+
+ option {
+ background-color: globals.$dark-background-color;
+ color: globals.$body-color;
+ }
+ }
+
+ &__reviews-sort {
+ display: flex;
+ flex-direction: column;
+ gap: calc(globals.$spacing-unit * 0.75);
+ min-width: 150px;
+ }
+
+ &__reviews-sort-label {
+ display: block;
+ font-size: globals.$body-font-size;
+ color: globals.$body-color;
+ }
+
+ &__reviews-sort-select {
+ background-color: rgba(255, 255, 255, 0.05);
+ border: 1px solid globals.$border-color;
+ border-radius: 4px;
+ padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1);
+ color: globals.$body-color;
+ font-size: globals.$body-font-size;
+ font-family: inherit;
+ cursor: pointer;
+ transition: border-color 0.2s ease, background-color 0.2s ease;
+
+ &:focus {
+ outline: none;
+ background-color: rgba(255, 255, 255, 0.08);
+ border-color: globals.$brand-teal;
+ }
+
+ &:hover {
+ border-color: rgba(255, 255, 255, 0.15);
+ }
+
+ option {
+ background-color: globals.$dark-background-color;
+ color: globals.$body-color;
+ }
+ }
+
+ &__review-submit-button {
+ background-color: rgba(255, 255, 255, 0.05);
+ border: 1px solid globals.$border-color;
+ color: globals.$body-color;
+ padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1.5);
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: globals.$small-font-size;
+ font-family: inherit;
+ transition: all ease 0.2s;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ white-space: nowrap;
+
+ &:hover:not(:disabled) {
+ background-color: rgba(255, 255, 255, 0.08);
+ border-color: rgba(255, 255, 255, 0.15);
+ }
+
+ &:active {
+ opacity: 0.9;
+ }
+
+ &:disabled {
+ background: rgba(255, 255, 255, 0.1);
+ cursor: not-allowed;
+ color: rgba(255, 255, 255, 0.5);
+ }
+ }
+
+ &__reviews-list {
+ margin-top: calc(globals.$spacing-unit * 3);
+ }
+
+ &__reviews-separator {
+ height: 1px;
+ background: rgba(255, 255, 255, 0.1);
+ margin: calc(globals.$spacing-unit * 3) 0;
+ width: 100%;
+ }
+
+ &__reviews-list-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: calc(globals.$spacing-unit * 2);
+ padding-bottom: calc(globals.$spacing-unit * 1);
+ }
+
+ &__reviews-empty {
+ text-align: center;
+ padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2);
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ margin-bottom: calc(globals.$spacing-unit * 2);
+ }
+
+ &__reviews-empty-icon {
+ font-size: 48px;
+ margin-bottom: calc(globals.$spacing-unit * 2);
+ opacity: 0.6;
+ }
+
+ &__reviews-empty-title {
+ color: rgba(255, 255, 255, 0.9);
+ font-weight: 600;
+ margin: 0 0 calc(globals.$spacing-unit * 1) 0;
+ }
+
+ &__reviews-empty-message {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: globals.$small-font-size;
+ margin: 0;
+ line-height: 1.4;
+ }
+
+ &__review-item {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 6px;
+ padding: calc(globals.$spacing-unit * 2);
+ margin-bottom: calc(globals.$spacing-unit * 2);
+ }
+
+ &__review-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: calc(globals.$spacing-unit * 1.5);
+ }
+
+ &__review-user {
+ display: flex;
+ align-items: center;
+ gap: calc(globals.$spacing-unit * 1);
+ }
+
+ &__review-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 2px solid rgba(255, 255, 255, 0.1);
+ }
+
+ &__review-user-info {
+ display: flex;
+ flex-direction: column;
+ gap: calc(globals.$spacing-unit * 0.25);
+ }
+
+ &__review-display-name {
+ color: rgba(255, 255, 255, 0.9);
+ font-size: globals.$small-font-size;
+ font-weight: 600;
+
+ &--clickable {
+ cursor: pointer;
+ transition: color 0.2s ease;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ &__review-actions {
+ margin-top: 12px;
+ padding-top: 8px;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ &__review-votes {
+ display: flex;
+ gap: 12px;
+ }
+
+ &__vote-button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 6px;
+ padding: 6px 12px;
+ color: #ccc;
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.2);
+ }
+
+ &--upvote:hover {
+ color: #4caf50;
+ border-color: #4caf50;
+ }
+
+ &--downvote:hover {
+ color: #f44336;
+ border-color: #f44336;
+ }
+
+ &--active {
+ &.game-details__vote-button--upvote {
+ svg {
+ fill: white;
+ }
+ }
+
+ &.game-details__vote-button--downvote {
+ svg {
+ fill: white;
+ }
+ }
+ }
+
+ span {
+ font-weight: 500;
+ }
+ }
+
+ &__delete-review-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(244, 67, 54, 0.1);
+ border: 1px solid rgba(244, 67, 54, 0.3);
+ border-radius: 6px;
+ padding: 6px;
+ color: #f44336;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: rgba(244, 67, 54, 0.2);
+ border-color: #f44336;
+ color: #ff5722;
+ }
+ }
+
+ &__blocked-review-simple {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: globals.$small-font-size;
+ display: flex;
+ align-items: center;
+ gap: calc(globals.$spacing-unit * 0.5);
+ }
+
+ &__blocked-review-show-link {
+ background: none;
+ border: none;
+ color: #ffc107;
+ font-size: globals.$small-font-size;
+ cursor: pointer;
+ text-decoration: underline;
+ padding: 0;
+ transition: color 0.2s ease;
+
+ &:hover {
+ color: #ffeb3b;
+ }
+ }
+
+ &__blocked-review-hide-link {
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: globals.$small-font-size;
+ cursor: pointer;
+ text-decoration: underline;
+ padding: 0;
+ transition: color 0.2s ease;
+
+ &:hover {
+ color: rgba(255, 255, 255, 0.8);
+ }
+ }
+
+ &__review-score {
+ background: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.9);
+ padding: calc(globals.$spacing-unit * 0.5) calc(globals.$spacing-unit * 1);
+ border-radius: 4px;
+ font-size: globals.$small-font-size;
+ font-weight: 600;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ }
+
+ &__review-date {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: rgba(255, 255, 255, 0.6);
+ font-size: globals.$small-font-size;
+ }
+
+ &__review-content {
+ color: globals.$body-color;
+ line-height: 1.5;
+ }
+
+ &__reviews-loading {
+ text-align: center;
+ color: rgba(255, 255, 255, 0.6);
+ padding: calc(globals.$spacing-unit * 2);
+ }
+
+ &__load-more-reviews {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid globals.$border-color;
+ color: globals.$body-color;
+ padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 2);
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: globals.$body-font-size;
+ font-family: inherit;
+ transition: all 0.2s ease;
+ width: 100%;
+ margin-top: calc(globals.$spacing-unit * 2);
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ border-color: globals.$brand-teal;
+ }
+ }
+
&__hero {
width: 100%;
height: $hero-height;
@@ -192,6 +604,8 @@ $hero-height: 300px;
min-width: 0;
flex: 1;
overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
}
&__description {
@@ -203,6 +617,7 @@ $hero-height: 300px;
margin-right: auto;
overflow-x: auto;
min-height: auto;
+ transition: max-height 0.3s ease-in-out;
@media (min-width: 1280px) {
width: 60%;
@@ -212,6 +627,27 @@ $hero-height: 300px;
width: 50%;
}
+ &--collapsed {
+ max-height: 300px;
+ overflow: hidden;
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 60px;
+ background: linear-gradient(transparent, globals.$background-color);
+ pointer-events: none;
+ }
+ }
+
+ &--expanded {
+ max-height: none;
+ }
+
img,
video {
border-radius: 5px;
@@ -237,6 +673,24 @@ $hero-height: 300px;
}
}
+ &__description-toggle {
+ background: none;
+ border: 1px solid globals.$border-color;
+ color: globals.$body-color;
+ padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1.5);
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: globals.$body-font-size;
+ margin-top: calc(globals.$spacing-unit * 1.5);
+ transition: all 0.2s ease;
+ align-self: center;
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ border-color: globals.$brand-teal;
+ }
+ }
+
&__description-skeleton {
display: flex;
flex-direction: column;
@@ -367,4 +821,206 @@ $hero-height: 300px;
flex: 1;
transition: opacity 0.2s ease;
}
+
+ &__reviews-section {
+ margin-top: calc(globals.$spacing-unit * 3);
+ padding-top: calc(globals.$spacing-unit * 3);
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ width: 100%;
+ margin-left: auto;
+ margin-right: auto;
+
+ @media (min-width: 1280px) {
+ width: 60%;
+ }
+
+ @media (min-width: 1536px) {
+ width: 50%;
+ }
+ }
+
+ &__reviews-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: calc(globals.$spacing-unit * 2);
+ gap: calc(globals.$spacing-unit * 2);
+
+ @media (max-width: 768px) {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: calc(globals.$spacing-unit * 1.5);
+ }
+ }
+
+ &__reviews-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: globals.$muted-color;
+ margin: 0;
+ }
+
+ &__reviews-title-group {
+ display: flex;
+ align-items: center;
+ gap: calc(globals.$spacing-unit);
+ flex: 1;
+ }
+
+ &__reviews-badge {
+ background-color: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.7);
+ padding: 4px 8px;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ min-width: 24px;
+ text-align: center;
+ flex-shrink: 0;
+ }
+
+ &__leave-review-cta {
+ display: flex;
+ align-items: center;
+ gap: calc(globals.$spacing-unit * 0.5);
+ padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1.5);
+ background: linear-gradient(135deg, globals.$brand-teal, globals.$brand-blue);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ margin-bottom: calc(globals.$spacing-unit);
+
+ &:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(globals.$brand-teal, 0.3);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+
+ svg {
+ flex-shrink: 0;
+ }
+ }
+
+ &__review-input-container {
+ width: 100%;
+ position: relative;
+ }
+
+ &__review-input-bottom {
+ position: absolute;
+ bottom: 8px;
+ left: 8px;
+ right: 8px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ z-index: 10;
+ }
+
+ &__review-editor-toolbar {
+ display: flex;
+ gap: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 4px;
+ padding: 4px;
+ backdrop-filter: blur(10px);
+ background: rgba(0, 0, 0, 0.3);
+ }
+
+ &__editor-button {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 3px;
+ color: globals.$body-color;
+ padding: 4px 6px;
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 600;
+ transition: all 0.2s ease;
+ min-width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.2);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ &.is-active {
+ background: globals.$brand-teal;
+ border-color: globals.$brand-teal;
+ color: white;
+ }
+ }
+
+ &__review-input {
+ width: 100%;
+ background-color: rgba(255, 255, 255, 0.05);
+ border: 1px solid globals.$border-color;
+ border-radius: 6px;
+ padding: calc(globals.$spacing-unit * 1.5);
+ padding-bottom: calc(globals.$spacing-unit * 3.5);
+ color: globals.$body-color;
+ font-size: globals.$body-font-size;
+ font-family: inherit;
+ line-height: 1.5;
+ min-height: 100px;
+ transition: border-color 0.2s ease, background-color 0.2s ease;
+
+ &:focus-within {
+ outline: none;
+ background-color: rgba(255, 255, 255, 0.08);
+ border-color: globals.$brand-teal;
+ }
+
+ &:hover {
+ border-color: rgba(255, 255, 255, 0.15);
+ }
+
+ .ProseMirror {
+ outline: none;
+ min-height: 80px;
+
+ &:empty:before {
+ content: attr(data-placeholder);
+ color: rgba(208, 209, 215, 0.6);
+ pointer-events: none;
+ }
+
+ p {
+ margin: 0 0 8px 0;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ strong {
+ font-weight: 700;
+ }
+
+ em {
+ font-style: italic;
+ }
+
+ u {
+ text-decoration: underline;
+ }
+ }
+ }
}
diff --git a/src/renderer/src/pages/game-details/review-prompt-banner.scss b/src/renderer/src/pages/game-details/review-prompt-banner.scss
new file mode 100644
index 00000000..28ba1e47
--- /dev/null
+++ b/src/renderer/src/pages/game-details/review-prompt-banner.scss
@@ -0,0 +1,46 @@
+@use "../../scss/globals.scss";
+
+.review-prompt-banner {
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 8px;
+ padding: calc(globals.$spacing-unit * 2);
+ margin-bottom: calc(globals.$spacing-unit * 3);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+
+ &__content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: calc(globals.$spacing-unit * 2.5);
+
+ @media (max-width: 768px) {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: calc(globals.$spacing-unit * 2);
+ }
+ }
+
+ &__text {
+ display: flex;
+ flex-direction: column;
+ gap: calc(globals.$spacing-unit * 0.5);
+ }
+
+ &__playtime {
+ font-size: globals.$body-font-size;
+ color: globals.$body-color;
+ font-weight: 600;
+ }
+
+ &__question {
+ font-size: globals.$small-font-size;
+ color: globals.$muted-color;
+ font-weight: 400;
+ }
+
+ &__actions {
+ display: flex;
+ gap: globals.$spacing-unit;
+ align-items: center;
+ }
+}
\ No newline at end of file
diff --git a/src/renderer/src/pages/game-details/review-prompt-banner.tsx b/src/renderer/src/pages/game-details/review-prompt-banner.tsx
new file mode 100644
index 00000000..87c1b170
--- /dev/null
+++ b/src/renderer/src/pages/game-details/review-prompt-banner.tsx
@@ -0,0 +1,44 @@
+import { useTranslation } from "react-i18next";
+import { Button } from "@renderer/components";
+import "./review-prompt-banner.scss";
+
+interface ReviewPromptBannerProps {
+ onYesClick: () => void;
+ onLaterClick: () => void;
+}
+
+export function ReviewPromptBanner({
+ onYesClick,
+ onLaterClick,
+}: ReviewPromptBannerProps) {
+ const { t } = useTranslation("game_details");
+
+ return (
+
+
+
+
+ You've seemed to enjoy this game
+
+
+ {t("would_you_recommend_this_game")}
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/renderer/src/pages/game-details/review-sort-options.scss b/src/renderer/src/pages/game-details/review-sort-options.scss
new file mode 100644
index 00000000..e982cb24
--- /dev/null
+++ b/src/renderer/src/pages/game-details/review-sort-options.scss
@@ -0,0 +1,72 @@
+@use "../../scss/globals.scss";
+
+.review-sort-options {
+ &__container {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: calc(globals.$spacing-unit);
+ }
+
+ &__label {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 14px;
+ font-weight: 400;
+ }
+
+ &__options {
+ display: flex;
+ align-items: center;
+ gap: calc(globals.$spacing-unit);
+ font-size: 14px;
+ flex-wrap: wrap;
+
+ @media (max-width: 768px) {
+ gap: calc(globals.$spacing-unit * 0.75);
+ }
+ }
+
+ &__option {
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.4);
+ cursor: pointer;
+ padding: 4px 0;
+ font-size: 14px;
+ font-weight: 300;
+ transition: all ease 0.2s;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+
+ &:hover:not(:disabled) {
+ color: rgba(255, 255, 255, 0.6);
+ }
+
+ &.active {
+ color: rgba(255, 255, 255, 0.9);
+ font-weight: 500;
+ }
+
+ span {
+ display: inline-block;
+
+ @media (max-width: 480px) {
+ display: none;
+ }
+ }
+
+ @media (max-width: 480px) {
+ gap: 0;
+ }
+ }
+
+ &__separator {
+ color: rgba(255, 255, 255, 0.3);
+ font-size: 14px;
+
+ @media (max-width: 480px) {
+ display: none;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/renderer/src/pages/game-details/review-sort-options.tsx b/src/renderer/src/pages/game-details/review-sort-options.tsx
new file mode 100644
index 00000000..5ec25c31
--- /dev/null
+++ b/src/renderer/src/pages/game-details/review-sort-options.tsx
@@ -0,0 +1,60 @@
+import { CalendarIcon, StarIcon, ThumbsupIcon, ClockIcon } from "@primer/octicons-react";
+import { useTranslation } from "react-i18next";
+import "./review-sort-options.scss";
+
+type ReviewSortOption = "newest" | "oldest" | "score_high" | "score_low" | "most_voted";
+
+interface ReviewSortOptionsProps {
+ sortBy: ReviewSortOption;
+ onSortChange: (sortBy: ReviewSortOption) => void;
+}
+
+export function ReviewSortOptions({ sortBy, onSortChange }: ReviewSortOptionsProps) {
+ const { t } = useTranslation("game_details");
+
+ return (
+
+
+
+ |
+
+ |
+
+ |
+
+ |
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/types/index.ts b/src/types/index.ts
index 593c45be..f4b0645b 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -234,6 +234,25 @@ export interface GameStats {
downloadCount: number;
playerCount: number;
assets: ShopAssets | null;
+ averageScore: number | null;
+}
+
+export interface GameReview {
+ id: string;
+ reviewHtml: string;
+ score: number;
+ createdAt: string;
+ updatedAt: string;
+ upvotes: number;
+ downvotes: number;
+ isBlocked: boolean;
+ hasUpvoted: boolean;
+ hasDownvoted: boolean;
+ user: {
+ id: string;
+ displayName: string;
+ profileImageUrl: string | null;
+ } | null;
}
export interface TrendingGame extends ShopAssets {
diff --git a/yarn.lock b/yarn.lock
index 2c321857..71551858 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2073,6 +2073,11 @@
redux-thunk "^3.1.0"
reselect "^5.1.0"
+"@remirror/core-constants@3.0.0":
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/@remirror/core-constants/-/core-constants-3.0.0.tgz#96fdb89d25c62e7b6a5d08caf0ce5114370e3b8f"
+ integrity sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==
+
"@remix-run/router@1.19.2":
version "1.19.2"
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.19.2.tgz#0c896535473291cb41f152c180bedd5680a3b273"
@@ -2840,6 +2845,201 @@
dependencies:
uint8-util "^2.2.5"
+"@tiptap/core@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.6.2.tgz#abda4116e4a39779fca7070e316b9ed9fdcded7e"
+ integrity sha512-XKZYrCVFsyQGF6dXQR73YR222l/76wkKfZ+2/4LCrem5qtcOarmv5pYxjUBG8mRuBPskTTBImSFTeQltJIUNCg==
+
+"@tiptap/extension-blockquote@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-3.6.2.tgz#01b589565c87a691e586e189ddcbcdc5f35618fc"
+ integrity sha512-TSl41UZhi3ugJMDaf91CA4F5NeFylgTSm6GqnZAHOE6IREdCpAK3qej2zaW3EzfpzxW7sRGLlytkZRvpeyjgJA==
+
+"@tiptap/extension-bold@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.6.2.tgz#ed721961daf3210c7ba4433a5aeae981043c2d77"
+ integrity sha512-Q9KO8CCPCAXYqHzIw8b/ookVmrfqfCg2cyh9h9Hvw6nhO4LOOnJMcGVmWsrpFItbwCGMafI5iY9SbSj7RpCyuw==
+
+"@tiptap/extension-bubble-menu@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.6.2.tgz#237d84f217c8da52c0bc5265a36557fb27d64eaf"
+ integrity sha512-OF5CxCmYExcXZjcectwAeujSeDZ4IltPy+SsqBZLbQRDts9PQhzv5azGDvYdL2eMMkT3yhO2gWkXxSHMxI3O6w==
+ dependencies:
+ "@floating-ui/dom" "^1.0.0"
+
+"@tiptap/extension-bullet-list@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.6.2.tgz#be20b6c795c53bc0d199bdc4dd9f01b6270a1bee"
+ integrity sha512-Y5Uhir+za7xMm6RAe592aNNlLvCayVSQt2HfSckOr+c/v/Zd2bFUHv0ef6l/nUzUhDBs32Bg9SvfWx/yyMyNEw==
+
+"@tiptap/extension-code-block@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-3.6.2.tgz#cb3f6f607dcfb36e3eff25255fdcfdedfb3940a7"
+ integrity sha512-5jfoiQ/3AUrIyuVU1NmEXar6sZFnY7wDFf3ZU2zpcBUG++yg/CmpOe5bXpoolczhl58cM/jyBG5gumQjyOxLNg==
+
+"@tiptap/extension-code@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.6.2.tgz#5c6500d748fd4f52ddbe01ff114d4933c7a09e8f"
+ integrity sha512-U6jilbcpCxtLZAgJrTapXzzVJTXnS78kJITFSOLyGCTyGSm6PXatQ4hnaxVGmNet66GySONGjhwAVZ8+l94Rwg==
+
+"@tiptap/extension-document@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.6.2.tgz#5c3f3a85d12868f5d4e6d6d258b8fa0b8000b778"
+ integrity sha512-4qg3KWL3aO1M7hfDpZR6/vSo7Cfqr3McyGUfqb/BXqYDW1DwT8jJkDTcHrGU7WUKRlWgoyPyzM8pZiGlP0uQHg==
+
+"@tiptap/extension-dropcursor@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.6.2.tgz#22a64a4da25ac17cf0cd33e1e924762000152817"
+ integrity sha512-6R5sma/i2TKd5h9OpIcy3a0wOGp5BNT/zIgnE/1HTmKi40eNcCAVe8sxd6+iWA5ETONP1E48kDy4hqA5ZzZCiQ==
+
+"@tiptap/extension-floating-menu@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.6.2.tgz#cc9c97cdd5fa55407631d3135e00ca8051516444"
+ integrity sha512-ym7YMKGY3QhFUKUS6JYOwtdi8s2PeGmOhu7TwI9/U0LmGbELeKJBJl2BP1yB+Sjpv25pVL++CwJQ6dsrjDlZ8g==
+
+"@tiptap/extension-gapcursor@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.6.2.tgz#790c94d20a5b8ded4c0d38960254d24704a2bc08"
+ integrity sha512-gXg+EvUKlv3ZO1GxKkRmAsi/V4yyA8AzLW6ppOcYrM2CKf6epmPaVRgAjdwHCA6cm3QuCBJyWeGTCAjhjNakhw==
+
+"@tiptap/extension-hard-break@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.6.2.tgz#3c379d9104cd7d9e942277f22ba62c57fae267ad"
+ integrity sha512-ncuPBHhGY58QjluJvEH6vXotaa1QZ/vphXBGAr55kiATZwMIEHgwh2Hgc6AiFTcw057gabGn6jNFDfRB+HjbmA==
+
+"@tiptap/extension-heading@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-3.6.2.tgz#3884c309de60c9d61f1bb60c521410b3a0d88ed7"
+ integrity sha512-JQ2yjwXGAiwGc+MhS1mULBr354MHfmWqVDQLRg8ey6LkdXggTDDJ1Ni3GrUS7B5YcA/ICdhr4krXaQpNkT5Syw==
+
+"@tiptap/extension-horizontal-rule@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.6.2.tgz#f5680b3209bc48bf8635f3674355bd3d47f15622"
+ integrity sha512-3TlPqedPDM9QkRTUPhOTxNxQVPSsBwlsuLrAZOgyM1y871Xi7M1DFX0h9LLXuqzPndYzUY16NjrfBGFJX+O56w==
+
+"@tiptap/extension-italic@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.6.2.tgz#ea314f5e723499c9e7a1021ad7836693db9c653c"
+ integrity sha512-46zYKqM3o9w1A2G9hWr0ERGbJpqIncoH45XIfLdAI6ZldZVVf+NeXMGwjOPf4+03cZ5/emk3MRTnVp9vF4ToIg==
+
+"@tiptap/extension-link@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.6.2.tgz#5577d100cd3b735247db327b15d91de025cc76b6"
+ integrity sha512-3yiRDWa187h30e6iUOJeejZLsbzbJthLfBwTeJGx7pHh7RngsEW82npBRuqLoI3udhJGTkXbzwAFZ9qOGOjl1Q==
+ dependencies:
+ linkifyjs "^4.3.2"
+
+"@tiptap/extension-list-item@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-3.6.2.tgz#705f782a872e4bbb6f0e125fe277c45aeefe8161"
+ integrity sha512-ma/D2GKylpNB04FfNI3tDMY+C9nz7Yk85H21YTIGv8QL5KlDK97L6orydmx6IVRc2nNMZQVitBIEKDOXcczX9w==
+
+"@tiptap/extension-list-keymap@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.6.2.tgz#f14e173325b443a89dbbca7f418b76ec3d5c9a21"
+ integrity sha512-1kl/lggH+LL/FUwcSx8p761ebk9L5ZGK06mGyDDU9XiGLS310CktZYLnpEuFgn/oMPbRHo26oNl9SXLn1/U53A==
+
+"@tiptap/extension-list@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-list/-/extension-list-3.6.2.tgz#beb4d965f48085fa7f69197e10109cde8c175046"
+ integrity sha512-ZLaEHGVq4eL26hZZFE9e7RArk2rEjcVstN/YTRTKElTnLaf58kLTKN3nlgy1PWGwzfWGUuXURBuEBLaq5l6djg==
+
+"@tiptap/extension-ordered-list@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.6.2.tgz#43b83757f67264ff0050c03825e780da43680c1d"
+ integrity sha512-KdJ5MLIw19N+XiqQ2COXGtaq9TzUbtlLE5dgYCJQ2EumeZKIGELvUnHjrnIB9gH/gRlMs+hprLTh23xVUDJovg==
+
+"@tiptap/extension-paragraph@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.6.2.tgz#d6cc89cdc369e463dd7dd4eb9121718441c984a0"
+ integrity sha512-jeJWj2xKib3392iHQEcB7wYZ30dUgXuwqpCTwtN9eANor+Zvv6CpDKBs1R2al6BYFbIJCgKeTulqxce0yoC80g==
+
+"@tiptap/extension-strike@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.6.2.tgz#2dab3f253a4ecfd525c5609ab5edb9325a6364c2"
+ integrity sha512-976u5WaioIN/0xCjl/UIEypmzACzxgVz6OGgfIsYyreMUiPjhhgzXb0A/2Po5p3nZpKcaMcxifOdhqdw+lDpIQ==
+
+"@tiptap/extension-text@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.6.2.tgz#77313173a9f91208e40d298bc2d40b39371b8fca"
+ integrity sha512-fFSUEv1H3lM92yr6jZdELk0gog8rPTK5hTf08kP8RsY8pA80Br1ADVenejrMV4UNTmT1JWTXGBGhMqfQFHUvAQ==
+
+"@tiptap/extension-underline@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.6.2.tgz#9f0dfb9722bd3d0cd144fc955bcb94a3fcf5eac2"
+ integrity sha512-IrG6vjxTMI2EeyhZCtx0sNTEu83PsAvzIh4vxmG1fUi/RYokks+sFbgGMuq0jtO96iVNEszlpAC/vaqfxFJwew==
+
+"@tiptap/extensions@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.6.2.tgz#591fbd5b9fa41f98f69dbd7d21d5d38a2241d94b"
+ integrity sha512-tg7/DgaI6SpkeawryapUtNoBxsJUMJl3+nSjTfTvsaNXed+BHzLPsvmPbzlF9ScrAbVEx8nj6CCkneECYIQ4CQ==
+
+"@tiptap/pm@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.6.2.tgz#2121d4917f92d11229529a26955a7033aa8a8843"
+ integrity sha512-g+NXjqjbj6NfHOMl22uNWVYIu8oCq7RFfbnpohPMsSKJLaHYE8mJR++7T6P5R9FoqhIFdwizg1jTpwRU5CHqXQ==
+ dependencies:
+ prosemirror-changeset "^2.3.0"
+ prosemirror-collab "^1.3.1"
+ prosemirror-commands "^1.6.2"
+ prosemirror-dropcursor "^1.8.1"
+ prosemirror-gapcursor "^1.3.2"
+ prosemirror-history "^1.4.1"
+ prosemirror-inputrules "^1.4.0"
+ prosemirror-keymap "^1.2.2"
+ prosemirror-markdown "^1.13.1"
+ prosemirror-menu "^1.2.4"
+ prosemirror-model "^1.24.1"
+ prosemirror-schema-basic "^1.2.3"
+ prosemirror-schema-list "^1.5.0"
+ prosemirror-state "^1.4.3"
+ prosemirror-tables "^1.6.4"
+ prosemirror-trailing-node "^3.0.0"
+ prosemirror-transform "^1.10.2"
+ prosemirror-view "^1.38.1"
+
+"@tiptap/react@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-3.6.2.tgz#5495776c9051a60ece7522da176c9f211a67c7df"
+ integrity sha512-jgG+bM/GDvI6jnqW3YyLtr/vOR6iO2ta9PYVzoWqNYIxISsMOJeRfinsIqB8l6hkiGZApn9bQji6oUXTc59fgA==
+ dependencies:
+ "@types/use-sync-external-store" "^0.0.6"
+ fast-deep-equal "^3.1.3"
+ use-sync-external-store "^1.4.0"
+ optionalDependencies:
+ "@tiptap/extension-bubble-menu" "^3.6.2"
+ "@tiptap/extension-floating-menu" "^3.6.2"
+
+"@tiptap/starter-kit@^3.6.2":
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-3.6.2.tgz#ddd5612d4836a87082254779c9f152bb51e757bc"
+ integrity sha512-nPzraIx/f1cOUNqG1LSC0OTnEu3mudcN3jQVuyGh3dvdOnik7FUciJEVfHKnloAyeoijidEeiLpiGHInp2uREg==
+ dependencies:
+ "@tiptap/core" "^3.6.2"
+ "@tiptap/extension-blockquote" "^3.6.2"
+ "@tiptap/extension-bold" "^3.6.2"
+ "@tiptap/extension-bullet-list" "^3.6.2"
+ "@tiptap/extension-code" "^3.6.2"
+ "@tiptap/extension-code-block" "^3.6.2"
+ "@tiptap/extension-document" "^3.6.2"
+ "@tiptap/extension-dropcursor" "^3.6.2"
+ "@tiptap/extension-gapcursor" "^3.6.2"
+ "@tiptap/extension-hard-break" "^3.6.2"
+ "@tiptap/extension-heading" "^3.6.2"
+ "@tiptap/extension-horizontal-rule" "^3.6.2"
+ "@tiptap/extension-italic" "^3.6.2"
+ "@tiptap/extension-link" "^3.6.2"
+ "@tiptap/extension-list" "^3.6.2"
+ "@tiptap/extension-list-item" "^3.6.2"
+ "@tiptap/extension-list-keymap" "^3.6.2"
+ "@tiptap/extension-ordered-list" "^3.6.2"
+ "@tiptap/extension-paragraph" "^3.6.2"
+ "@tiptap/extension-strike" "^3.6.2"
+ "@tiptap/extension-text" "^3.6.2"
+ "@tiptap/extension-underline" "^3.6.2"
+ "@tiptap/extensions" "^3.6.2"
+ "@tiptap/pm" "^3.6.2"
+
"@tokenizer/inflate@^0.2.6":
version "0.2.7"
resolved "https://registry.yarnpkg.com/@tokenizer/inflate/-/inflate-0.2.7.tgz#32dd9dfc9abe457c89b3d9b760fc0690c85a103b"
@@ -3014,6 +3214,11 @@
dependencies:
"@types/node" "*"
+"@types/linkify-it@^5":
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
+ integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==
+
"@types/lodash-es@^4.17.12":
version "4.17.12"
resolved "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz"
@@ -3033,6 +3238,19 @@
dependencies:
"@types/node" "*"
+"@types/markdown-it@^14.0.0":
+ version "14.1.2"
+ resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61"
+ integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==
+ dependencies:
+ "@types/linkify-it" "^5"
+ "@types/mdurl" "^2"
+
+"@types/mdurl@^2":
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
+ integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
+
"@types/minimatch@*":
version "5.1.2"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca"
@@ -3125,6 +3343,11 @@
resolved "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz"
integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==
+"@types/use-sync-external-store@^0.0.6":
+ version "0.0.6"
+ resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc"
+ integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==
+
"@types/user-agents@^1.0.4":
version "1.0.4"
resolved "https://registry.npmjs.org/@types/user-agents/-/user-agents-1.0.4.tgz"
@@ -4209,6 +4432,11 @@ create-require@^1.1.0:
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
+crelt@^1.0.0:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
+ integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
+
cross-fetch-ponyfill@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/cross-fetch-ponyfill/-/cross-fetch-ponyfill-1.0.3.tgz#5c5524e3bd3374e71d5016c2327e416369a57527"
@@ -6614,6 +6842,18 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
+linkify-it@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
+ integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
+ dependencies:
+ uc.micro "^2.0.0"
+
+linkifyjs@^4.3.2:
+ version "4.3.2"
+ resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1"
+ integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==
+
locate-path@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
@@ -6779,6 +7019,11 @@ lru-cache@^7.7.1:
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89"
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
+lucide-react@^0.544.0:
+ version "0.544.0"
+ resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.544.0.tgz#4719953c10fd53a64dd8343bb0ed16ec79f3eeef"
+ integrity sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==
+
magic-string@^0.30.17:
version "0.30.17"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453"
@@ -6822,6 +7067,18 @@ make-fetch-happen@^10.2.1:
socks-proxy-agent "^7.0.0"
ssri "^9.0.0"
+markdown-it@^14.0.0:
+ version "14.1.0"
+ resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45"
+ integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
+ dependencies:
+ argparse "^2.0.1"
+ entities "^4.4.0"
+ linkify-it "^5.0.0"
+ mdurl "^2.0.0"
+ punycode.js "^2.3.1"
+ uc.micro "^2.1.0"
+
matcher@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/matcher/-/matcher-3.0.0.tgz#bd9060f4c5b70aa8041ccc6f80368760994f30ca"
@@ -6844,6 +7101,11 @@ maybe-combine-errors@^1.0.0:
resolved "https://registry.yarnpkg.com/maybe-combine-errors/-/maybe-combine-errors-1.0.0.tgz#e9592832e61fc47643a92cff3c1f33e27211e5be"
integrity sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A==
+mdurl@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
+ integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
+
meow@^12.0.1:
version "12.1.1"
resolved "https://registry.yarnpkg.com/meow/-/meow-12.1.1.tgz#e558dddbab12477b69b2e9a2728c327f191bace6"
@@ -7264,6 +7526,11 @@ ora@^5.1.0:
strip-ansi "^6.0.0"
wcwidth "^1.0.1"
+orderedmap@^2.0.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2"
+ integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==
+
own-keys@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358"
@@ -7499,6 +7766,160 @@ property-expr@^2.0.5:
resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8"
integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==
+prosemirror-changeset@^2.3.0:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz#eee3299cfabc7a027694e9abdc4e85505e9dd5e7"
+ integrity sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==
+ dependencies:
+ prosemirror-transform "^1.0.0"
+
+prosemirror-collab@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz#0e8c91e76e009b53457eb3b3051fb68dad029a33"
+ integrity sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==
+ dependencies:
+ prosemirror-state "^1.0.0"
+
+prosemirror-commands@^1.0.0, prosemirror-commands@^1.6.2:
+ version "1.7.1"
+ resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz#d101fef85618b1be53d5b99ea17bee5600781b38"
+ integrity sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.10.2"
+
+prosemirror-dropcursor@^1.8.1:
+ version "1.8.2"
+ resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz#2ed30c4796109ddeb1cf7282372b3850528b7228"
+ integrity sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.1.0"
+ prosemirror-view "^1.1.0"
+
+prosemirror-gapcursor@^1.3.2:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz#5fa336b83789c6199a7341c9493587e249215cb4"
+ integrity sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==
+ dependencies:
+ prosemirror-keymap "^1.0.0"
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-view "^1.0.0"
+
+prosemirror-history@^1.0.0, prosemirror-history@^1.4.1:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.4.1.tgz#cc370a46fb629e83a33946a0e12612e934ab8b98"
+ integrity sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==
+ dependencies:
+ prosemirror-state "^1.2.2"
+ prosemirror-transform "^1.0.0"
+ prosemirror-view "^1.31.0"
+ rope-sequence "^1.3.0"
+
+prosemirror-inputrules@^1.4.0:
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.5.0.tgz#e22bfaf1d6ea4fe240ad447c184af3d520d43c37"
+ integrity sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.0.0"
+
+prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.2:
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz#c0f6ab95f75c0b82c97e44eb6aaf29cbfc150472"
+ integrity sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ w3c-keyname "^2.2.0"
+
+prosemirror-markdown@^1.13.1:
+ version "1.13.2"
+ resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz#863eb3fd5f57a444e4378174622b562735b1c503"
+ integrity sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==
+ dependencies:
+ "@types/markdown-it" "^14.0.0"
+ markdown-it "^14.0.0"
+ prosemirror-model "^1.25.0"
+
+prosemirror-menu@^1.2.4:
+ version "1.2.5"
+ resolved "https://registry.yarnpkg.com/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz#dea00e7b623cea89f4d76963bee22d2ac2343250"
+ integrity sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==
+ dependencies:
+ crelt "^1.0.0"
+ prosemirror-commands "^1.0.0"
+ prosemirror-history "^1.0.0"
+ prosemirror-state "^1.0.0"
+
+prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0:
+ version "1.25.3"
+ resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.3.tgz#c657c60a361cb1e9c9f683d19118c0af50a6f7a9"
+ integrity sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==
+ dependencies:
+ orderedmap "^2.0.0"
+
+prosemirror-schema-basic@^1.2.3:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz#389ce1ec09b8a30ea9bbb92c58569cb690c2d695"
+ integrity sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==
+ dependencies:
+ prosemirror-model "^1.25.0"
+
+prosemirror-schema-list@^1.5.0:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz#5869c8f749e8745c394548bb11820b0feb1e32f5"
+ integrity sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.7.3"
+
+prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3:
+ version "1.4.3"
+ resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.3.tgz#94aecf3ffd54ec37e87aa7179d13508da181a080"
+ integrity sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-transform "^1.0.0"
+ prosemirror-view "^1.27.0"
+
+prosemirror-tables@^1.6.4:
+ version "1.8.1"
+ resolved "https://registry.yarnpkg.com/prosemirror-tables/-/prosemirror-tables-1.8.1.tgz#896a234e3e18240b629b747a871369dae78c8a9a"
+ integrity sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==
+ dependencies:
+ prosemirror-keymap "^1.2.2"
+ prosemirror-model "^1.25.0"
+ prosemirror-state "^1.4.3"
+ prosemirror-transform "^1.10.3"
+ prosemirror-view "^1.39.1"
+
+prosemirror-trailing-node@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz#5bc223d4fc1e8d9145e4079ec77a932b54e19e04"
+ integrity sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==
+ dependencies:
+ "@remirror/core-constants" "3.0.0"
+ escape-string-regexp "^4.0.0"
+
+prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.3, prosemirror-transform@^1.7.3:
+ version "1.10.4"
+ resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz#56419eac14f9f56612c806ae46f9238648f3f02e"
+ integrity sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==
+ dependencies:
+ prosemirror-model "^1.21.0"
+
+prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1:
+ version "1.41.2"
+ resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.2.tgz#e69ad3883bfd3c9f3c9cf6da5cee940210df0b6f"
+ integrity sha512-PGS/jETmh+Qjmre/6vcG7SNHAKiGc4vKOJmHMPRmvcUl7ISuVtrtHmH06UDUwaim4NDJfZfVMl7U7JkMMETa6g==
+ dependencies:
+ prosemirror-model "^1.20.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.1.0"
+
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
@@ -7517,6 +7938,11 @@ pump@^3.0.0:
end-of-stream "^1.1.0"
once "^1.3.1"
+punycode.js@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
+ integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
+
punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
@@ -7901,6 +8327,11 @@ rollup@^4.20.0:
"@rollup/rollup-win32-x64-msvc" "4.23.0"
fsevents "~2.3.2"
+rope-sequence@^1.3.0:
+ version "1.3.4"
+ resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425"
+ integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==
+
rrweb-cssom@^0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz#c73451a484b86dd7cfb1e0b2898df4b703183e4b"
@@ -8902,6 +9333,11 @@ typescript@^5.3.3, typescript@^5.4.3:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0"
integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==
+uc.micro@^2.0.0, uc.micro@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
+ integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
+
uint8-util@^2.2.2, uint8-util@^2.2.5:
version "2.2.5"
resolved "https://registry.yarnpkg.com/uint8-util/-/uint8-util-2.2.5.tgz#f1a8ff800e4e10a3ac1c82ee3667c99245123896"
@@ -9021,6 +9457,11 @@ use-sync-external-store@^1.0.0:
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9"
integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==
+use-sync-external-store@^1.4.0:
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0"
+ integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==
+
user-agents@^1.1.387:
version "1.1.387"
resolved "https://registry.yarnpkg.com/user-agents/-/user-agents-1.1.387.tgz#afc69da00b50eee7ffa17724890e755a6672b99f"
@@ -9087,6 +9528,11 @@ void-elements@3.1.0:
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"
integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==
+w3c-keyname@^2.2.0:
+ version "2.2.8"
+ resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
+ integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
+
w3c-xmlserializer@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c"
From 461da55070f1bf9384d58519cc53e5cf60be3762 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Thu, 2 Oct 2025 00:45:33 +0300
Subject: [PATCH 02/38] fix: push fix
---
.../events/catalogue/check-game-review.ts | 10 +-
.../events/catalogue/create-game-review.ts | 2 +-
src/main/events/catalogue/delete-review.ts | 2 +-
src/main/events/catalogue/get-game-reviews.ts | 2 +-
src/main/events/catalogue/vote-review.ts | 9 +-
src/preload/index.ts | 10 +-
.../src/components/game-card/game-card.tsx | 4 +-
src/renderer/src/declaration.d.ts | 2 +-
.../game-details/game-details-content.tsx | 284 +++++++++++-------
.../game-details/game-details-skeleton.tsx | 6 +-
.../src/pages/game-details/game-details.scss | 35 ++-
.../game-details/review-prompt-banner.scss | 2 +-
.../game-details/review-prompt-banner.tsx | 14 +-
.../game-details/review-sort-options.scss | 4 +-
.../game-details/review-sort-options.tsx | 21 +-
15 files changed, 242 insertions(+), 165 deletions(-)
diff --git a/src/main/events/catalogue/check-game-review.ts b/src/main/events/catalogue/check-game-review.ts
index c46ede07..5fa71e29 100644
--- a/src/main/events/catalogue/check-game-review.ts
+++ b/src/main/events/catalogue/check-game-review.ts
@@ -7,11 +7,9 @@ const checkGameReview = async (
shop: GameShop,
objectId: string
) => {
- return HydraApi.get(
- `/games/${shop}/${objectId}/reviews/check`,
- null,
- { needsAuth: true }
- );
+ return HydraApi.get(`/games/${shop}/${objectId}/reviews/check`, null, {
+ needsAuth: true,
+ });
};
-registerEvent("checkGameReview", checkGameReview);
\ No newline at end of file
+registerEvent("checkGameReview", checkGameReview);
diff --git a/src/main/events/catalogue/create-game-review.ts b/src/main/events/catalogue/create-game-review.ts
index 7f29b639..57c74d45 100644
--- a/src/main/events/catalogue/create-game-review.ts
+++ b/src/main/events/catalogue/create-game-review.ts
@@ -15,4 +15,4 @@ const createGameReview = async (
});
};
-registerEvent("createGameReview", createGameReview);
\ No newline at end of file
+registerEvent("createGameReview", createGameReview);
diff --git a/src/main/events/catalogue/delete-review.ts b/src/main/events/catalogue/delete-review.ts
index 2048b3e7..e617a288 100644
--- a/src/main/events/catalogue/delete-review.ts
+++ b/src/main/events/catalogue/delete-review.ts
@@ -11,4 +11,4 @@ const deleteReview = async (
return HydraApi.delete(`/games/${shop}/${objectId}/reviews/${reviewId}`);
};
-registerEvent("deleteReview", deleteReview);
\ No newline at end of file
+registerEvent("deleteReview", deleteReview);
diff --git a/src/main/events/catalogue/get-game-reviews.ts b/src/main/events/catalogue/get-game-reviews.ts
index d3c31780..8f29db3f 100644
--- a/src/main/events/catalogue/get-game-reviews.ts
+++ b/src/main/events/catalogue/get-game-reviews.ts
@@ -23,4 +23,4 @@ const getGameReviews = async (
);
};
-registerEvent("getGameReviews", getGameReviews);
\ No newline at end of file
+registerEvent("getGameReviews", getGameReviews);
diff --git a/src/main/events/catalogue/vote-review.ts b/src/main/events/catalogue/vote-review.ts
index b60062c3..a562eada 100644
--- a/src/main/events/catalogue/vote-review.ts
+++ b/src/main/events/catalogue/vote-review.ts
@@ -7,9 +7,12 @@ const voteReview = async (
shop: GameShop,
objectId: string,
reviewId: string,
- voteType: 'upvote' | 'downvote'
+ voteType: "upvote" | "downvote"
) => {
- return HydraApi.put(`/games/${shop}/${objectId}/reviews/${reviewId}/${voteType}`, {});
+ return HydraApi.put(
+ `/games/${shop}/${objectId}/reviews/${reviewId}/${voteType}`,
+ {}
+ );
};
-registerEvent("voteReview", voteReview);
\ No newline at end of file
+registerEvent("voteReview", voteReview);
diff --git a/src/preload/index.ts b/src/preload/index.ts
index eda43369..7596fd11 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -82,7 +82,8 @@ contextBridge.exposeInMainWorld("electron", {
objectId: string,
reviewHtml: string,
score: number
- ) => ipcRenderer.invoke("createGameReview", shop, objectId, reviewHtml, score),
+ ) =>
+ ipcRenderer.invoke("createGameReview", shop, objectId, reviewHtml, score),
getGameReviews: (
shop: GameShop,
objectId: string,
@@ -96,11 +97,8 @@ contextBridge.exposeInMainWorld("electron", {
reviewId: string,
voteType: "upvote" | "downvote"
) => ipcRenderer.invoke("voteReview", shop, objectId, reviewId, voteType),
- deleteReview: (
- shop: GameShop,
- objectId: string,
- reviewId: string
- ) => ipcRenderer.invoke("deleteReview", shop, objectId, reviewId),
+ deleteReview: (shop: GameShop, objectId: string, reviewId: string) =>
+ ipcRenderer.invoke("deleteReview", shop, objectId, reviewId),
checkGameReview: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("checkGameReview", shop, objectId),
onUpdateAchievements: (
diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx
index cb9a060c..15b5439b 100644
--- a/src/renderer/src/components/game-card/game-card.tsx
+++ b/src/renderer/src/components/game-card/game-card.tsx
@@ -110,9 +110,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
{stats?.averageScore && (
-
- {stats.averageScore.toFixed(1)}
-
+ {stats.averageScore.toFixed(1)}
)}
diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts
index 752a1115..c1e06a89 100644
--- a/src/renderer/src/declaration.d.ts
+++ b/src/renderer/src/declaration.d.ts
@@ -110,7 +110,7 @@ declare global {
shop: GameShop,
objectId: string,
reviewId: string,
- voteType: 'upvote' | 'downvote'
+ voteType: "upvote" | "downvote"
) => Promise;
deleteReview: (
shop: GameShop,
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 48228e8e..0c53177f 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -2,11 +2,11 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { PencilIcon, TrashIcon, ClockIcon } from "@primer/octicons-react";
import { ThumbsUp, ThumbsDown } from "lucide-react";
import { useNavigate } from "react-router-dom";
-import { useEditor, EditorContent } from '@tiptap/react';
-import StarterKit from '@tiptap/starter-kit';
-import Bold from '@tiptap/extension-bold';
-import Italic from '@tiptap/extension-italic';
-import Underline from '@tiptap/extension-underline';
+import { useEditor, EditorContent } from "@tiptap/react";
+import StarterKit from "@tiptap/starter-kit";
+import Bold from "@tiptap/extension-bold";
+import Italic from "@tiptap/extension-italic";
+import Underline from "@tiptap/extension-underline";
import type { GameReview } from "@types";
import { HeroPanel } from "./hero";
@@ -32,8 +32,14 @@ export function GameDetailsContent() {
const { t } = useTranslation("game_details");
- const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame, shop } =
- useContext(gameDetailsContext);
+ const {
+ objectId,
+ shopDetails,
+ game,
+ hasNSFWContentBlocked,
+ updateGame,
+ shop,
+ } = useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription();
@@ -93,7 +99,7 @@ export function GameDetailsContent() {
const [backdropOpacity, setBackdropOpacity] = useState(1);
const [showEditGameModal, setShowEditGameModal] = useState(false);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
-
+
// Reviews state management
const [reviews, setReviews] = useState([]);
const [reviewsLoading, setReviewsLoading] = useState(false);
@@ -102,10 +108,12 @@ export function GameDetailsContent() {
const [reviewsSortBy, setReviewsSortBy] = useState("newest");
const [reviewsPage, setReviewsPage] = useState(0);
const [hasMoreReviews, setHasMoreReviews] = useState(true);
- const [visibleBlockedReviews, setVisibleBlockedReviews] = useState>(new Set());
+ const [visibleBlockedReviews, setVisibleBlockedReviews] = useState<
+ Set
+ >(new Set());
const [totalReviewCount, setTotalReviewCount] = useState(0);
const [showReviewForm, setShowReviewForm] = useState(false);
-
+
// Review prompt banner state
const [showReviewPrompt, setShowReviewPrompt] = useState(false);
const [hasUserReviewed, setHasUserReviewed] = useState(false);
@@ -113,17 +121,12 @@ export function GameDetailsContent() {
// Tiptap editor for review input
const editor = useEditor({
- extensions: [
- StarterKit,
- Bold,
- Italic,
- Underline,
- ],
- content: '',
+ extensions: [StarterKit, Bold, Italic, Underline],
+ content: "",
editorProps: {
attributes: {
- class: 'game-details__review-editor',
- 'data-placeholder': t("write_review_placeholder"),
+ class: "game-details__review-editor",
+ "data-placeholder": t("write_review_placeholder"),
},
},
});
@@ -164,15 +167,19 @@ export function GameDetailsContent() {
// Reviews functions
const checkUserReview = async () => {
if (!objectId || !userDetails) return;
-
+
setReviewCheckLoading(true);
try {
const response = await window.electron.checkGameReview(shop, objectId);
const hasReviewed = (response as any)?.hasReviewed || false;
setHasUserReviewed(hasReviewed);
-
+
// Show prompt only if user hasn't reviewed and has played the game
- if (!hasReviewed && game?.playTimeInMilliseconds && game.playTimeInMilliseconds > 0) {
+ if (
+ !hasReviewed &&
+ game?.playTimeInMilliseconds &&
+ game.playTimeInMilliseconds > 0
+ ) {
setShowReviewPrompt(true);
}
} catch (error) {
@@ -184,7 +191,7 @@ export function GameDetailsContent() {
const loadReviews = async (reset = false) => {
if (!objectId) return;
-
+
setReviewsLoading(true);
try {
const skip = reset ? 0 : reviewsPage * 20;
@@ -195,19 +202,19 @@ export function GameDetailsContent() {
skip,
reviewsSortBy
);
-
+
// Handle the response structure: { totalCount: number, reviews: Review[] }
const reviewsData = (response as any)?.reviews || [];
const reviewCount = (response as any)?.totalCount || 0;
-
+
if (reset) {
setReviews(reviewsData);
setReviewsPage(0);
setTotalReviewCount(reviewCount);
} else {
- setReviews(prev => [...prev, ...reviewsData]);
+ setReviews((prev) => [...prev, ...reviewsData]);
}
-
+
setHasMoreReviews(reviewsData.length === 20);
} catch (error) {
console.error("Failed to load reviews:", error);
@@ -216,9 +223,12 @@ export function GameDetailsContent() {
}
};
- const handleVoteReview = async (reviewId: string, voteType: 'upvote' | 'downvote') => {
+ const handleVoteReview = async (
+ reviewId: string,
+ voteType: "upvote" | "downvote"
+ ) => {
if (!objectId) return;
-
+
try {
await window.electron.voteReview(shop, objectId, reviewId, voteType);
// Reload reviews to get updated vote counts
@@ -230,13 +240,13 @@ export function GameDetailsContent() {
const handleDeleteReview = async (reviewId: string) => {
if (!objectId) return;
-
+
try {
await window.electron.deleteReview(shop, objectId, reviewId);
// Reload reviews after deletion
loadReviews(true);
} catch (error) {
- console.error('Failed to delete review:', error);
+ console.error("Failed to delete review:", error);
}
};
@@ -244,17 +254,17 @@ export function GameDetailsContent() {
console.log("handleSubmitReview called");
console.log("game:", game);
console.log("objectId:", objectId);
-
- const reviewHtml = editor?.getHTML() || '';
+
+ const reviewHtml = editor?.getHTML() || "";
console.log("reviewHtml:", reviewHtml);
console.log("reviewScore:", reviewScore);
console.log("submittingReview:", submittingReview);
-
+
if (!objectId || !reviewHtml.trim() || submittingReview) {
console.log("Early return - validation failed");
return;
}
-
+
console.log("Starting review submission...");
setSubmittingReview(true);
try {
@@ -265,7 +275,7 @@ export function GameDetailsContent() {
reviewHtml,
reviewScore
);
-
+
console.log("Review submitted successfully");
editor?.commands.clearContent();
setReviewScore(5);
@@ -285,14 +295,16 @@ export function GameDetailsContent() {
const handleReviewPromptYes = () => {
setShowReviewPrompt(false);
setShowReviewForm(true);
-
+
// Scroll to review form
setTimeout(() => {
- const reviewFormElement = document.querySelector('.game-details__review-form');
+ const reviewFormElement = document.querySelector(
+ ".game-details__review-form"
+ );
if (reviewFormElement) {
- reviewFormElement.scrollIntoView({
- behavior: 'smooth',
- block: 'start'
+ reviewFormElement.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
});
}
}, 100);
@@ -310,7 +322,7 @@ export function GameDetailsContent() {
};
const toggleBlockedReview = (reviewId: string) => {
- setVisibleBlockedReviews(prev => {
+ setVisibleBlockedReviews((prev) => {
const newSet = new Set(prev);
if (newSet.has(reviewId)) {
newSet.delete(reviewId);
@@ -323,7 +335,7 @@ export function GameDetailsContent() {
const loadMoreReviews = () => {
if (!reviewsLoading && hasMoreReviews) {
- setReviewsPage(prev => prev + 1);
+ setReviewsPage((prev) => prev + 1);
loadReviews(false);
}
};
@@ -457,13 +469,17 @@ export function GameDetailsContent() {
{/* Review Prompt Banner */}
- {showReviewPrompt && userDetails && game?.playTimeInMilliseconds && !hasUserReviewed && !reviewCheckLoading && (
-
- )}
-
+ {showReviewPrompt &&
+ userDetails &&
+ game?.playTimeInMilliseconds &&
+ !hasUserReviewed &&
+ !reviewCheckLoading && (
+
+ )}
+
@@ -472,10 +488,12 @@ export function GameDetailsContent() {
__html: aboutTheGame,
}}
className={`game-details__description ${
- isDescriptionExpanded ? 'game-details__description--expanded' : 'game-details__description--collapsed'
+ isDescriptionExpanded
+ ? "game-details__description--expanded"
+ : "game-details__description--collapsed"
}`}
/>
-
+
{aboutTheGame && aboutTheGame.length > 500 && (
-
@@ -589,7 +621,7 @@ export function GameDetailsContent() {
{t("loading_reviews")}
)}
-
+
{!reviewsLoading && reviews.length === 0 && (
📝
@@ -601,13 +633,14 @@ export function GameDetailsContent() {
)}
-
+
{reviews.map((review, index) => (
- {review.isBlocked && !visibleBlockedReviews.has(review.id) ? (
+ {review.isBlocked &&
+ !visibleBlockedReviews.has(review.id) ? (
- Review from blocked user —
-
toggleBlockedReview(review.id)}
>
@@ -619,22 +652,38 @@ export function GameDetailsContent() {
{review.user?.profileImageUrl && (
-

)}
-
review.user?.id && navigate(`/profile/${review.user.id}`)}
+ onClick={() =>
+ review.user?.id &&
+ navigate(`/profile/${review.user.id}`)
+ }
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ review.user?.id &&
+ navigate(`/profile/${review.user.id}`);
+ }
+ }}
+ role="button"
+ tabIndex={0}
>
- {review.user?.displayName || 'Anonymous'}
+ {review.user?.displayName || "Anonymous"}
- {formatDistance(new Date(review.createdAt), new Date(), { addSuffix: true })}
+ {formatDistance(
+ new Date(review.createdAt),
+ new Date(),
+ { addSuffix: true }
+ )}
@@ -642,29 +691,35 @@ export function GameDetailsContent() {
{review.score}/10
-
- handleVoteReview(review.id, 'upvote')}
+
+ handleVoteReview(review.id, "upvote")
+ }
>
{review.upvotes || 0}
- handleVoteReview(review.id, 'downvote')}
+
+ handleVoteReview(review.id, "downvote")
+ }
>
{review.downvotes || 0}
{userDetails?.id === review.user?.id && (
-
handleDeleteReview(review.id)}
title={t("delete_review")}
@@ -672,20 +727,21 @@ export function GameDetailsContent() {
)}
- {review.isBlocked && visibleBlockedReviews.has(review.id) && (
-
toggleBlockedReview(review.id)}
- >
- Hide
-
- )}
+ {review.isBlocked &&
+ visibleBlockedReviews.has(review.id) && (
+
toggleBlockedReview(review.id)}
+ >
+ Hide
+
+ )}
>
)}
))}
-
+
{hasMoreReviews && !reviewsLoading && (
)}
-
+
{reviewsLoading && reviews.length > 0 && (
{t("loading_more_reviews")}
diff --git a/src/renderer/src/pages/game-details/game-details-skeleton.tsx b/src/renderer/src/pages/game-details/game-details-skeleton.tsx
index adaf4ab2..750f92e1 100644
--- a/src/renderer/src/pages/game-details/game-details-skeleton.tsx
+++ b/src/renderer/src/pages/game-details/game-details-skeleton.tsx
@@ -35,7 +35,11 @@ export function GameDetailsSkeleton() {
))}
-
+
diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss
index b82bd6b1..f6b724ab 100644
--- a/src/renderer/src/pages/game-details/game-details.scss
+++ b/src/renderer/src/pages/game-details/game-details.scss
@@ -86,7 +86,9 @@ $hero-height: 300px;
font-size: globals.$body-font-size;
font-family: inherit;
cursor: pointer;
- transition: border-color 0.2s ease, background-color 0.2s ease;
+ transition:
+ border-color 0.2s ease,
+ background-color 0.2s ease;
&:focus {
outline: none;
@@ -126,7 +128,9 @@ $hero-height: 300px;
font-size: globals.$body-font-size;
font-family: inherit;
cursor: pointer;
- transition: border-color 0.2s ease, background-color 0.2s ease;
+ transition:
+ border-color 0.2s ease,
+ background-color 0.2s ease;
&:focus {
outline: none;
@@ -148,7 +152,8 @@ $hero-height: 300px;
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
color: globals.$body-color;
- padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1.5);
+ padding: calc(globals.$spacing-unit * 0.75)
+ calc(globals.$spacing-unit * 1.5);
border-radius: 6px;
cursor: pointer;
font-size: globals.$small-font-size;
@@ -631,9 +636,9 @@ $hero-height: 300px;
max-height: 300px;
overflow: hidden;
position: relative;
-
+
&::after {
- content: '';
+ content: "";
position: absolute;
bottom: 0;
left: 0;
@@ -677,7 +682,8 @@ $hero-height: 300px;
background: none;
border: 1px solid globals.$border-color;
color: globals.$body-color;
- padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1.5);
+ padding: calc(globals.$spacing-unit * 0.75)
+ calc(globals.$spacing-unit * 1.5);
border-radius: 4px;
cursor: pointer;
font-size: globals.$body-font-size;
@@ -883,8 +889,13 @@ $hero-height: 300px;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
- padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1.5);
- background: linear-gradient(135deg, globals.$brand-teal, globals.$brand-blue);
+ padding: calc(globals.$spacing-unit * 0.75)
+ calc(globals.$spacing-unit * 1.5);
+ background: linear-gradient(
+ 135deg,
+ globals.$brand-teal,
+ globals.$brand-blue
+ );
color: white;
border: none;
border-radius: 8px;
@@ -980,7 +991,9 @@ $hero-height: 300px;
font-family: inherit;
line-height: 1.5;
min-height: 100px;
- transition: border-color 0.2s ease, background-color 0.2s ease;
+ transition:
+ border-color 0.2s ease,
+ background-color 0.2s ease;
&:focus-within {
outline: none;
@@ -995,7 +1008,7 @@ $hero-height: 300px;
.ProseMirror {
outline: none;
min-height: 80px;
-
+
&:empty:before {
content: attr(data-placeholder);
color: rgba(208, 209, 215, 0.6);
@@ -1004,7 +1017,7 @@ $hero-height: 300px;
p {
margin: 0 0 8px 0;
-
+
&:last-child {
margin-bottom: 0;
}
diff --git a/src/renderer/src/pages/game-details/review-prompt-banner.scss b/src/renderer/src/pages/game-details/review-prompt-banner.scss
index 28ba1e47..b8f7557b 100644
--- a/src/renderer/src/pages/game-details/review-prompt-banner.scss
+++ b/src/renderer/src/pages/game-details/review-prompt-banner.scss
@@ -43,4 +43,4 @@
gap: globals.$spacing-unit;
align-items: center;
}
-}
\ No newline at end of file
+}
diff --git a/src/renderer/src/pages/game-details/review-prompt-banner.tsx b/src/renderer/src/pages/game-details/review-prompt-banner.tsx
index 87c1b170..7bd96613 100644
--- a/src/renderer/src/pages/game-details/review-prompt-banner.tsx
+++ b/src/renderer/src/pages/game-details/review-prompt-banner.tsx
@@ -18,27 +18,21 @@ export function ReviewPromptBanner({
- You've seemed to enjoy this game
+ You've seemed to enjoy this game
{t("would_you_recommend_this_game")}
-
+
{t("yes")}
-
+
{t("maybe_later")}
);
-}
\ No newline at end of file
+}
diff --git a/src/renderer/src/pages/game-details/review-sort-options.scss b/src/renderer/src/pages/game-details/review-sort-options.scss
index e982cb24..5b374728 100644
--- a/src/renderer/src/pages/game-details/review-sort-options.scss
+++ b/src/renderer/src/pages/game-details/review-sort-options.scss
@@ -50,7 +50,7 @@
span {
display: inline-block;
-
+
@media (max-width: 480px) {
display: none;
}
@@ -69,4 +69,4 @@
display: none;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/renderer/src/pages/game-details/review-sort-options.tsx b/src/renderer/src/pages/game-details/review-sort-options.tsx
index 5ec25c31..858faefd 100644
--- a/src/renderer/src/pages/game-details/review-sort-options.tsx
+++ b/src/renderer/src/pages/game-details/review-sort-options.tsx
@@ -1,15 +1,28 @@
-import { CalendarIcon, StarIcon, ThumbsupIcon, ClockIcon } from "@primer/octicons-react";
+import {
+ CalendarIcon,
+ StarIcon,
+ ThumbsupIcon,
+ ClockIcon,
+} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./review-sort-options.scss";
-type ReviewSortOption = "newest" | "oldest" | "score_high" | "score_low" | "most_voted";
+type ReviewSortOption =
+ | "newest"
+ | "oldest"
+ | "score_high"
+ | "score_low"
+ | "most_voted";
interface ReviewSortOptionsProps {
sortBy: ReviewSortOption;
onSortChange: (sortBy: ReviewSortOption) => void;
}
-export function ReviewSortOptions({ sortBy, onSortChange }: ReviewSortOptionsProps) {
+export function ReviewSortOptions({
+ sortBy,
+ onSortChange,
+}: ReviewSortOptionsProps) {
const { t } = useTranslation("game_details");
return (
@@ -57,4 +70,4 @@ export function ReviewSortOptions({ sortBy, onSortChange }: ReviewSortOptionsPro
);
-}
\ No newline at end of file
+}
From 19cf24ef489e04045c9b9d3c8e3facb8e4c23f06 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Thu, 2 Oct 2025 01:30:44 +0300
Subject: [PATCH 03/38] feat: changed profile pictures in reviews to squares,
changed sorting buttons
---
.../game-details/game-details-content.tsx | 2 +-
.../src/pages/game-details/game-details.scss | 2 +-
.../game-details/review-sort-options.scss | 15 +++++
.../game-details/review-sort-options.tsx | 59 ++++++++++---------
4 files changed, 47 insertions(+), 31 deletions(-)
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 0c53177f..6f2558ef 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -666,7 +666,7 @@ export function GameDetailsContent() {
navigate(`/profile/${review.user.id}`)
}
onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === ' ') {
+ if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
review.user?.id &&
navigate(`/profile/${review.user.id}`);
diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss
index f6b724ab..fe097839 100644
--- a/src/renderer/src/pages/game-details/game-details.scss
+++ b/src/renderer/src/pages/game-details/game-details.scss
@@ -252,7 +252,7 @@ $hero-height: 300px;
&__review-avatar {
width: 32px;
height: 32px;
- border-radius: 50%;
+ border-radius: 4px;
object-fit: cover;
border: 2px solid rgba(255, 255, 255, 0.1);
}
diff --git a/src/renderer/src/pages/game-details/review-sort-options.scss b/src/renderer/src/pages/game-details/review-sort-options.scss
index 5b374728..eafe9972 100644
--- a/src/renderer/src/pages/game-details/review-sort-options.scss
+++ b/src/renderer/src/pages/game-details/review-sort-options.scss
@@ -61,6 +61,21 @@
}
}
+ &__toggle-option {
+ &.active {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 4px;
+ padding: 6px 8px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ }
+
+ &:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 4px;
+ padding: 6px 8px;
+ }
+ }
+
&__separator {
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
diff --git a/src/renderer/src/pages/game-details/review-sort-options.tsx b/src/renderer/src/pages/game-details/review-sort-options.tsx
index 858faefd..fc7f431a 100644
--- a/src/renderer/src/pages/game-details/review-sort-options.tsx
+++ b/src/renderer/src/pages/game-details/review-sort-options.tsx
@@ -1,8 +1,7 @@
import {
- CalendarIcon,
- StarIcon,
ThumbsupIcon,
- ClockIcon,
+ ChevronUpIcon,
+ ChevronDownIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./review-sort-options.scss";
@@ -25,44 +24,46 @@ export function ReviewSortOptions({
}: ReviewSortOptionsProps) {
const { t } = useTranslation("game_details");
+ const handleDateToggle = () => {
+ const newSort = sortBy === "newest" ? "oldest" : "newest";
+ onSortChange(newSort);
+ };
+
+ const handleScoreToggle = () => {
+ const newSort = sortBy === "score_high" ? "score_low" : "score_high";
+ onSortChange(newSort);
+ };
+
+ const handleMostVotedClick = () => {
+ onSortChange("most_voted");
+ };
+
+ const isDateActive = sortBy === "newest" || sortBy === "oldest";
+ const isScoreActive = sortBy === "score_high" || sortBy === "score_low";
+ const isMostVotedActive = sortBy === "most_voted";
+
return (
onSortChange("newest")}
+ className={`review-sort-options__option review-sort-options__toggle-option ${isDateActive ? "active" : ""}`}
+ onClick={handleDateToggle}
>
-
- {t("sort_newest")}
+ {sortBy === "newest" ? : }
+ {sortBy === "oldest" ? t("sort_oldest") : t("sort_newest")}
|
onSortChange("oldest")}
+ className={`review-sort-options__option review-sort-options__toggle-option ${isScoreActive ? "active" : ""}`}
+ onClick={handleScoreToggle}
>
-
- {t("sort_oldest")}
+ {sortBy === "score_high" ? : }
+ {sortBy === "score_low" ? t("sort_lowest_score") : t("sort_highest_score")}
|
onSortChange("score_high")}
- >
-
- {t("sort_highest_score")}
-
-
|
-
onSortChange("score_low")}
- >
-
- {t("sort_lowest_score")}
-
-
|
-
onSortChange("most_voted")}
+ className={`review-sort-options__option ${isMostVotedActive ? "active" : ""}`}
+ onClick={handleMostVotedClick}
>
{t("sort_most_voted")}
From 449ea92268b63da455fee47ac2133f3e242d5ffe Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Thu, 2 Oct 2025 19:04:25 +0300
Subject: [PATCH 04/38] feat: Added karma to user profile, added warning modal
before deleting review, fixed sorting buttons for reviews
---
src/locales/en/translation.json | 11 +-
.../game-details/game-details-content.tsx | 480 +++++++++---------
.../src/pages/game-details/game-details.scss | 2 -
.../modals/delete-review-modal.scss | 19 +
.../modals/delete-review-modal.tsx | 45 ++
.../src/pages/game-details/modals/index.ts | 1 +
.../game-details/review-sort-options.scss | 18 +-
.../game-details/review-sort-options.tsx | 22 +-
.../profile-content/profile-content.tsx | 2 +
.../profile-content/user-karma-box.scss | 47 ++
.../profile-content/user-karma-box.tsx | 40 ++
src/types/index.ts | 1 +
12 files changed, 433 insertions(+), 255 deletions(-)
create mode 100644 src/renderer/src/pages/game-details/modals/delete-review-modal.scss
create mode 100644 src/renderer/src/pages/game-details/modals/delete-review-modal.tsx
create mode 100644 src/renderer/src/pages/profile/profile-content/user-karma-box.scss
create mode 100644 src/renderer/src/pages/profile/profile-content/user-karma-box.tsx
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index b0fee465..c42f000e 100755
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -317,7 +317,11 @@
"caption": "Caption",
"audio": "Audio",
"filter_by_source": "Filter by source",
- "no_repacks_found": "No sources found for this game"
+ "no_repacks_found": "No sources found for this game",
+"delete_review": "Delete review",
+ "delete_review_modal_title": "Delete Review",
+ "delete_review_modal_description": "Are you sure you want to delete your review? This action cannot be undone.",
+ "delete_review_karma_warning": "You will lose any karma points earned from this review."
},
"activation": {
"title": "Activate Hydra",
@@ -624,7 +628,10 @@
"error_adding_friend": "Could not send friend request. Please check friend code",
"friend_code_length_error": "Friend code must have 8 characters",
"game_removed_from_pinned": "Game removed from pinned",
- "game_added_to_pinned": "Game added to pinned"
+ "game_added_to_pinned": "Game added to pinned",
+ "karma": "Karma",
+ "karma_count": "karma",
+ "karma_description": "Earned from positive likes on your reviews"
},
"achievement": {
"achievement_unlocked": "Achievement unlocked",
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 6f2558ef..de66e5a6 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -4,16 +4,13 @@ import { ThumbsUp, ThumbsDown } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
-import Bold from "@tiptap/extension-bold";
-import Italic from "@tiptap/extension-italic";
-import Underline from "@tiptap/extension-underline";
import type { GameReview } from "@types";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
import { GallerySlider } from "./gallery-slider/gallery-slider";
import { Sidebar } from "./sidebar/sidebar";
-import { EditGameModal } from "./modals";
+import { EditGameModal, DeleteReviewModal } from "./modals";
import { ReviewSortOptions } from "./review-sort-options";
import { ReviewPromptBanner } from "./review-prompt-banner";
@@ -98,6 +95,8 @@ export function GameDetailsContent() {
const [backdropOpacity, setBackdropOpacity] = useState(1);
const [showEditGameModal, setShowEditGameModal] = useState(false);
+ const [showDeleteReviewModal, setShowDeleteReviewModal] = useState(false);
+ const [reviewToDelete, setReviewToDelete] = useState(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
// Reviews state management
@@ -121,7 +120,7 @@ export function GameDetailsContent() {
// Tiptap editor for review input
const editor = useEditor({
- extensions: [StarterKit, Bold, Italic, Underline],
+ extensions: [StarterKit],
content: "",
editorProps: {
attributes: {
@@ -239,12 +238,19 @@ export function GameDetailsContent() {
};
const handleDeleteReview = async (reviewId: string) => {
- if (!objectId) return;
+ setReviewToDelete(reviewId);
+ setShowDeleteReviewModal(true);
+ };
+
+ const confirmDeleteReview = async () => {
+ if (!objectId || !reviewToDelete) return;
try {
- await window.electron.deleteReview(shop, objectId, reviewId);
+ await window.electron.deleteReview(shop, objectId, reviewToDelete);
// Reload reviews after deletion
loadReviews(true);
+ setShowDeleteReviewModal(false);
+ setReviewToDelete(null);
} catch (error) {
console.error("Failed to delete review:", error);
}
@@ -469,7 +475,8 @@ export function GameDetailsContent() {
{/* Review Prompt Banner */}
- {showReviewPrompt &&
+ {game?.shop !== "custom" &&
+ showReviewPrompt &&
userDetails &&
game?.playTimeInMilliseconds &&
!hasUserReviewed &&
@@ -504,260 +511,262 @@ export function GameDetailsContent() {
)}
-
- {showReviewForm && (
- <>
-
-
- {t("leave_a_review")}
-
-
+ {game?.shop !== "custom" && (
+
+ {showReviewForm && (
+ <>
+
+
+ {t("leave_a_review")}
+
+
+
+
+
+
+
+
+
+ editor?.chain().focus().toggleBold().run()
+ }
+ className={`game-details__editor-button ${editor?.isActive("bold") ? "is-active" : ""}`}
+ disabled={!editor}
+ >
+ B
+
+
+ editor?.chain().focus().toggleItalic().run()
+ }
+ className={`game-details__editor-button ${editor?.isActive("italic") ? "is-active" : ""}`}
+ disabled={!editor}
+ >
+ I
+
+
+ editor?.chain().focus().toggleUnderline().run()
+ }
+ className={`game-details__editor-button ${editor?.isActive("underline") ? "is-active" : ""}`}
+ disabled={!editor}
+ >
+ U
+
+
-
-
-
-
-
- editor?.chain().focus().toggleBold().run()
+ className="game-details__review-submit-button"
+ onClick={handleSubmitReview}
+ disabled={
+ !editor?.getHTML().trim() || submittingReview
}
- className={`game-details__editor-button ${editor?.isActive("bold") ? "is-active" : ""}`}
- disabled={!editor}
>
- B
-
-
- editor?.chain().focus().toggleItalic().run()
- }
- className={`game-details__editor-button ${editor?.isActive("italic") ? "is-active" : ""}`}
- disabled={!editor}
- >
- I
-
-
- editor?.chain().focus().toggleUnderline().run()
- }
- className={`game-details__editor-button ${editor?.isActive("underline") ? "is-active" : ""}`}
- disabled={!editor}
- >
- U
+ {submittingReview
+ ? t("submitting")
+ : t("submit_review")}
+
-
- {submittingReview
- ? t("submitting")
- : t("submit_review")}
-
+
+
+
+
+
+ >
+ )}
-
-
-
-
-
+ {showReviewForm && (
+
+ )}
+
+
+
+
+
+ {t("reviews")}
+
+
+ {totalReviewCount}
+
- >
- )}
-
- {showReviewForm && (
-
- )}
-
-
-
-
-
- {t("reviews")}
-
-
- {totalReviewCount}
-
-
-
- {reviewsLoading && reviews.length === 0 && (
-
- {t("loading_reviews")}
-
- )}
+ {reviewsLoading && reviews.length === 0 && (
+
+ {t("loading_reviews")}
+
+ )}
- {!reviewsLoading && reviews.length === 0 && (
-
-
📝
-
- {t("no_reviews_yet")}
-
-
- {t("be_first_to_review")}
-
-
- )}
+ {!reviewsLoading && reviews.length === 0 && (
+
+
📝
+
+ {t("no_reviews_yet")}
+
+
+ {t("be_first_to_review")}
+
+
+ )}
- {reviews.map((review, index) => (
-
- {review.isBlocked &&
- !visibleBlockedReviews.has(review.id) ? (
-
- Review from blocked user —
- toggleBlockedReview(review.id)}
- >
- Show
-
-
- ) : (
- <>
-
-
- {review.user?.profileImageUrl && (
-

- )}
-
-
- review.user?.id &&
- navigate(`/profile/${review.user.id}`)
- }
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
+ {reviews.map((review, index) => (
+
+ {review.isBlocked &&
+ !visibleBlockedReviews.has(review.id) ? (
+
+ Review from blocked user —
+ toggleBlockedReview(review.id)}
+ >
+ Show
+
+
+ ) : (
+ <>
+
+
+ {review.user?.profileImageUrl && (
+

+ )}
+
+
review.user?.id &&
- navigate(`/profile/${review.user.id}`);
+ navigate(`/profile/${review.user.id}`)
}
- }}
- role="button"
- tabIndex={0}
- >
- {review.user?.displayName || "Anonymous"}
-
-
-
- {formatDistance(
- new Date(review.createdAt),
- new Date(),
- { addSuffix: true }
- )}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ review.user?.id &&
+ navigate(`/profile/${review.user.id}`);
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ >
+ {review.user?.displayName || "Anonymous"}
+
+
+
+ {formatDistance(
+ new Date(review.createdAt),
+ new Date(),
+ { addSuffix: true }
+ )}
+
+
+ {review.score}/10
+
-
- {review.score}/10
-
-
-
-
-
-
- handleVoteReview(review.id, "upvote")
- }
- >
-
- {review.upvotes || 0}
-
-
- handleVoteReview(review.id, "downvote")
- }
- >
-
- {review.downvotes || 0}
-
-
- {userDetails?.id === review.user?.id && (
-
handleDeleteReview(review.id)}
- title={t("delete_review")}
- >
-
-
- )}
- {review.isBlocked &&
- visibleBlockedReviews.has(review.id) && (
+
+
+
toggleBlockedReview(review.id)}
+ className={`game-details__vote-button game-details__vote-button--upvote ${review.hasUpvoted ? "game-details__vote-button--active" : ""}`}
+ onClick={() =>
+ handleVoteReview(review.id, "upvote")
+ }
>
- Hide
+
+ {review.upvotes || 0}
+
+
+ handleVoteReview(review.id, "downvote")
+ }
+ >
+
+ {review.downvotes || 0}
+
+
+ {userDetails?.id === review.user?.id && (
+
handleDeleteReview(review.id)}
+ title={t("delete_review")}
+ >
+
)}
-
- >
- )}
-
- ))}
+ {review.isBlocked &&
+ visibleBlockedReviews.has(review.id) && (
+
toggleBlockedReview(review.id)}
+ >
+ Hide
+
+ )}
+
+ >
+ )}
+
+ ))}
- {hasMoreReviews && !reviewsLoading && (
-
- {t("load_more_reviews")}
-
- )}
+ {hasMoreReviews && !reviewsLoading && (
+
+ {t("load_more_reviews")}
+
+ )}
- {reviewsLoading && reviews.length > 0 && (
-
- {t("loading_more_reviews")}
-
- )}
+ {reviewsLoading && reviews.length > 0 && (
+
+ {t("loading_more_reviews")}
+
+ )}
+
-
+ )}
{game?.shop !== "custom" &&
}
@@ -773,6 +782,15 @@ export function GameDetailsContent() {
onGameUpdated={handleGameUpdated}
/>
)}
+
+
{
+ setShowDeleteReviewModal(false);
+ setReviewToDelete(null);
+ }}
+ onConfirm={confirmDeleteReview}
+ />
);
}
diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss
index fe097839..da3745e9 100644
--- a/src/renderer/src/pages/game-details/game-details.scss
+++ b/src/renderer/src/pages/game-details/game-details.scss
@@ -196,7 +196,6 @@ $hero-height: 300px;
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: calc(globals.$spacing-unit * 2);
padding-bottom: calc(globals.$spacing-unit * 1);
}
@@ -850,7 +849,6 @@ $hero-height: 300px;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 2);
- gap: calc(globals.$spacing-unit * 2);
@media (max-width: 768px) {
flex-direction: column;
diff --git a/src/renderer/src/pages/game-details/modals/delete-review-modal.scss b/src/renderer/src/pages/game-details/modals/delete-review-modal.scss
new file mode 100644
index 00000000..40ad6e59
--- /dev/null
+++ b/src/renderer/src/pages/game-details/modals/delete-review-modal.scss
@@ -0,0 +1,19 @@
+@use "../../../scss/globals.scss";
+
+.delete-review-modal {
+ &__karma-warning {
+ background-color: rgba(255, 193, 7, 0.1);
+ border: 1px solid rgba(255, 193, 7, 0.3);
+ border-radius: 4px;
+ padding: 12px;
+ margin-bottom: 16px;
+ color: #ffc107;
+ font-size: 14px;
+ font-weight: 500;
+ }
+
+ &__actions {
+ display: flex;
+ justify-content: flex-end;
+ }
+}
\ No newline at end of file
diff --git a/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx b/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx
new file mode 100644
index 00000000..45501b88
--- /dev/null
+++ b/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx
@@ -0,0 +1,45 @@
+import { useTranslation } from "react-i18next";
+import { Button, Modal } from "@renderer/components";
+import "./delete-review-modal.scss";
+
+interface DeleteReviewModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+}
+
+export function DeleteReviewModal({
+ visible,
+ onClose,
+ onConfirm,
+}: DeleteReviewModalProps) {
+ const { t } = useTranslation("game_details");
+
+ const handleDeleteReview = () => {
+ onConfirm();
+ onClose();
+ };
+
+ return (
+
+
+ {t("delete_review_karma_warning")}
+
+
+
+
+ {t("cancel")}
+
+
+
+ {t("delete")}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/renderer/src/pages/game-details/modals/index.ts b/src/renderer/src/pages/game-details/modals/index.ts
index 724e0003..d0f27b0f 100644
--- a/src/renderer/src/pages/game-details/modals/index.ts
+++ b/src/renderer/src/pages/game-details/modals/index.ts
@@ -2,3 +2,4 @@ export * from "./repacks-modal";
export * from "./download-settings-modal";
export * from "./game-options-modal";
export * from "./edit-game-modal";
+export * from "./delete-review-modal";
diff --git a/src/renderer/src/pages/game-details/review-sort-options.scss b/src/renderer/src/pages/game-details/review-sort-options.scss
index eafe9972..fba6c50f 100644
--- a/src/renderer/src/pages/game-details/review-sort-options.scss
+++ b/src/renderer/src/pages/game-details/review-sort-options.scss
@@ -6,6 +6,7 @@
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit);
+ margin-bottom: calc(globals.$spacing-unit * 3);
}
&__label {
@@ -37,7 +38,7 @@
transition: all ease 0.2s;
display: flex;
align-items: center;
- gap: 6px;
+ gap: 4px;
&:hover:not(:disabled) {
color: rgba(255, 255, 255, 0.6);
@@ -61,21 +62,6 @@
}
}
- &__toggle-option {
- &.active {
- background: rgba(255, 255, 255, 0.05);
- border-radius: 4px;
- padding: 6px 8px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- }
-
- &:hover:not(:disabled) {
- background: rgba(255, 255, 255, 0.03);
- border-radius: 4px;
- padding: 6px 8px;
- }
- }
-
&__separator {
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
diff --git a/src/renderer/src/pages/game-details/review-sort-options.tsx b/src/renderer/src/pages/game-details/review-sort-options.tsx
index fc7f431a..ca11d056 100644
--- a/src/renderer/src/pages/game-details/review-sort-options.tsx
+++ b/src/renderer/src/pages/game-details/review-sort-options.tsx
@@ -49,16 +49,30 @@ export function ReviewSortOptions({
className={`review-sort-options__option review-sort-options__toggle-option ${isDateActive ? "active" : ""}`}
onClick={handleDateToggle}
>
- {sortBy === "newest" ?
:
}
-
{sortBy === "oldest" ? t("sort_oldest") : t("sort_newest")}
+ {sortBy === "newest" ? (
+
+ ) : (
+
+ )}
+
+ {sortBy === "oldest" ? t("sort_oldest") : t("sort_newest")}
+
|
- {sortBy === "score_high" ? : }
- {sortBy === "score_low" ? t("sort_lowest_score") : t("sort_highest_score")}
+ {sortBy === "score_high" ? (
+
+ ) : (
+
+ )}
+
+ {sortBy === "score_low"
+ ? t("sort_lowest_score")
+ : t("sort_highest_score")}
+
|
+
diff --git a/src/renderer/src/pages/profile/profile-content/user-karma-box.scss b/src/renderer/src/pages/profile/profile-content/user-karma-box.scss
new file mode 100644
index 00000000..5f5610e3
--- /dev/null
+++ b/src/renderer/src/pages/profile/profile-content/user-karma-box.scss
@@ -0,0 +1,47 @@
+@use "../../../scss/globals.scss";
+
+.user-karma {
+ &__box {
+ background-color: globals.$background-color;
+ border-radius: 4px;
+ border: solid 1px globals.$border-color;
+ padding: calc(globals.$spacing-unit * 2);
+ }
+
+ &__section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: calc(globals.$spacing-unit * 2);
+ }
+
+ &__content {
+ display: flex;
+ flex-direction: column;
+ gap: calc(globals.$spacing-unit * 1.5);
+ }
+
+ &__stats-row {
+ display: flex;
+ align-items: center;
+ color: globals.$body-color;
+ }
+
+ &__description {
+ display: flex;
+ align-items: center;
+ gap: globals.$spacing-unit;
+ font-weight: 600;
+ font-size: 1.1rem;
+ }
+
+ &__info {
+ padding-top: calc(globals.$spacing-unit * 0.5);
+ }
+
+ &__info-text {
+ color: globals.$muted-color;
+ font-size: 0.85rem;
+ line-height: 1.4;
+ }
+}
\ No newline at end of file
diff --git a/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx b/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx
new file mode 100644
index 00000000..4668bf28
--- /dev/null
+++ b/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx
@@ -0,0 +1,40 @@
+import { useContext } from "react";
+import { userProfileContext } from "@renderer/context";
+import { useTranslation } from "react-i18next";
+import { useFormat } from "@renderer/hooks";
+import { Award } from "lucide-react";
+import { useUserDetails } from "@renderer/hooks";
+import "./user-karma-box.scss";
+
+export function UserKarmaBox() {
+ const { isMe } = 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;
+
+ return (
+
+
+
{t("karma")}
+
+
+
+
+
+
+ {numberFormatter.format(userDetails.karma)} {t("karma_count")}
+
+
+
+
+ {t("karma_description")}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/types/index.ts b/src/types/index.ts
index f4b0645b..17ed08cc 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -182,6 +182,7 @@ export interface UserDetails {
bio: string;
featurebaseJwt: string;
subscription: Subscription | null;
+ karma: number;
quirks?: {
backupsPerGameLimit: number;
};
From 4116459577eef7e1e4b45a66d6f70f854c3f4c6a Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Thu, 2 Oct 2025 19:07:58 +0300
Subject: [PATCH 05/38] Fix: Typescript error regarding missing karma property
---
src/locales/en/translation.json | 2 +-
src/renderer/src/hooks/use-user-details.ts | 1 +
.../src/pages/game-details/modals/delete-review-modal.scss | 2 +-
.../src/pages/game-details/modals/delete-review-modal.tsx | 4 ++--
.../src/pages/profile/profile-content/user-karma-box.scss | 2 +-
.../src/pages/profile/profile-content/user-karma-box.tsx | 5 +++--
6 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index c42f000e..5326b24b 100755
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -318,7 +318,7 @@
"audio": "Audio",
"filter_by_source": "Filter by source",
"no_repacks_found": "No sources found for this game",
-"delete_review": "Delete review",
+ "delete_review": "Delete review",
"delete_review_modal_title": "Delete Review",
"delete_review_modal_description": "Are you sure you want to delete your review? This action cannot be undone.",
"delete_review_karma_warning": "You will lose any karma points earned from this review."
diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts
index a35a760b..19877765 100644
--- a/src/renderer/src/hooks/use-user-details.ts
+++ b/src/renderer/src/hooks/use-user-details.ts
@@ -68,6 +68,7 @@ export function useUserDetails() {
username: userDetails?.username || "",
subscription: userDetails?.subscription || null,
featurebaseJwt: userDetails?.featurebaseJwt || "",
+ karma: userDetails?.karma || 0,
});
},
[
diff --git a/src/renderer/src/pages/game-details/modals/delete-review-modal.scss b/src/renderer/src/pages/game-details/modals/delete-review-modal.scss
index 40ad6e59..0ba1fb54 100644
--- a/src/renderer/src/pages/game-details/modals/delete-review-modal.scss
+++ b/src/renderer/src/pages/game-details/modals/delete-review-modal.scss
@@ -16,4 +16,4 @@
display: flex;
justify-content: flex-end;
}
-}
\ No newline at end of file
+}
diff --git a/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx b/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx
index 45501b88..fb1ef992 100644
--- a/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx
+++ b/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx
@@ -30,7 +30,7 @@ export function DeleteReviewModal({
{t("delete_review_karma_warning")}
-
+
{t("cancel")}
@@ -42,4 +42,4 @@ export function DeleteReviewModal({
);
-}
\ No newline at end of file
+}
diff --git a/src/renderer/src/pages/profile/profile-content/user-karma-box.scss b/src/renderer/src/pages/profile/profile-content/user-karma-box.scss
index 5f5610e3..63015b4d 100644
--- a/src/renderer/src/pages/profile/profile-content/user-karma-box.scss
+++ b/src/renderer/src/pages/profile/profile-content/user-karma-box.scss
@@ -44,4 +44,4 @@
font-size: 0.85rem;
line-height: 1.4;
}
-}
\ No newline at end of file
+}
diff --git a/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx b/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx
index 4668bf28..fa69d88f 100644
--- a/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx
+++ b/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx
@@ -25,7 +25,8 @@ export function UserKarmaBox() {
- {numberFormatter.format(userDetails.karma)} {t("karma_count")}
+ {numberFormatter.format(userDetails.karma)}{" "}
+ {t("karma_count")}
@@ -37,4 +38,4 @@ export function UserKarmaBox() {
);
-}
\ No newline at end of file
+}
From 80275dc08fb466d5983f029c695818031445c2a1 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Thu, 2 Oct 2025 19:29:50 +0300
Subject: [PATCH 06/38] Fix: Review delete modal button color + added missing
translation
---
src/locales/en/translation.json | 5 +++--
.../src/pages/game-details/modals/delete-review-modal.tsx | 8 ++------
2 files changed, 5 insertions(+), 8 deletions(-)
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 5326b24b..670dda4f 100755
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -319,8 +319,9 @@
"filter_by_source": "Filter by source",
"no_repacks_found": "No sources found for this game",
"delete_review": "Delete review",
- "delete_review_modal_title": "Delete Review",
- "delete_review_modal_description": "Are you sure you want to delete your review? This action cannot be undone.",
+ "delete_review_modal_title": "Are you sure you want to delete your review?",
+ "delete_review_modal_description": "This action cannot be undone.",
+ "delete_review_button": "Delete",
"delete_review_karma_warning": "You will lose any karma points earned from this review."
},
"activation": {
diff --git a/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx b/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx
index fb1ef992..958c23db 100644
--- a/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx
+++ b/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx
@@ -27,17 +27,13 @@ export function DeleteReviewModal({
description={t("delete_review_modal_description")}
onClose={onClose}
>
-
- {t("delete_review_karma_warning")}
-
-
{t("cancel")}
-
- {t("delete")}
+
+ {t("delete_review_button")}
From 8d5b169166ae984bc7f37da26bcd0d1dc04b15b2 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Thu, 2 Oct 2025 21:22:09 +0300
Subject: [PATCH 07/38] Feat: updated input field design, fixed text overflow
---
.../confirm-modal/confirm-modal.tsx | 13 +-
.../game-details/game-details.context.tsx | 3 +-
.../game-details/game-details-content.tsx | 68 +++++--
.../src/pages/game-details/game-details.scss | 185 ++++++------------
.../game-details/hero/hero-panel-actions.tsx | 32 ++-
.../game-details/modals/repacks-modal.tsx | 2 +-
6 files changed, 153 insertions(+), 150 deletions(-)
diff --git a/src/renderer/src/components/confirm-modal/confirm-modal.tsx b/src/renderer/src/components/confirm-modal/confirm-modal.tsx
index d210c035..75a8f5c9 100644
--- a/src/renderer/src/components/confirm-modal/confirm-modal.tsx
+++ b/src/renderer/src/components/confirm-modal/confirm-modal.tsx
@@ -33,9 +33,18 @@ export function ConfirmModal({
};
return (
-
+
-
+
{confirmLabel || t("confirm")}
diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx
index 23ea3845..5be5cf98 100644
--- a/src/renderer/src/context/game-details/game-details.context.tsx
+++ b/src/renderer/src/context/game-details/game-details.context.tsx
@@ -201,7 +201,8 @@ export function GameDetailsContextProvider({
}, [objectId, gameTitle, dispatch]);
useEffect(() => {
- const state = (location && (location.state as Record)) || {};
+ const state =
+ (location && (location.state as Record)) || {};
if (state.openRepacks) {
setShowRepacksModal(true);
try {
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 de66e5a6..bb6c2e85 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -104,6 +104,8 @@ export function GameDetailsContent() {
const [reviewsLoading, setReviewsLoading] = useState(false);
const [reviewScore, setReviewScore] = useState(5);
const [submittingReview, setSubmittingReview] = useState(false);
+ const [reviewCharCount, setReviewCharCount] = useState(0);
+ const MAX_REVIEW_CHARS = 1000;
const [reviewsSortBy, setReviewsSortBy] = useState("newest");
const [reviewsPage, setReviewsPage] = useState(0);
const [hasMoreReviews, setHasMoreReviews] = useState(true);
@@ -127,6 +129,31 @@ export function GameDetailsContent() {
class: "game-details__review-editor",
"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 currentText = view.state.doc.textContent;
+ const remainingChars = MAX_REVIEW_CHARS - currentText.length;
+
+ if (text && remainingChars > 0) {
+ event.preventDefault();
+ const truncatedText = text.slice(0, remainingChars);
+ view.dispatch(view.state.tr.insertText(truncatedText));
+ return true;
+ }
+ return false;
+ },
+ },
+ onUpdate: ({ editor }) => {
+ 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);
+ setReviewCharCount(MAX_REVIEW_CHARS);
+ }
},
});
@@ -266,7 +293,7 @@ export function GameDetailsContent() {
console.log("reviewScore:", reviewScore);
console.log("submittingReview:", submittingReview);
- if (!objectId || !reviewHtml.trim() || submittingReview) {
+ if (!objectId || !reviewHtml.trim() || submittingReview || reviewCharCount > MAX_REVIEW_CHARS) {
console.log("Early return - validation failed");
return;
}
@@ -523,11 +550,7 @@ export function GameDetailsContent() {
-
-
+
U
-
-
- {submittingReview
- ? t("submitting")
- : t("submit_review")}
-
+
+ MAX_REVIEW_CHARS ? "over-limit" : ""}>
+ {reviewCharCount}/{MAX_REVIEW_CHARS}
+
+
+
@@ -599,6 +619,18 @@ export function GameDetailsContent() {
+
+
MAX_REVIEW_CHARS
+ }
+ >
+ {submittingReview
+ ? t("submitting")
+ : t("submit_review")}
+
>
diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss
index da3745e9..e9e94aea 100644
--- a/src/renderer/src/pages/game-details/game-details.scss
+++ b/src/renderer/src/pages/game-details/game-details.scss
@@ -30,12 +30,8 @@ $hero-height: 300px;
&__review-form {
display: flex;
flex-direction: column;
- gap: calc(globals.$spacing-unit * 2);
- margin-bottom: calc(globals.$spacing-unit * 3);
- padding: calc(globals.$spacing-unit * 2);
- background: rgba(255, 255, 255, 0.02);
- border-radius: 8px;
- border: 1px solid rgba(255, 255, 255, 0.05);
+ gap: 16px;
+ margin-bottom: 24px;
}
&__review-form-controls {
@@ -54,55 +50,35 @@ $hero-height: 300px;
&__review-form-bottom {
display: flex;
justify-content: space-between;
- align-items: flex-end;
- gap: calc(globals.$spacing-unit * 2);
-
- @media (max-width: 768px) {
- flex-direction: column;
- align-items: stretch;
- gap: calc(globals.$spacing-unit * 1.5);
- }
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
}
&__review-score-container {
display: flex;
- flex-direction: column;
- gap: calc(globals.$spacing-unit * 0.75);
- min-width: 120px;
+ align-items: center;
+ gap: 8px;
}
&__review-score-label {
- display: block;
- font-size: globals.$body-font-size;
- color: globals.$body-color;
+ font-size: 14px;
+ color: #ffffff;
+ font-weight: 500;
}
&__review-score-select {
- background-color: rgba(255, 255, 255, 0.05);
- border: 1px solid globals.$border-color;
+ background-color: #2a2a2a;
+ border: 1px solid #3a3a3a;
border-radius: 4px;
- padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1);
- color: globals.$body-color;
- font-size: globals.$body-font-size;
- font-family: inherit;
+ color: #ffffff;
+ padding: 6px 12px;
+ font-size: 14px;
cursor: pointer;
- transition:
- border-color 0.2s ease,
- background-color 0.2s ease;
&:focus {
outline: none;
- background-color: rgba(255, 255, 255, 0.08);
- border-color: globals.$brand-teal;
- }
-
- &:hover {
- border-color: rgba(255, 255, 255, 0.15);
- }
-
- option {
- background-color: globals.$dark-background-color;
- color: globals.$body-color;
+ border-color: #0078d4;
}
}
@@ -150,19 +126,14 @@ $hero-height: 300px;
&__review-submit-button {
background-color: rgba(255, 255, 255, 0.05);
- border: 1px solid globals.$border-color;
- color: globals.$body-color;
- padding: calc(globals.$spacing-unit * 0.75)
- calc(globals.$spacing-unit * 1.5);
+ border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
+ color: #ffffff;
+ padding: 10px 20px;
+ font-size: 14px;
+ font-weight: 500;
cursor: pointer;
- font-size: globals.$small-font-size;
- font-family: inherit;
- transition: all ease 0.2s;
- height: 32px;
- display: flex;
- align-items: center;
- justify-content: center;
+ transition: all 0.2s ease;
white-space: nowrap;
&:hover:not(:disabled) {
@@ -170,12 +141,8 @@ $hero-height: 300px;
border-color: rgba(255, 255, 255, 0.15);
}
- &:active {
- opacity: 0.9;
- }
-
&:disabled {
- background: rgba(255, 255, 255, 0.1);
+ background-color: rgba(255, 255, 255, 0.1);
cursor: not-allowed;
color: rgba(255, 255, 255, 0.5);
}
@@ -918,111 +885,87 @@ $hero-height: 300px;
}
&__review-input-container {
- width: 100%;
- position: relative;
+ display: flex;
+ flex-direction: column;
+ border: 1px solid #3a3a3a;
+ border-radius: 8px;
+ background-color: #1e1e1e;
+ overflow: hidden;
}
- &__review-input-bottom {
- position: absolute;
- bottom: 8px;
- left: 8px;
- right: 8px;
+ &__review-input-header {
display: flex;
justify-content: space-between;
align-items: center;
- gap: 8px;
- z-index: 10;
+ padding: 8px 12px;
+ background-color: #2a2a2a;
+ border-bottom: 1px solid #3a3a3a;
}
&__review-editor-toolbar {
display: flex;
gap: 4px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 4px;
- padding: 4px;
- backdrop-filter: blur(10px);
- background: rgba(0, 0, 0, 0.3);
}
&__editor-button {
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 3px;
- color: globals.$body-color;
- padding: 4px 6px;
+ background: none;
+ border: 1px solid #4a4a4a;
+ border-radius: 4px;
+ color: #ffffff;
+ padding: 4px 8px;
cursor: pointer;
font-size: 12px;
- font-weight: 600;
transition: all 0.2s ease;
- min-width: 24px;
- height: 24px;
- display: flex;
- align-items: center;
- justify-content: center;
- &:hover:not(:disabled) {
- background: rgba(255, 255, 255, 0.1);
- border-color: rgba(255, 255, 255, 0.2);
+ &:hover {
+ background-color: #3a3a3a;
+ border-color: #5a5a5a;
+ }
+
+ &.is-active {
+ background-color: #0078d4;
+ border-color: #0078d4;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
+ }
- &.is-active {
- background: globals.$brand-teal;
- border-color: globals.$brand-teal;
- color: white;
+ &__review-char-counter {
+ font-size: 12px;
+ color: #888888;
+
+ .over-limit {
+ color: #ff6b6b;
}
}
&__review-input {
- width: 100%;
- background-color: rgba(255, 255, 255, 0.05);
- border: 1px solid globals.$border-color;
- border-radius: 6px;
- padding: calc(globals.$spacing-unit * 1.5);
- padding-bottom: calc(globals.$spacing-unit * 3.5);
- color: globals.$body-color;
- font-size: globals.$body-font-size;
- font-family: inherit;
- line-height: 1.5;
- min-height: 100px;
- transition:
- border-color 0.2s ease,
- background-color 0.2s ease;
-
- &:focus-within {
- outline: none;
- background-color: rgba(255, 255, 255, 0.08);
- border-color: globals.$brand-teal;
- }
-
- &:hover {
- border-color: rgba(255, 255, 255, 0.15);
- }
-
+ min-height: 120px;
+ padding: 12px;
+
.ProseMirror {
outline: none;
- min-height: 80px;
-
- &:empty:before {
- content: attr(data-placeholder);
- color: rgba(208, 209, 215, 0.6);
- pointer-events: none;
+ color: #ffffff;
+ font-size: 14px;
+ line-height: 1.5;
+
+ &:focus {
+ outline: none;
}
p {
margin: 0 0 8px 0;
-
+
&:last-child {
margin-bottom: 0;
}
}
strong {
- font-weight: 700;
+ font-weight: bold;
}
em {
diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx
index ac8a1615..e23120a8 100644
--- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx
+++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx
@@ -69,14 +69,32 @@ export function HeroPanelActions() {
updateGame();
};
- window.addEventListener("hydra:game-favorite-toggled", onFavoriteToggled as EventListener);
- window.addEventListener("hydra:game-removed-from-library", onGameRemoved as EventListener);
- window.addEventListener("hydra:game-files-removed", onFilesRemoved as EventListener);
+ window.addEventListener(
+ "hydra:game-favorite-toggled",
+ onFavoriteToggled as EventListener
+ );
+ window.addEventListener(
+ "hydra:game-removed-from-library",
+ onGameRemoved as EventListener
+ );
+ window.addEventListener(
+ "hydra:game-files-removed",
+ onFilesRemoved as EventListener
+ );
return () => {
- window.removeEventListener("hydra:game-favorite-toggled", onFavoriteToggled as EventListener);
- window.removeEventListener("hydra:game-removed-from-library", onGameRemoved as EventListener);
- window.removeEventListener("hydra:game-files-removed", onFilesRemoved as EventListener);
+ window.removeEventListener(
+ "hydra:game-favorite-toggled",
+ onFavoriteToggled as EventListener
+ );
+ window.removeEventListener(
+ "hydra:game-removed-from-library",
+ onGameRemoved as EventListener
+ );
+ window.removeEventListener(
+ "hydra:game-files-removed",
+ onFilesRemoved as EventListener
+ );
};
}, [updateLibrary, updateGame]);
@@ -226,7 +244,7 @@ export function HeroPanelActions() {
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={isGameDownloading}
- className={`hero-panel-actions__action ${!repacks.length ? 'hero-panel-actions__action--disabled' : ''}`}
+ className={`hero-panel-actions__action ${!repacks.length ? "hero-panel-actions__action--disabled" : ""}`}
>
{t("download")}
diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx
index ec7dc3f8..97b8b1b5 100644
--- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx
+++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx
@@ -277,4 +277,4 @@ export function RepacksModal({
>
);
-}
\ No newline at end of file
+}
From fab02c4d16daafffba34bf006777d1f5a2fc3f1a Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Fri, 3 Oct 2025 02:55:41 +0300
Subject: [PATCH 08/38] Fix: Format-check fail and translations. Feat: added
animations to upvote and downvote buttons
---
src/locales/en/translation.json | 6 +-
.../game-details/game-details-content.tsx | 68 ++++++++++++++++---
.../src/pages/game-details/game-details.scss | 8 +--
.../modals/delete-review-modal.scss | 1 +
.../modals/delete-review-modal.tsx | 4 +-
5 files changed, 66 insertions(+), 21 deletions(-)
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 22c54234..22bb9380 100755
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -213,7 +213,6 @@
"leave_a_review": "Leave a Review",
"write_review_placeholder": "Share your thoughts about this game...",
"sort_newest": "Newest",
- "sort_by": "Sort by",
"no_reviews_yet": "No reviews yet",
"be_first_to_review": "Be the first to share your thoughts about this game!",
"sort_oldest": "Oldest",
@@ -226,7 +225,6 @@
"loading_reviews": "Loading reviews...",
"loading_more_reviews": "Loading more reviews...",
"load_more_reviews": "Load More Reviews",
- "youve_played_for_hours": "You've played for {{hours}} hours",
"would_you_recommend_this_game": "Would you like to leave a review to this game?",
"yes": "Yes",
"maybe_later": "Maybe Later",
@@ -330,8 +328,8 @@
"delete_review": "Delete 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_button": "Delete",
- "delete_review_karma_warning": "You will lose any karma points earned from this review."
+ "delete_review_modal_delete_button": "Delete",
+ "delete_review_modal_cancel_button": "Cancel"
},
"activation": {
"title": "Activate Hydra",
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 bb6c2e85..f3db5164 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -4,6 +4,7 @@ import { ThumbsUp, ThumbsDown } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
+import { motion } from "framer-motion";
import type { GameReview } from "@types";
import { HeroPanel } from "./hero";
@@ -131,10 +132,10 @@ export function GameDetailsContent() {
},
handlePaste: (view, event) => {
// Strip formatting from pasted content to prevent overflow issues
- const text = event.clipboardData?.getData('text/plain') || '';
+ const text = event.clipboardData?.getData("text/plain") || "";
const currentText = view.state.doc.textContent;
const remainingChars = MAX_REVIEW_CHARS - currentText.length;
-
+
if (text && remainingChars > 0) {
event.preventDefault();
const truncatedText = text.slice(0, remainingChars);
@@ -147,7 +148,7 @@ export function GameDetailsContent() {
onUpdate: ({ editor }) => {
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);
@@ -293,7 +294,12 @@ export function GameDetailsContent() {
console.log("reviewScore:", reviewScore);
console.log("submittingReview:", submittingReview);
- if (!objectId || !reviewHtml.trim() || submittingReview || reviewCharCount > MAX_REVIEW_CHARS) {
+ if (
+ !objectId ||
+ !reviewHtml.trim() ||
+ submittingReview ||
+ reviewCharCount > MAX_REVIEW_CHARS
+ ) {
console.log("Early return - validation failed");
return;
}
@@ -584,7 +590,13 @@ export function GameDetailsContent() {
- MAX_REVIEW_CHARS ? "over-limit" : ""}>
+ MAX_REVIEW_CHARS
+ ? "over-limit"
+ : ""
+ }
+ >
{reviewCharCount}/{MAX_REVIEW_CHARS}
@@ -619,12 +631,14 @@ export function GameDetailsContent() {
-
+
MAX_REVIEW_CHARS
+ !editor?.getHTML().trim() ||
+ submittingReview ||
+ reviewCharCount > MAX_REVIEW_CHARS
}
>
{submittingReview
@@ -739,24 +753,56 @@ export function GameDetailsContent() {
/>
-
handleVoteReview(review.id, "upvote")
}
+ whileTap={{
+ scale: 0.9,
+ transition: { duration: 0.1 },
+ }}
+ whileHover={{
+ scale: 1.05,
+ transition: { duration: 0.2 },
+ }}
+ animate={
+ review.hasUpvoted
+ ? {
+ scale: [1, 1.2, 1],
+ transition: { duration: 0.3 },
+ }
+ : {}
+ }
>
{review.upvotes || 0}
-
-
+
handleVoteReview(review.id, "downvote")
}
+ whileTap={{
+ scale: 0.9,
+ transition: { duration: 0.1 },
+ }}
+ whileHover={{
+ scale: 1.05,
+ transition: { duration: 0.2 },
+ }}
+ animate={
+ review.hasDownvoted
+ ? {
+ scale: [1, 1.2, 1],
+ transition: { duration: 0.3 },
+ }
+ : {}
+ }
>
{review.downvotes || 0}
-
+
{userDetails?.id === review.user?.id && (
- {t("cancel")}
+ {t("delete_review_modal_cancel_button")}
- {t("delete_review_button")}
+ {t("delete_review_modal_delete_button")}
From 1b5f70a075bea27415e76fa907b7204a534b0268 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Fri, 3 Oct 2025 03:01:37 +0300
Subject: [PATCH 09/38] Fix: replaced array index with review.id and marked
props as read-only
---
.../pages/game-details/game-details-content.tsx | 17 ++++-------------
.../game-details/modals/delete-review-modal.tsx | 2 +-
.../pages/game-details/review-prompt-banner.tsx | 2 +-
.../pages/game-details/review-sort-options.tsx | 2 +-
4 files changed, 7 insertions(+), 16 deletions(-)
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 f3db5164..8a09712e 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -688,8 +688,8 @@ export function GameDetailsContent() {
)}
- {reviews.map((review, index) => (
-
+ {reviews.map((review) => (
+
{review.isBlocked &&
!visibleBlockedReviews.has(review.id) ? (
@@ -713,24 +713,15 @@ export function GameDetailsContent() {
/>
)}
-
review.user?.id &&
navigate(`/profile/${review.user.id}`)
}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- review.user?.id &&
- navigate(`/profile/${review.user.id}`);
- }
- }}
- role="button"
- tabIndex={0}
>
{review.user?.displayName || "Anonymous"}
-
+
{formatDistance(
diff --git a/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx b/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx
index 2ed352c5..fe612bbd 100644
--- a/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx
+++ b/src/renderer/src/pages/game-details/modals/delete-review-modal.tsx
@@ -12,7 +12,7 @@ export function DeleteReviewModal({
visible,
onClose,
onConfirm,
-}: DeleteReviewModalProps) {
+}: Readonly
) {
const { t } = useTranslation("game_details");
const handleDeleteReview = () => {
diff --git a/src/renderer/src/pages/game-details/review-prompt-banner.tsx b/src/renderer/src/pages/game-details/review-prompt-banner.tsx
index 7bd96613..aeddaaad 100644
--- a/src/renderer/src/pages/game-details/review-prompt-banner.tsx
+++ b/src/renderer/src/pages/game-details/review-prompt-banner.tsx
@@ -10,7 +10,7 @@ interface ReviewPromptBannerProps {
export function ReviewPromptBanner({
onYesClick,
onLaterClick,
-}: ReviewPromptBannerProps) {
+}: Readonly) {
const { t } = useTranslation("game_details");
return (
diff --git a/src/renderer/src/pages/game-details/review-sort-options.tsx b/src/renderer/src/pages/game-details/review-sort-options.tsx
index ca11d056..75ec0f39 100644
--- a/src/renderer/src/pages/game-details/review-sort-options.tsx
+++ b/src/renderer/src/pages/game-details/review-sort-options.tsx
@@ -21,7 +21,7 @@ interface ReviewSortOptionsProps {
export function ReviewSortOptions({
sortBy,
onSortChange,
-}: ReviewSortOptionsProps) {
+}: Readonly) {
const { t } = useTranslation("game_details");
const handleDateToggle = () => {
From 899f68318fd6fc8bb60315fe1a3e58f136b4e544 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Fri, 3 Oct 2025 03:06:37 +0300
Subject: [PATCH 10/38] Fix: multiple imports, ambigious spacing and unexpected
negated condition
---
src/renderer/src/pages/game-details/game-details-content.tsx | 2 +-
.../src/pages/game-details/hero/hero-panel-actions.tsx | 2 +-
.../src/pages/profile/profile-content/user-karma-box.tsx | 3 +--
3 files changed, 3 insertions(+), 4 deletions(-)
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 8a09712e..a66971b6 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -693,7 +693,7 @@ export function GameDetailsContent() {
{review.isBlocked &&
!visibleBlockedReviews.has(review.id) ? (
- Review from blocked user —
+ Review from blocked user —{" "}
toggleBlockedReview(review.id)}
diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx
index e23120a8..2e91d361 100644
--- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx
+++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx
@@ -244,7 +244,7 @@ export function HeroPanelActions() {
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={isGameDownloading}
- className={`hero-panel-actions__action ${!repacks.length ? "hero-panel-actions__action--disabled" : ""}`}
+ className={`hero-panel-actions__action ${repacks.length === 0 ? "hero-panel-actions__action--disabled" : ""}`}
>
{t("download")}
diff --git a/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx b/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx
index fa69d88f..8c85217d 100644
--- a/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx
+++ b/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx
@@ -1,9 +1,8 @@
import { useContext } from "react";
import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next";
-import { useFormat } from "@renderer/hooks";
+import { useFormat, useUserDetails } from "@renderer/hooks";
import { Award } from "lucide-react";
-import { useUserDetails } from "@renderer/hooks";
import "./user-karma-box.scss";
export function UserKarmaBox() {
From a92563509bebc6e2a29ccc19be6f7fc36711ab3e Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Fri, 3 Oct 2025 15:52:40 +0300
Subject: [PATCH 11/38] Fix: fixed zalgo text + html formatting inside of the
review message
---
.../game-details/game-details-content.tsx | 10 ++++-
.../src/pages/game-details/game-details.scss | 8 ++++
src/shared/html-sanitizer.ts | 41 +++++++++++++++++++
src/shared/index.ts | 1 +
4 files changed, 58 insertions(+), 2 deletions(-)
create mode 100644 src/shared/html-sanitizer.ts
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 a66971b6..07ffcd10 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -15,6 +15,7 @@ import { EditGameModal, DeleteReviewModal } from "./modals";
import { ReviewSortOptions } from "./review-sort-options";
import { ReviewPromptBanner } from "./review-prompt-banner";
+import { sanitizeHtml } from "@shared";
import { useTranslation } from "react-i18next";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { AuthPage } from "@shared";
@@ -123,7 +124,12 @@ export function GameDetailsContent() {
// Tiptap editor for review input
const editor = useEditor({
- extensions: [StarterKit],
+ extensions: [
+ StarterKit.configure({
+ // Disable link extension to prevent automatic link rendering and XSS
+ link: false,
+ }),
+ ],
content: "",
editorProps: {
attributes: {
@@ -739,7 +745,7 @@ export function GameDetailsContent() {
diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss
index 13847f55..dd479ab1 100644
--- a/src/renderer/src/pages/game-details/game-details.scss
+++ b/src/renderer/src/pages/game-details/game-details.scss
@@ -200,6 +200,8 @@ $hero-height: 300px;
border-radius: 6px;
padding: calc(globals.$spacing-unit * 2);
margin-bottom: calc(globals.$spacing-unit * 2);
+ overflow: hidden;
+ word-wrap: break-word;
}
&__review-header {
@@ -233,6 +235,7 @@ $hero-height: 300px;
color: rgba(255, 255, 255, 0.9);
font-size: globals.$small-font-size;
font-weight: 600;
+ display: inline-flex;
&--clickable {
cursor: pointer;
@@ -383,6 +386,11 @@ $hero-height: 300px;
&__review-content {
color: globals.$body-color;
line-height: 1.5;
+ word-wrap: break-word;
+ word-break: break-word;
+ overflow-wrap: break-word;
+ white-space: pre-wrap;
+ max-width: 100%;
}
&__reviews-loading {
diff --git a/src/shared/html-sanitizer.ts b/src/shared/html-sanitizer.ts
new file mode 100644
index 00000000..55af55aa
--- /dev/null
+++ b/src/shared/html-sanitizer.ts
@@ -0,0 +1,41 @@
+function removeZalgoText(text: string): string {
+ const zalgoRegex = /[\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/g;
+
+ return text.replace(zalgoRegex, '');
+}
+
+export function sanitizeHtml(html: string): string {
+ if (!html || typeof html !== 'string') {
+ return '';
+ }
+
+ let cleanText = html.replace(/<[^>]*>/g, '');
+
+ const tempDiv = document.createElement('div');
+ tempDiv.innerHTML = cleanText;
+ cleanText = tempDiv.textContent || tempDiv.innerText || '';
+
+ cleanText = removeZalgoText(cleanText);
+
+ cleanText = cleanText.replace(/\s+/g, ' ').trim();
+
+ if (!cleanText || cleanText.length === 0) {
+ return '';
+ }
+
+ return cleanText;
+}
+
+export function stripHtml(html: string): string {
+ if (!html || typeof html !== 'string') {
+ return '';
+ }
+
+ const tempDiv = document.createElement('div');
+ tempDiv.innerHTML = html;
+ let cleanText = tempDiv.textContent || tempDiv.innerText || '';
+
+ cleanText = removeZalgoText(cleanText);
+
+ return cleanText;
+}
\ No newline at end of file
diff --git a/src/shared/index.ts b/src/shared/index.ts
index 000ffd22..9a4b4516 100644
--- a/src/shared/index.ts
+++ b/src/shared/index.ts
@@ -19,6 +19,7 @@ import { format } from "date-fns";
import { AchievementNotificationInfo } from "@types";
export * from "./constants";
+export * from "./html-sanitizer";
export class UserNotLoggedInError extends Error {
constructor() {
From f11296f3a90a24316537d199de72b0244d45a098 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Fri, 3 Oct 2025 15:54:40 +0300
Subject: [PATCH 12/38] Fix: eslint error
---
src/shared/html-sanitizer.ts | 46 +++++++++++++++++++-----------------
1 file changed, 24 insertions(+), 22 deletions(-)
diff --git a/src/shared/html-sanitizer.ts b/src/shared/html-sanitizer.ts
index 55af55aa..10c8241d 100644
--- a/src/shared/html-sanitizer.ts
+++ b/src/shared/html-sanitizer.ts
@@ -1,41 +1,43 @@
function removeZalgoText(text: string): string {
- const zalgoRegex = /[\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/g;
-
- return text.replace(zalgoRegex, '');
+ // eslint-disable-next-line no-misleading-character-class
+ const zalgoRegex =
+ /[\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/g;
+
+ return text.replace(zalgoRegex, "");
}
export function sanitizeHtml(html: string): string {
- if (!html || typeof html !== 'string') {
- return '';
+ if (!html || typeof html !== "string") {
+ return "";
}
- let cleanText = html.replace(/<[^>]*>/g, '');
-
- const tempDiv = document.createElement('div');
+ let cleanText = html.replace(/<[^>]*>/g, "");
+
+ const tempDiv = document.createElement("div");
tempDiv.innerHTML = cleanText;
- cleanText = tempDiv.textContent || tempDiv.innerText || '';
-
+ cleanText = tempDiv.textContent || tempDiv.innerText || "";
+
cleanText = removeZalgoText(cleanText);
-
- cleanText = cleanText.replace(/\s+/g, ' ').trim();
-
+
+ cleanText = cleanText.replace(/\s+/g, " ").trim();
+
if (!cleanText || cleanText.length === 0) {
- return '';
+ return "";
}
-
+
return cleanText;
}
export function stripHtml(html: string): string {
- if (!html || typeof html !== 'string') {
- return '';
+ if (!html || typeof html !== "string") {
+ return "";
}
- const tempDiv = document.createElement('div');
+ const tempDiv = document.createElement("div");
tempDiv.innerHTML = html;
- let cleanText = tempDiv.textContent || tempDiv.innerText || '';
-
+ let cleanText = tempDiv.textContent || tempDiv.innerText || "";
+
cleanText = removeZalgoText(cleanText);
-
+
return cleanText;
-}
\ No newline at end of file
+}
From 2e680180596f558d296e1c302b41f808d5f068c0 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Fri, 3 Oct 2025 15:56:02 +0300
Subject: [PATCH 13/38] fix: eslint error
---
src/shared/html-sanitizer.ts | 19 ++++++++++++++++---
1 file changed, 16 insertions(+), 3 deletions(-)
diff --git a/src/shared/html-sanitizer.ts b/src/shared/html-sanitizer.ts
index 10c8241d..7d7012f6 100644
--- a/src/shared/html-sanitizer.ts
+++ b/src/shared/html-sanitizer.ts
@@ -6,6 +6,21 @@ function removeZalgoText(text: string): string {
return text.replace(zalgoRegex, "");
}
+function decodeHtmlEntities(text: string): string {
+ const entityMap: { [key: string]: string } = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ ''': "'",
+ ' ': ' ',
+ };
+
+ return text.replace(/&[#\w]+;/g, (entity) => {
+ return entityMap[entity] || entity;
+ });
+}
+
export function sanitizeHtml(html: string): string {
if (!html || typeof html !== "string") {
return "";
@@ -13,9 +28,7 @@ export function sanitizeHtml(html: string): string {
let cleanText = html.replace(/<[^>]*>/g, "");
- const tempDiv = document.createElement("div");
- tempDiv.innerHTML = cleanText;
- cleanText = tempDiv.textContent || tempDiv.innerText || "";
+ cleanText = decodeHtmlEntities(cleanText);
cleanText = removeZalgoText(cleanText);
From e3fb325b7be63fa7bcf16cffce4b508b4aee8c97 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Fri, 3 Oct 2025 15:57:06 +0300
Subject: [PATCH 14/38] fix: eslint fix
---
src/shared/html-sanitizer.ts | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/src/shared/html-sanitizer.ts b/src/shared/html-sanitizer.ts
index 7d7012f6..839c3b11 100644
--- a/src/shared/html-sanitizer.ts
+++ b/src/shared/html-sanitizer.ts
@@ -1,6 +1,6 @@
function removeZalgoText(text: string): string {
- // eslint-disable-next-line no-misleading-character-class
const zalgoRegex =
+ // eslint-disable-next-line no-misleading-character-class
/[\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/g;
return text.replace(zalgoRegex, "");
@@ -8,14 +8,14 @@ function removeZalgoText(text: string): string {
function decodeHtmlEntities(text: string): string {
const entityMap: { [key: string]: string } = {
- '&': '&',
- '<': '<',
- '>': '>',
- '"': '"',
- ''': "'",
- ' ': ' ',
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ """: '"',
+ "'": "'",
+ " ": " ",
};
-
+
return text.replace(/&[#\w]+;/g, (entity) => {
return entityMap[entity] || entity;
});
From b91306e70ea1c27944c23d157a4b7e81979366f8 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Fri, 3 Oct 2025 16:16:33 +0300
Subject: [PATCH 15/38] fix: possible DoS
---
src/shared/html-sanitizer.ts | 21 ++++++++++++++++++++-
1 file changed, 20 insertions(+), 1 deletion(-)
diff --git a/src/shared/html-sanitizer.ts b/src/shared/html-sanitizer.ts
index 839c3b11..d2127635 100644
--- a/src/shared/html-sanitizer.ts
+++ b/src/shared/html-sanitizer.ts
@@ -21,12 +21,31 @@ function decodeHtmlEntities(text: string): string {
});
}
+function removeHtmlTags(html: string): string {
+ let result = "";
+ let inTag = false;
+
+ for (let i = 0; i < html.length; i++) {
+ const char = html[i];
+
+ if (char === "<") {
+ inTag = true;
+ } else if (char === ">") {
+ inTag = false;
+ } else if (!inTag) {
+ result += char;
+ }
+ }
+
+ return result;
+}
+
export function sanitizeHtml(html: string): string {
if (!html || typeof html !== "string") {
return "";
}
- let cleanText = html.replace(/<[^>]*>/g, "");
+ let cleanText = removeHtmlTags(html);
cleanText = decodeHtmlEntities(cleanText);
From 52d3750acc7420b87e323b247d413a4a77ebc0f8 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Fri, 3 Oct 2025 16:20:32 +0300
Subject: [PATCH 16/38] fix: multiple imports and other minor issues
---
.../src/pages/game-details/game-details-content.tsx | 3 +--
src/shared/html-sanitizer.ts | 10 ++++------
2 files changed, 5 insertions(+), 8 deletions(-)
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 07ffcd10..28321f1e 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -15,10 +15,9 @@ import { EditGameModal, DeleteReviewModal } from "./modals";
import { ReviewSortOptions } from "./review-sort-options";
import { ReviewPromptBanner } from "./review-prompt-banner";
-import { sanitizeHtml } from "@shared";
+import { sanitizeHtml, AuthPage } from "@shared";
import { useTranslation } from "react-i18next";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
-import { AuthPage } from "@shared";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails, useLibrary, useDate } from "@renderer/hooks";
diff --git a/src/shared/html-sanitizer.ts b/src/shared/html-sanitizer.ts
index d2127635..d8391d88 100644
--- a/src/shared/html-sanitizer.ts
+++ b/src/shared/html-sanitizer.ts
@@ -3,7 +3,7 @@ function removeZalgoText(text: string): string {
// eslint-disable-next-line no-misleading-character-class
/[\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/g;
- return text.replace(zalgoRegex, "");
+ return text.replaceAll(zalgoRegex, "");
}
function decodeHtmlEntities(text: string): string {
@@ -16,7 +16,7 @@ function decodeHtmlEntities(text: string): string {
" ": " ",
};
- return text.replace(/&[#\w]+;/g, (entity) => {
+ return text.replaceAll(/&[#\w]+;/g, (entity) => {
return entityMap[entity] || entity;
});
}
@@ -25,9 +25,7 @@ function removeHtmlTags(html: string): string {
let result = "";
let inTag = false;
- for (let i = 0; i < html.length; i++) {
- const char = html[i];
-
+ for (const char of html) {
if (char === "<") {
inTag = true;
} else if (char === ">") {
@@ -51,7 +49,7 @@ export function sanitizeHtml(html: string): string {
cleanText = removeZalgoText(cleanText);
- cleanText = cleanText.replace(/\s+/g, " ").trim();
+ cleanText = cleanText.replaceAll(/\s+/g, " ").trim();
if (!cleanText || cleanText.length === 0) {
return "";
From 1f05dc8f78683addf65df8f8ebc1f7db29b27316 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Sat, 4 Oct 2025 20:12:01 +0300
Subject: [PATCH 17/38] Feat: Rating score display redesign, Rating choosing
redesign, added avg rating on the game page
---
src/locales/en/translation.json | 3 ++
.../game-details/game-details-content.tsx | 43 ++++++++++++++----
.../src/pages/game-details/game-details.scss | 45 ++++++++++++++++++-
.../pages/game-details/sidebar/sidebar.scss | 5 +--
.../pages/game-details/sidebar/sidebar.tsx | 11 +++++
src/shared/html-sanitizer.ts | 4 +-
6 files changed, 95 insertions(+), 16 deletions(-)
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 22bb9380..b54fe2fb 100755
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -200,6 +200,7 @@
"stats": "Stats",
"download_count": "Downloads",
"player_count": "Active players",
+ "rating_count": "Rating",
"download_error": "This download option is not available",
"download": "Download",
"executable_path_in_use": "Executable already in use by \"{{game}}\"",
@@ -220,6 +221,8 @@
"sort_lowest_score": "Lowest Score",
"sort_most_voted": "Most Voted",
"rating": "Rating",
+ "rating_stats": "Rating",
+ "select_rating": "Select Rating",
"submit_review": "Submit Review",
"submitting": "Submitting...",
"loading_reviews": "Loading reviews...",
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 28321f1e..03959506 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -24,6 +24,14 @@ import { useUserDetails, useLibrary, useDate } 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";
+ return "";
+};
+
export function GameDetailsContent() {
const heroRef = useRef(null);
const navigate = useNavigate();
@@ -103,7 +111,7 @@ export function GameDetailsContent() {
// Reviews state management
const [reviews, setReviews] = useState([]);
const [reviewsLoading, setReviewsLoading] = useState(false);
- const [reviewScore, setReviewScore] = useState(5);
+ const [reviewScore, setReviewScore] = useState(null);
const [submittingReview, setSubmittingReview] = useState(false);
const [reviewCharCount, setReviewCharCount] = useState(0);
const MAX_REVIEW_CHARS = 1000;
@@ -302,6 +310,7 @@ export function GameDetailsContent() {
if (
!objectId ||
!reviewHtml.trim() ||
+ reviewScore === null ||
submittingReview ||
reviewCharCount > MAX_REVIEW_CHARS
) {
@@ -322,7 +331,7 @@ export function GameDetailsContent() {
console.log("Review submitted successfully");
editor?.commands.clearContent();
- setReviewScore(5);
+ setReviewScore(null);
await loadReviews(true); // Reload reviews after submission
setShowReviewForm(false); // Hide the review form after successful submission
setShowReviewPrompt(false); // Hide the prompt banner
@@ -606,10 +615,14 @@ export function GameDetailsContent() {
-
+ onClick={() => editor?.commands.focus()}
+ >
+
+
@@ -618,12 +631,23 @@ export function GameDetailsContent() {
{t("rating")}
-
diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss
index dd479ab1..76b6cdcb 100644
--- a/src/renderer/src/pages/game-details/game-details.scss
+++ b/src/renderer/src/pages/game-details/game-details.scss
@@ -75,10 +75,30 @@ $hero-height: 300px;
padding: 6px 12px;
font-size: 14px;
cursor: pointer;
+ transition: border-color 0.2s ease, background-color 0.2s ease;
&:focus {
outline: none;
- border-color: #0078d4;
+ }
+
+ &--red {
+ border-color: #e74c3c;
+ background-color: rgba(231, 76, 60, 0.1);
+ }
+
+ &--yellow {
+ border-color: #f39c12;
+ background-color: rgba(243, 156, 18, 0.1);
+ }
+
+ &--green {
+ border-color: #27ae60;
+ background-color: rgba(39, 174, 96, 0.1);
+ }
+
+ option {
+ background-color: #2a2a2a;
+ color: #ffffff;
}
}
@@ -373,6 +393,25 @@ $hero-height: 300px;
font-size: globals.$small-font-size;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.15);
+
+ // Color variants based on score
+ &--red {
+ background: rgba(239, 68, 68, 0.2);
+ color: #fca5a5;
+ border-color: rgba(239, 68, 68, 0.4);
+ }
+
+ &--yellow {
+ background: rgba(245, 158, 11, 0.2);
+ color: #fcd34d;
+ border-color: rgba(245, 158, 11, 0.4);
+ }
+
+ &--green {
+ background: rgba(34, 197, 94, 0.2);
+ color: #86efac;
+ border-color: rgba(34, 197, 94, 0.4);
+ }
}
&__review-date {
@@ -953,12 +992,16 @@ $hero-height: 300px;
&__review-input {
min-height: 120px;
padding: 12px;
+ cursor: text;
.ProseMirror {
outline: none;
color: #ffffff;
font-size: 14px;
line-height: 1.5;
+ min-height: 96px; // 120px - 24px padding
+ width: 100%;
+ cursor: text;
&:focus {
outline: none;
diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.scss b/src/renderer/src/pages/game-details/sidebar/sidebar.scss
index 06519f6c..b48e8a8f 100755
--- a/src/renderer/src/pages/game-details/sidebar/sidebar.scss
+++ b/src/renderer/src/pages/game-details/sidebar/sidebar.scss
@@ -106,7 +106,7 @@
.stats {
&__section {
display: flex;
- gap: calc(globals.$spacing-unit * 2);
+ gap: calc(globals.$spacing-unit * 1);
padding: calc(globals.$spacing-unit * 2);
justify-content: space-between;
transition: max-height ease 0.5s;
@@ -116,9 +116,6 @@
flex-direction: column;
}
- @media (min-width: 1280px) {
- flex-direction: row;
- }
}
&__category-title {
diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
index 0a24c418..d8aa2128 100755
--- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
+++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
@@ -14,6 +14,7 @@ import {
DownloadIcon,
LockIcon,
PeopleIcon,
+ StarIcon,
} from "@primer/octicons-react";
import { HowLongToBeatSection } from "./how-long-to-beat-section";
import { howLongToBeatEntriesTable } from "@renderer/dexie";
@@ -225,6 +226,16 @@ export function Sidebar() {
{numberFormatter.format(stats?.playerCount)}
+
+ {stats?.averageScore && (
+
+
+
+ {t("rating_count")}
+
+
{stats.averageScore.toFixed(1)}/10
+
+ )}
)}
diff --git a/src/shared/html-sanitizer.ts b/src/shared/html-sanitizer.ts
index d8391d88..4f8042e3 100644
--- a/src/shared/html-sanitizer.ts
+++ b/src/shared/html-sanitizer.ts
@@ -24,7 +24,7 @@ function decodeHtmlEntities(text: string): string {
function removeHtmlTags(html: string): string {
let result = "";
let inTag = false;
-
+
for (const char of html) {
if (char === "<") {
inTag = true;
@@ -34,7 +34,7 @@ function removeHtmlTags(html: string): string {
result += char;
}
}
-
+
return result;
}
From 72562b13efaf2329f3410d9674e008417fe88252 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Sat, 4 Oct 2025 20:14:27 +0300
Subject: [PATCH 18/38] Feat: Rating score display redesign, Rating choosing
redesign, added avg rating on the game page
---
.../game-details/game-details-content.tsx | 35 ++++++++++++-------
.../src/pages/game-details/game-details.scss | 4 ++-
.../pages/game-details/sidebar/sidebar.scss | 1 -
3 files changed, 26 insertions(+), 14 deletions(-)
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 03959506..9e0ea490 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -615,13 +615,20 @@ export function GameDetailsContent() {
-
editor?.commands.focus()}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ editor?.commands.focus();
+ }
+ }}
+ role="textbox"
+ tabIndex={0}
+ aria-label={t("write_review_placeholder")}
>
-
+
@@ -632,17 +639,19 @@ export function GameDetailsContent() {
-
diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss
index 76b6cdcb..83758524 100644
--- a/src/renderer/src/pages/game-details/game-details.scss
+++ b/src/renderer/src/pages/game-details/game-details.scss
@@ -75,7 +75,9 @@ $hero-height: 300px;
padding: 6px 12px;
font-size: 14px;
cursor: pointer;
- transition: border-color 0.2s ease, background-color 0.2s ease;
+ transition:
+ border-color 0.2s ease,
+ background-color 0.2s ease;
&:focus {
outline: none;
diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.scss b/src/renderer/src/pages/game-details/sidebar/sidebar.scss
index b48e8a8f..1330d278 100755
--- a/src/renderer/src/pages/game-details/sidebar/sidebar.scss
+++ b/src/renderer/src/pages/game-details/sidebar/sidebar.scss
@@ -115,7 +115,6 @@
@media (min-width: 1024px) {
flex-direction: column;
}
-
}
&__category-title {
From 1f7947f50f31a427ab6672eb88fb3a52391084fc Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Sat, 4 Oct 2025 20:20:48 +0300
Subject: [PATCH 19/38] fix: refactoring function, using proper attributes and
extracted ternary operation
---
.../game-details/game-details-content.tsx | 80 ++++++++++---------
1 file changed, 42 insertions(+), 38 deletions(-)
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 9e0ea490..7cf67e4c 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -32,6 +32,45 @@ const getScoreColorClass = (score: number): string => {
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";
+ $image.style.boxSizing = "border-box";
+ });
+
+ // 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";
+ $video.style.boxSizing = "border-box";
+ });
+};
+
+// 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) return "game-details__review-score-select--yellow";
+ if (score >= 8 && score <= 10) return "game-details__review-score-select--green";
+ return "";
+};
+
export function GameDetailsContent() {
const heroRef = useRef(null);
const navigate = useNavigate();
@@ -64,33 +103,7 @@ export function GameDetailsContent() {
"text/html"
);
- 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";
- $image.style.boxSizing = "border-box";
- });
-
- // 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";
- $video.style.boxSizing = "border-box";
- });
+ processMediaElements(document);
return document.body.outerHTML;
}
@@ -619,14 +632,11 @@ export function GameDetailsContent() {
className="game-details__review-input"
onClick={() => editor?.commands.focus()}
onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === ' ') {
+ if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
editor?.commands.focus();
}
}}
- role="textbox"
- tabIndex={0}
- aria-label={t("write_review_placeholder")}
>
@@ -639,13 +649,7 @@ export function GameDetailsContent() {
@@ -649,7 +654,9 @@ export function GameDetailsContent() {
- editor?.commands.focus()}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- editor?.commands.focus();
- }
- }}
- role="button"
- tabIndex={0}
- aria-label="Click to focus review editor"
- >
+
From 6667e00c9104737434361398ff4cf103be53b881 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Sun, 5 Oct 2025 20:32:41 +0300
Subject: [PATCH 22/38] 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
---
src/locales/en/translation.json | 18 +-
src/locales/ru/translation.json | 50 +++-
.../src/components/game-card/game-card.scss | 7 +-
.../src/components/game-card/game-card.tsx | 17 +-
src/renderer/src/components/index.ts | 1 +
.../src/components/star-rating/index.ts | 1 +
.../components/star-rating/star-rating.scss | 54 +++++
.../components/star-rating/star-rating.tsx | 64 +++++
.../game-details/game-details-content.tsx | 220 ++++++++++--------
.../src/pages/game-details/game-details.scss | 131 ++++++++---
.../game-details/review-prompt-banner.scss | 2 +-
.../game-details/review-prompt-banner.tsx | 2 +-
.../game-details/review-sort-options.tsx | 4 +-
.../pages/game-details/sidebar/sidebar.tsx | 23 +-
.../profile-content/user-karma-box.tsx | 11 +-
src/types/index.ts | 1 +
16 files changed, 448 insertions(+), 158 deletions(-)
create mode 100644 src/renderer/src/components/star-rating/index.ts
create mode 100644 src/renderer/src/components/star-rating/star-rating.scss
create mode 100644 src/renderer/src/components/star-rating/star-rating.tsx
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) => (
+ setReviewScore(starValue)}
+ title={getRatingText(starValue, t)}
+ >
+
+
+ ))}
+
+
>
@@ -716,7 +718,9 @@ export function GameDetailsContent() {
{!reviewsLoading && reviews.length === 0 && (
-
📝
+
+
+
{t("no_reviews_yet")}
@@ -770,10 +774,23 @@ export function GameDetailsContent() {
-
- {review.score}/10
+
+ {[1, 2, 3, 4, 5].map((starValue) => (
+
+ ))}
+
{t("remove_review")}
)}
{review.isBlocked &&
diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss
index 83758524..b0726655 100644
--- a/src/renderer/src/pages/game-details/game-details.scss
+++ b/src/renderer/src/pages/game-details/game-details.scss
@@ -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;
}
}
diff --git a/src/renderer/src/pages/game-details/review-prompt-banner.scss b/src/renderer/src/pages/game-details/review-prompt-banner.scss
index b8f7557b..f9358e52 100644
--- a/src/renderer/src/pages/game-details/review-prompt-banner.scss
+++ b/src/renderer/src/pages/game-details/review-prompt-banner.scss
@@ -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 {
diff --git a/src/renderer/src/pages/game-details/review-prompt-banner.tsx b/src/renderer/src/pages/game-details/review-prompt-banner.tsx
index aeddaaad..01fdd075 100644
--- a/src/renderer/src/pages/game-details/review-prompt-banner.tsx
+++ b/src/renderer/src/pages/game-details/review-prompt-banner.tsx
@@ -18,7 +18,7 @@ export function ReviewPromptBanner({
- You've seemed to enjoy this game
+ {t("you_seemed_to_enjoy_this_game")}
{t("would_you_recommend_this_game")}
diff --git a/src/renderer/src/pages/game-details/review-sort-options.tsx b/src/renderer/src/pages/game-details/review-sort-options.tsx
index 75ec0f39..0944e58e 100644
--- a/src/renderer/src/pages/game-details/review-sort-options.tsx
+++ b/src/renderer/src/pages/game-details/review-sort-options.tsx
@@ -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";
diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
index d8aa2128..febb6a8b 100755
--- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
+++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
@@ -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() {
{numberFormatter.format(stats?.playerCount)}
- {stats?.averageScore && (
-
-
-
- {t("rating_count")}
-
-
{stats.averageScore.toFixed(1)}/10
-
- )}
+
+
+
+ {t("rating_count")}
+
+
+
)}
diff --git a/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx b/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx
index 8c85217d..d2232276 100644
--- a/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx
+++ b/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx
@@ -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 (
@@ -24,7 +27,7 @@ export function UserKarmaBox() {
- {numberFormatter.format(userDetails.karma)}{" "}
+ {numberFormatter.format(karma)}{" "}
{t("karma_count")}
diff --git a/src/types/index.ts b/src/types/index.ts
index 17ed08cc..0c6af89b 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -203,6 +203,7 @@ export interface UserProfile {
currentGame: UserProfileCurrentGame | null;
bio: string;
hasActiveSubscription: boolean;
+ karma: number;
quirks: {
backupsPerGameLimit: number;
};
From 063e97e0ec8a3cdff31c7c1e6e0dc85d73cc529e Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Sun, 5 Oct 2025 20:36:20 +0300
Subject: [PATCH 23/38] Fix: marked props read-only and catch error
---
src/locales/en/translation.json | 2 +-
.../src/components/game-card/game-card.scss | 2 +-
.../src/components/game-card/game-card.tsx | 2 +-
.../src/components/star-rating/index.ts | 2 +-
.../components/star-rating/star-rating.scss | 2 +-
.../components/star-rating/star-rating.tsx | 39 ++++++----
.../game-details/game-details-content.tsx | 72 ++++++++++++-------
.../src/pages/game-details/game-details.scss | 4 +-
8 files changed, 79 insertions(+), 46 deletions(-)
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 1af953fe..d118b20b 100755
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -223,7 +223,7 @@
"rating": "Rating",
"rating_stats": "Rating",
"rating_very_negative": "Very Negative",
- "rating_negative": "Negative",
+ "rating_negative": "Negative",
"rating_neutral": "Neutral",
"rating_positive": "Positive",
"rating_very_positive": "Very Positive",
diff --git a/src/renderer/src/components/game-card/game-card.scss b/src/renderer/src/components/game-card/game-card.scss
index 99aa866e..1830762f 100644
--- a/src/renderer/src/components/game-card/game-card.scss
+++ b/src/renderer/src/components/game-card/game-card.scss
@@ -73,7 +73,7 @@
color: globals.$muted-color;
font-size: 12px;
align-items: center;
-
+
// Ensure star rating is properly aligned
.star-rating {
align-items: center;
diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx
index 1aa58ba7..6e790500 100644
--- a/src/renderer/src/components/game-card/game-card.tsx
+++ b/src/renderer/src/components/game-card/game-card.tsx
@@ -109,7 +109,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
-
) {
if (rating === null && showCalculating) {
return (
@@ -40,25 +40,36 @@ export function StarRating({
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 2b6de1d8..9060dc39 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -1,5 +1,10 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
-import { PencilIcon, TrashIcon, ClockIcon, NoteIcon } from "@primer/octicons-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";
@@ -68,12 +73,18 @@ const getSelectScoreColorClass = (score: number): string => {
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 "";
+ 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 "";
}
};
@@ -163,23 +174,23 @@ export function GameDetailsContent() {
handlePaste: (view, event) => {
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 ((htmlContent || plainText) && remainingChars > 0) {
event.preventDefault();
-
+
if (htmlContent) {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlContent;
const textLength = tempDiv.textContent?.length || 0;
-
+
if (textLength <= remainingChars) {
- return false;
+ return false;
}
}
-
+
const truncatedText = plainText.slice(0, remainingChars);
view.dispatch(view.state.tr.insertText(truncatedText));
return true;
@@ -343,7 +354,7 @@ export function GameDetailsContent() {
}
setSubmittingReview(true);
-
+
try {
await window.electron.createGameReview(
shop,
@@ -355,12 +366,13 @@ export function GameDetailsContent() {
editor?.commands.clearContent();
setReviewScore(null);
showSuccessToast(t("review_submitted_successfully"));
-
- await loadReviews(true);
- setShowReviewForm(false);
- setShowReviewPrompt(false);
+
+ 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);
@@ -387,7 +399,7 @@ export function GameDetailsContent() {
const handleReviewPromptLater = () => {
setShowReviewPrompt(false);
if (objectId) {
- sessionStorage.setItem(`reviewPromptDismissed_${objectId}`, 'true');
+ sessionStorage.setItem(`reviewPromptDismissed_${objectId}`, "true");
}
};
@@ -422,7 +434,7 @@ export function GameDetailsContent() {
useEffect(() => {
if (objectId && (game || shop)) {
loadReviews(true);
- checkUserReview();
+ checkUserReview();
}
}, [game, shop, objectId, reviewsSortBy, userDetails]);
@@ -661,9 +673,13 @@ export function GameDetailsContent() {
onClick={() => setReviewScore(starValue)}
title={getRatingText(starValue, t)}
>
-
))}
@@ -684,7 +700,6 @@ export function GameDetailsContent() {
? t("submitting")
: t("submit_review")}
-
>
@@ -774,12 +789,19 @@ export function GameDetailsContent() {
-
+
{[1, 2, 3, 4, 5].map((starValue) => (
Date: Mon, 6 Oct 2025 15:41:12 +0300
Subject: [PATCH 24/38] Fix: review prompt banner appearing in all games
---
.../src/components/game-card/game-card.scss | 3 +--
.../src/pages/game-details/game-details-content.tsx | 13 +++++++++++--
2 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/src/renderer/src/components/game-card/game-card.scss b/src/renderer/src/components/game-card/game-card.scss
index 1830762f..46c6bec9 100644
--- a/src/renderer/src/components/game-card/game-card.scss
+++ b/src/renderer/src/components/game-card/game-card.scss
@@ -73,8 +73,7 @@
color: globals.$muted-color;
font-size: 12px;
align-items: center;
-
- // Ensure star rating is properly aligned
+
.star-rating {
align-items: center;
}
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 9060dc39..16ee7386 100644
--- a/src/renderer/src/pages/game-details/game-details-content.tsx
+++ b/src/renderer/src/pages/game-details/game-details-content.tsx
@@ -106,7 +106,7 @@ export function GameDetailsContent() {
const { showHydraCloudModal } = useSubscription();
const { userDetails, hasActiveSubscription } = useUserDetails();
- const { updateLibrary } = useLibrary();
+ const { updateLibrary, library } = useLibrary();
const { formatDistance } = useDate();
const { showSuccessToast, showErrorToast } = useToast();
@@ -159,6 +159,14 @@ export function GameDetailsContent() {
const [hasUserReviewed, setHasUserReviewed] = useState(false);
const [reviewCheckLoading, setReviewCheckLoading] = useState(false);
+ // Check if the current game is in the user's library
+ const isGameInLibrary = useMemo(() => {
+ if (!library || !shop || !objectId) return false;
+ return library.some(
+ (libItem) => libItem.shop === shop && libItem.objectId === objectId
+ );
+ }, [library, shop, objectId]);
+
const editor = useEditor({
extensions: [
StarterKit.configure({
@@ -561,7 +569,8 @@ export function GameDetailsContent() {
showReviewPrompt &&
userDetails &&
!hasUserReviewed &&
- !reviewCheckLoading && (
+ !reviewCheckLoading &&
+ isGameInLibrary && (
Date: Mon, 6 Oct 2025 15:48:19 +0300
Subject: [PATCH 25/38] fix: formatting
---
src/renderer/src/components/game-card/game-card.scss | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/renderer/src/components/game-card/game-card.scss b/src/renderer/src/components/game-card/game-card.scss
index 46c6bec9..9d1eaf93 100644
--- a/src/renderer/src/components/game-card/game-card.scss
+++ b/src/renderer/src/components/game-card/game-card.scss
@@ -73,7 +73,7 @@
color: globals.$muted-color;
font-size: 12px;
align-items: center;
-
+
.star-rating {
align-items: center;
}
From 47ac8e63acdcbe150bacc564dd35851b7b2efa50 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Mon, 6 Oct 2025 18:30:56 +0300
Subject: [PATCH 26/38] fix: fixed button layout in prompt message and fixed
rating display in stats in game page
---
src/renderer/src/components/star-rating/star-rating.tsx | 6 ++++--
.../src/pages/game-details/review-prompt-banner.tsx | 6 +++---
src/renderer/src/pages/game-details/sidebar/sidebar.tsx | 5 +++--
3 files changed, 10 insertions(+), 7 deletions(-)
diff --git a/src/renderer/src/components/star-rating/star-rating.tsx b/src/renderer/src/components/star-rating/star-rating.tsx
index cdd04e03..50d08afd 100644
--- a/src/renderer/src/components/star-rating/star-rating.tsx
+++ b/src/renderer/src/components/star-rating/star-rating.tsx
@@ -7,6 +7,7 @@ export interface StarRatingProps {
size?: number;
showCalculating?: boolean;
calculatingText?: string;
+ hideIcon?: boolean;
}
export function StarRating({
@@ -15,11 +16,12 @@ export function StarRating({
size = 12,
showCalculating = false,
calculatingText = "Calculating",
+ hideIcon = false,
}: Readonly) {
if (rating === null && showCalculating) {
return (
-
+ {!hideIcon && }
{calculatingText}
);
@@ -28,7 +30,7 @@ export function StarRating({
if (rating === null || rating === undefined) {
return (
-
+ {!hideIcon && }
…
);
diff --git a/src/renderer/src/pages/game-details/review-prompt-banner.tsx b/src/renderer/src/pages/game-details/review-prompt-banner.tsx
index 01fdd075..053c97e8 100644
--- a/src/renderer/src/pages/game-details/review-prompt-banner.tsx
+++ b/src/renderer/src/pages/game-details/review-prompt-banner.tsx
@@ -25,12 +25,12 @@ export function ReviewPromptBanner({
-
- {t("yes")}
-
{t("maybe_later")}
+
+ {t("yes")}
+
diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
index febb6a8b..33009508 100755
--- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
+++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
@@ -233,10 +233,11 @@ export function Sidebar() {
{t("rating_count")}
From 9bada771df3a0027946de57f1d59c869485c00bc Mon Sep 17 00:00:00 2001
From: Zamitto <167933696+zamitto@users.noreply.github.com>
Date: Sat, 11 Oct 2025 11:26:05 -0300
Subject: [PATCH 27/38] feat: separate game assets from game stats
---
src/main/events/catalogue/get-game-assets.ts | 51 +++++++++++++++++++
.../events/catalogue/save-game-shop-assets.ts | 25 ---------
.../library/add-custom-game-to-library.ts | 1 +
.../events/library/create-steam-shortcut.ts | 10 ++--
src/main/level/sublevels/game-shop-assets.ts | 12 ++---
.../library-sync/merge-with-remote-games.ts | 4 ++
src/main/services/notifications/index.ts | 7 ++-
.../services/ws/events/friend-game-session.ts | 11 ++--
src/preload/index.ts | 5 +-
.../game-details/game-details.context.tsx | 40 +++++++--------
src/renderer/src/declaration.d.ts | 10 ++--
.../pages/game-details/sidebar/sidebar.tsx | 13 ++++-
.../user-library-game-card.tsx | 2 +-
src/types/index.ts | 4 +-
14 files changed, 111 insertions(+), 84 deletions(-)
create mode 100644 src/main/events/catalogue/get-game-assets.ts
delete mode 100644 src/main/events/catalogue/save-game-shop-assets.ts
diff --git a/src/main/events/catalogue/get-game-assets.ts b/src/main/events/catalogue/get-game-assets.ts
new file mode 100644
index 00000000..04a03808
--- /dev/null
+++ b/src/main/events/catalogue/get-game-assets.ts
@@ -0,0 +1,51 @@
+import type { GameShop, ShopAssets } from "@types";
+import { registerEvent } from "../register-event";
+import { HydraApi } from "@main/services";
+import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
+
+const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 30; // 30 minutes
+
+export const getGameAssets = async (objectId: string, shop: GameShop) => {
+ const cachedAssets = await gamesShopAssetsSublevel.get(
+ levelKeys.game(shop, objectId)
+ );
+
+ if (
+ cachedAssets &&
+ cachedAssets.updatedAt + LOCAL_CACHE_EXPIRATION > Date.now()
+ ) {
+ return cachedAssets;
+ }
+
+ return HydraApi.get(
+ `/games/${shop}/${objectId}/assets`,
+ null,
+ {
+ needsAuth: false,
+ }
+ ).then(async (assets) => {
+ if (!assets) return null;
+
+ // Preserve existing title if it differs from the incoming title (indicating it was customized)
+ const shouldPreserveTitle =
+ cachedAssets?.title && cachedAssets.title !== assets.title;
+
+ await gamesShopAssetsSublevel.put(levelKeys.game(shop, objectId), {
+ ...assets,
+ title: shouldPreserveTitle ? cachedAssets.title : assets.title,
+ updatedAt: Date.now(),
+ });
+
+ return assets;
+ });
+};
+
+const getGameAssetsEvent = async (
+ _event: Electron.IpcMainInvokeEvent,
+ objectId: string,
+ shop: GameShop
+) => {
+ return getGameAssets(objectId, shop);
+};
+
+registerEvent("getGameAssets", getGameAssetsEvent);
diff --git a/src/main/events/catalogue/save-game-shop-assets.ts b/src/main/events/catalogue/save-game-shop-assets.ts
deleted file mode 100644
index bf5f8b81..00000000
--- a/src/main/events/catalogue/save-game-shop-assets.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { GameShop, ShopAssets } from "@types";
-import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
-import { registerEvent } from "../register-event";
-
-const saveGameShopAssets = async (
- _event: Electron.IpcMainInvokeEvent,
- objectId: string,
- shop: GameShop,
- assets: ShopAssets
-): Promise => {
- const key = levelKeys.game(shop, objectId);
- const existingAssets = await gamesShopAssetsSublevel.get(key);
-
- // Preserve existing title if it differs from the incoming title (indicating it was customized)
- const shouldPreserveTitle =
- existingAssets?.title && existingAssets.title !== assets.title;
-
- return gamesShopAssetsSublevel.put(key, {
- ...existingAssets,
- ...assets,
- title: shouldPreserveTitle ? existingAssets.title : assets.title,
- });
-};
-
-registerEvent("saveGameShopAssets", saveGameShopAssets);
diff --git a/src/main/events/library/add-custom-game-to-library.ts b/src/main/events/library/add-custom-game-to-library.ts
index 47fd3436..f85c008b 100644
--- a/src/main/events/library/add-custom-game-to-library.ts
+++ b/src/main/events/library/add-custom-game-to-library.ts
@@ -27,6 +27,7 @@ const addCustomGameToLibrary = async (
}
const assets = {
+ updatedAt: Date.now(),
objectId,
shop,
title,
diff --git a/src/main/events/library/create-steam-shortcut.ts b/src/main/events/library/create-steam-shortcut.ts
index f83dd675..d5434d7f 100644
--- a/src/main/events/library/create-steam-shortcut.ts
+++ b/src/main/events/library/create-steam-shortcut.ts
@@ -1,12 +1,11 @@
import { registerEvent } from "../register-event";
-import type { GameShop, GameStats } from "@types";
+import type { GameShop, ShopAssets } from "@types";
import { gamesSublevel, levelKeys } from "@main/level";
import {
composeSteamShortcut,
getSteamLocation,
getSteamShortcuts,
getSteamUsersIds,
- HydraApi,
logger,
SystemPath,
writeSteamShortcuts,
@@ -15,6 +14,7 @@ import fs from "node:fs";
import axios from "axios";
import path from "node:path";
import { ASSETS_PATH } from "@main/constants";
+import { getGameAssets } from "../catalogue/get-game-assets";
const downloadAsset = async (downloadPath: string, url?: string | null) => {
try {
@@ -41,7 +41,7 @@ const downloadAsset = async (downloadPath: string, url?: string | null) => {
const downloadAssetsFromSteam = async (
shop: GameShop,
objectId: string,
- assets: GameStats["assets"]
+ assets: ShopAssets | null
) => {
const gameAssetsPath = path.join(ASSETS_PATH, `${shop}-${objectId}`);
@@ -86,9 +86,7 @@ const createSteamShortcut = async (
throw new Error("No executable path found for game");
}
- const { assets } = await HydraApi.get(
- `/games/${shop}/${objectId}/stats`
- );
+ const assets = await getGameAssets(objectId, shop);
const steamUserIds = await getSteamUsersIds();
diff --git a/src/main/level/sublevels/game-shop-assets.ts b/src/main/level/sublevels/game-shop-assets.ts
index 561d85df..806e041f 100644
--- a/src/main/level/sublevels/game-shop-assets.ts
+++ b/src/main/level/sublevels/game-shop-assets.ts
@@ -3,9 +3,9 @@ import type { ShopAssets } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
-export const gamesShopAssetsSublevel = db.sublevel(
- levelKeys.gameShopAssets,
- {
- valueEncoding: "json",
- }
-);
+export const gamesShopAssetsSublevel = db.sublevel<
+ string,
+ ShopAssets & { updatedAt: number }
+>(levelKeys.gameShopAssets, {
+ valueEncoding: "json",
+});
diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts
index 68b4b835..f7ea2744 100644
--- a/src/main/services/library-sync/merge-with-remote-games.ts
+++ b/src/main/services/library-sync/merge-with-remote-games.ts
@@ -58,7 +58,11 @@ export const mergeWithRemoteGames = async () => {
});
}
+ const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey);
+
await gamesShopAssetsSublevel.put(gameKey, {
+ updatedAt: Date.now(),
+ ...localGameShopAsset,
shop: game.shop,
objectId: game.objectId,
title: localGame?.title || game.title, // Preserve local title if it exists
diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts
index fa9ac593..d28c3cd7 100644
--- a/src/main/services/notifications/index.ts
+++ b/src/main/services/notifications/index.ts
@@ -10,7 +10,7 @@ import icon from "@resources/icon.png?asset";
import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger";
import { WindowManager } from "../window-manager";
-import type { Game, GameStats, UserPreferences, UserProfile } from "@types";
+import type { Game, UserPreferences, UserProfile } from "@types";
import { db, levelKeys } from "@main/level";
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
import { SystemPath } from "../system-path";
@@ -108,15 +108,14 @@ export const publishNewFriendRequestNotification = async (
};
export const publishFriendStartedPlayingGameNotification = async (
- friend: UserProfile,
- game: GameStats
+ friend: UserProfile
) => {
new Notification({
title: t("friend_started_playing_game", {
ns: "notifications",
displayName: friend.displayName,
}),
- body: game.assets?.title,
+ body: friend?.currentGame?.title,
icon: friend?.profileImageUrl
? await downloadImage(friend.profileImageUrl)
: trayIcon,
diff --git a/src/main/services/ws/events/friend-game-session.ts b/src/main/services/ws/events/friend-game-session.ts
index 67967b3c..b089c421 100644
--- a/src/main/services/ws/events/friend-game-session.ts
+++ b/src/main/services/ws/events/friend-game-session.ts
@@ -2,7 +2,7 @@ import type { FriendGameSession } from "@main/generated/envelope";
import { db, levelKeys } from "@main/level";
import { HydraApi } from "@main/services/hydra-api";
import { publishFriendStartedPlayingGameNotification } from "@main/services/notifications";
-import type { GameStats, UserPreferences, UserProfile } from "@types";
+import type { UserPreferences, UserProfile } from "@types";
export const friendGameSessionEvent = async (payload: FriendGameSession) => {
const userPreferences = await db.get(
@@ -14,12 +14,9 @@ export const friendGameSessionEvent = async (payload: FriendGameSession) => {
if (userPreferences?.friendStartGameNotificationsEnabled === false) return;
- const [friend, gameStats] = await Promise.all([
- HydraApi.get(`/users/${payload.friendId}`),
- HydraApi.get(`/games/steam/${payload.objectId}/stats`),
- ]).catch(() => [null, null]);
+ const friend = await HydraApi.get(`/users/${payload.friendId}`);
- if (friend && gameStats) {
- publishFriendStartedPlayingGameNotification(friend, gameStats);
+ if (friend) {
+ publishFriendStartedPlayingGameNotification(friend);
}
};
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 813758f0..e26909d4 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -17,7 +17,6 @@ import type {
Theme,
FriendRequestSync,
ShortcutLocation,
- ShopAssets,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
@@ -67,8 +66,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("searchGames", payload, take, skip),
getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category),
- saveGameShopAssets: (objectId: string, shop: GameShop, assets: ShopAssets) =>
- ipcRenderer.invoke("saveGameShopAssets", objectId, shop, assets),
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
@@ -76,6 +73,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getHowLongToBeat", objectId, shop),
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
+ getGameAssets: (objectId: string, shop: GameShop) =>
+ ipcRenderer.invoke("getGameAssets", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
createGameReview: (
shop: GameShop,
diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx
index 5be5cf98..778fa3fe 100644
--- a/src/renderer/src/context/game-details/game-details.context.tsx
+++ b/src/renderer/src/context/game-details/game-details.context.tsx
@@ -142,29 +142,23 @@ export function GameDetailsContextProvider({
}
});
- const statsPromise = window.electron
- .getGameStats(objectId, shop)
- .then((result) => {
- if (abortController.signal.aborted) return null;
- setStats(result);
- return result;
- });
+ window.electron.getGameStats(objectId, shop).then((result) => {
+ if (abortController.signal.aborted) return;
+ setStats(result);
+ });
- Promise.all([shopDetailsPromise, statsPromise])
- .then(([_, stats]) => {
- if (stats) {
- const assets = stats.assets;
- if (assets) {
- window.electron.saveGameShopAssets(objectId, shop, assets);
+ const assetsPromise = window.electron.getGameAssets(objectId, shop);
- setShopDetails((prev) => {
- if (!prev) return null;
- return {
- ...prev,
- assets,
- };
- });
- }
+ Promise.all([shopDetailsPromise, assetsPromise])
+ .then(([_, assets]) => {
+ if (assets) {
+ setShopDetails((prev) => {
+ if (!prev) return null;
+ return {
+ ...prev,
+ assets,
+ };
+ });
}
})
.finally(() => {
@@ -207,8 +201,8 @@ export function GameDetailsContextProvider({
setShowRepacksModal(true);
try {
window.history.replaceState({}, document.title, location.pathname);
- } catch (_e) {
- void _e;
+ } catch (e) {
+ console.error(e);
}
}
}, [location]);
diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts
index 82bbeb28..9f9e4177 100644
--- a/src/renderer/src/declaration.d.ts
+++ b/src/renderer/src/declaration.d.ts
@@ -39,6 +39,7 @@ import type {
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
UserLibraryResponse,
+ Game,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
@@ -77,11 +78,6 @@ declare global {
skip: number
) => Promise<{ edges: CatalogueSearchResult[]; count: number }>;
getCatalogue: (category: CatalogueCategory) => Promise;
- saveGameShopAssets: (
- objectId: string,
- shop: GameShop,
- assets: ShopAssets
- ) => Promise;
getGameShopDetails: (
objectId: string,
shop: GameShop,
@@ -93,6 +89,10 @@ declare global {
shop: GameShop
) => Promise;
getGameStats: (objectId: string, shop: GameShop) => Promise;
+ getGameAssets: (
+ objectId: string,
+ shop: GameShop
+ ) => Promise;
getTrendingGames: () => Promise;
createGameReview: (
shop: GameShop,
diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
index 33009508..df1429ec 100755
--- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
+++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
@@ -233,9 +233,18 @@ export function Sidebar() {
{t("rating_count")}
diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx
index a3d24958..72b48a8c 100644
--- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx
+++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.tsx
@@ -234,7 +234,7 @@ export function UserLibraryGameCard({
diff --git a/src/types/index.ts b/src/types/index.ts
index 0c6af89b..6a864f3a 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -45,7 +45,7 @@ export interface ShopAssets {
libraryImageUrl: string;
logoImageUrl: string;
logoPosition: string | null;
- coverImageUrl: string;
+ coverImageUrl: string | null;
}
export type ShopDetails = SteamAppDetails & {
@@ -235,8 +235,8 @@ export interface DownloadSourceValidationResult {
export interface GameStats {
downloadCount: number;
playerCount: number;
- assets: ShopAssets | null;
averageScore: number | null;
+ reviewCount: number;
}
export interface GameReview {
From b21c97ea66d6e68bb6b87cdc2ba0ae32f0364972 Mon Sep 17 00:00:00 2001
From: Zamitto <167933696+zamitto@users.noreply.github.com>
Date: Sat, 11 Oct 2025 11:59:54 -0300
Subject: [PATCH 28/38] feat: remove import
---
src/main/events/index.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/main/events/index.ts b/src/main/events/index.ts
index 1d537db3..ecea6463 100644
--- a/src/main/events/index.ts
+++ b/src/main/events/index.ts
@@ -3,7 +3,6 @@ import { ipcMain } from "electron";
import "./catalogue/get-catalogue";
import "./catalogue/get-game-shop-details";
-import "./catalogue/save-game-shop-assets";
import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game";
import "./catalogue/search-games";
From df6d9df31d453c08e8b83d96c74a9d5d7fe8a26d Mon Sep 17 00:00:00 2001
From: Zamitto <167933696+zamitto@users.noreply.github.com>
Date: Sun, 12 Oct 2025 12:18:43 -0300
Subject: [PATCH 29/38] feat: update cache time
---
src/main/events/catalogue/get-game-assets.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/main/events/catalogue/get-game-assets.ts b/src/main/events/catalogue/get-game-assets.ts
index 04a03808..de1d2b1f 100644
--- a/src/main/events/catalogue/get-game-assets.ts
+++ b/src/main/events/catalogue/get-game-assets.ts
@@ -3,7 +3,7 @@ import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
-const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 30; // 30 minutes
+const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60 * 8; // 8 hours
export const getGameAssets = async (objectId: string, shop: GameShop) => {
const cachedAssets = await gamesShopAssetsSublevel.get(
From 741f9de85c5d320054803ee59fef43b35f7b1b55 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Sun, 12 Oct 2025 19:51:26 +0300
Subject: [PATCH 30/38] Fix: formatting
---
.../src/pages/game-details/sidebar/sidebar.tsx | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
index 33009508..df1429ec 100755
--- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
+++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx
@@ -233,9 +233,18 @@ export function Sidebar() {
{t("rating_count")}
From 2240a8c9fb5be0261c0dd64b2fcbe862311b1fc8 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Sun, 12 Oct 2025 19:54:45 +0300
Subject: [PATCH 31/38] Fix: TipTap formatting not displaying on the review
message
---
src/shared/html-sanitizer.ts | 48 ++++++++++++++++++++++++++++++------
1 file changed, 40 insertions(+), 8 deletions(-)
diff --git a/src/shared/html-sanitizer.ts b/src/shared/html-sanitizer.ts
index 4f8042e3..9cd50fc6 100644
--- a/src/shared/html-sanitizer.ts
+++ b/src/shared/html-sanitizer.ts
@@ -43,19 +43,51 @@ export function sanitizeHtml(html: string): string {
return "";
}
- let cleanText = removeHtmlTags(html);
+ // Use DOM-based sanitization to preserve safe formatting while removing dangerous content.
+ const tempDiv = document.createElement("div");
+ tempDiv.innerHTML = html;
- cleanText = decodeHtmlEntities(cleanText);
+ // Remove clearly unsafe elements entirely.
+ const disallowedSelectors = [
+ "script",
+ "style",
+ "iframe",
+ "object",
+ "embed",
+ "link",
+ "meta",
+ ];
+ disallowedSelectors.forEach((sel) => {
+ tempDiv.querySelectorAll(sel).forEach((el) => el.remove());
+ });
- cleanText = removeZalgoText(cleanText);
+ // Strip potentially dangerous attributes from remaining elements.
+ tempDiv.querySelectorAll("*").forEach((el) => {
+ Array.from(el.attributes).forEach((attr) => {
+ const name = attr.name.toLowerCase();
+ if (
+ name.startsWith("on") || // Event handlers
+ name === "style" ||
+ name === "src" ||
+ name === "href" // Links disabled in editor; avoid javascript: URLs
+ ) {
+ el.removeAttribute(attr.name);
+ }
+ });
+ });
- cleanText = cleanText.replaceAll(/\s+/g, " ").trim();
-
- if (!cleanText || cleanText.length === 0) {
- return "";
+ // Clean Zalgo text characters within text nodes.
+ const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT);
+ let node: Node | null;
+ // eslint-disable-next-line no-cond-assign
+ while ((node = walker.nextNode())) {
+ const textNode = node as Text;
+ const value = textNode.nodeValue || "";
+ textNode.nodeValue = removeZalgoText(value);
}
- return cleanText;
+ const cleanHtml = tempDiv.innerHTML.trim();
+ return cleanHtml;
}
export function stripHtml(html: string): string {
From 602b2fef91e108275de43a8d279643cc1b9f8a17 Mon Sep 17 00:00:00 2001
From: Moyasee
Date: Sun, 12 Oct 2025 19:57:12 +0300
Subject: [PATCH 32/38] Fix: TipTap formatting not displaying on the review
message
---
src/shared/html-sanitizer.ts | 30 ------------------------------
1 file changed, 30 deletions(-)
diff --git a/src/shared/html-sanitizer.ts b/src/shared/html-sanitizer.ts
index 9cd50fc6..ea3d475b 100644
--- a/src/shared/html-sanitizer.ts
+++ b/src/shared/html-sanitizer.ts
@@ -6,37 +6,7 @@ function removeZalgoText(text: string): string {
return text.replaceAll(zalgoRegex, "");
}
-function decodeHtmlEntities(text: string): string {
- const entityMap: { [key: string]: string } = {
- "&": "&",
- "<": "<",
- ">": ">",
- """: '"',
- "'": "'",
- " ": " ",
- };
- return text.replaceAll(/&[#\w]+;/g, (entity) => {
- return entityMap[entity] || entity;
- });
-}
-
-function removeHtmlTags(html: string): string {
- let result = "";
- let inTag = false;
-
- for (const char of html) {
- if (char === "<") {
- inTag = true;
- } else if (char === ">") {
- inTag = false;
- } else if (!inTag) {
- result += char;
- }
- }
-
- return result;
-}
export function sanitizeHtml(html: string): string {
if (!html || typeof html !== "string") {
From 14204f1fbebbec7a0cd5b76de318b410243e04c4 Mon Sep 17 00:00:00 2001
From: Chubby Granny Chaser
Date: Sun, 12 Oct 2025 18:39:41 +0100
Subject: [PATCH 33/38] feat: adding review styling
---
src/locales/en/translation.json | 17 +-
src/locales/pt-BR/translation.json | 117 ++++++-
src/main/constants.ts | 11 +
src/main/events/index.ts | 3 +
.../misc/check-homebrew-folder-exists.ts | 13 +
.../misc/get-hydra-decky-plugin-info.ts | 63 ++++
.../events/misc/install-hydra-decky-plugin.ts | 50 +++
src/main/main.ts | 5 +
src/main/services/decky-plugin.ts | 313 ++++++++++++++++++
src/main/services/index.ts | 1 +
src/preload/index.ts | 4 +
src/renderer/src/assets/icons/decky.png | Bin 0 -> 215327 bytes
.../confirm-modal/confirm-modal.scss | 11 -
.../confirm-modal/confirm-modal.tsx | 57 ----
.../confirmation-modal.scss | 2 +-
.../confirmation-modal/confirmation-modal.tsx | 2 +-
.../game-context-menu/game-context-menu.tsx | 32 +-
.../src/components/sidebar/sidebar.scss | 22 ++
.../src/components/sidebar/sidebar.tsx | 131 +++++++-
src/renderer/src/declaration.d.ts | 13 +
src/renderer/src/helpers.ts | 8 +
.../description-header.scss | 18 +-
.../game-details/game-details-content.tsx | 132 ++++++--
.../src/pages/game-details/game-details.scss | 84 +++--
.../src/pages/settings/settings-debrid.scss | 71 ++++
.../src/pages/settings/settings-debrid.tsx | 228 +++++++++++++
src/renderer/src/pages/settings/settings.tsx | 33 +-
27 files changed, 1226 insertions(+), 215 deletions(-)
create mode 100644 src/main/events/misc/check-homebrew-folder-exists.ts
create mode 100644 src/main/events/misc/get-hydra-decky-plugin-info.ts
create mode 100644 src/main/events/misc/install-hydra-decky-plugin.ts
create mode 100644 src/main/services/decky-plugin.ts
create mode 100644 src/renderer/src/assets/icons/decky.png
delete mode 100644 src/renderer/src/components/confirm-modal/confirm-modal.scss
delete mode 100644 src/renderer/src/components/confirm-modal/confirm-modal.tsx
create mode 100644 src/renderer/src/pages/settings/settings-debrid.scss
create mode 100644 src/renderer/src/pages/settings/settings-debrid.tsx
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index c3b3e452..3dc93d90 100755
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -76,7 +76,18 @@
"edit_game_modal_drop_hero_image_here": "Drop hero image here",
"edit_game_modal_drop_to_replace_icon": "Drop to replace icon",
"edit_game_modal_drop_to_replace_logo": "Drop to replace logo",
- "edit_game_modal_drop_to_replace_hero": "Drop to replace hero"
+ "edit_game_modal_drop_to_replace_hero": "Drop to replace hero",
+ "install_decky_plugin": "Install Decky Plugin",
+ "decky_plugin_installed_version": "Decky Plugin (v{{version}})",
+ "install_decky_plugin_title": "Install Hydra Decky Plugin",
+ "install_decky_plugin_message": "This will download and install the Hydra plugin for Decky Loader. This may require elevated permissions. Continue?",
+ "update_decky_plugin_title": "Update Hydra Decky Plugin",
+ "update_decky_plugin_message": "A new version of the Hydra Decky plugin is available. Would you like to update it now?",
+ "decky_plugin_installed": "Decky plugin v{{version}} installed successfully",
+ "decky_plugin_installation_failed": "Failed to install Decky plugin: {{error}}",
+ "decky_plugin_installation_error": "Error installing Decky plugin: {{error}}",
+ "confirm": "Confirm",
+ "cancel": "Cancel"
},
"header": {
"search": "Search games",
@@ -227,7 +238,7 @@
"rating_neutral": "Neutral",
"rating_positive": "Positive",
"rating_very_positive": "Very Positive",
- "submit_review": "Submit Review",
+ "submit_review": "Submit",
"submitting": "Submitting...",
"review_submitted_successfully": "Review submitted successfully!",
"review_submission_failed": "Failed to submit review. Please try again.",
@@ -486,6 +497,8 @@
"delete_theme_description": "This will delete the theme {{theme}}",
"cancel": "Cancel",
"appearance": "Appearance",
+ "debrid": "Debrid",
+ "debrid_description": "Debrid services are premium unrestricted downloaders that allow you to quickly download files hosted on various file hosting services, only limited by your internet speed.",
"enable_torbox": "Enable TorBox",
"torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.",
"torbox_account_linked": "TorBox account linked",
diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json
index 37569701..e9a84c89 100755
--- a/src/locales/pt-BR/translation.json
+++ b/src/locales/pt-BR/translation.json
@@ -27,21 +27,67 @@
"friends": "Amigos",
"need_help": "Precisa de ajuda?",
"favorites": "Favoritos",
+ "playable_button_title": "Mostrar apenas jogos que você pode jogar agora",
"add_custom_game_tooltip": "Adicionar jogo personalizado",
+ "show_playable_only_tooltip": "Mostrar Apenas Jogáveis",
"custom_game_modal": "Adicionar jogo personalizado",
+ "custom_game_modal_description": "Adicione um jogo personalizado à sua biblioteca selecionando um arquivo executável",
+ "custom_game_modal_executable_path": "Caminho do Executável",
+ "custom_game_modal_select_executable": "Selecionar arquivo executável",
+ "custom_game_modal_title": "Título",
+ "custom_game_modal_enter_title": "Insira o título",
"edit_game_modal_title": "Título",
- "playable_button_title": "",
- "custom_game_modal_add": "Adicionar Jogo",
- "custom_game_modal_adding": "Adicionando...",
"custom_game_modal_browse": "Buscar",
"custom_game_modal_cancel": "Cancelar",
- "edit_game_modal_assets": "Imagens",
- "edit_game_modal_icon": "Ícone",
- "edit_game_modal_browse": "Buscar",
- "edit_game_modal_cancel": "Cancelar",
+ "custom_game_modal_add": "Adicionar Jogo",
+ "custom_game_modal_adding": "Adicionando...",
+ "custom_game_modal_success": "Jogo personalizado adicionado com sucesso",
+ "custom_game_modal_failed": "Falha ao adicionar jogo personalizado",
+ "custom_game_modal_executable": "Executável",
+ "edit_game_modal": "Personalizar detalhes",
+ "edit_game_modal_description": "Personalize os recursos e detalhes do jogo",
"edit_game_modal_enter_title": "Insira o título",
+ "edit_game_modal_image": "Imagem",
+ "edit_game_modal_select_image": "Selecionar imagem",
+ "edit_game_modal_browse": "Buscar",
+ "edit_game_modal_image_preview": "Visualização da imagem",
+ "edit_game_modal_icon": "Ícone",
+ "edit_game_modal_select_icon": "Selecionar ícone",
+ "edit_game_modal_icon_preview": "Visualização do ícone",
"edit_game_modal_logo": "Logo",
- "edit_game_modal": "Personalizar detalhes"
+ "edit_game_modal_select_logo": "Selecionar logo",
+ "edit_game_modal_logo_preview": "Visualização do logo",
+ "edit_game_modal_hero": "Hero da Biblioteca",
+ "edit_game_modal_select_hero": "Selecionar imagem hero da biblioteca",
+ "edit_game_modal_hero_preview": "Visualização da imagem hero da biblioteca",
+ "edit_game_modal_cancel": "Cancelar",
+ "edit_game_modal_update": "Atualizar",
+ "edit_game_modal_updating": "Atualizando...",
+ "edit_game_modal_fill_required": "Por favor, preencha todos os campos obrigatórios",
+ "edit_game_modal_success": "Recursos atualizados com sucesso",
+ "edit_game_modal_failed": "Falha ao atualizar recursos",
+ "edit_game_modal_image_filter": "Imagem",
+ "edit_game_modal_icon_resolution": "Resolução recomendada: 256x256px",
+ "edit_game_modal_logo_resolution": "Resolução recomendada: 640x360px",
+ "edit_game_modal_hero_resolution": "Resolução recomendada: 1920x620px",
+ "edit_game_modal_assets": "Imagens",
+ "edit_game_modal_drop_icon_image_here": "Solte a imagem do ícone aqui",
+ "edit_game_modal_drop_logo_image_here": "Solte a imagem do logo aqui",
+ "edit_game_modal_drop_hero_image_here": "Solte a imagem hero aqui",
+ "edit_game_modal_drop_to_replace_icon": "Solte para substituir o ícone",
+ "edit_game_modal_drop_to_replace_logo": "Solte para substituir o logo",
+ "edit_game_modal_drop_to_replace_hero": "Solte para substituir o hero",
+ "install_decky_plugin": "Instalar Plugin Decky",
+ "decky_plugin_installed_version": "Plugin Decky (v{{version}})",
+ "install_decky_plugin_title": "Instalar Plugin Hydra Decky",
+ "install_decky_plugin_message": "Isso irá baixar e instalar o plugin Hydra para Decky Loader. Pode ser necessário permissões elevadas. Continuar?",
+ "update_decky_plugin_title": "Atualizar Plugin Hydra Decky",
+ "update_decky_plugin_message": "Uma nova versão do plugin Hydra Decky está disponível. Gostaria de atualizar agora?",
+ "decky_plugin_installed": "Plugin Decky v{{version}} instalado com sucesso",
+ "decky_plugin_installation_failed": "Falha ao instalar plugin Decky: {{error}}",
+ "decky_plugin_installation_error": "Erro ao instalar plugin Decky: {{error}}",
+ "confirm": "Confirmar",
+ "cancel": "Cancelar"
},
"header": {
"search": "Buscar jogos",
@@ -256,7 +302,48 @@
"update_playtime": "Modificar tempo de jogo",
"update_playtime_description": "Atualizar manualmente o tempo de jogo de {{game}}",
"update_playtime_error": "Falha ao atualizar tempo de jogo",
- "update_playtime_title": "Atualizar tempo de jogo"
+ "update_playtime_title": "Atualizar tempo de jogo",
+ "update_playtime_success": "Tempo de jogo atualizado com sucesso",
+ "show_more": "Mostrar mais",
+ "show_less": "Mostrar menos",
+ "reviews": "Avaliações",
+ "leave_a_review": "Deixar uma Avaliação",
+ "write_review_placeholder": "Compartilhe seus pensamentos sobre este jogo...",
+ "sort_newest": "Mais Recentes",
+ "sort_oldest": "Mais Antigas",
+ "sort_highest_score": "Maior Nota",
+ "sort_lowest_score": "Menor Nota",
+ "sort_most_voted": "Mais Votadas",
+ "no_reviews_yet": "Ainda não há avaliações",
+ "be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!",
+ "rating": "Avaliação",
+ "rating_stats": "Avaliação",
+ "rating_very_negative": "Muito Negativo",
+ "rating_negative": "Negativo",
+ "rating_neutral": "Neutro",
+ "rating_positive": "Positivo",
+ "rating_very_positive": "Muito Positivo",
+ "submit_review": "Enviar",
+ "submitting": "Enviando...",
+ "review_submitted_successfully": "Avaliação enviada com sucesso!",
+ "review_submission_failed": "Falha ao enviar avaliação. Por favor, tente novamente.",
+ "review_cannot_be_empty": "O campo de texto da avaliação não pode estar vazio.",
+ "review_deleted_successfully": "Avaliação excluída com sucesso.",
+ "review_deletion_failed": "Falha ao excluir avaliação. Por favor, tente novamente.",
+ "loading_reviews": "Carregando avaliações...",
+ "loading_more_reviews": "Carregando mais avaliações...",
+ "load_more_reviews": "Carregar Mais Avaliações",
+ "you_seemed_to_enjoy_this_game": "Parece que você gostou deste jogo",
+ "would_you_recommend_this_game": "Gostaria de deixar uma avaliação para este jogo?",
+ "yes": "Sim",
+ "maybe_later": "Talvez Mais Tarde",
+ "delete_review": "Excluir avaliação",
+ "remove_review": "Remover Avaliação",
+ "delete_review_modal_title": "Tem certeza de que deseja excluir sua avaliação?",
+ "delete_review_modal_description": "Esta ação não pode ser desfeita.",
+ "delete_review_modal_delete_button": "Excluir",
+ "delete_review_modal_cancel_button": "Cancelar",
+ "rating_count": "Avaliação"
},
"activation": {
"title": "Ativação",
@@ -395,6 +482,8 @@
"delete_theme_description": "Isso irá deletar o tema {{theme}}",
"cancel": "Cancelar",
"appearance": "Aparência",
+ "debrid": "Debrid",
+ "debrid_description": "Serviços Debrid são downloaders premium sem restrições que permitem baixar rapidamente arquivos hospedados em vários serviços de hospedagem de arquivos, limitados apenas pela sua velocidade de internet.",
"enable_torbox": "Habilitar TorBox",
"torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.",
"torbox_account_linked": "Conta do TorBox vinculada",
@@ -457,7 +546,8 @@
"game_card": {
"available_one": "Disponível",
"available_other": "Disponíveis",
- "no_downloads": "Sem downloads disponíveis"
+ "no_downloads": "Sem downloads disponíveis",
+ "calculating": "Calculando"
},
"binary_not_found_modal": {
"title": "Programas não instalados",
@@ -569,7 +659,12 @@
"amount_minutes_short": "{{amount}}m",
"amount_hours_short": "{{amount}}h",
"game_added_to_pinned": "Jogo adicionado aos fixados",
- "achievements_earned": "Conquistas recebidas"
+ "game_removed_from_pinned": "Jogo removido dos fixados",
+ "achievements_earned": "Conquistas recebidas",
+ "karma": "Karma",
+ "karma_count": "karma",
+ "karma_description": "Ganho a partir de curtidas positivas em avaliações",
+ "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada",
diff --git a/src/main/constants.ts b/src/main/constants.ts
index b067be80..82b99b2a 100644
--- a/src/main/constants.ts
+++ b/src/main/constants.ts
@@ -42,3 +42,14 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets");
export const MAIN_LOOP_INTERVAL = 2000;
+
+export const DECKY_PLUGINS_LOCATION = path.join(
+ SystemPath.getPath("home"),
+ "homebrew",
+ "plugins"
+);
+
+export const HYDRA_DECKY_PLUGIN_LOCATION = path.join(
+ DECKY_PLUGINS_LOCATION,
+ "Hydra"
+);
diff --git a/src/main/events/index.ts b/src/main/events/index.ts
index 1d537db3..6146da22 100644
--- a/src/main/events/index.ts
+++ b/src/main/events/index.ts
@@ -58,6 +58,9 @@ import "./misc/install-common-redist";
import "./misc/can-install-common-redist";
import "./misc/save-temp-file";
import "./misc/delete-temp-file";
+import "./misc/install-hydra-decky-plugin";
+import "./misc/get-hydra-decky-plugin-info";
+import "./misc/check-homebrew-folder-exists";
import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download";
diff --git a/src/main/events/misc/check-homebrew-folder-exists.ts b/src/main/events/misc/check-homebrew-folder-exists.ts
new file mode 100644
index 00000000..32e09754
--- /dev/null
+++ b/src/main/events/misc/check-homebrew-folder-exists.ts
@@ -0,0 +1,13 @@
+import { registerEvent } from "../register-event";
+import { DECKY_PLUGINS_LOCATION } from "@main/constants";
+import fs from "node:fs";
+import path from "node:path";
+
+const checkHomebrewFolderExists = async (
+ _event: Electron.IpcMainInvokeEvent
+): Promise => {
+ const homebrewPath = path.dirname(DECKY_PLUGINS_LOCATION);
+ return fs.existsSync(homebrewPath);
+};
+
+registerEvent("checkHomebrewFolderExists", checkHomebrewFolderExists);
diff --git a/src/main/events/misc/get-hydra-decky-plugin-info.ts b/src/main/events/misc/get-hydra-decky-plugin-info.ts
new file mode 100644
index 00000000..da72033e
--- /dev/null
+++ b/src/main/events/misc/get-hydra-decky-plugin-info.ts
@@ -0,0 +1,63 @@
+import { registerEvent } from "../register-event";
+import { logger } from "@main/services";
+import { HYDRA_DECKY_PLUGIN_LOCATION } from "@main/constants";
+import fs from "node:fs";
+import path from "node:path";
+
+const getHydraDeckyPluginInfo = async (
+ _event: Electron.IpcMainInvokeEvent
+): Promise<{
+ installed: boolean;
+ version: string | null;
+ path: string;
+}> => {
+ try {
+ // Check if plugin folder exists
+ if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
+ logger.log("Hydra Decky plugin not installed");
+ return {
+ installed: false,
+ version: null,
+ path: HYDRA_DECKY_PLUGIN_LOCATION,
+ };
+ }
+
+ // Check if package.json exists
+ const packageJsonPath = path.join(
+ HYDRA_DECKY_PLUGIN_LOCATION,
+ "package.json"
+ );
+
+ if (!fs.existsSync(packageJsonPath)) {
+ logger.log("Hydra Decky plugin package.json not found");
+ return {
+ installed: false,
+ version: null,
+ path: HYDRA_DECKY_PLUGIN_LOCATION,
+ };
+ }
+
+ // Read and parse package.json
+ const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
+ const packageJson = JSON.parse(packageJsonContent);
+ const version = packageJson.version;
+
+ logger.log(`Hydra Decky plugin installed, version: ${version}`);
+
+ return {
+ installed: true,
+ version,
+ path: HYDRA_DECKY_PLUGIN_LOCATION,
+ };
+ } catch (error) {
+ logger.error("Failed to get plugin info:", error);
+ return {
+ installed: false,
+ version: null,
+ path: HYDRA_DECKY_PLUGIN_LOCATION,
+ };
+ }
+};
+
+registerEvent("getHydraDeckyPluginInfo", getHydraDeckyPluginInfo);
+
diff --git a/src/main/events/misc/install-hydra-decky-plugin.ts b/src/main/events/misc/install-hydra-decky-plugin.ts
new file mode 100644
index 00000000..3ddbbd64
--- /dev/null
+++ b/src/main/events/misc/install-hydra-decky-plugin.ts
@@ -0,0 +1,50 @@
+import { registerEvent } from "../register-event";
+import { logger, DeckyPlugin } from "@main/services";
+import { HYDRA_DECKY_PLUGIN_LOCATION } from "@main/constants";
+
+const installHydraDeckyPlugin = async (
+ _event: Electron.IpcMainInvokeEvent
+): Promise<{
+ success: boolean;
+ path: string;
+ currentVersion: string | null;
+ expectedVersion: string;
+ error?: string;
+}> => {
+ try {
+ logger.log("Installing/updating Hydra Decky plugin...");
+
+ const result = await DeckyPlugin.checkPluginVersion();
+
+ if (result.exists && !result.outdated) {
+ logger.log("Plugin installed successfully");
+ return {
+ success: true,
+ path: HYDRA_DECKY_PLUGIN_LOCATION,
+ currentVersion: result.currentVersion,
+ expectedVersion: result.expectedVersion,
+ };
+ } else {
+ logger.error("Failed to install plugin");
+ return {
+ success: false,
+ path: HYDRA_DECKY_PLUGIN_LOCATION,
+ currentVersion: result.currentVersion,
+ expectedVersion: result.expectedVersion,
+ error: "Plugin installation failed",
+ };
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ logger.error("Failed to install plugin:", error);
+ return {
+ success: false,
+ path: HYDRA_DECKY_PLUGIN_LOCATION,
+ currentVersion: null,
+ expectedVersion: "0.0.3",
+ error: errorMessage,
+ };
+ }
+};
+
+registerEvent("installHydraDeckyPlugin", installHydraDeckyPlugin);
diff --git a/src/main/main.ts b/src/main/main.ts
index 67391057..9b8ecc2b 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -16,6 +16,7 @@ import {
startMainLoop,
Ludusavi,
Lock,
+ DeckyPlugin,
} from "@main/services";
export const loadState = async () => {
@@ -49,6 +50,10 @@ export const loadState = async () => {
Ludusavi.copyConfigFileToUserData();
Ludusavi.copyBinaryToUserData();
+ if (process.platform === "linux") {
+ DeckyPlugin.checkAndUpdateIfOutdated();
+ }
+
await HydraApi.setupApi().then(() => {
uploadGamesBatch();
// WSClient.connect();
diff --git a/src/main/services/decky-plugin.ts b/src/main/services/decky-plugin.ts
new file mode 100644
index 00000000..7e178189
--- /dev/null
+++ b/src/main/services/decky-plugin.ts
@@ -0,0 +1,313 @@
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+import axios from "axios";
+import sudo from "sudo-prompt";
+import { app } from "electron";
+import {
+ HYDRA_DECKY_PLUGIN_LOCATION,
+ DECKY_PLUGINS_LOCATION,
+} from "@main/constants";
+import { logger } from "./logger";
+import { SevenZip } from "./7zip";
+import { SystemPath } from "./system-path";
+
+export class DeckyPlugin {
+ private static readonly EXPECTED_VERSION = "0.0.3";
+ private static readonly DOWNLOAD_URL =
+ "https://github.com/hydralauncher/decky-hydra-launcher/releases/download/0.0.3/Hydra.zip";
+
+ private static getPackageJsonPath(): string {
+ return path.join(HYDRA_DECKY_PLUGIN_LOCATION, "package.json");
+ }
+
+ private static async downloadPlugin(): Promise {
+ logger.log("Downloading Hydra Decky plugin...");
+
+ const tempDir = SystemPath.getPath("temp");
+ const zipPath = path.join(tempDir, "Hydra.zip");
+
+ const response = await axios.get(this.DOWNLOAD_URL, {
+ responseType: "arraybuffer",
+ });
+
+ await fs.promises.writeFile(zipPath, response.data);
+ logger.log(`Plugin downloaded to: ${zipPath}`);
+
+ return zipPath;
+ }
+
+ private static async extractPlugin(zipPath: string): Promise {
+ logger.log("Extracting Hydra Decky plugin...");
+
+ const tempDir = SystemPath.getPath("temp");
+ const extractPath = path.join(tempDir, "hydra-decky-plugin");
+
+ if (fs.existsSync(extractPath)) {
+ await fs.promises.rm(extractPath, { recursive: true, force: true });
+ }
+
+ await fs.promises.mkdir(extractPath, { recursive: true });
+
+ return new Promise((resolve, reject) => {
+ SevenZip.extractFile(
+ {
+ filePath: zipPath,
+ outputPath: extractPath,
+ },
+ () => {
+ logger.log(`Plugin extracted to: ${extractPath}`);
+ resolve(extractPath);
+ },
+ () => {
+ reject(new Error("Failed to extract plugin"));
+ }
+ );
+ });
+ }
+
+ private static needsSudo(): boolean {
+ try {
+ if (fs.existsSync(DECKY_PLUGINS_LOCATION)) {
+ fs.accessSync(DECKY_PLUGINS_LOCATION, fs.constants.W_OK);
+ return false;
+ }
+
+ const parentDir = path.dirname(DECKY_PLUGINS_LOCATION);
+ if (fs.existsSync(parentDir)) {
+ fs.accessSync(parentDir, fs.constants.W_OK);
+ return false;
+ }
+
+ return true;
+ } catch (error) {
+ if (
+ error &&
+ typeof error === "object" &&
+ "code" in error &&
+ (error.code === "EACCES" || error.code === "EPERM")
+ ) {
+ return true;
+ }
+ throw error;
+ }
+ }
+
+ private static async installPluginWithSudo(
+ extractPath: string
+ ): Promise {
+ logger.log("Installing plugin with sudo...");
+
+ const username = os.userInfo().username;
+ const sourcePath = path.join(extractPath, "Hydra");
+
+ return new Promise((resolve, reject) => {
+ const command = `mkdir -p "${DECKY_PLUGINS_LOCATION}" && rm -rf "${HYDRA_DECKY_PLUGIN_LOCATION}" && cp -r "${sourcePath}" "${HYDRA_DECKY_PLUGIN_LOCATION}" && chown -R ${username}: "${DECKY_PLUGINS_LOCATION}"`;
+
+ sudo.exec(
+ command,
+ { name: app.getName() },
+ (sudoError, _stdout, stderr) => {
+ if (sudoError) {
+ logger.error("Failed to install plugin with sudo:", sudoError);
+ reject(sudoError);
+ } else {
+ logger.log("Plugin installed successfully with sudo");
+ if (stderr) {
+ logger.log("Sudo stderr:", stderr);
+ }
+ resolve();
+ }
+ }
+ );
+ });
+ }
+
+ private static async installPluginWithoutSudo(
+ extractPath: string
+ ): Promise {
+ logger.log("Installing plugin without sudo...");
+
+ const sourcePath = path.join(extractPath, "Hydra");
+
+ if (!fs.existsSync(DECKY_PLUGINS_LOCATION)) {
+ await fs.promises.mkdir(DECKY_PLUGINS_LOCATION, { recursive: true });
+ }
+
+ if (fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
+ await fs.promises.rm(HYDRA_DECKY_PLUGIN_LOCATION, {
+ recursive: true,
+ force: true,
+ });
+ }
+
+ await fs.promises.cp(sourcePath, HYDRA_DECKY_PLUGIN_LOCATION, {
+ recursive: true,
+ });
+
+ logger.log("Plugin installed successfully");
+ }
+
+ private static async installPlugin(extractPath: string): Promise {
+ if (this.needsSudo()) {
+ await this.installPluginWithSudo(extractPath);
+ } else {
+ await this.installPluginWithoutSudo(extractPath);
+ }
+ }
+
+ private static async updatePlugin(): Promise {
+ let zipPath: string | null = null;
+ let extractPath: string | null = null;
+
+ try {
+ zipPath = await this.downloadPlugin();
+ extractPath = await this.extractPlugin(zipPath);
+ await this.installPlugin(extractPath);
+
+ logger.log("Plugin update completed successfully");
+ } catch (error) {
+ logger.error("Failed to update plugin:", error);
+ throw error;
+ } finally {
+ if (zipPath) {
+ try {
+ await fs.promises.rm(zipPath, { force: true });
+ logger.log("Cleaned up downloaded zip file");
+ } catch (cleanupError) {
+ logger.error("Failed to clean up zip file:", cleanupError);
+ }
+ }
+
+ if (extractPath) {
+ try {
+ await fs.promises.rm(extractPath, { recursive: true, force: true });
+ logger.log("Cleaned up extraction directory");
+ } catch (cleanupError) {
+ logger.error(
+ "Failed to clean up extraction directory:",
+ cleanupError
+ );
+ }
+ }
+ }
+ }
+
+ public static async checkAndUpdateIfOutdated(): Promise {
+ if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
+ logger.log("Hydra Decky plugin not installed, skipping update check");
+ return;
+ }
+
+ const packageJsonPath = this.getPackageJsonPath();
+
+ try {
+ if (!fs.existsSync(packageJsonPath)) {
+ logger.log(
+ "Hydra Decky plugin package.json not found, skipping update"
+ );
+ return;
+ }
+
+ const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
+ const packageJson = JSON.parse(packageJsonContent);
+ const currentVersion = packageJson.version;
+ const isOutdated = currentVersion !== this.EXPECTED_VERSION;
+
+ if (isOutdated) {
+ logger.log(
+ `Hydra Decky plugin is outdated. Current: ${currentVersion}, Expected: ${this.EXPECTED_VERSION}. Updating...`
+ );
+
+ await this.updatePlugin();
+ logger.log("Hydra Decky plugin updated successfully");
+ } else {
+ logger.log(`Hydra Decky plugin is up to date (${currentVersion})`);
+ }
+ } catch (error) {
+ logger.error(`Error checking/updating Hydra Decky plugin: ${error}`);
+ }
+ }
+
+ public static async checkPluginVersion(): Promise<{
+ exists: boolean;
+ outdated: boolean;
+ currentVersion: string | null;
+ expectedVersion: string;
+ }> {
+ if (!fs.existsSync(HYDRA_DECKY_PLUGIN_LOCATION)) {
+ logger.log("Hydra Decky plugin folder not found, installing...");
+
+ try {
+ await this.updatePlugin();
+ return {
+ exists: true,
+ outdated: false,
+ currentVersion: this.EXPECTED_VERSION,
+ expectedVersion: this.EXPECTED_VERSION,
+ };
+ } catch (error) {
+ logger.error("Failed to install plugin:", error);
+ return {
+ exists: false,
+ outdated: true,
+ currentVersion: null,
+ expectedVersion: this.EXPECTED_VERSION,
+ };
+ }
+ }
+
+ const packageJsonPath = this.getPackageJsonPath();
+
+ try {
+ if (!fs.existsSync(packageJsonPath)) {
+ logger.log("Hydra Decky plugin package.json not found, installing...");
+
+ await this.updatePlugin();
+ return {
+ exists: true,
+ outdated: false,
+ currentVersion: this.EXPECTED_VERSION,
+ expectedVersion: this.EXPECTED_VERSION,
+ };
+ }
+
+ const packageJsonContent = fs.readFileSync(packageJsonPath, "utf-8");
+ const packageJson = JSON.parse(packageJsonContent);
+ const currentVersion = packageJson.version;
+ const isOutdated = currentVersion !== this.EXPECTED_VERSION;
+
+ if (isOutdated) {
+ logger.log(
+ `Hydra Decky plugin is outdated. Current: ${currentVersion}, Expected: ${this.EXPECTED_VERSION}`
+ );
+
+ await this.updatePlugin();
+
+ return {
+ exists: true,
+ outdated: false,
+ currentVersion: this.EXPECTED_VERSION,
+ expectedVersion: this.EXPECTED_VERSION,
+ };
+ } else {
+ logger.log(`Hydra Decky plugin is up to date (${currentVersion})`);
+ }
+
+ return {
+ exists: true,
+ outdated: isOutdated,
+ currentVersion,
+ expectedVersion: this.EXPECTED_VERSION,
+ };
+ } catch (error) {
+ logger.error(`Error checking Hydra Decky plugin version: ${error}`);
+ return {
+ exists: false,
+ outdated: true,
+ currentVersion: null,
+ expectedVersion: this.EXPECTED_VERSION,
+ };
+ }
+ }
+}
diff --git a/src/main/services/index.ts b/src/main/services/index.ts
index 727805c7..88b39d1b 100644
--- a/src/main/services/index.ts
+++ b/src/main/services/index.ts
@@ -17,3 +17,4 @@ export * from "./system-path";
export * from "./library-sync";
export * from "./wine";
export * from "./lock";
+export * from "./decky-plugin";
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 813758f0..700561ac 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -386,6 +386,10 @@ contextBridge.exposeInMainWorld("electron", {
getBadges: () => ipcRenderer.invoke("getBadges"),
canInstallCommonRedist: () => ipcRenderer.invoke("canInstallCommonRedist"),
installCommonRedist: () => ipcRenderer.invoke("installCommonRedist"),
+ installHydraDeckyPlugin: () => ipcRenderer.invoke("installHydraDeckyPlugin"),
+ getHydraDeckyPluginInfo: () => ipcRenderer.invoke("getHydraDeckyPluginInfo"),
+ checkHomebrewFolderExists: () =>
+ ipcRenderer.invoke("checkHomebrewFolderExists"),
platform: process.platform,
/* Auto update */
diff --git a/src/renderer/src/assets/icons/decky.png b/src/renderer/src/assets/icons/decky.png
new file mode 100644
index 0000000000000000000000000000000000000000..205552dd3d7558e885ad4808e4345ac2d00738b6
GIT binary patch
literal 215327
zcmeAS@N?(olHy`uVBq!ia0y~yV44rY9Bd2>3>q&Mb1*O{FnGE+hE&XXvzK#Es(Yy3
zzH9sL*1mu1S9Rj8;b)t&N#|@FoF-gc(7?oI(81GnV)FL~jSu20ye6pgop3(zvhlLB
zjNlhrkxGt?DV~)xJ((1f)93A)Ja6~=mH*$**}VGO_I*)X^W7C*zPs{B{MWp_*%4c_
zLf5X}w`%Wpuc=z0tKJ-4>chnKyHfTu
zpR-PG?a;~oKYNPfj45v7D_*5=BsQ|l6yfA{DG8E_G}yI%SHcvt6HI}fnd+`7^E%#$
zOq3N$*}duwhth4;7THC+VpG3Vx3_GF4L$z7ZU5u_iaGMjgeSJQ|F`|}bp8GM+wy;Y
zstMjya@^L%_n$Y~wWNDRj_Aa*N}Jx^+yDEI0uP)2mYb_XRt7z_*ZJk59kA4Es#fvI
zPu%A(HphQEq^eZ3qeDhxk+$n0qrljOtJv54JD9E;e6=;vAVOtTu+X8pxBCy@zpNaf
zWfbppA@3IV<9aFIdM}Pes}9%aNG)Ayv}JR;*NVzp4Ab|{G}|xlmSEO$cGWJ^76Yc)
zN}D`Gwy%8k;rq1R|G#bKZ#|g65%FH0@w&qI&J(ehUaT$ub5h;huHt8Sb8s{`EDeJW
zrcc!hoo5hP@+49Cw`}#3e@dHNLZ*34So(RoUfiZ{;dTE61TB4LKRfYozvB~++!7AE
z*=DQD6!ah5e;fO}Tw#m(AKCN+752}hTi!)6UrrHVHah-eGDF6y)!tTm<~{Y>pY}TD
zF)(P~{kvlIwwG=`%N