Merge pull request #1815 from hydralauncher/feat/adding-review-translations
Some checks failed
Build Renderer / build (push) Has been cancelled
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled

feat: adding translations
This commit is contained in:
Chubby Granny Chaser
2025-10-17 15:38:30 +01:00
committed by GitHub
8 changed files with 130 additions and 14 deletions

View File

@@ -357,7 +357,11 @@
"delete_review_modal_description": "This action cannot be undone.",
"delete_review_modal_delete_button": "Delete",
"delete_review_modal_cancel_button": "Cancel",
"vote_failed": "Failed to register your vote. Please try again."
"vote_failed": "Failed to register your vote. Please try again.",
"show_original": "Show original",
"show_translation": "Show translation",
"show_original_translated_from": "Show original (translated from {{language}})",
"hide_original": "Hide original"
},
"activation": {
"title": "Activate Hydra",

View File

@@ -345,6 +345,10 @@
"delete_review_modal_description": "Esta ação não pode ser desfeita.",
"delete_review_modal_delete_button": "Excluir",
"delete_review_modal_cancel_button": "Cancelar",
"show_original": "Mostrar original",
"show_translation": "Mostrar tradução",
"show_original_translated_from": "Mostrar original (traduzido do {{language}})",
"hide_original": "Ocultar original",
"rating_count": "Avaliação"
},
"activation": {

View File

@@ -259,6 +259,10 @@
"delete_review_modal_description": "Это действие нельзя отменить.",
"delete_review_modal_delete_button": "Удалить",
"delete_review_modal_cancel_button": "Отмена",
"show_original": "Показать оригинал",
"show_translation": "Показать перевод",
"show_original_translated_from": "Показать оригинал (переведено с {{language}})",
"hide_original": "Скрыть оригинал",
"cloud_save": "Облачное сохранение",
"cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве",
"backups": "Резервные копии",

View File

@@ -39,7 +39,7 @@ export function GameReviews({
hasUserReviewed,
onUserReviewedChange,
}: Readonly<GameReviewsProps>) {
const { t } = useTranslation("game_details");
const { t, i18n } = useTranslation("game_details");
const { showSuccessToast, showErrorToast } = useToast();
const [reviews, setReviews] = useState<GameReview[]>([]);
@@ -129,9 +129,7 @@ export function GameReviews({
const twoHoursInMilliseconds = 2 * 60 * 60 * 1000;
const hasEnoughPlaytime =
game &&
game.playTimeInMilliseconds >= twoHoursInMilliseconds &&
!game.hasManuallyUpdatedPlaytime;
game && game.playTimeInMilliseconds >= twoHoursInMilliseconds;
if (
!hasReviewed &&
@@ -146,6 +144,8 @@ export function GameReviews({
}
}, [objectId, userDetailsId, shop, game, onUserReviewedChange]);
console.log("reviews", reviews);
const loadReviews = useCallback(
async (reset = false) => {
if (!objectId) return;
@@ -164,6 +164,7 @@ export function GameReviews({
take: "20",
skip: skip.toString(),
sortBy: reviewsSortBy,
language: i18n.language,
});
const response = await window.electron.hydraApi.get(
@@ -200,7 +201,7 @@ export function GameReviews({
}
}
},
[objectId, shop, reviewsPage, reviewsSortBy]
[objectId, shop, reviewsPage, reviewsSortBy, i18n.language]
);
const handleVoteReview = async (
@@ -439,6 +440,8 @@ export function GameReviews({
});
}, [reviews]);
console.log("reviews", reviews);
return (
<div className="game-details__reviews-section">
{showReviewPrompt &&

View File

@@ -0,0 +1,23 @@
@use "../../scss/globals.scss";
.game-details {
&__review-translation-toggle {
display: inline-flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
margin-top: calc(globals.$spacing-unit * 1.5);
padding: 0;
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 0.875rem;
cursor: pointer;
text-decoration: none;
transition: all 0.2s ease;
&:hover {
text-decoration: underline;
color: rgba(255, 255, 255, 0.9);
}
}
}

View File

@@ -1,8 +1,9 @@
import { TrashIcon, ClockIcon } from "@primer/octicons-react";
import { ThumbsUp, ThumbsDown, Star } from "lucide-react";
import { ThumbsUp, ThumbsDown, Star, Languages } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import type { GameReview } from "@types";
import { sanitizeHtml } from "@shared";
@@ -10,6 +11,8 @@ import { useDate } from "@renderer/hooks";
import { formatNumber } from "@renderer/helpers";
import { Avatar } from "@renderer/components";
import "./review-item.scss";
interface ReviewItemProps {
review: GameReview;
userDetailsId?: string;
@@ -63,9 +66,45 @@ export function ReviewItem({
onAnimationComplete,
}: Readonly<ReviewItemProps>) {
const navigate = useNavigate();
const { t } = useTranslation("game_details");
const { t, i18n } = useTranslation("game_details");
const { formatDistance } = useDate();
const [showOriginal, setShowOriginal] = useState(false);
// Check if this is the user's own review
const isOwnReview = userDetailsId === review.user.id;
// Helper to get base language code (e.g., "pt" from "pt-BR")
const getBaseLanguage = (lang: string) => lang.split("-")[0];
// Check if the review is in a different language (comparing base language codes)
const isDifferentLanguage =
getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language);
// Check if translation is available and needed (but not for own reviews)
const needsTranslation =
!isOwnReview &&
isDifferentLanguage &&
review.translations &&
review.translations[i18n.language];
// Get the full language name using Intl.DisplayNames
const getLanguageName = (languageCode: string) => {
try {
const displayNames = new Intl.DisplayNames([i18n.language], {
type: "language",
});
return displayNames.of(languageCode) || languageCode.toUpperCase();
} catch {
return languageCode.toUpperCase();
}
};
// Determine which content to show - always show original for own reviews
const displayContent = needsTranslation
? review.translations[i18n.language]
: review.reviewHtml;
if (isBlocked && !isVisible) {
return (
<div className="game-details__review-item">
@@ -135,12 +174,41 @@ export function ReviewItem({
))}
</div>
</div>
<div
className="game-details__review-content"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(review.reviewHtml),
}}
/>
<div>
<div
className="game-details__review-content"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(displayContent),
}}
/>
{needsTranslation && (
<>
<button
className="game-details__review-translation-toggle"
onClick={() => setShowOriginal(!showOriginal)}
>
<Languages size={13} />
{showOriginal
? t("hide_original")
: t("show_original_translated_from", {
language: getLanguageName(review.detectedLanguage),
})}
</button>
{showOriginal && (
<div
className="game-details__review-content"
style={{
opacity: 0.6,
marginTop: "12px",
}}
dangerouslySetInnerHTML={{
__html: sanitizeHtml(review.reviewHtml),
}}
/>
)}
</>
)}
</div>
<div className="game-details__review-actions">
<div className="game-details__review-votes">
<motion.button

View File

@@ -39,6 +39,12 @@ export function sanitizeHtml(html: string): string {
}
}
// Remove code and pre tags but keep their text content
for (const el of tempDiv.querySelectorAll("code, pre")) {
const textNode = document.createTextNode(el.textContent || "");
el.replaceWith(textNode);
}
for (const el of tempDiv.querySelectorAll("*")) {
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();

View File

@@ -260,6 +260,10 @@ export interface GameReview {
displayName: string;
profileImageUrl: string | null;
};
translations: {
[key: string]: string;
};
detectedLanguage: string;
}
export interface TrendingGame extends ShopAssets {