diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..533be1a9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM node:22-alpine AS builder + +# Install git (required for git dependencies) +RUN apk add --no-cache git + +WORKDIR /app + +COPY package.json yarn.lock ./ + +# Install dependencies but skip postinstall scripts (electron-builder deps not needed for web build) +RUN yarn install --frozen-lockfile --ignore-scripts + +COPY . . + +RUN yarn electron-vite build + +FROM nginx:alpine + +COPY --from=builder /app/out/renderer /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] + diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 0ca77d87..daf6feed 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -356,7 +356,8 @@ "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", - "delete_review_modal_cancel_button": "Cancel" + "delete_review_modal_cancel_button": "Cancel", + "vote_failed": "Failed to register your vote. Please try again." }, "activation": { "title": "Activate Hydra", diff --git a/src/main/events/catalogue/check-game-review.ts b/src/main/events/catalogue/check-game-review.ts deleted file mode 100644 index 5fa71e29..00000000 --- a/src/main/events/catalogue/check-game-review.ts +++ /dev/null @@ -1,15 +0,0 @@ -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); diff --git a/src/main/events/catalogue/create-game-review.ts b/src/main/events/catalogue/create-game-review.ts deleted file mode 100644 index 57c74d45..00000000 --- a/src/main/events/catalogue/create-game-review.ts +++ /dev/null @@ -1,18 +0,0 @@ -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); diff --git a/src/main/events/catalogue/delete-review.ts b/src/main/events/catalogue/delete-review.ts deleted file mode 100644 index e617a288..00000000 --- a/src/main/events/catalogue/delete-review.ts +++ /dev/null @@ -1,14 +0,0 @@ -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); diff --git a/src/main/events/catalogue/get-catalogue.ts b/src/main/events/catalogue/get-catalogue.ts deleted file mode 100644 index c8c24cbe..00000000 --- a/src/main/events/catalogue/get-catalogue.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import { CatalogueCategory } from "@shared"; -import { ShopAssets } from "@types"; - -const getCatalogue = async ( - _event: Electron.IpcMainInvokeEvent, - category: CatalogueCategory -) => { - const params = new URLSearchParams({ - take: "12", - skip: "0", - }); - - return HydraApi.get( - `/catalogue/${category}?${params.toString()}`, - {}, - { needsAuth: false } - ); -}; - -registerEvent("getCatalogue", getCatalogue); diff --git a/src/main/events/catalogue/get-developers.ts b/src/main/events/catalogue/get-developers.ts deleted file mode 100644 index 76ae566b..00000000 --- a/src/main/events/catalogue/get-developers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { HydraApi } from "@main/services"; -import { registerEvent } from "../register-event"; - -const getDevelopers = async (_event: Electron.IpcMainInvokeEvent) => { - return HydraApi.get(`/catalogue/developers`, null, { - needsAuth: false, - }); -}; - -registerEvent("getDevelopers", getDevelopers); diff --git a/src/main/events/catalogue/get-game-reviews.ts b/src/main/events/catalogue/get-game-reviews.ts deleted file mode 100644 index 8f29db3f..00000000 --- a/src/main/events/catalogue/get-game-reviews.ts +++ /dev/null @@ -1,26 +0,0 @@ -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); diff --git a/src/main/events/catalogue/get-how-long-to-beat.ts b/src/main/events/catalogue/get-how-long-to-beat.ts deleted file mode 100644 index 0d630164..00000000 --- a/src/main/events/catalogue/get-how-long-to-beat.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { GameShop, HowLongToBeatCategory } from "@types"; - -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const getHowLongToBeat = async ( - _event: Electron.IpcMainInvokeEvent, - objectId: string, - shop: GameShop -): Promise => { - return HydraApi.get(`/games/${shop}/${objectId}/how-long-to-beat`, null, { - needsAuth: false, - }); -}; - -registerEvent("getHowLongToBeat", getHowLongToBeat); diff --git a/src/main/events/catalogue/get-publishers.ts b/src/main/events/catalogue/get-publishers.ts deleted file mode 100644 index 3b8fdc5f..00000000 --- a/src/main/events/catalogue/get-publishers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { HydraApi } from "@main/services"; -import { registerEvent } from "../register-event"; - -const getPublishers = async (_event: Electron.IpcMainInvokeEvent) => { - return HydraApi.get(`/catalogue/publishers`, null, { - needsAuth: false, - }); -}; - -registerEvent("getPublishers", getPublishers); diff --git a/src/main/events/catalogue/get-trending-games.ts b/src/main/events/catalogue/get-trending-games.ts deleted file mode 100644 index 4c587f37..00000000 --- a/src/main/events/catalogue/get-trending-games.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { db, levelKeys } from "@main/level"; -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { TrendingGame } from "@types"; - -const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => { - const language = await db - .get(levelKeys.language, { - valueEncoding: "utf8", - }) - .then((language) => language || "en"); - - const trendingGames = await HydraApi.get( - "/catalogue/featured", - { language }, - { needsAuth: false } - ).catch(() => []); - - return trendingGames.slice(0, 1); -}; - -registerEvent("getTrendingGames", getTrendingGames); diff --git a/src/main/events/catalogue/search-games.ts b/src/main/events/catalogue/search-games.ts deleted file mode 100644 index 8b22101d..00000000 --- a/src/main/events/catalogue/search-games.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { CatalogueSearchPayload } from "@types"; -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const searchGames = async ( - _event: Electron.IpcMainInvokeEvent, - payload: CatalogueSearchPayload, - take: number, - skip: number -) => { - return HydraApi.post( - "/catalogue/search", - { ...payload, take, skip }, - { needsAuth: false } - ); -}; - -registerEvent("searchGames", searchGames); diff --git a/src/main/events/catalogue/vote-review.ts b/src/main/events/catalogue/vote-review.ts deleted file mode 100644 index a562eada..00000000 --- a/src/main/events/catalogue/vote-review.ts +++ /dev/null @@ -1,18 +0,0 @@ -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); diff --git a/src/main/events/cloud-save/delete-game-artifact.ts b/src/main/events/cloud-save/delete-game-artifact.ts deleted file mode 100644 index e293bc56..00000000 --- a/src/main/events/cloud-save/delete-game-artifact.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { HydraApi } from "@main/services"; -import { registerEvent } from "../register-event"; - -const deleteGameArtifact = async ( - _event: Electron.IpcMainInvokeEvent, - gameArtifactId: string -) => - HydraApi.delete<{ ok: boolean }>( - `/profile/games/artifacts/${gameArtifactId}` - ); - -registerEvent("deleteGameArtifact", deleteGameArtifact); diff --git a/src/main/events/cloud-save/get-game-artifacts.ts b/src/main/events/cloud-save/get-game-artifacts.ts deleted file mode 100644 index 3fa8552c..00000000 --- a/src/main/events/cloud-save/get-game-artifacts.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { HydraApi } from "@main/services"; -import { registerEvent } from "../register-event"; -import type { GameArtifact, GameShop } from "@types"; -import { SubscriptionRequiredError, UserNotLoggedInError } from "@shared"; - -const getGameArtifacts = async ( - _event: Electron.IpcMainInvokeEvent, - objectId: string, - shop: GameShop -) => { - const params = new URLSearchParams({ - objectId, - shop, - }); - - return HydraApi.get( - `/profile/games/artifacts?${params.toString()}`, - {}, - { needsSubscription: true } - ).catch((err) => { - if (err instanceof SubscriptionRequiredError) { - return []; - } - - if (err instanceof UserNotLoggedInError) { - return []; - } - - throw err; - }); -}; - -registerEvent("getGameArtifacts", getGameArtifacts); diff --git a/src/main/events/cloud-save/rename-game-artifact.ts b/src/main/events/cloud-save/rename-game-artifact.ts deleted file mode 100644 index f8257c4b..00000000 --- a/src/main/events/cloud-save/rename-game-artifact.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const renameGameArtifact = async ( - _event: Electron.IpcMainInvokeEvent, - gameArtifactId: string, - label: string -) => { - await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}`, { - label, - }); -}; - -registerEvent("renameGameArtifact", renameGameArtifact); diff --git a/src/main/events/cloud-save/toggle-artifact-freeze.ts b/src/main/events/cloud-save/toggle-artifact-freeze.ts deleted file mode 100644 index d532d459..00000000 --- a/src/main/events/cloud-save/toggle-artifact-freeze.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const toggleArtifactFreeze = async ( - _event: Electron.IpcMainInvokeEvent, - gameArtifactId: string, - freeze: boolean -) => { - if (freeze) { - await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}/freeze`); - } else { - await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}/unfreeze`); - } -}; - -registerEvent("toggleArtifactFreeze", toggleArtifactFreeze); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index dbb4039e..b690e8a3 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -1,20 +1,9 @@ import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants"; import { ipcMain } from "electron"; -import "./catalogue/get-catalogue"; import "./catalogue/get-game-shop-details"; -import "./catalogue/get-how-long-to-beat"; import "./catalogue/get-random-game"; -import "./catalogue/search-games"; 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"; @@ -50,9 +39,7 @@ import "./library/copy-custom-game-asset"; import "./misc/open-checkout"; import "./misc/open-external"; import "./misc/show-open-dialog"; -import "./misc/get-features"; import "./misc/show-item-in-folder"; -import "./misc/get-badges"; import "./misc/install-common-redist"; import "./misc/can-install-common-redist"; import "./misc/save-temp-file"; @@ -60,6 +47,7 @@ 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 "./misc/hydra-api-call"; import "./torrenting/cancel-game-download"; import "./torrenting/pause-game-download"; import "./torrenting/resume-game-download"; @@ -79,33 +67,17 @@ import "./download-sources/put-download-source"; import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; -import "./user/get-user"; -import "./user/get-user-library"; -import "./user/get-blocked-users"; -import "./user/block-user"; -import "./user/unblock-user"; -import "./user/get-user-friends"; import "./user/get-auth"; -import "./user/get-user-stats"; -import "./user/report-user"; import "./user/get-unlocked-achievements"; import "./user/get-compared-unlocked-achievements"; -import "./profile/get-friend-requests"; import "./profile/get-me"; -import "./profile/undo-friendship"; -import "./profile/update-friend-request"; import "./profile/update-profile"; import "./profile/process-profile-image"; -import "./profile/send-friend-request"; import "./profile/sync-friend-requests"; import "./cloud-save/download-game-artifact"; -import "./cloud-save/get-game-artifacts"; import "./cloud-save/get-game-backup-preview"; import "./cloud-save/upload-save-game"; -import "./cloud-save/delete-game-artifact"; import "./cloud-save/select-game-backup-path"; -import "./cloud-save/toggle-artifact-freeze"; -import "./cloud-save/rename-game-artifact"; import "./notifications/publish-new-repacks-notification"; import "./notifications/update-achievement-notification-window"; import "./notifications/show-achievement-test-notification"; diff --git a/src/main/events/misc/get-badges.ts b/src/main/events/misc/get-badges.ts deleted file mode 100644 index c1d62782..00000000 --- a/src/main/events/misc/get-badges.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Badge } from "@types"; -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import { db, levelKeys } from "@main/level"; - -const getBadges = async (_event: Electron.IpcMainInvokeEvent) => { - const language = await db - .get(levelKeys.language, { - valueEncoding: "utf8", - }) - .then((language) => language || "en"); - - const params = new URLSearchParams({ - locale: language, - }); - - return HydraApi.get(`/badges?${params.toString()}`, null, { - needsAuth: false, - }); -}; - -registerEvent("getBadges", getBadges); diff --git a/src/main/events/misc/get-features.ts b/src/main/events/misc/get-features.ts deleted file mode 100644 index 766c84aa..00000000 --- a/src/main/events/misc/get-features.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const getFeatures = async (_event: Electron.IpcMainInvokeEvent) => { - return HydraApi.get("/features", null, { needsAuth: false }); -}; - -registerEvent("getFeatures", getFeatures); diff --git a/src/main/events/misc/hydra-api-call.ts b/src/main/events/misc/hydra-api-call.ts new file mode 100644 index 00000000..3b5f78ec --- /dev/null +++ b/src/main/events/misc/hydra-api-call.ts @@ -0,0 +1,38 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +interface HydraApiCallPayload { + method: "get" | "post" | "put" | "patch" | "delete"; + url: string; + data?: unknown; + params?: unknown; + options?: { + needsAuth?: boolean; + needsSubscription?: boolean; + ifModifiedSince?: Date; + }; +} + +const hydraApiCall = async ( + _event: Electron.IpcMainInvokeEvent, + payload: HydraApiCallPayload +) => { + const { method, url, data, params, options } = payload; + + switch (method) { + case "get": + return HydraApi.get(url, params, options); + case "post": + return HydraApi.post(url, data, options); + case "put": + return HydraApi.put(url, data, options); + case "patch": + return HydraApi.patch(url, data, options); + case "delete": + return HydraApi.delete(url, options); + default: + throw new Error(`Unsupported HTTP method: ${method}`); + } +}; + +registerEvent("hydraApiCall", hydraApiCall); diff --git a/src/main/events/profile/get-friend-requests.ts b/src/main/events/profile/get-friend-requests.ts deleted file mode 100644 index 39573b67..00000000 --- a/src/main/events/profile/get-friend-requests.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { FriendRequest } from "@types"; - -const getFriendRequests = async ( - _event: Electron.IpcMainInvokeEvent -): Promise => { - return HydraApi.get(`/profile/friend-requests`).catch(() => []); -}; - -registerEvent("getFriendRequests", getFriendRequests); diff --git a/src/main/events/profile/send-friend-request.ts b/src/main/events/profile/send-friend-request.ts deleted file mode 100644 index d696606f..00000000 --- a/src/main/events/profile/send-friend-request.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const sendFriendRequest = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string -) => { - return HydraApi.post("/profile/friend-requests", { friendCode: userId }); -}; - -registerEvent("sendFriendRequest", sendFriendRequest); diff --git a/src/main/events/profile/undo-friendship.ts b/src/main/events/profile/undo-friendship.ts deleted file mode 100644 index 371bc5cc..00000000 --- a/src/main/events/profile/undo-friendship.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const undoFriendship = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string -) => { - await HydraApi.delete(`/profile/friends/${userId}`); -}; - -registerEvent("undoFriendship", undoFriendship); diff --git a/src/main/events/profile/update-friend-request.ts b/src/main/events/profile/update-friend-request.ts deleted file mode 100644 index b265f88c..00000000 --- a/src/main/events/profile/update-friend-request.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { FriendRequestAction } from "@types"; - -const updateFriendRequest = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string, - action: FriendRequestAction -) => { - if (action == "CANCEL") { - return HydraApi.delete(`/profile/friend-requests/${userId}`); - } - - return HydraApi.patch(`/profile/friend-requests/${userId}`, { - requestState: action, - }); -}; - -registerEvent("updateFriendRequest", updateFriendRequest); diff --git a/src/main/events/user/block-user.ts b/src/main/events/user/block-user.ts deleted file mode 100644 index c81231e5..00000000 --- a/src/main/events/user/block-user.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const blockUser = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string -) => { - await HydraApi.post(`/users/${userId}/block`); -}; - -registerEvent("blockUser", blockUser); diff --git a/src/main/events/user/get-blocked-users.ts b/src/main/events/user/get-blocked-users.ts deleted file mode 100644 index 9696cd7b..00000000 --- a/src/main/events/user/get-blocked-users.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import { UserNotLoggedInError } from "@shared"; -import type { UserBlocks } from "@types"; - -export const getBlockedUsers = async ( - _event: Electron.IpcMainInvokeEvent, - take: number, - skip: number -): Promise => { - return HydraApi.get(`/profile/blocks`, { take, skip }).catch((err) => { - if (err instanceof UserNotLoggedInError) { - return { blocks: [] }; - } - throw err; - }); -}; - -registerEvent("getBlockedUsers", getBlockedUsers); diff --git a/src/main/events/user/get-user-friends.ts b/src/main/events/user/get-user-friends.ts deleted file mode 100644 index aefc7052..00000000 --- a/src/main/events/user/get-user-friends.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { db } from "@main/level"; -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { User, UserFriends } from "@types"; -import { levelKeys } from "@main/level/sublevels"; - -export const getUserFriends = async ( - userId: string, - take: number, - skip: number -): Promise => { - const user = await db.get(levelKeys.user, { - valueEncoding: "json", - }); - - if (user?.id === userId) { - return HydraApi.get(`/profile/friends`, { take, skip }); - } - - return HydraApi.get(`/users/${userId}/friends`, { take, skip }); -}; - -const getUserFriendsEvent = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string, - take: number, - skip: number -) => { - return getUserFriends(userId, take, skip); -}; - -registerEvent("getUserFriends", getUserFriendsEvent); diff --git a/src/main/events/user/get-user-library.ts b/src/main/events/user/get-user-library.ts deleted file mode 100644 index f3c3eed5..00000000 --- a/src/main/events/user/get-user-library.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { UserLibraryResponse } from "@types"; - -const getUserLibrary = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string, - take: number = 12, - skip: number = 0, - sortBy?: string -): Promise => { - const params = new URLSearchParams(); - - params.append("take", take.toString()); - params.append("skip", skip.toString()); - - if (sortBy) { - params.append("sortBy", sortBy); - } - - const queryString = params.toString(); - const baseUrl = `/users/${userId}/library`; - const url = queryString ? `${baseUrl}?${queryString}` : baseUrl; - - return HydraApi.get(url).catch(() => null); -}; - -registerEvent("getUserLibrary", getUserLibrary); diff --git a/src/main/events/user/get-user-stats.ts b/src/main/events/user/get-user-stats.ts deleted file mode 100644 index f88a4f12..00000000 --- a/src/main/events/user/get-user-stats.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { UserStats } from "@types"; - -export const getUserStats = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string -): Promise => { - return HydraApi.get(`/users/${userId}/stats`); -}; - -registerEvent("getUserStats", getUserStats); diff --git a/src/main/events/user/get-user.ts b/src/main/events/user/get-user.ts deleted file mode 100644 index fe77a7c1..00000000 --- a/src/main/events/user/get-user.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; -import type { UserProfile } from "@types"; - -const getUser = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string -): Promise => { - return HydraApi.get(`/users/${userId}`).catch(() => null); -}; - -registerEvent("getUser", getUser); diff --git a/src/main/events/user/report-user.ts b/src/main/events/user/report-user.ts deleted file mode 100644 index 1e8efbaa..00000000 --- a/src/main/events/user/report-user.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -export const reportUser = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string, - reason: string, - description: string -): Promise => { - return HydraApi.post(`/users/${userId}/report`, { - reason, - description, - }); -}; - -registerEvent("reportUser", reportUser); diff --git a/src/main/events/user/unblock-user.ts b/src/main/events/user/unblock-user.ts deleted file mode 100644 index c604a0b5..00000000 --- a/src/main/events/user/unblock-user.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi } from "@main/services"; - -const unblockUser = async ( - _event: Electron.IpcMainInvokeEvent, - userId: string -) => { - await HydraApi.post(`/users/${userId}/unblock`); -}; - -registerEvent("unblockUser", unblockUser); diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index ef88b062..138614b7 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -12,7 +12,7 @@ import { db } from "@main/level"; import { levelKeys } from "@main/level/sublevels"; import type { Auth, User } from "@types"; -interface HydraApiOptions { +export interface HydraApiOptions { needsAuth?: boolean; needsSubscription?: boolean; ifModifiedSince?: Date; diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 70ff130e..b794529f 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -62,12 +62,16 @@ export class WindowManager { `${process.env["ELECTRON_RENDERER_URL"]}#/${hash}` ); } else { - this.mainWindow?.loadFile( - path.join(__dirname, "../renderer/index.html"), - { - hash, - } - ); + this.mainWindow + ?.loadURL(`https://staging.hydralauncher.gg/#/${hash}`) + .catch(() => { + this.mainWindow?.loadFile( + path.join(__dirname, "../renderer/index.html"), + { + hash, + } + ); + }); } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 6e36fcf0..0368d661 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,7 +11,6 @@ import type { GameRunning, FriendRequestAction, UpdateProfileRequest, - CatalogueSearchPayload, SeedingStatus, GameAchievement, Theme, @@ -20,7 +19,7 @@ import type { AchievementCustomNotificationPosition, AchievementNotificationInfo, } from "@types"; -import type { AuthPage, CatalogueCategory } from "@shared"; +import type { AuthPage } from "@shared"; import type { AxiosProgressEvent } from "axios"; contextBridge.exposeInMainWorld("electron", { @@ -62,44 +61,13 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("checkDebridAvailability", magnets), /* Catalogue */ - searchGames: (payload: CatalogueSearchPayload, take: number, skip: number) => - ipcRenderer.invoke("searchGames", payload, take, skip), - getCatalogue: (category: CatalogueCategory) => - ipcRenderer.invoke("getCatalogue", category), getGameShopDetails: (objectId: string, shop: GameShop, language: string) => ipcRenderer.invoke("getGameShopDetails", objectId, shop, language), getRandomGame: () => ipcRenderer.invoke("getRandomGame"), - getHowLongToBeat: (objectId: string, shop: GameShop) => - 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, - 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, @@ -309,10 +277,6 @@ contextBridge.exposeInMainWorld("electron", { downloadOptionTitle: string | null ) => ipcRenderer.invoke("uploadSaveGame", objectId, shop, downloadOptionTitle), - toggleArtifactFreeze: (gameArtifactId: string, freeze: boolean) => - ipcRenderer.invoke("toggleArtifactFreeze", gameArtifactId, freeze), - renameGameArtifact: (gameArtifactId: string, label: string) => - ipcRenderer.invoke("renameGameArtifact", gameArtifactId, label), downloadGameArtifact: ( objectId: string, shop: GameShop, @@ -323,8 +287,6 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("getGameArtifacts", objectId, shop), getGameBackupPreview: (objectId: string, shop: GameShop) => ipcRenderer.invoke("getGameBackupPreview", objectId, shop), - deleteGameArtifact: (gameArtifactId: string) => - ipcRenderer.invoke("deleteGameArtifact", gameArtifactId), selectGameBackupPath: ( shop: GameShop, objectId: string, @@ -381,8 +343,93 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("showOpenDialog", options), showItemInFolder: (path: string) => ipcRenderer.invoke("showItemInFolder", path), - getFeatures: () => ipcRenderer.invoke("getFeatures"), - getBadges: () => ipcRenderer.invoke("getBadges"), + hydraApi: { + get: ( + url: string, + options?: { + params?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + ifModifiedSince?: Date; + } + ) => + ipcRenderer.invoke("hydraApiCall", { + method: "get", + url, + params: options?.params, + options: { + needsAuth: options?.needsAuth, + needsSubscription: options?.needsSubscription, + ifModifiedSince: options?.ifModifiedSince, + }, + }), + post: ( + url: string, + options?: { + data?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => + ipcRenderer.invoke("hydraApiCall", { + method: "post", + url, + data: options?.data, + options: { + needsAuth: options?.needsAuth, + needsSubscription: options?.needsSubscription, + }, + }), + put: ( + url: string, + options?: { + data?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => + ipcRenderer.invoke("hydraApiCall", { + method: "put", + url, + data: options?.data, + options: { + needsAuth: options?.needsAuth, + needsSubscription: options?.needsSubscription, + }, + }), + patch: ( + url: string, + options?: { + data?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => + ipcRenderer.invoke("hydraApiCall", { + method: "patch", + url, + data: options?.data, + options: { + needsAuth: options?.needsAuth, + needsSubscription: options?.needsSubscription, + }, + }), + delete: ( + url: string, + options?: { + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => + ipcRenderer.invoke("hydraApiCall", { + method: "delete", + url, + options: { + needsAuth: options?.needsAuth, + needsSubscription: options?.needsSubscription, + }, + }), + }, canInstallCommonRedist: () => ipcRenderer.invoke("canInstallCommonRedist"), installCommonRedist: () => ipcRenderer.invoke("installCommonRedist"), installHydraDeckyPlugin: () => ipcRenderer.invoke("installHydraDeckyPlugin"), @@ -419,13 +466,10 @@ contextBridge.exposeInMainWorld("electron", { /* Profile */ getMe: () => ipcRenderer.invoke("getMe"), - undoFriendship: (userId: string) => - ipcRenderer.invoke("undoFriendship", userId), updateProfile: (updateProfile: UpdateProfileRequest) => ipcRenderer.invoke("updateProfile", updateProfile), processProfileImage: (imagePath: string) => ipcRenderer.invoke("processProfileImage", imagePath), - getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"), syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"), onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => { const listener = ( @@ -438,26 +482,8 @@ contextBridge.exposeInMainWorld("electron", { }, updateFriendRequest: (userId: string, action: FriendRequestAction) => ipcRenderer.invoke("updateFriendRequest", userId, action), - sendFriendRequest: (userId: string) => - ipcRenderer.invoke("sendFriendRequest", userId), /* User */ - getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), - getUserLibrary: ( - userId: string, - take?: number, - skip?: number, - sortBy?: string - ) => ipcRenderer.invoke("getUserLibrary", userId, take, skip, sortBy), - blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId), - unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId), - getUserFriends: (userId: string, take: number, skip: number) => - ipcRenderer.invoke("getUserFriends", userId, take, skip), - getBlockedUsers: (take: number, skip: number) => - ipcRenderer.invoke("getBlockedUsers", take, skip), - getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId), - reportUser: (userId: string, reason: string, description: string) => - ipcRenderer.invoke("reportUser", userId, reason, description), getComparedUnlockedAchievements: ( objectId: string, shop: GameShop, diff --git a/src/renderer/src/components/hero/hero.tsx b/src/renderer/src/components/hero/hero.tsx index ce73d144..a1e36719 100644 --- a/src/renderer/src/components/hero/hero.tsx +++ b/src/renderer/src/components/hero/hero.tsx @@ -18,10 +18,18 @@ export function Hero() { useEffect(() => { setIsLoading(true); - window.electron - .getTrendingGames() + const language = i18n.language.split("-")[0]; + + window.electron.hydraApi + .get("/catalogue/featured", { + params: { language }, + needsAuth: false, + }) .then((result) => { - setFeaturedGameDetails(result); + setFeaturedGameDetails(result.slice(0, 1)); + }) + .catch(() => { + setFeaturedGameDetails([]); }) .finally(() => { setIsLoading(false); diff --git a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx index e1ea9e2f..b94c94d7 100644 --- a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx +++ b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx @@ -98,7 +98,18 @@ export function CloudSyncContextProvider({ ); const getGameArtifacts = useCallback(async () => { - const results = await window.electron.getGameArtifacts(objectId, shop); + const params = new URLSearchParams({ + objectId, + shop, + }); + + const results = await window.electron.hydraApi + .get(`/profile/games/artifacts?${params.toString()}`, { + needsSubscription: true, + }) + .catch(() => { + return []; + }); setArtifacts(results); }, [objectId, shop]); @@ -137,7 +148,10 @@ export function CloudSyncContextProvider({ async (gameArtifactId: string, freeze: boolean) => { setFreezingArtifact(true); try { - await window.electron.toggleArtifactFreeze(gameArtifactId, freeze); + const endpoint = freeze ? "freeze" : "unfreeze"; + await window.electron.hydraApi.put( + `/profile/games/artifacts/${gameArtifactId}/${endpoint}` + ); getGameArtifacts(); } catch (err) { logger.error("Failed to toggle artifact freeze", objectId, shop, err); @@ -185,10 +199,12 @@ export function CloudSyncContextProvider({ const deleteGameArtifact = useCallback( async (gameArtifactId: string) => { - return window.electron.deleteGameArtifact(gameArtifactId).then(() => { - getGameBackupPreview(); - getGameArtifacts(); - }); + return window.electron.hydraApi + .delete<{ ok: boolean }>(`/profile/games/artifacts/${gameArtifactId}`) + .then(() => { + getGameBackupPreview(); + getGameArtifacts(); + }); }, [getGameBackupPreview, getGameArtifacts] ); diff --git a/src/renderer/src/context/settings/settings.context.tsx b/src/renderer/src/context/settings/settings.context.tsx index 5c79f38d..1160ca3e 100644 --- a/src/renderer/src/context/settings/settings.context.tsx +++ b/src/renderer/src/context/settings/settings.context.tsx @@ -116,7 +116,13 @@ export function SettingsContextProvider({ }, []); const fetchBlockedUsers = useCallback(async () => { - const blockedUsers = await window.electron.getBlockedUsers(12, 0); + const blockedUsers = await window.electron.hydraApi + .get("/profile/blocks", { + params: { take: 12, skip: 0 }, + }) + .catch(() => { + return { blocks: [] }; + }); setBlockedUsers(blockedUsers.blocks); }, []); 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 2750442a..cb656be2 100644 --- a/src/renderer/src/context/user-profile/user-profile.context.tsx +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -82,26 +82,38 @@ export function UserProfileContextProvider({ return ""; }; - const { t } = useTranslation("user_profile"); + const { t, i18n } = useTranslation("user_profile"); const { showErrorToast } = useToast(); const navigate = useNavigate(); const getUserStats = useCallback(async () => { - window.electron.getUserStats(userId).then((stats) => { - setUserStats(stats); - }); + window.electron.hydraApi + .get(`/users/${userId}/stats`) + .then((stats) => { + setUserStats(stats); + }); }, [userId]); const getUserLibraryGames = useCallback( async (sortBy?: string) => { try { - const response = await window.electron.getUserLibrary( - userId, - 12, - 0, - sortBy - ); + const params = new URLSearchParams(); + params.append("take", "12"); + params.append("skip", "0"); + 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) { setLibraryGames(response.library); @@ -122,26 +134,36 @@ export function UserProfileContextProvider({ getUserStats(); getUserLibraryGames(); - return window.electron.getUser(userId).then((userProfile) => { - if (userProfile) { - setUserProfile(userProfile); + return window.electron.hydraApi + .get(`/users/${userId}`) + .then((userProfile) => { + if (userProfile) { + setUserProfile(userProfile); - if (userProfile.profileImageUrl) { - getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then( - (color) => setHeroBackground(color) - ); + if (userProfile.profileImageUrl) { + getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then( + (color) => setHeroBackground(color) + ); + } + } else { + showErrorToast(t("user_not_found")); + navigate(-1); } - } else { - showErrorToast(t("user_not_found")); - navigate(-1); - } - }); + }); }, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]); const getBadges = useCallback(async () => { - const badges = await window.electron.getBadges(); + const language = i18n.language.split("-")[0]; + const params = new URLSearchParams({ + locale: language, + }); + + const badges = await window.electron.hydraApi.get( + `/badges?${params.toString()}`, + { needsAuth: false } + ); setBadges(badges); - }, []); + }, [i18n]); useEffect(() => { setUserProfile(null); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 3dc30cf2..6151f506 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -1,8 +1,7 @@ -import type { AuthPage, CatalogueCategory } from "@shared"; +import type { AuthPage } from "@shared"; import type { AppUpdaterEvent, GameShop, - HowLongToBeatCategory, Steam250Game, DownloadProgress, SeedingStatus, @@ -11,34 +10,25 @@ import type { RealDebridUser, AllDebridUser, UserProfile, - FriendRequest, FriendRequestAction, - UserFriends, - UserBlocks, UpdateProfileRequest, GameStats, - TrendingGame, - UserStats, UserDetails, FriendRequestSync, GameArtifact, LudusaviBackup, UserAchievement, ComparedAchievements, - CatalogueSearchPayload, LibraryGame, GameRunning, TorBoxUser, Theme, - Badge, Auth, ShortcutLocation, - CatalogueSearchResult, ShopAssets, ShopDetailsWithAssets, AchievementCustomNotificationPosition, AchievementNotificationInfo, - UserLibraryResponse, Game, } from "@types"; import type { AxiosProgressEvent } from "axios"; @@ -72,63 +62,22 @@ declare global { ) => Promise>; /* Catalogue */ - searchGames: ( - payload: CatalogueSearchPayload, - take: number, - skip: number - ) => Promise<{ edges: CatalogueSearchResult[]; count: number }>; - getCatalogue: (category: CatalogueCategory) => Promise; getGameShopDetails: ( objectId: string, shop: GameShop, language: string ) => Promise; getRandomGame: () => Promise; - getHowLongToBeat: ( - objectId: string, - shop: GameShop - ) => Promise; getGameStats: (objectId: string, shop: GameShop) => Promise; getGameAssets: ( 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, cb: (achievements: UserAchievement[]) => void ) => () => Electron.IpcRenderer; - getPublishers: () => Promise; - getDevelopers: () => Promise; /* Library */ toggleAutomaticCloudSync: ( @@ -280,14 +229,6 @@ declare global { shop: GameShop, downloadOptionTitle: string | null ) => Promise; - toggleArtifactFreeze: ( - gameArtifactId: string, - freeze: boolean - ) => Promise; - renameGameArtifact: ( - gameArtifactId: string, - label: string - ) => Promise; downloadGameArtifact: ( objectId: string, shop: GameShop, @@ -301,7 +242,6 @@ declare global { objectId: string, shop: GameShop ) => Promise; - deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>; selectGameBackupPath: ( shop: GameShop, objectId: string, @@ -335,8 +275,48 @@ declare global { options: Electron.OpenDialogOptions ) => Promise; showItemInFolder: (path: string) => Promise; - getFeatures: () => Promise; - getBadges: () => Promise; + hydraApi: { + get: ( + url: string, + options?: { + params?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + ifModifiedSince?: Date; + } + ) => Promise; + post: ( + url: string, + options?: { + data?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => Promise; + put: ( + url: string, + options?: { + data?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => Promise; + patch: ( + url: string, + options?: { + data?: unknown; + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => Promise; + delete: ( + url: string, + options?: { + needsAuth?: boolean; + needsSubscription?: boolean; + } + ) => Promise; + }; canInstallCommonRedist: () => Promise; installCommonRedist: () => Promise; installHydraDeckyPlugin: () => Promise<{ @@ -378,27 +358,6 @@ declare global { onSignOut: (cb: () => void) => () => Electron.IpcRenderer; /* User */ - getUser: (userId: string) => Promise; - getUserLibrary: ( - userId: string, - take?: number, - skip?: number, - sortBy?: string - ) => Promise; - blockUser: (userId: string) => Promise; - unblockUser: (userId: string) => Promise; - getUserFriends: ( - userId: string, - take: number, - skip: number - ) => Promise; - getBlockedUsers: (take: number, skip: number) => Promise; - getUserStats: (userId: string) => Promise; - reportUser: ( - userId: string, - reason: string, - description: string - ) => Promise; getComparedUnlockedAchievements: ( objectId: string, shop: GameShop, @@ -411,7 +370,6 @@ declare global { /* Profile */ getMe: () => Promise; - undoFriendship: (userId: string) => Promise; updateProfile: ( updateProfile: UpdateProfileRequest ) => Promise; @@ -419,7 +377,6 @@ declare global { processProfileImage: ( path: string ) => Promise<{ imagePath: string; mimeType: string }>; - getFriendRequests: () => Promise; syncFriendRequests: () => Promise; onSyncFriendRequests: ( cb: (friendRequests: FriendRequestSync) => void @@ -428,7 +385,6 @@ declare global { userId: string, action: FriendRequestAction ) => Promise; - sendFriendRequest: (userId: string) => Promise; /* Notifications */ publishNewRepacksNotification: (newRepacksCount: number) => Promise; diff --git a/src/renderer/src/hooks/use-feature.ts b/src/renderer/src/hooks/use-feature.ts index d4727105..c4841f8b 100644 --- a/src/renderer/src/hooks/use-feature.ts +++ b/src/renderer/src/hooks/use-feature.ts @@ -11,10 +11,12 @@ export function useFeature() { const [features, setFeatures] = useState(null); useEffect(() => { - window.electron.getFeatures().then((features) => { - localStorage.setItem("features", JSON.stringify(features || [])); - setFeatures(features || []); - }); + window.electron.hydraApi + .get("/features", { needsAuth: false }) + .then((features) => { + localStorage.setItem("features", JSON.stringify(features || [])); + setFeatures(features || []); + }); }, []); const isFeatureEnabled = useCallback( diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index 19877765..6d89f9b4 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -11,6 +11,7 @@ import type { FriendRequestAction, UpdateProfileRequest, UserDetails, + FriendRequest, } from "@types"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; @@ -76,12 +77,13 @@ export function useUserDetails() { userDetails?.username, userDetails?.subscription, userDetails?.featurebaseJwt, + userDetails?.karma, ] ); const fetchFriendRequests = useCallback(async () => { - return window.electron - .getFriendRequests() + return window.electron.hydraApi + .get("/profile/friend-requests") .then((friendRequests) => { window.electron.syncFriendRequests(); dispatch(setFriendRequests(friendRequests)); @@ -103,8 +105,10 @@ export function useUserDetails() { const sendFriendRequest = useCallback( async (userId: string) => { - return window.electron - .sendFriendRequest(userId) + return window.electron.hydraApi + .post("/profile/friend-requests", { + data: { friendCode: userId }, + }) .then(() => fetchFriendRequests()); }, [fetchFriendRequests] @@ -112,19 +116,31 @@ export function useUserDetails() { const updateFriendRequestState = useCallback( async (userId: string, action: FriendRequestAction) => { - return window.electron - .updateFriendRequest(userId, action) + if (action === "CANCEL") { + return window.electron.hydraApi + .delete(`/profile/friend-requests/${userId}`) + .then(() => fetchFriendRequests()); + } + + return window.electron.hydraApi + .patch(`/profile/friend-requests/${userId}`, { + data: { + requestState: action, + }, + }) .then(() => fetchFriendRequests()); }, [fetchFriendRequests] ); const undoFriendship = (userId: string) => - window.electron.undoFriendship(userId); + window.electron.hydraApi.delete(`/profile/friends/${userId}`); - const blockUser = (userId: string) => window.electron.blockUser(userId); + const blockUser = (userId: string) => + window.electron.hydraApi.post(`/users/${userId}/block`); - const unblockUser = (userId: string) => window.electron.unblockUser(userId); + const unblockUser = (userId: string) => + window.electron.hydraApi.post(`/users/${userId}/unblock`); const hasActiveSubscription = useMemo(() => { const expiresAt = new Date(userDetails?.subscription?.expiresAt ?? 0); diff --git a/src/renderer/src/pages/catalogue/catalogue.tsx b/src/renderer/src/pages/catalogue/catalogue.tsx index 421f9695..3c6a2b80 100644 --- a/src/renderer/src/pages/catalogue/catalogue.tsx +++ b/src/renderer/src/pages/catalogue/catalogue.tsx @@ -63,11 +63,13 @@ export default function Catalogue() { const abortController = new AbortController(); abortControllerRef.current = abortController; - const response = await window.electron.searchGames( - filters, - pageSize, - offset - ); + const response = await window.electron.hydraApi.post<{ + edges: CatalogueSearchResult[]; + count: number; + }>("/catalogue/search", { + data: { ...filters, take: pageSize, skip: offset }, + needsAuth: false, + }); if (abortController.signal.aborted) return; diff --git a/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.tsx b/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.tsx index 25525331..c2e6e3a5 100644 --- a/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.tsx +++ b/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.tsx @@ -58,7 +58,14 @@ export function CloudSyncRenameArtifactModal({ try { if (!artifact) return; - await window.electron.renameGameArtifact(artifact.id, data.label); + await window.electron.hydraApi.put( + `/profile/games/artifacts/${artifact.id}`, + { + data: { + label: data.label, + }, + } + ); await getGameArtifacts(); showSuccessToast(t("artifact_renamed")); 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 94da6a06..edf314c7 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -1,50 +1,23 @@ -import { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "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"; -import { motion, AnimatePresence } from "framer-motion"; -import type { GameReview } from "@types"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { PencilIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; 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, DeleteReviewModal } from "./modals"; -import { ReviewSortOptions } from "./review-sort-options"; -import { ReviewPromptBanner } from "./review-prompt-banner"; +import { EditGameModal } from "./modals"; +import { GameReviews } from "./game-reviews"; +import { GameLogo } from "./game-logo"; -import { sanitizeHtml, AuthPage } from "@shared"; -import { useTranslation } from "react-i18next"; +import { AuthPage } from "@shared"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif"; -import { useUserDetails, useLibrary, useDate, useToast } from "@renderer/hooks"; +import { useUserDetails, useLibrary } from "@renderer/hooks"; import { useSubscription } from "@renderer/hooks/use-subscription"; -import { formatNumber } from "@renderer/helpers"; -import { Button } from "@renderer/components"; import "./game-details.scss"; -const getScoreColorClass = (score: number): string => { - 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 ""; -}; - const processMediaElements = (document: Document) => { const $images = Array.from(document.querySelectorAll("img")); $images.forEach(($image) => { @@ -71,35 +44,16 @@ const processMediaElements = (document: Document) => { }); }; -const getSelectScoreColorClass = (score: number): string => { - 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 >= 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 ""; - } +const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined +) => { + return customUrl || originalUrl || fallbackUrl || ""; }; export function GameDetailsContent() { const heroRef = useRef(null); - const navigate = useNavigate(); const { t } = useTranslation("game_details"); @@ -116,8 +70,6 @@ export function GameDetailsContent() { const { userDetails, hasActiveSubscription } = useUserDetails(); const { updateLibrary, library } = useLibrary(); - const { formatDistance } = useDate(); - const { showSuccessToast, showErrorToast } = useToast(); const { setShowCloudSyncModal, getGameArtifacts } = useContext(cloudSyncContext); @@ -144,34 +96,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 - const [reviews, setReviews] = useState([]); - const [reviewsLoading, setReviewsLoading] = useState(false); - const [reviewScore, setReviewScore] = useState(null); - const [submittingReview, setSubmittingReview] = useState(false); - const [reviewCharCount, setReviewCharCount] = useState(0); - const MAX_REVIEW_CHARS = 1000; - const [reviewsSortBy, setReviewsSortBy] = useState("newest"); - const previousVotesRef = useRef< - Map - >(new Map()); - const [reviewsPage, setReviewsPage] = useState(0); - const [hasMoreReviews, setHasMoreReviews] = useState(true); - const [visibleBlockedReviews, setVisibleBlockedReviews] = useState< - Set - >(new Set()); - const [totalReviewCount, setTotalReviewCount] = useState(0); - const [showReviewForm, setShowReviewForm] = useState(false); - - const [showReviewPrompt, setShowReviewPrompt] = useState(false); const [hasUserReviewed, setHasUserReviewed] = useState(false); - const [reviewCheckLoading, setReviewCheckLoading] = useState(false); - - const abortControllerRef = useRef(null); // Check if the current game is in the user's library const isGameInLibrary = useMemo(() => { @@ -181,67 +107,8 @@ export function GameDetailsContent() { ); }, [library, shop, objectId]); - const editor = useEditor({ - extensions: [ - StarterKit.configure({ - link: false, - }), - ], - content: "", - editorProps: { - attributes: { - class: "game-details__review-editor", - "data-placeholder": t("write_review_placeholder"), - }, - 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; - } - } - - const truncatedText = plainText.slice(0, remainingChars); - view.dispatch(view.state.tr.insertText(truncatedText)); - return true; - } - return false; - }, - }, - onUpdate: ({ editor }) => { - const text = editor.getText(); - setReviewCharCount(text.length); - - if (text.length > MAX_REVIEW_CHARS) { - const truncatedContent = text.slice(0, MAX_REVIEW_CHARS); - editor.commands.setContent(truncatedContent); - setReviewCharCount(MAX_REVIEW_CHARS); - } - }, - }); - useEffect(() => { setBackdropOpacity(1); - - // Cleanup: abort any pending review requests when objectId changes - return () => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }; }, [objectId]); const handleCloudSaveButtonClick = () => { @@ -262,7 +129,7 @@ export function GameDetailsContent() { setShowEditGameModal(true); }; - const handleGameUpdated = (_updatedGame: any) => { + const handleGameUpdated = () => { updateGame(); updateLibrary(); }; @@ -273,255 +140,6 @@ export function GameDetailsContent() { const isCustomGame = game?.shop === "custom"; - const checkUserReview = useCallback(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); - - const twoHoursInMilliseconds = 2 * 60 * 60 * 1000; - const hasEnoughPlaytime = - game && - game.playTimeInMilliseconds >= twoHoursInMilliseconds && - !game.hasManuallyUpdatedPlaytime; - - if ( - !hasReviewed && - hasEnoughPlaytime && - !sessionStorage.getItem(`reviewPromptDismissed_${objectId}`) - ) { - setShowReviewPrompt(true); - setShowReviewForm(true); - } - } catch (error) { - console.error("Failed to check user review:", error); - } finally { - setReviewCheckLoading(false); - } - }, [objectId, userDetails, shop, game]); - - const loadReviews = useCallback( - async (reset = false) => { - if (!objectId) return; - - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - const abortController = new AbortController(); - abortControllerRef.current = abortController; - - setReviewsLoading(true); - try { - const skip = reset ? 0 : reviewsPage * 20; - const response = await window.electron.getGameReviews( - shop, - objectId, - 20, - skip, - reviewsSortBy - ); - - if (abortController.signal.aborted) { - return; - } - - 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) { - if (!abortController.signal.aborted) { - console.error("Failed to load reviews:", error); - } - } finally { - if (!abortController.signal.aborted) { - setReviewsLoading(false); - } - } - }, - [objectId, shop, reviewsPage, reviewsSortBy] - ); - - const handleVoteReview = async ( - reviewId: string, - voteType: "upvote" | "downvote" - ) => { - if (!objectId) return; - - try { - await window.electron.voteReview(shop, objectId, reviewId, voteType); - loadReviews(true); - } catch (error) { - console.error(`Failed to ${voteType} review:`, error); - } - }; - - const handleDeleteReview = async (reviewId: string) => { - setReviewToDelete(reviewId); - setShowDeleteReviewModal(true); - }; - - const confirmDeleteReview = async () => { - if (!objectId || !reviewToDelete) return; - - try { - await window.electron.deleteReview(shop, objectId, reviewToDelete); - loadReviews(true); - setShowDeleteReviewModal(false); - setReviewToDelete(null); - setHasUserReviewed(false); - setShowReviewForm(true); - showSuccessToast(t("review_deleted_successfully")); - } catch (error) { - console.error("Failed to delete review:", error); - showErrorToast(t("review_deletion_failed")); - } - }; - - const handleSubmitReview = async () => { - const reviewHtml = editor?.getHTML() || ""; - const reviewText = editor?.getText() || ""; - - if (!objectId) { - return; - } - - if (!reviewText.trim()) { - showErrorToast(t("review_cannot_be_empty")); - return; - } - - if (submittingReview || reviewCharCount > MAX_REVIEW_CHARS) { - return; - } - - if (reviewScore === null) { - return; - } - - setSubmittingReview(true); - - try { - await window.electron.createGameReview( - shop, - objectId, - reviewHtml, - reviewScore - ); - - editor?.commands.clearContent(); - setReviewScore(null); - 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); - } - }; - - const handleReviewPromptYes = () => { - setShowReviewPrompt(false); - - setTimeout(() => { - const reviewFormElement = document.querySelector( - ".game-details__review-form" - ); - if (reviewFormElement) { - reviewFormElement.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - } - }, 100); - }; - - const handleReviewPromptLater = () => { - setShowReviewPrompt(false); - setShowReviewForm(false); - if (objectId) { - sessionStorage.setItem(`reviewPromptDismissed_${objectId}`, "true"); - } - }; - - const handleSortChange = (newSortBy: string) => { - if (newSortBy !== reviewsSortBy) { - 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); - } - }; - - useEffect(() => { - if (objectId && (game || shop)) { - loadReviews(true); - checkUserReview(); - } - }, [game, shop, objectId, loadReviews, checkUserReview]); - - useEffect(() => { - if (reviewsPage > 0) { - loadReviews(false); - } - }, [reviewsPage, loadReviews]); - - // Initialize previousVotesRef for new reviews - useEffect(() => { - reviews.forEach((review) => { - if (!previousVotesRef.current.has(review.id)) { - previousVotesRef.current.set(review.id, { - upvotes: review.upvotes || 0, - downvotes: review.downvotes || 0, - }); - } - }); - }, [reviews]); - - const getImageWithCustomPriority = ( - customUrl: string | null | undefined, - originalUrl: string | null | undefined, - fallbackUrl?: string | null | undefined - ) => { - return customUrl || originalUrl || fallbackUrl || ""; - }; - const heroImage = isCustomGame ? game?.libraryHeroImageUrl || game?.iconUrl || "" : getImageWithCustomPriority( @@ -529,41 +147,6 @@ export function GameDetailsContent() { shopDetails?.assets?.libraryHeroImageUrl ); - const logoImage = isCustomGame - ? game?.logoImageUrl || "" - : getImageWithCustomPriority( - game?.customLogoImageUrl, - shopDetails?.assets?.logoImageUrl - ); - - const renderGameLogo = () => { - if (isCustomGame) { - // For custom games, show logo image if available, otherwise show game title as text - if (logoImage) { - return ( - {game?.title} - ); - } else { - return ( -
{game?.title}
- ); - } - } else { - // For non-custom games, show logo image if available - return logoImage ? ( - {game?.title} - ) : null; - } - }; - return (
- {renderGameLogo()} +
{game && ( @@ -626,19 +209,6 @@ export function GameDetailsContent() {
- {/* Review Prompt Banner */} - {game?.shop !== "custom" && - showReviewPrompt && - userDetails && - !hasUserReviewed && - !reviewCheckLoading && - isGameInLibrary && ( - - )} - @@ -663,404 +233,16 @@ export function GameDetailsContent() { )} - {game?.shop !== "custom" && ( -
- {showReviewForm && ( - <> -
-

- {t("leave_a_review")} -

-
- -
-
-
-
- - - -
-
- MAX_REVIEW_CHARS - ? "over-limit" - : "" - } - > - {reviewCharCount}/{MAX_REVIEW_CHARS} - -
-
-
- -
-
- -
-
-
- {[1, 2, 3, 4, 5].map((starValue) => ( - - ))} -
-
- - -
-
- - )} - - {showReviewForm && ( -
- )} - -
-
-
-

- {t("reviews")} -

- - {totalReviewCount} - -
-
- - - {reviewsLoading && reviews.length === 0 && ( -
- {t("loading_reviews")} -
- )} - - {!reviewsLoading && reviews.length === 0 && ( -
-
- -
-

- {t("no_reviews_yet")} -

-

- {t("be_first_to_review")} -

-
- )} - -
0 ? 0.5 : 1, - transition: "opacity 0.2s ease", - }} - > - {reviews.map((review) => ( -
- {review.isBlocked && - !visibleBlockedReviews.has(review.id) ? ( -
- Review from blocked user —{" "} - -
- ) : ( - <> -
-
- {review.user?.profileImageUrl && ( - - )} -
- -
- - {formatDistance( - new Date(review.createdAt), - new Date(), - { addSuffix: true } - )} -
-
-
-
- {[1, 2, 3, 4, 5].map((starValue) => ( - - ))} -
-
-
-
-
- - handleVoteReview(review.id, "upvote") - } - animate={ - review.hasUpvoted - ? { - scale: [1, 1.2, 1], - transition: { duration: 0.3 }, - } - : {} - } - > - - - - (previousVotesRef.current.get(review.id) - ?.upvotes || 0) - } - variants={{ - enter: (isIncreasing: boolean) => ({ - y: isIncreasing ? 10 : -10, - opacity: 0, - }), - center: { y: 0, opacity: 1 }, - exit: (isIncreasing: boolean) => ({ - y: isIncreasing ? -10 : 10, - opacity: 0, - }), - }} - initial="enter" - animate="center" - exit="exit" - transition={{ duration: 0.2 }} - onAnimationComplete={() => { - previousVotesRef.current.set( - review.id, - { - upvotes: review.upvotes || 0, - downvotes: review.downvotes || 0, - } - ); - }} - > - {formatNumber(review.upvotes || 0)} - - - - - handleVoteReview(review.id, "downvote") - } - animate={ - review.hasDownvoted - ? { - scale: [1, 1.2, 1], - transition: { duration: 0.3 }, - } - : {} - } - > - - - - (previousVotesRef.current.get(review.id) - ?.downvotes || 0) - } - variants={{ - enter: (isIncreasing: boolean) => ({ - y: isIncreasing ? 10 : -10, - opacity: 0, - }), - center: { y: 0, opacity: 1 }, - exit: (isIncreasing: boolean) => ({ - y: isIncreasing ? -10 : 10, - opacity: 0, - }), - }} - initial="enter" - animate="center" - exit="exit" - transition={{ duration: 0.2 }} - onAnimationComplete={() => { - previousVotesRef.current.set( - review.id, - { - upvotes: review.upvotes || 0, - downvotes: review.downvotes || 0, - } - ); - }} - > - {formatNumber(review.downvotes || 0)} - - - -
- {userDetails?.id === review.user?.id && ( - - )} - {review.isBlocked && - visibleBlockedReviews.has(review.id) && ( - - )} -
- - )} -
- ))} -
- - {hasMoreReviews && !reviewsLoading && ( - - )} - - {reviewsLoading && reviews.length > 0 && ( -
- {t("loading_more_reviews")} -
- )} -
-
+ {game?.shop !== "custom" && shop && objectId && ( + )}
@@ -1077,15 +259,6 @@ 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 aee2e639..555d0797 100644 --- a/src/renderer/src/pages/game-details/game-details.scss +++ b/src/renderer/src/pages/game-details/game-details.scss @@ -291,31 +291,6 @@ $hero-height: 300px; gap: calc(globals.$spacing-unit * 1); } - &__review-avatar-button { - background: none; - border: none; - padding: 0; - cursor: pointer; - transition: opacity 0.2s ease; - - &:hover { - opacity: 0.8; - } - - &:active { - opacity: 0.6; - } - } - - &__review-avatar { - width: 32px; - height: 32px; - border-radius: 4px; - object-fit: cover; - border: 2px solid rgba(255, 255, 255, 0.1); - display: block; - } - &__review-user-info { display: flex; flex-direction: column; diff --git a/src/renderer/src/pages/game-details/game-logo.tsx b/src/renderer/src/pages/game-details/game-logo.tsx new file mode 100644 index 00000000..f0cb92ae --- /dev/null +++ b/src/renderer/src/pages/game-details/game-logo.tsx @@ -0,0 +1,49 @@ +import type { Game, ShopDetailsWithAssets } from "@types"; + +interface GameLogoProps { + game: Game | null; + shopDetails: ShopDetailsWithAssets | null; +} + +const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined +) => { + return customUrl || originalUrl || fallbackUrl || ""; +}; + +export function GameLogo({ game, shopDetails }: Readonly) { + const isCustomGame = game?.shop === "custom"; + + const logoImage = isCustomGame + ? game?.logoImageUrl || "" + : getImageWithCustomPriority( + game?.customLogoImageUrl, + shopDetails?.assets?.logoImageUrl + ); + + if (isCustomGame) { + // For custom games, show logo image if available, otherwise show game title as text + if (logoImage) { + return ( + {game?.title} + ); + } else { + return
{game?.title}
; + } + } else { + // For non-custom games, show logo image if available + return logoImage ? ( + {game?.title} + ) : null; + } +} diff --git a/src/renderer/src/pages/game-details/game-reviews.tsx b/src/renderer/src/pages/game-details/game-reviews.tsx new file mode 100644 index 00000000..467b53b2 --- /dev/null +++ b/src/renderer/src/pages/game-details/game-reviews.tsx @@ -0,0 +1,548 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { NoteIcon } from "@primer/octicons-react"; +import { useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { useTranslation } from "react-i18next"; +import type { GameReview, Game, GameShop } from "@types"; + +import { ReviewForm } from "./review-form"; +import { ReviewItem } from "./review-item"; +import { ReviewSortOptions } from "./review-sort-options"; +import { ReviewPromptBanner } from "./review-prompt-banner"; +import { useToast } from "@renderer/hooks"; + +type ReviewSortOption = + | "newest" + | "oldest" + | "score_high" + | "score_low" + | "most_voted"; + +interface GameReviewsProps { + shop: GameShop; + objectId: string; + game: Game | null; + userDetailsId?: string; + isGameInLibrary: boolean; + hasUserReviewed: boolean; + onUserReviewedChange: (hasReviewed: boolean) => void; +} + +const MAX_REVIEW_CHARS = 1000; + +export function GameReviews({ + shop, + objectId, + game, + userDetailsId, + isGameInLibrary, + hasUserReviewed, + onUserReviewedChange, +}: Readonly) { + const { t } = useTranslation("game_details"); + const { showSuccessToast, showErrorToast } = useToast(); + + const [reviews, setReviews] = useState([]); + const [reviewsLoading, setReviewsLoading] = useState(false); + const [reviewScore, setReviewScore] = useState(null); + const [submittingReview, setSubmittingReview] = useState(false); + const [reviewCharCount, setReviewCharCount] = useState(0); + const [reviewsSortBy, setReviewsSortBy] = + useState("newest"); + const [reviewsPage, setReviewsPage] = useState(0); + const [hasMoreReviews, setHasMoreReviews] = useState(true); + const [visibleBlockedReviews, setVisibleBlockedReviews] = useState< + Set + >(new Set()); + const [totalReviewCount, setTotalReviewCount] = useState(0); + const [showReviewForm, setShowReviewForm] = useState(false); + const [votingReviews, setVotingReviews] = useState>(new Set()); + const [showReviewPrompt, setShowReviewPrompt] = useState(false); + + const previousVotesRef = useRef< + Map + >(new Map()); + const abortControllerRef = useRef(null); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + link: false, + }), + ], + content: "", + editorProps: { + attributes: { + class: "game-details__review-editor", + "data-placeholder": t("write_review_placeholder"), + }, + 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; + } + } + + const truncatedText = plainText.slice(0, remainingChars); + view.dispatch(view.state.tr.insertText(truncatedText)); + return true; + } + return false; + }, + }, + onUpdate: ({ editor }) => { + const text = editor.getText(); + setReviewCharCount(text.length); + + if (text.length > MAX_REVIEW_CHARS) { + const truncatedContent = text.slice(0, MAX_REVIEW_CHARS); + editor.commands.setContent(truncatedContent); + setReviewCharCount(MAX_REVIEW_CHARS); + } + }, + }); + + const checkUserReview = useCallback(async () => { + if (!objectId || !userDetailsId) return; + + try { + const response = await window.electron.hydraApi.get<{ + hasReviewed: boolean; + }>(`/games/${shop}/${objectId}/reviews/check`, { + needsAuth: true, + }); + const hasReviewed = response?.hasReviewed || false; + onUserReviewedChange(hasReviewed); + + const twoHoursInMilliseconds = 2 * 60 * 60 * 1000; + const hasEnoughPlaytime = + game && + game.playTimeInMilliseconds >= twoHoursInMilliseconds && + !game.hasManuallyUpdatedPlaytime; + + if ( + !hasReviewed && + hasEnoughPlaytime && + !sessionStorage.getItem(`reviewPromptDismissed_${objectId}`) + ) { + setShowReviewPrompt(true); + setShowReviewForm(true); + } + } catch (error) { + console.error("Failed to check user review:", error); + } + }, [objectId, userDetailsId, shop, game, onUserReviewedChange]); + + const loadReviews = useCallback( + async (reset = false) => { + if (!objectId) return; + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setReviewsLoading(true); + try { + const skip = reset ? 0 : reviewsPage * 20; + const params = new URLSearchParams({ + take: "20", + skip: skip.toString(), + sortBy: reviewsSortBy, + }); + + const response = await window.electron.hydraApi.get( + `/games/${shop}/${objectId}/reviews?${params.toString()}`, + { needsAuth: false } + ); + + if (abortController.signal.aborted) { + return; + } + + const typedResponse = response as unknown as + | { reviews: GameReview[]; totalCount: number } + | undefined; + const reviewsData = typedResponse?.reviews || []; + const reviewCount = typedResponse?.totalCount || 0; + + if (reset) { + setReviews(reviewsData); + setReviewsPage(0); + setTotalReviewCount(reviewCount); + } else { + setReviews((prev) => [...prev, ...reviewsData]); + } + + setHasMoreReviews(reviewsData.length === 20); + } catch (error) { + if (!abortController.signal.aborted) { + console.error("Failed to load reviews:", error); + } + } finally { + if (!abortController.signal.aborted) { + setReviewsLoading(false); + } + } + }, + [objectId, shop, reviewsPage, reviewsSortBy] + ); + + const handleVoteReview = async ( + reviewId: string, + voteType: "upvote" | "downvote" + ) => { + if (!objectId || votingReviews.has(reviewId)) return; + + setVotingReviews((prev) => new Set(prev).add(reviewId)); + + const reviewIndex = reviews.findIndex((r) => r.id === reviewId); + if (reviewIndex === -1) { + setVotingReviews((prev) => { + const next = new Set(prev); + next.delete(reviewId); + return next; + }); + return; + } + + const review = reviews[reviewIndex]; + const originalReview = { ...review }; + + const updatedReviews = [...reviews]; + const updatedReview = { ...review }; + + if (voteType === "upvote") { + if (review.hasUpvoted) { + updatedReview.hasUpvoted = false; + updatedReview.upvotes = Math.max(0, (review.upvotes || 0) - 1); + } else { + updatedReview.hasUpvoted = true; + updatedReview.upvotes = (review.upvotes || 0) + 1; + + if (review.hasDownvoted) { + updatedReview.hasDownvoted = false; + updatedReview.downvotes = Math.max(0, (review.downvotes || 0) - 1); + } + } + } else { + if (review.hasDownvoted) { + updatedReview.hasDownvoted = false; + updatedReview.downvotes = Math.max(0, (review.downvotes || 0) - 1); + } else { + updatedReview.hasDownvoted = true; + updatedReview.downvotes = (review.downvotes || 0) + 1; + + if (review.hasUpvoted) { + updatedReview.hasUpvoted = false; + updatedReview.upvotes = Math.max(0, (review.upvotes || 0) - 1); + } + } + } + + updatedReviews[reviewIndex] = updatedReview; + setReviews(updatedReviews); + + try { + await window.electron.hydraApi.put( + `/games/${shop}/${objectId}/reviews/${reviewId}/${voteType}`, + { data: {} } + ); + } catch (error) { + console.error(`Failed to ${voteType} review:`, error); + + const rolledBackReviews = [...reviews]; + rolledBackReviews[reviewIndex] = originalReview; + setReviews(rolledBackReviews); + + showErrorToast(t("vote_failed")); + } finally { + setTimeout(() => { + setVotingReviews((prev) => { + const next = new Set(prev); + next.delete(reviewId); + return next; + }); + }, 500); + } + }; + + const handleDeleteReview = async (reviewId: string) => { + if (!objectId) return; + + try { + await window.electron.hydraApi.delete( + `/games/${shop}/${objectId}/reviews/${reviewId}` + ); + loadReviews(true); + onUserReviewedChange(false); + setShowReviewForm(true); + showSuccessToast(t("review_deleted_successfully")); + } catch (error) { + console.error("Failed to delete review:", error); + showErrorToast(t("review_deletion_failed")); + } + }; + + const handleSubmitReview = async () => { + const reviewHtml = editor?.getHTML() || ""; + const reviewText = editor?.getText() || ""; + + if (!objectId) return; + + if (!reviewText.trim()) { + showErrorToast(t("review_cannot_be_empty")); + return; + } + + if (submittingReview || reviewCharCount > MAX_REVIEW_CHARS) { + return; + } + + if (reviewScore === null) { + return; + } + + setSubmittingReview(true); + + try { + await window.electron.hydraApi.post( + `/games/${shop}/${objectId}/reviews`, + { + data: { + reviewHtml, + score: reviewScore, + }, + } + ); + + editor?.commands.clearContent(); + setReviewScore(null); + showSuccessToast(t("review_submitted_successfully")); + + await loadReviews(true); + setShowReviewForm(false); + setShowReviewPrompt(false); + onUserReviewedChange(true); + } catch (error) { + console.error("Failed to submit review:", error); + showErrorToast(t("review_submission_failed")); + } finally { + setSubmittingReview(false); + } + }; + + const handleReviewPromptYes = () => { + setShowReviewPrompt(false); + + setTimeout(() => { + const reviewFormElement = document.querySelector( + ".game-details__review-form" + ); + if (reviewFormElement) { + reviewFormElement.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + }, 100); + }; + + const handleReviewPromptLater = () => { + setShowReviewPrompt(false); + setShowReviewForm(false); + if (objectId) { + sessionStorage.setItem(`reviewPromptDismissed_${objectId}`, "true"); + } + }; + + const handleSortChange = (newSortBy: ReviewSortOption) => { + if (newSortBy !== reviewsSortBy) { + 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); + } + }; + + const handleVoteAnimationComplete = ( + reviewId: string, + votes: { upvotes: number; downvotes: number } + ) => { + previousVotesRef.current.set(reviewId, votes); + }; + + useEffect(() => { + if (objectId) { + loadReviews(true); + if (userDetailsId) { + checkUserReview(); + } + } + + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }; + }, [objectId, userDetailsId, checkUserReview, loadReviews]); + + useEffect(() => { + if (reviewsPage > 0) { + loadReviews(false); + } + }, [reviewsPage, loadReviews]); + + useEffect(() => { + reviews.forEach((review) => { + if (!previousVotesRef.current.has(review.id)) { + previousVotesRef.current.set(review.id, { + upvotes: review.upvotes || 0, + downvotes: review.downvotes || 0, + }); + } + }); + }, [reviews]); + + return ( +
+ {showReviewPrompt && + userDetailsId && + !hasUserReviewed && + isGameInLibrary && ( + + )} + + {showReviewForm && ( + <> + +
+ + )} + +
+
+
+

{t("reviews")}

+ + {totalReviewCount} + +
+
+ + + {reviewsLoading && reviews.length === 0 && ( +
+ {t("loading_reviews")} +
+ )} + + {!reviewsLoading && reviews.length === 0 && ( +
+
+ +
+

+ {t("no_reviews_yet")} +

+

+ {t("be_first_to_review")} +

+
+ )} + +
0 ? 0.5 : 1, + transition: "opacity 0.2s ease", + }} + > + {reviews.map((review) => ( + + ))} +
+ + {hasMoreReviews && !reviewsLoading && ( + + )} + + {reviewsLoading && reviews.length > 0 && ( +
+ {t("loading_more_reviews")} +
+ )} +
+
+ ); +} diff --git a/src/renderer/src/pages/game-details/review-form.tsx b/src/renderer/src/pages/game-details/review-form.tsx new file mode 100644 index 00000000..2cc83a19 --- /dev/null +++ b/src/renderer/src/pages/game-details/review-form.tsx @@ -0,0 +1,149 @@ +import { Star } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { EditorContent, Editor } from "@tiptap/react"; +import { Button } from "@renderer/components"; + +interface ReviewFormProps { + editor: Editor | null; + reviewScore: number | null; + reviewCharCount: number; + maxReviewChars: number; + submittingReview: boolean; + onScoreChange: (score: number) => void; + onSubmit: () => void; +} + +const getSelectScoreColorClass = (score: number): string => { + 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 >= 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 ReviewForm({ + editor, + reviewScore, + reviewCharCount, + maxReviewChars, + submittingReview, + onScoreChange, + onSubmit, +}: Readonly) { + const { t } = useTranslation("game_details"); + + return ( + <> +
+

{t("leave_a_review")}

+
+ +
+
+
+
+ + + +
+
+ maxReviewChars ? "over-limit" : ""} + > + {reviewCharCount}/{maxReviewChars} + +
+
+
+ +
+
+ +
+
+
+ {[1, 2, 3, 4, 5].map((starValue) => ( + + ))} +
+
+ + +
+
+ + ); +} diff --git a/src/renderer/src/pages/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx new file mode 100644 index 00000000..85c81e70 --- /dev/null +++ b/src/renderer/src/pages/game-details/review-item.tsx @@ -0,0 +1,264 @@ +import { TrashIcon, ClockIcon } from "@primer/octicons-react"; +import { ThumbsUp, ThumbsDown, Star } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { motion, AnimatePresence } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import type { GameReview } from "@types"; + +import { sanitizeHtml } from "@shared"; +import { useDate } from "@renderer/hooks"; +import { formatNumber } from "@renderer/helpers"; +import { Avatar } from "@renderer/components"; + +interface ReviewItemProps { + review: GameReview; + userDetailsId?: string; + isBlocked: boolean; + isVisible: boolean; + isVoting: boolean; + previousVotes: { upvotes: number; downvotes: number }; + onVote: (reviewId: string, voteType: "upvote" | "downvote") => void; + onDelete: (reviewId: string) => void; + onToggleVisibility: (reviewId: string) => void; + onAnimationComplete: ( + reviewId: string, + votes: { upvotes: number; downvotes: number } + ) => void; +} + +const getScoreColorClass = (score: number): string => { + 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 ""; +}; + +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 ReviewItem({ + review, + userDetailsId, + isBlocked, + isVisible, + isVoting, + previousVotes, + onVote, + onDelete, + onToggleVisibility, + onAnimationComplete, +}: Readonly) { + const navigate = useNavigate(); + const { t } = useTranslation("game_details"); + const { formatDistance } = useDate(); + + if (isBlocked && !isVisible) { + return ( +
+
+ Review from blocked user —{" "} + +
+
+ ); + } + + return ( +
+
+
+ +
+ +
+ + {formatDistance(new Date(review.createdAt), new Date(), { + addSuffix: true, + })} +
+
+
+
+ {[1, 2, 3, 4, 5].map((starValue) => ( + + ))} +
+
+
+
+
+ onVote(review.id, "upvote")} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + animate={ + review.hasUpvoted + ? { + scale: [1, 1.2, 1], + transition: { duration: 0.3 }, + } + : {} + } + > + + + previousVotes.upvotes} + variants={{ + enter: (isIncreasing: boolean) => ({ + y: isIncreasing ? 10 : -10, + opacity: 0, + }), + center: { y: 0, opacity: 1 }, + exit: (isIncreasing: boolean) => ({ + y: isIncreasing ? -10 : 10, + opacity: 0, + }), + }} + initial="enter" + animate="center" + exit="exit" + transition={{ duration: 0.2 }} + onAnimationComplete={() => { + onAnimationComplete(review.id, { + upvotes: review.upvotes || 0, + downvotes: review.downvotes || 0, + }); + }} + > + {formatNumber(review.upvotes || 0)} + + + + onVote(review.id, "downvote")} + disabled={isVoting} + style={{ + opacity: isVoting ? 0.5 : 1, + cursor: isVoting ? "not-allowed" : "pointer", + }} + animate={ + review.hasDownvoted + ? { + scale: [1, 1.2, 1], + transition: { duration: 0.3 }, + } + : {} + } + > + + + previousVotes.downvotes} + variants={{ + enter: (isIncreasing: boolean) => ({ + y: isIncreasing ? 10 : -10, + opacity: 0, + }), + center: { y: 0, opacity: 1 }, + exit: (isIncreasing: boolean) => ({ + y: isIncreasing ? -10 : 10, + opacity: 0, + }), + }} + initial="enter" + animate="center" + exit="exit" + transition={{ duration: 0.2 }} + onAnimationComplete={() => { + onAnimationComplete(review.id, { + upvotes: review.upvotes || 0, + downvotes: review.downvotes || 0, + }); + }} + > + {formatNumber(review.downvotes || 0)} + + + +
+ {userDetailsId === review.user.id && ( + + )} + {isBlocked && isVisible && ( + + )} +
+
+ ); +} diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx index df1429ec..d16f4d3f 100755 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx @@ -91,10 +91,11 @@ export function Sidebar() { }); } else { try { - const howLongToBeat = await window.electron.getHowLongToBeat( - objectId, - shop - ); + const howLongToBeat = await window.electron.hydraApi.get< + HowLongToBeatCategory[] | null + >(`/games/${shop}/${objectId}/how-long-to-beat`, { + needsAuth: false, + }); if (howLongToBeat) { howLongToBeatEntriesTable.add({ diff --git a/src/renderer/src/pages/home/home.tsx b/src/renderer/src/pages/home/home.tsx index 0b762882..40bf181d 100644 --- a/src/renderer/src/pages/home/home.tsx +++ b/src/renderer/src/pages/home/home.tsx @@ -40,7 +40,15 @@ export default function Home() { setCurrentCatalogueCategory(category); setIsLoading(true); - const catalogue = await window.electron.getCatalogue(category); + const params = new URLSearchParams({ + take: "12", + skip: "0", + }); + + const catalogue = await window.electron.hydraApi.get( + `/catalogue/${category}?${params.toString()}`, + { needsAuth: false } + ); setCatalogue((prev) => ({ ...prev, [category]: catalogue })); } finally { diff --git a/src/renderer/src/pages/profile/report-profile/report-profile.tsx b/src/renderer/src/pages/profile/report-profile/report-profile.tsx index 40084aba..67b7a72a 100644 --- a/src/renderer/src/pages/profile/report-profile/report-profile.tsx +++ b/src/renderer/src/pages/profile/report-profile/report-profile.tsx @@ -54,8 +54,13 @@ export function ReportProfile() { const onSubmit = useCallback( async (values: FormValues) => { - return window.electron - .reportUser(userProfile!.id, values.reason, values.description) + return window.electron.hydraApi + .post(`/users/${userProfile!.id}/report`, { + data: { + reason: values.reason, + description: values.description, + }, + }) .then(() => { showSuccessToast(t("profile_reported")); setShowReportProfileModal(false); diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx index 610816fa..6ae91cfd 100644 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx @@ -34,8 +34,13 @@ export const UserFriendModalList = ({ const loadNextPage = () => { if (page > maxPage) return; setIsLoading(true); - window.electron - .getUserFriends(userId, pageSize, page * pageSize) + + const url = isMe ? "/profile/friends" : `/users/${userId}/friends`; + + window.electron.hydraApi + .get<{ totalFriends: number; friends: UserFriend[] }>(url, { + params: { take: pageSize, skip: page * pageSize }, + }) .then((newPage) => { if (page === 0) { setMaxPage(newPage.totalFriends / pageSize); diff --git a/src/types/index.ts b/src/types/index.ts index 6a864f3a..b4d8044a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -254,7 +254,7 @@ export interface GameReview { id: string; displayName: string; profileImageUrl: string | null; - } | null; + }; } export interface TrendingGame extends ShopAssets { diff --git a/yarn.lock b/yarn.lock index 71551858..2a610688 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7301,9 +7301,9 @@ ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== nan@^2.18.0: - version "2.22.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.22.0.tgz#31bc433fc33213c97bad36404bb68063de604de3" - integrity sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw== + version "2.23.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.23.0.tgz#24aa4ddffcc37613a2d2935b97683c1ec96093c6" + integrity sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ== nanoid@^3.3.7: version "3.3.7"