diff --git a/package.json b/package.json index ee039574..e2fec5ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.7.3", + "version": "3.7.4", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -75,6 +75,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-hook-form": "^7.53.0", "react-i18next": "^14.1.0", + "react-infinite-scroll-component": "^6.1.0", "react-loading-skeleton": "^3.4.0", "react-redux": "^9.1.1", "react-router-dom": "^6.22.3", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index f470f549..9989f153 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -695,7 +695,10 @@ "game_added_to_pinned": "Game added to pinned", "karma": "Karma", "karma_count": "karma", - "karma_description": "Earned from positive likes on reviews" + "karma_description": "Earned from positive likes on reviews", + "user_reviews": "Reviews", + "delete_review": "Delete Review", + "loading_reviews": "Loading reviews..." }, "library": { "library": "Library", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index adf25e33..c7e9d13e 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -325,6 +325,7 @@ "maybe_later": "Tal vez después", "no_repacks_found": "Sin fuentes encontradas para este juego", "no_reviews_yet": "Sin reseñas aún", + "review_played_for": "Jugado por", "properties": "Propiedades", "rating": "Calificación", "rating_count": "Calificación", @@ -681,7 +682,11 @@ "karma_count": "karma", "karma_description": "Conseguido por me gustas positivos en reseñas", "sort_by": "Filtrar por:", - "game_added_to_pinned": "Juego añadido a fijados" + "game_added_to_pinned": "Juego añadido a fijados", + "user_reviews": "Reseñas", + "loading_reviews": "Cargando reseñas...", + "no_reviews": "Sin reseñas aún", + "delete_review": "Eliminar reseña" }, "achievement": { "achievement_unlocked": "Logro desbloqueado", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 42743a64..50049140 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -317,6 +317,7 @@ "sort_lowest_score": "Menor Nota", "sort_most_voted": "Mais Votadas", "no_reviews_yet": "Ainda não há avaliações", + "review_played_for": "Jogado por", "be_first_to_review": "Seja o primeiro a compartilhar seus pensamentos sobre este jogo!", "rating": "Avaliação", "rating_stats": "Avaliação", @@ -696,7 +697,11 @@ "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" + "manual_playtime_tooltip": "Este tempo de jogo foi atualizado manualmente", + "user_reviews": "Avaliações", + "loading_reviews": "Carregando avaliações...", + "no_reviews": "Ainda não há avaliações", + "delete_review": "Excluir avaliação" }, "achievement": { "achievement_unlocked": "Conquista desbloqueada", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 6c1963cc..c8e4586d 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -183,7 +183,8 @@ "create_start_menu_shortcut": "Criar atalho no Menu Iniciar", "review_from_blocked_user": "Avaliação de utilizador bloqueado", "show": "Mostrar", - "hide": "Ocultar" + "hide": "Ocultar", + "review_played_for": "Jogado por" }, "activation": { "title": "Ativação", @@ -469,7 +470,11 @@ "achievements_unlocked": "Conquistas desbloqueadas", "earned_points": "Pontos ganhos", "show_achievements_on_profile": "Mostre as suas conquistas no perfil", - "show_points_on_profile": "Mostre os seus pontos ganhos no perfil" + "show_points_on_profile": "Mostre os seus pontos ganhos no perfil", + "user_reviews": "Avaliações", + "loading_reviews": "A carregar avaliações...", + "no_reviews": "Ainda não há avaliações", + "delete_review": "Eliminar avaliação" }, "achievement": { "achievement_unlocked": "Conquista desbloqueada", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 6f4d4b92..2e7c1504 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -227,6 +227,7 @@ "write_review_placeholder": "Поделитесь своими мыслями об этой игре...", "sort_newest": "Сначала новые", "no_reviews_yet": "Пока нет отзывов", + "review_played_for": "Играли", "be_first_to_review": "Станьте первым, кто поделится своими мыслями об этой игре!", "sort_oldest": "Сначала старые", "sort_highest_score": "Высший балл", @@ -692,7 +693,11 @@ "game_added_to_pinned": "Игра добавлена в закрепленные", "karma": "Карма", "karma_count": "карма", - "karma_description": "Заработана положительными оценками отзывов" + "karma_description": "Заработана положительными оценками отзывов", + "user_reviews": "Отзывы", + "loading_reviews": "Загрузка отзывов...", + "no_reviews": "Пока нет отзывов", + "delete_review": "Удалить отзыв" }, "achievement": { "achievement_unlocked": "Достижение разблокировано", diff --git a/src/main/main.ts b/src/main/main.ts index ffb8f8a9..6ae927a5 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -16,6 +16,7 @@ import { Ludusavi, Lock, DeckyPlugin, + WSClient, } from "@main/services"; import { migrateDownloadSources } from "./helpers/migrate-download-sources"; @@ -56,7 +57,7 @@ export const loadState = async () => { const { syncDownloadSourcesFromApi } = await import("./services/user"); void syncDownloadSourcesFromApi(); - // WSClient.connect(); + WSClient.connect(); }); const downloads = await downloadsSublevel diff --git a/src/main/services/hosters/datanodes.ts b/src/main/services/hosters/datanodes.ts index 29708322..4cfb5242 100644 --- a/src/main/services/hosters/datanodes.ts +++ b/src/main/services/hosters/datanodes.ts @@ -1,6 +1,7 @@ import axios, { AxiosResponse } from "axios"; import { wrapper } from "axios-cookiejar-support"; import { CookieJar } from "tough-cookie"; +import { logger } from "@main/services"; export class DatanodesApi { private static readonly jar = new CookieJar(); @@ -20,51 +21,42 @@ export class DatanodesApi { await this.jar.setCookie("lang=english;", "https://datanodes.to"); - const payload = new URLSearchParams({ - op: "download2", - id: fileCode, - method_free: "Free Download >>", - dl: "1", - }); + const formData = new FormData(); + formData.append("op", "download2"); + formData.append("id", fileCode); + formData.append("rand", ""); + formData.append("referer", "https://datanodes.to/download"); + formData.append("method_free", "Free Download >>"); + formData.append("method_premium", ""); + formData.append("__dl", "1"); const response: AxiosResponse = await this.session.post( "https://datanodes.to/download", - payload, + formData, { headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0", + accept: "*/*", + "accept-language": "en-US,en;q=0.9", + priority: "u=1, i", + "sec-ch-ua": + '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", Referer: "https://datanodes.to/download", - Origin: "https://datanodes.to", - "Content-Type": "application/x-www-form-urlencoded", }, - maxRedirects: 0, - validateStatus: (status: number) => status === 302 || status < 400, } ); - if (response.status === 302) { - return response.headers["location"]; - } - if (typeof response.data === "object" && response.data.url) { return decodeURIComponent(response.data.url); } - const htmlContent = String(response.data); - if (!htmlContent) { - throw new Error("Empty response received"); - } - - const downloadLinkRegex = /href=["'](https:\/\/[^"']+)["']/; - const downloadLinkMatch = downloadLinkRegex.exec(htmlContent); - if (downloadLinkMatch) { - return downloadLinkMatch[1]; - } - throw new Error("Failed to get the download link"); } catch (error) { - console.error("Error fetching download URL:", error); + logger.error("Error fetching download URL:", error); throw error; } } diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 12090df3..f77a152f 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -11,6 +11,7 @@ import { getUserData } from "./user/get-user-data"; import { db } from "@main/level"; import { levelKeys } from "@main/level/sublevels"; import type { Auth, User } from "@types"; +import { WSClient } from "./ws"; export interface HydraApiOptions { needsAuth?: boolean; @@ -103,8 +104,8 @@ export class HydraApi { await clearGamesRemoteIds(); uploadGamesBatch(); - // WSClient.close(); - // WSClient.connect(); + WSClient.close(); + WSClient.connect(); const { syncDownloadSourcesFromApi } = await import("./user"); syncDownloadSourcesFromApi(); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 168a4435..1ab76381 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -279,7 +279,11 @@ export function App() {
-
+
diff --git a/src/renderer/src/context/user-profile/user-profile.context.tsx b/src/renderer/src/context/user-profile/user-profile.context.tsx index 87e2a669..c2a6864c 100644 --- a/src/renderer/src/context/user-profile/user-profile.context.tsx +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -14,12 +14,15 @@ export interface UserProfileContext { isMe: boolean; userStats: UserStats | null; getUserProfile: () => Promise; - getUserLibraryGames: (sortBy?: string) => Promise; + getUserLibraryGames: (sortBy?: string, reset?: boolean) => Promise; + loadMoreLibraryGames: (sortBy?: string) => Promise; setSelectedBackgroundImage: React.Dispatch>; backgroundImage: string; badges: Badge[]; libraryGames: UserGame[]; pinnedGames: UserGame[]; + hasMoreLibraryGames: boolean; + isLoadingLibraryGames: boolean; } export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3"; @@ -30,12 +33,15 @@ export const userProfileContext = createContext({ isMe: false, userStats: null, getUserProfile: async () => {}, - getUserLibraryGames: async (_sortBy?: string) => {}, + getUserLibraryGames: async (_sortBy?: string, _reset?: boolean) => {}, + loadMoreLibraryGames: async (_sortBy?: string) => false, setSelectedBackgroundImage: () => {}, backgroundImage: "", badges: [], libraryGames: [], pinnedGames: [], + hasMoreLibraryGames: false, + isLoadingLibraryGames: false, }); const { Provider } = userProfileContext; @@ -62,6 +68,9 @@ export function UserProfileContextProvider({ DEFAULT_USER_PROFILE_BACKGROUND ); const [selectedBackgroundImage, setSelectedBackgroundImage] = useState(""); + const [libraryPage, setLibraryPage] = useState(0); + const [hasMoreLibraryGames, setHasMoreLibraryGames] = useState(true); + const [isLoadingLibraryGames, setIsLoadingLibraryGames] = useState(false); const isMe = userDetails?.id === userProfile?.id; @@ -93,7 +102,13 @@ export function UserProfileContextProvider({ }, [userId]); const getUserLibraryGames = useCallback( - async (sortBy?: string) => { + async (sortBy?: string, reset = true) => { + if (reset) { + setLibraryPage(0); + setHasMoreLibraryGames(true); + setIsLoadingLibraryGames(true); + } + try { const params = new URLSearchParams(); params.append("take", "12"); @@ -115,18 +130,74 @@ export function UserProfileContextProvider({ if (response) { setLibraryGames(response.library); setPinnedGames(response.pinnedGames); + setHasMoreLibraryGames(response.library.length === 12); } else { setLibraryGames([]); setPinnedGames([]); + setHasMoreLibraryGames(false); } } catch (error) { setLibraryGames([]); setPinnedGames([]); + setHasMoreLibraryGames(false); + } finally { + setIsLoadingLibraryGames(false); } }, [userId] ); + const loadMoreLibraryGames = useCallback( + async (sortBy?: string): Promise => { + if (isLoadingLibraryGames || !hasMoreLibraryGames) { + return false; + } + + setIsLoadingLibraryGames(true); + try { + const nextPage = libraryPage + 1; + const params = new URLSearchParams(); + params.append("take", "12"); + params.append("skip", String(nextPage * 12)); + if (sortBy) { + params.append("sortBy", sortBy); + } + + const queryString = params.toString(); + const url = queryString + ? `/users/${userId}/library?${queryString}` + : `/users/${userId}/library`; + + const response = await window.electron.hydraApi.get<{ + library: UserGame[]; + pinnedGames: UserGame[]; + }>(url); + + if (response && response.library.length > 0) { + setLibraryGames((prev) => { + const existingIds = new Set(prev.map((game) => game.objectId)); + const newGames = response.library.filter( + (game) => !existingIds.has(game.objectId) + ); + return [...prev, ...newGames]; + }); + setLibraryPage(nextPage); + setHasMoreLibraryGames(response.library.length === 12); + return true; + } else { + setHasMoreLibraryGames(false); + return false; + } + } catch (error) { + setHasMoreLibraryGames(false); + return false; + } finally { + setIsLoadingLibraryGames(false); + } + }, + [userId, libraryPage, hasMoreLibraryGames, isLoadingLibraryGames] + ); + const getUserProfile = useCallback(async () => { getUserStats(); getUserLibraryGames(); @@ -164,6 +235,8 @@ export function UserProfileContextProvider({ setLibraryGames([]); setPinnedGames([]); setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND); + setLibraryPage(0); + setHasMoreLibraryGames(true); getUserProfile(); getBadges(); @@ -177,12 +250,15 @@ export function UserProfileContextProvider({ isMe, getUserProfile, getUserLibraryGames, + loadMoreLibraryGames, setSelectedBackgroundImage, backgroundImage: getBackgroundImageUrl(), userStats, badges, libraryGames, pinnedGames, + hasMoreLibraryGames, + isLoadingLibraryGames, }} > {children} diff --git a/src/renderer/src/hooks/use-section-collapse.ts b/src/renderer/src/hooks/use-section-collapse.ts index 7cd22224..59ffd6af 100644 --- a/src/renderer/src/hooks/use-section-collapse.ts +++ b/src/renderer/src/hooks/use-section-collapse.ts @@ -3,12 +3,14 @@ import { useState, useCallback } from "react"; interface SectionCollapseState { pinned: boolean; library: boolean; + reviews: boolean; } export function useSectionCollapse() { const [collapseState, setCollapseState] = useState({ pinned: false, library: false, + reviews: false, }); const toggleSection = useCallback((section: keyof SectionCollapseState) => { @@ -23,5 +25,6 @@ export function useSectionCollapse() { toggleSection, isPinnedCollapsed: collapseState.pinned, isLibraryCollapsed: collapseState.library, + isReviewsCollapsed: collapseState.reviews, }; } diff --git a/src/renderer/src/pages/game-details/review-item.scss b/src/renderer/src/pages/game-details/review-item.scss index b3577a75..64657bfd 100644 --- a/src/renderer/src/pages/game-details/review-item.scss +++ b/src/renderer/src/pages/game-details/review-item.scss @@ -8,11 +8,23 @@ &__review-header { display: flex; - justify-content: space-between; - align-items: center; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1); margin-bottom: calc(globals.$spacing-unit * 1.5); } + &__review-header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + } + + &__review-header-bottom { + display: flex; + justify-content: flex-start; + align-items: center; + } + &__review-user { display: flex; align-items: center; diff --git a/src/renderer/src/pages/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx index 6b03e7ea..09d91df8 100644 --- a/src/renderer/src/pages/game-details/review-item.tsx +++ b/src/renderer/src/pages/game-details/review-item.tsx @@ -126,61 +126,62 @@ export function ReviewItem({ return (
-
- -
+
+
-
-
+
- {Boolean( - review.playTimeInSeconds && review.playTimeInSeconds > 0 - ) && ( -
- - - {t("review_played_for")}{" "} - {formatPlayTime(review.playTimeInSeconds || 0)} - -
- )} + {review.user.displayName || "Anonymous"} +
-
-
- {formatDistance(new Date(review.createdAt), new Date(), { addSuffix: true, })}
+
+
+
+ + + {review.score}/5 + +
+ {Boolean( + review.playTimeInSeconds && review.playTimeInSeconds > 0 + ) && ( +
+ + + {t("review_played_for")}{" "} + {formatPlayTime(review.playTimeInSeconds || 0)} + +
+ )} +
+
void; + pinnedGames: UserGame[]; + libraryGames: UserGame[]; + hasMoreLibraryGames: boolean; + isLoadingLibraryGames: boolean; + statsIndex: number; + userStats: { libraryCount: number } | null; + animatedGameIdsRef: React.MutableRefObject>; + onLoadMore: () => void; + onMouseEnter: () => void; + onMouseLeave: () => void; + isMe: boolean; +} + +export function LibraryTab({ + sortBy, + onSortChange, + pinnedGames, + libraryGames, + hasMoreLibraryGames, + isLoadingLibraryGames, + statsIndex, + userStats, + animatedGameIdsRef, + onLoadMore, + onMouseEnter, + onMouseLeave, + isMe, +}: Readonly) { + const { t } = useTranslation("user_profile"); + const { numberFormatter } = useFormat(); + + const hasGames = libraryGames.length > 0; + const hasPinnedGames = pinnedGames.length > 0; + const hasAnyGames = hasGames || hasPinnedGames; + + return ( + + {hasAnyGames && ( + + )} + + {!hasAnyGames && ( +
+
+ +
+

{t("no_recent_activity_title")}

+ {isMe &&

{t("no_recent_activity_description")}

} +
+ )} + + {hasAnyGames && ( +
+ {hasPinnedGames && ( +
+
+
+

{t("pinned")}

+ + {pinnedGames.length} + +
+
+ +
    + {pinnedGames?.map((game) => ( +
  • + +
  • + ))} +
+
+ )} + + {hasGames && ( +
+
+
+

{t("library")}

+ {userStats && ( + + {numberFormatter.format(userStats.libraryCount)} + + )} +
+
+ + +
    + {libraryGames?.map((game, index) => { + const hasAnimated = animatedGameIdsRef.current.has( + game.objectId + ); + const isNewGame = !hasAnimated && !isLoadingLibraryGames; + + return ( + { + if (isNewGame) { + animatedGameIdsRef.current.add(game.objectId); + } + }} + > + + + ); + })} +
+
+
+ )} +
+ )} +
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss index c3c71d9a..958fe52d 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -101,6 +101,11 @@ gap: calc(globals.$spacing-unit); margin-bottom: calc(globals.$spacing-unit * 2); border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + } + + &__tab-wrapper { + position: relative; } &__tab { @@ -111,19 +116,40 @@ cursor: pointer; font-size: 14px; font-weight: 500; - border-bottom: 2px solid transparent; - transition: all ease 0.2s; - - &:hover { - color: rgba(255, 255, 255, 0.8); - } + transition: color ease 0.2s; + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.5); &--active { color: white; - border-bottom-color: #c9aa71; } } + &__tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 6px; + background-color: rgba(255, 255, 255, 0.15); + border-radius: 9px; + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + line-height: 1; + } + + &__tab-underline { + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: white; + } + &__games-grid { list-style: none; margin: 0; @@ -175,5 +201,245 @@ backdrop-filter: blur(10px); } } + + &__tab-panels { + display: block; + } + } +} + +// Reviews minimal styles +.user-reviews__loading { + padding: calc(globals.$spacing-unit * 2); + color: rgba(255, 255, 255, 0.8); + text-align: center; + display: flex; + justify-content: center; + align-items: center; +} + +.user-reviews__empty { + text-align: center; + padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2); + color: rgba(255, 255, 255, 0.6); +} + +.user-reviews__list { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 4); +} + +.user-reviews__review-item { + border-radius: 8px; +} + +.user-reviews__review-header { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1); + margin-bottom: calc(globals.$spacing-unit * 1.5); +} + +.user-reviews__review-header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.user-reviews__review-header-bottom { + display: flex; + justify-content: space-between; + align-items: center; +} + +.user-reviews__review-meta-row { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.75); +} + +.user-reviews__review-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: calc(globals.$spacing-unit * 1.5); + margin-bottom: calc(globals.$spacing-unit * 1.5); +} + +.user-reviews__review-game { + display: flex; + gap: calc(globals.$spacing-unit); +} + +.user-reviews__game-icon { + width: 24px; + height: 24px; + object-fit: cover; +} + +.user-reviews__game-info { + display: flex; + flex-direction: column; +} + +.user-reviews__game-details { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.75); +} + +.user-reviews__game-title { + background: none; + border: none; + color: rgba(255, 255, 255, 0.9); + font-weight: 600; + cursor: pointer; + text-align: left; + + &--clickable:hover { + text-decoration: underline; + } +} + +.user-reviews__review-date { + display: flex; + align-items: center; + gap: 4px; + color: rgba(255, 255, 255, 0.6); + font-size: globals.$small-font-size; +} + +.user-reviews__review-score-stars { + display: flex; + align-items: center; + gap: 4px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.1); + font-size: 11px; + font-weight: 500; +} + +.user-reviews__review-star { + color: rgba(255, 255, 255, 0.7); + transition: color 0.2s ease; + + &--filled { + color: rgba(255, 255, 255, 0.7); + + svg { + fill: currentColor; + } + } +} + +.user-reviews__review-score-text { + font-weight: 500; +} + +.user-reviews__review-playtime { + display: flex; + align-items: center; + gap: 4px; + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + font-weight: 500; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.user-reviews__review-content { + color: rgba(255, 255, 255, 0.85); + line-height: 1.5; + word-wrap: break-word; + word-break: break-word; + overflow-wrap: break-word; + white-space: pre-wrap; + max-width: 100%; +} + +.user-reviews__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); + } +} + +.user-reviews__review-actions { + margin-top: calc(globals.$spacing-unit * 2); + padding-top: calc(globals.$spacing-unit); + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: space-between; + align-items: center; +} + +.user-reviews__review-votes { + display: flex; + gap: calc(globals.$spacing-unit); +} + +.user-reviews__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); + color: #ffffff; + } + + &--active { + color: #ffffff; + border-color: rgba(255, 255, 255, 0.3); + + svg { + fill: white; + } + } +} + +.user-reviews__delete-review-button { + display: flex; + align-items: center; + gap: 6px; + background: rgba(244, 67, 54, 0.1); + border: 1px solid rgba(244, 67, 54, 0.3); + border-radius: 6px; + padding: 6px 10px; + color: #f44336; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(244, 67, 54, 0.2); + border-color: rgba(244, 67, 54, 0.4); + color: #ff7961; } } diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index d2f1f074..8176bace 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -1,29 +1,82 @@ import { userProfileContext } from "@renderer/context"; -import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { ProfileHero } from "../profile-hero/profile-hero"; -import { useAppDispatch, useFormat } from "@renderer/hooks"; +import { useAppDispatch, useFormat, useUserDetails } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; -import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; +import type { GameShop } from "@types"; import { LockedProfile } from "./locked-profile"; import { ReportProfile } from "../report-profile/report-profile"; import { FriendsBox } from "./friends-box"; import { RecentGamesBox } from "./recent-games-box"; import { UserStatsBox } from "./user-stats-box"; import { UserKarmaBox } from "./user-karma-box"; -import { UserLibraryGameCard } from "./user-library-game-card"; -import { SortOptions } from "./sort-options"; -import { useSectionCollapse } from "@renderer/hooks/use-section-collapse"; -import { motion, AnimatePresence } from "framer-motion"; -import { - sectionVariants, - chevronVariants, - GAME_STATS_ANIMATION_DURATION_IN_MS, -} from "./profile-animations"; +import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; +import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import { ProfileTabs } from "./profile-tabs"; +import { LibraryTab } from "./library-tab"; +import { ReviewsTab } from "./reviews-tab"; +import { AnimatePresence } from "framer-motion"; import "./profile-content.scss"; type SortOption = "playtime" | "achievementCount" | "playedRecently"; +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: GameShop; + }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; +} + +interface UserReviewsResponse { + totalCount: number; + reviews: UserReview[]; +} + +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 ProfileContent() { const { userProfile, @@ -32,16 +85,43 @@ export function ProfileContent() { libraryGames, pinnedGames, getUserLibraryGames, + loadMoreLibraryGames, + hasMoreLibraryGames, + isLoadingLibraryGames, } = useContext(userProfileContext); + const { userDetails } = useUserDetails(); const [statsIndex, setStatsIndex] = useState(0); const [isAnimationRunning, setIsAnimationRunning] = useState(true); const [sortBy, setSortBy] = useState("playedRecently"); const statsAnimation = useRef(-1); - const { toggleSection, isPinnedCollapsed } = useSectionCollapse(); + + const [activeTab, setActiveTab] = useState<"library" | "reviews">("library"); + + // User reviews state + const [reviews, setReviews] = useState([]); + const [reviewsTotalCount, setReviewsTotalCount] = useState(0); + const [isLoadingReviews, setIsLoadingReviews] = useState(false); + const [votingReviews, setVotingReviews] = useState>(new Set()); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [reviewToDelete, setReviewToDelete] = useState(null); const dispatch = useAppDispatch(); const { t } = useTranslation("user_profile"); + const { numberFormatter } = useFormat(); + + const formatPlayTime = (playTimeInSeconds: number) => { + const minutes = playTimeInSeconds / 60; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t("amount_minutes", { + amount: minutes.toFixed(0), + }); + } + + const hours = minutes / 60; + return t("amount_hours", { amount: numberFormatter.format(hours) }); + }; useEffect(() => { dispatch(setHeaderTitle("")); @@ -53,10 +133,201 @@ export function ProfileContent() { useEffect(() => { if (userProfile) { - getUserLibraryGames(sortBy); + // When sortBy changes, clear animated games so all games animate in + if (currentSortByRef.current !== sortBy) { + animatedGameIdsRef.current.clear(); + currentSortByRef.current = sortBy; + } + getUserLibraryGames(sortBy, true); } }, [sortBy, getUserLibraryGames, userProfile]); + const animatedGameIdsRef = useRef>(new Set()); + const currentSortByRef = useRef(sortBy); + + const handleLoadMore = useCallback(() => { + if ( + activeTab === "library" && + hasMoreLibraryGames && + !isLoadingLibraryGames + ) { + loadMoreLibraryGames(sortBy); + } + }, [ + activeTab, + hasMoreLibraryGames, + isLoadingLibraryGames, + loadMoreLibraryGames, + sortBy, + ]); + + // Clear reviews state and reset tab when switching users + useEffect(() => { + setReviews([]); + setReviewsTotalCount(0); + setIsLoadingReviews(false); + setActiveTab("library"); + }, [userProfile?.id]); + + useEffect(() => { + if (userProfile?.id) { + fetchUserReviews(); + } + }, [userProfile?.id]); + + const fetchUserReviews = async () => { + if (!userProfile?.id) return; + + setIsLoadingReviews(true); + try { + const response = await window.electron.hydraApi.get( + `/users/${userProfile.id}/reviews`, + { needsAuth: true } + ); + setReviews(response.reviews); + setReviewsTotalCount(response.totalCount); + } catch (error) { + // Error handling for fetching reviews + } finally { + setIsLoadingReviews(false); + } + }; + + const handleDeleteReview = async (reviewId: string) => { + try { + const reviewToDeleteObj = reviews.find( + (review) => review.id === reviewId + ); + if (!reviewToDeleteObj) return; + + await window.electron.hydraApi.delete( + `/games/${reviewToDeleteObj.game.shop}/${reviewToDeleteObj.game.objectId}/reviews/${reviewId}` + ); + // Remove the review from the local state + setReviews((prev) => prev.filter((review) => review.id !== reviewId)); + setReviewsTotalCount((prev) => prev - 1); + } catch (error) { + console.error("Failed to delete review:", error); + } + }; + + const handleDeleteClick = (reviewId: string) => { + setReviewToDelete(reviewId); + setDeleteModalVisible(true); + }; + + const handleDeleteConfirm = () => { + if (reviewToDelete) { + handleDeleteReview(reviewToDelete); + setReviewToDelete(null); + } + }; + + const handleDeleteCancel = () => { + setDeleteModalVisible(false); + setReviewToDelete(null); + }; + + const handleVoteReview = async (reviewId: string, isUpvote: boolean) => { + if (votingReviews.has(reviewId)) return; + + setVotingReviews((prev) => new Set(prev).add(reviewId)); + + const review = reviews.find((r) => r.id === reviewId); + if (!review) { + setVotingReviews((prev) => { + const next = new Set(prev); + next.delete(reviewId); + return next; + }); + return; + } + + const wasUpvoted = review.hasUpvoted; + const wasDownvoted = review.hasDownvoted; + + // Optimistic update + setReviews((prev) => + prev.map((r) => { + if (r.id !== reviewId) return r; + + let newUpvotes = r.upvotes; + let newDownvotes = r.downvotes; + let newHasUpvoted = r.hasUpvoted; + let newHasDownvoted = r.hasDownvoted; + + if (isUpvote) { + if (wasUpvoted) { + // Remove upvote + newUpvotes--; + newHasUpvoted = false; + } else { + // Add upvote + newUpvotes++; + newHasUpvoted = true; + if (wasDownvoted) { + // Remove downvote if it was downvoted + newDownvotes--; + newHasDownvoted = false; + } + } + } else if (wasDownvoted) { + // Remove downvote + newDownvotes--; + newHasDownvoted = false; + } else { + // Add downvote + newDownvotes++; + newHasDownvoted = true; + if (wasUpvoted) { + // Remove upvote if it was upvoted + newUpvotes--; + newHasUpvoted = false; + } + } + + return { + ...r, + upvotes: newUpvotes, + downvotes: newDownvotes, + hasUpvoted: newHasUpvoted, + hasDownvoted: newHasDownvoted, + }; + }) + ); + + try { + const endpoint = isUpvote ? "upvote" : "downvote"; + await window.electron.hydraApi.put( + `/games/${review.game.shop}/${review.game.objectId}/reviews/${reviewId}/${endpoint}` + ); + } catch (error) { + console.error("Failed to vote on review:", error); + + // Rollback optimistic update on error + setReviews((prev) => + prev.map((r) => { + if (r.id !== reviewId) return r; + return { + ...r, + upvotes: review.upvotes, + downvotes: review.downvotes, + hasUpvoted: review.hasUpvoted, + hasDownvoted: review.hasDownvoted, + }; + }) + ); + } finally { + setTimeout(() => { + setVotingReviews((prev) => { + const newSet = new Set(prev); + newSet.delete(reviewId); + return newSet; + }); + }, 500); + } + }; + const handleOnMouseEnterGameCard = () => { setIsAnimationRunning(false); }; @@ -86,8 +357,6 @@ export function ProfileContent() { }; }, [setStatsIndex, isAnimationRunning]); - const { numberFormatter } = useFormat(); - const usersAreFriends = useMemo(() => { return userProfile?.relation?.status === "ACCEPTED"; }, [userProfile]); @@ -113,112 +382,46 @@ export function ProfileContent() { return (
- {hasAnyGames && ( - - )} + - {!hasAnyGames && ( -
-
- -
-

{t("no_recent_activity_title")}

- {isMe &&

{t("no_recent_activity_description")}

} -
- )} - - {hasAnyGames && ( -
- {hasPinnedGames && ( -
-
-
- -

{t("pinned")}

- - {pinnedGames.length} - -
-
- - - {!isPinnedCollapsed && ( - -
    - {pinnedGames?.map((game) => ( -
  • - -
  • - ))} -
-
- )} -
-
+
+ + {activeTab === "library" && ( + )} - {hasGames && ( -
-
-
-

{t("library")}

- {userStats && ( - - {numberFormatter.format(userStats.libraryCount)} - - )} -
-
- -
    - {libraryGames?.map((game) => ( -
  • - -
  • - ))} -
-
+ {activeTab === "reviews" && ( + )} -
- )} + +
{shouldShowRightContent && ( @@ -230,6 +433,12 @@ export function ProfileContent() {
)} + + ); }, [ @@ -242,9 +451,15 @@ export function ProfileContent() { statsIndex, libraryGames, pinnedGames, - isPinnedCollapsed, - toggleSection, + sortBy, + activeTab, + // ensure reviews UI updates correctly + reviews, + reviewsTotalCount, + isLoadingReviews, + votingReviews, + deleteModalVisible, ]); return ( diff --git a/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx b/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx new file mode 100644 index 00000000..bea569e7 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/profile-review-item.tsx @@ -0,0 +1,252 @@ +import { motion, AnimatePresence } from "framer-motion"; +import { useNavigate } from "react-router-dom"; +import { ClockIcon } from "@primer/octicons-react"; +import { Star, ThumbsUp, ThumbsDown, TrashIcon, Languages } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import type { GameShop } from "@types"; +import { sanitizeHtml } from "@shared"; +import { useDate } from "@renderer/hooks"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import "./profile-content.scss"; + +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: GameShop; + }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; +} + +interface ProfileReviewItemProps { + review: UserReview; + isOwnReview: boolean; + isVoting: boolean; + formatPlayTime: (playTimeInSeconds: number) => string; + getRatingText: (score: number, t: (key: string) => string) => string; + onVote: (reviewId: string, isUpvote: boolean) => void; + onDelete: (reviewId: string) => void; +} + +export function ProfileReviewItem({ + review, + isOwnReview, + isVoting, + formatPlayTime, + getRatingText, + onVote, + onDelete, +}: Readonly) { + const navigate = useNavigate(); + const { formatDistance } = useDate(); + const { t } = useTranslation("user_profile"); + const { t: tGameDetails, i18n } = useTranslation("game_details"); + const [showOriginal, setShowOriginal] = useState(false); + + const getBaseLanguage = (lang: string | null) => lang?.split("-")[0] || ""; + + const isDifferentLanguage = + getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language); + + const needsTranslation = + !isOwnReview && isDifferentLanguage && review.translations[i18n.language]; + + const getLanguageName = (languageCode: string | null) => { + if (!languageCode) return ""; + try { + const displayNames = new Intl.DisplayNames([i18n.language], { + type: "language", + }); + return displayNames.of(languageCode) || languageCode.toUpperCase(); + } catch { + return languageCode.toUpperCase(); + } + }; + + const displayContent = needsTranslation + ? review.translations[i18n.language] + : review.reviewHtml; + + return ( + +
+
+
+
+
+ {review.game.title} + +
+
+
+
+ {formatDistance(new Date(review.createdAt), new Date(), { + addSuffix: true, + })} +
+
+
+
+
+ + + {review.score}/5 + +
+ {Boolean( + review.playTimeInSeconds && review.playTimeInSeconds > 0 + ) && ( +
+ + + {tGameDetails("review_played_for")}{" "} + {formatPlayTime(review.playTimeInSeconds || 0)} + +
+ )} +
+
+
+ +
+
+ {needsTranslation && ( + <> + + {showOriginal && ( +
+ )} + + )} +
+ +
+
+ onVote(review.id, true)} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.upvotes} + + + + + onVote(review.id, false)} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.downvotes} + + + +
+ + {isOwnReview && ( + + )} +
+ + ); +} diff --git a/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx new file mode 100644 index 00000000..bc76f40c --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/profile-tabs.tsx @@ -0,0 +1,67 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import "./profile-content.scss"; + +interface ProfileTabsProps { + activeTab: "library" | "reviews"; + reviewsTotalCount: number; + onTabChange: (tab: "library" | "reviews") => void; +} + +export function ProfileTabs({ + activeTab, + reviewsTotalCount, + onTabChange, +}: Readonly) { + const { t } = useTranslation("user_profile"); + + return ( +
+
+ + {activeTab === "library" && ( + + )} +
+
+ + {activeTab === "reviews" && ( + + )} +
+
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx new file mode 100644 index 00000000..afcc417b --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/reviews-tab.tsx @@ -0,0 +1,96 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import type { GameShop } from "@types"; +import { ProfileReviewItem } from "./profile-review-item"; +import "./profile-content.scss"; + +interface UserReview { + id: string; + reviewHtml: string; + score: number; + playTimeInSeconds?: number; + upvotes: number; + downvotes: number; + hasUpvoted: boolean; + hasDownvoted: boolean; + createdAt: string; + updatedAt: string; + user: { + id: string; + }; + game: { + title: string; + iconUrl: string; + objectId: string; + shop: GameShop; + }; + translations: { + [key: string]: string; + }; + detectedLanguage: string | null; +} + +interface ReviewsTabProps { + reviews: UserReview[]; + isLoadingReviews: boolean; + votingReviews: Set; + userDetailsId?: string; + formatPlayTime: (playTimeInSeconds: number) => string; + getRatingText: (score: number, t: (key: string) => string) => string; + onVote: (reviewId: string, isUpvote: boolean) => void; + onDelete: (reviewId: string) => void; +} + +export function ReviewsTab({ + reviews, + isLoadingReviews, + votingReviews, + userDetailsId, + formatPlayTime, + getRatingText, + onVote, + onDelete, +}: Readonly) { + const { t } = useTranslation("user_profile"); + + return ( + + {isLoadingReviews && ( +
{t("loading_reviews")}
+ )} + {!isLoadingReviews && reviews.length === 0 && ( +
+

{t("no_reviews", "No reviews yet")}

+
+ )} + {!isLoadingReviews && reviews.length > 0 && ( +
+ {reviews.map((review) => { + const isOwnReview = userDetailsId === review.user.id; + + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss index 61640536..76bd25a9 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss @@ -36,6 +36,7 @@ box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5); width: 100%; position: relative; + overflow: hidden; &:before { content: ""; @@ -193,8 +194,28 @@ border-radius: 4px; width: 100%; height: 100%; - min-width: 100%; - min-height: 100%; + display: block; + } + + &__cover-placeholder { + position: relative; + width: 100%; + padding-bottom: 150%; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.04) 50%, + rgba(255, 255, 255, 0.08) 100% + ); + border-radius: 4px; + color: rgba(255, 255, 255, 0.3); + + svg { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } } &__achievements-progress { 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 72b48a8c..81db6334 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 @@ -2,7 +2,7 @@ import { UserGame } from "@types"; import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; import { useFormat, useToast } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; -import { useCallback, useContext, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { buildGameAchievementPath, buildGameDetailsPath, @@ -15,6 +15,7 @@ import { AlertFillIcon, PinIcon, PinSlashIcon, + ImageIcon, } from "@primer/octicons-react"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { Tooltip } from "react-tooltip"; @@ -44,6 +45,11 @@ export function UserLibraryGameCard({ const navigate = useNavigate(); const [isTooltipHovered, setIsTooltipHovered] = useState(false); const [isPinning, setIsPinning] = useState(false); + const [imageError, setImageError] = useState(false); + + useEffect(() => { + setImageError(false); + }, [game.coverImageUrl]); const getStatsItemCount = useCallback(() => { let statsCount = 1; @@ -233,11 +239,18 @@ export function UserLibraryGameCard({ )}
- {game.title} + {imageError || !game.coverImageUrl ? ( +
+ +
+ ) : ( + {game.title} setImageError(true)} + /> + )} =2.2.7 <3": version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"