mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-15 16:33:02 -03:00
Compare commits
17 Commits
fix/game-n
...
feat/ota
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32b9f88702 | ||
|
|
c9fc4dfc02 | ||
|
|
0ae3e35cb4 | ||
|
|
cd136c07a6 | ||
|
|
6e243822ff | ||
|
|
12274b8c57 | ||
|
|
a8bbb76190 | ||
|
|
59d4545476 | ||
|
|
621adbb1ab | ||
|
|
d530c384c9 | ||
|
|
d8e30a3f2f | ||
|
|
5e59e1a7d1 | ||
|
|
fc541daaed | ||
|
|
53d81018e9 | ||
|
|
25758a540f | ||
|
|
e5659543ce | ||
|
|
612350ac19 |
40
.github/workflows/build-renderer.yml
vendored
Normal file
40
.github/workflows/build-renderer.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Build Renderer
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.18.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build Renderer
|
||||
run: yarn build
|
||||
env:
|
||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
npx --yes wrangler@3 pages deploy out/renderer \
|
||||
--project-name="hydra" \
|
||||
--commit-dirty=true \
|
||||
--branch="main"
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -57,6 +57,7 @@ jobs:
|
||||
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
|
||||
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
|
||||
MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-2022'
|
||||
@@ -73,6 +74,7 @@ jobs:
|
||||
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
|
||||
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
|
||||
MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }}
|
||||
|
||||
- name: Create artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"tar": "^7.4.3",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"user-agents": "^1.1.387",
|
||||
"uuid": "^13.0.0",
|
||||
"winreg": "^1.2.5",
|
||||
"ws": "^8.18.1",
|
||||
"yaml": "^2.6.1",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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<ShopAssets[]>(
|
||||
`/catalogue/${category}?${params.toString()}`,
|
||||
{},
|
||||
{ needsAuth: false }
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("getCatalogue", getCatalogue);
|
||||
@@ -1,10 +0,0 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getDevelopers = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return HydraApi.get<string[]>(`/catalogue/developers`, null, {
|
||||
needsAuth: false,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getDevelopers", getDevelopers);
|
||||
@@ -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);
|
||||
@@ -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<HowLongToBeatCategory[] | null> => {
|
||||
return HydraApi.get(`/games/${shop}/${objectId}/how-long-to-beat`, null, {
|
||||
needsAuth: false,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getHowLongToBeat", getHowLongToBeat);
|
||||
@@ -1,10 +0,0 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getPublishers = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return HydraApi.get<string[]>(`/catalogue/publishers`, null, {
|
||||
needsAuth: false,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getPublishers", getPublishers);
|
||||
@@ -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<string, string>(levelKeys.language, {
|
||||
valueEncoding: "utf8",
|
||||
})
|
||||
.then((language) => language || "en");
|
||||
|
||||
const trendingGames = await HydraApi.get<TrendingGame[]>(
|
||||
"/catalogue/featured",
|
||||
{ language },
|
||||
{ needsAuth: false }
|
||||
).catch(() => []);
|
||||
|
||||
return trendingGames.slice(0, 1);
|
||||
};
|
||||
|
||||
registerEvent("getTrendingGames", getTrendingGames);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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<GameArtifact[]>(
|
||||
`/profile/games/artifacts?${params.toString()}`,
|
||||
{},
|
||||
{ needsSubscription: true }
|
||||
).catch((err) => {
|
||||
if (err instanceof SubscriptionRequiredError) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (err instanceof UserNotLoggedInError) {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getGameArtifacts", getGameArtifacts);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, string>(levelKeys.language, {
|
||||
valueEncoding: "utf8",
|
||||
})
|
||||
.then((language) => language || "en");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
locale: language,
|
||||
});
|
||||
|
||||
return HydraApi.get<Badge[]>(`/badges?${params.toString()}`, null, {
|
||||
needsAuth: false,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getBadges", getBadges);
|
||||
@@ -1,8 +0,0 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
|
||||
const getFeatures = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return HydraApi.get<string[]>("/features", null, { needsAuth: false });
|
||||
};
|
||||
|
||||
registerEvent("getFeatures", getFeatures);
|
||||
38
src/main/events/misc/hydra-api-call.ts
Normal file
38
src/main/events/misc/hydra-api-call.ts
Normal file
@@ -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);
|
||||
@@ -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<FriendRequest[]> => {
|
||||
return HydraApi.get(`/profile/friend-requests`).catch(() => []);
|
||||
};
|
||||
|
||||
registerEvent("getFriendRequests", getFriendRequests);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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<UserBlocks> => {
|
||||
return HydraApi.get(`/profile/blocks`, { take, skip }).catch((err) => {
|
||||
if (err instanceof UserNotLoggedInError) {
|
||||
return { blocks: [] };
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getBlockedUsers", getBlockedUsers);
|
||||
@@ -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<UserFriends> => {
|
||||
const user = await db.get<string, User>(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);
|
||||
@@ -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<UserLibraryResponse | null> => {
|
||||
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<UserLibraryResponse>(url).catch(() => null);
|
||||
};
|
||||
|
||||
registerEvent("getUserLibrary", getUserLibrary);
|
||||
@@ -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<UserStats> => {
|
||||
return HydraApi.get(`/users/${userId}/stats`);
|
||||
};
|
||||
|
||||
registerEvent("getUserStats", getUserStats);
|
||||
@@ -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<UserProfile | null> => {
|
||||
return HydraApi.get<UserProfile>(`/users/${userId}`).catch(() => null);
|
||||
};
|
||||
|
||||
registerEvent("getUser", getUser);
|
||||
@@ -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<void> => {
|
||||
return HydraApi.post(`/users/${userId}/report`, {
|
||||
reason,
|
||||
description,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("reportUser", reportUser);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -8,58 +8,65 @@ import { levelKeys } from "@main/level/sublevels";
|
||||
export const getUserData = async () => {
|
||||
return HydraApi.get<UserDetails>(`/profile/me`)
|
||||
.then(async (me) => {
|
||||
db.get<string, User>(levelKeys.user, { valueEncoding: "json" }).then(
|
||||
(user) => {
|
||||
return db.put<string, User>(
|
||||
levelKeys.user,
|
||||
{
|
||||
...user,
|
||||
id: me.id,
|
||||
displayName: me.displayName,
|
||||
profileImageUrl: me.profileImageUrl,
|
||||
backgroundImageUrl: me.backgroundImageUrl,
|
||||
subscription: me.subscription,
|
||||
},
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
const user = await db.get<string, User>(levelKeys.user, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
await db.put<string, User>(
|
||||
levelKeys.user,
|
||||
{
|
||||
...user,
|
||||
id: me.id,
|
||||
displayName: me.displayName,
|
||||
profileImageUrl: me.profileImageUrl,
|
||||
backgroundImageUrl: me.backgroundImageUrl,
|
||||
subscription: me.subscription,
|
||||
},
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to update user in DB", error);
|
||||
}
|
||||
return me;
|
||||
})
|
||||
.catch(async (err) => {
|
||||
if (err instanceof UserNotLoggedInError) {
|
||||
return null;
|
||||
}
|
||||
logger.error("Failed to get logged user");
|
||||
|
||||
const loggedUser = await db.get<string, User>(levelKeys.user, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
logger.error("Failed to get logged user", err);
|
||||
|
||||
if (loggedUser) {
|
||||
return {
|
||||
...loggedUser,
|
||||
username: "",
|
||||
bio: "",
|
||||
email: null,
|
||||
profileVisibility: "PUBLIC" as ProfileVisibility,
|
||||
quirks: {
|
||||
backupsPerGameLimit: 0,
|
||||
},
|
||||
subscription: loggedUser.subscription
|
||||
? {
|
||||
id: loggedUser.subscription.id,
|
||||
status: loggedUser.subscription.status,
|
||||
plan: {
|
||||
id: loggedUser.subscription.plan.id,
|
||||
name: loggedUser.subscription.plan.name,
|
||||
},
|
||||
expiresAt: loggedUser.subscription.expiresAt,
|
||||
}
|
||||
: null,
|
||||
featurebaseJwt: "",
|
||||
} as UserDetails;
|
||||
try {
|
||||
const loggedUser = await db.get<string, User>(levelKeys.user, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
|
||||
if (loggedUser) {
|
||||
return {
|
||||
...loggedUser,
|
||||
username: "",
|
||||
bio: "",
|
||||
email: null,
|
||||
profileVisibility: "PUBLIC" as ProfileVisibility,
|
||||
quirks: {
|
||||
backupsPerGameLimit: 0,
|
||||
},
|
||||
subscription: loggedUser.subscription
|
||||
? {
|
||||
id: loggedUser.subscription.id,
|
||||
status: loggedUser.subscription.status,
|
||||
plan: {
|
||||
id: loggedUser.subscription.plan.id,
|
||||
name: loggedUser.subscription.plan.name,
|
||||
},
|
||||
expiresAt: loggedUser.subscription.expiresAt,
|
||||
}
|
||||
: null,
|
||||
featurebaseJwt: "",
|
||||
} as UserDetails;
|
||||
}
|
||||
} catch (dbError) {
|
||||
logger.error("Failed to read user from DB", dbError);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -54,20 +54,37 @@ export class WindowManager {
|
||||
show: false,
|
||||
};
|
||||
|
||||
private static loadMainWindowURL(hash = "") {
|
||||
private static async loadWindowURL(window: BrowserWindow, hash: string = "") {
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
this.mainWindow?.loadURL(
|
||||
`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`
|
||||
);
|
||||
} else {
|
||||
this.mainWindow?.loadFile(
|
||||
path.join(__dirname, "../renderer/index.html"),
|
||||
{
|
||||
window.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`);
|
||||
} else if (process.env["MAIN_VITE_RENDERER_URL"]) {
|
||||
// Try to load from remote URL in production
|
||||
try {
|
||||
await window.loadURL(
|
||||
`${import.meta.env.MAIN_VITE_RENDERER_URL}#/${hash}`
|
||||
);
|
||||
} catch (error) {
|
||||
// Fall back to local file if remote URL fails
|
||||
console.error(
|
||||
"Failed to load from MAIN_VITE_RENDERER_URL, falling back to local file:",
|
||||
error
|
||||
);
|
||||
window.loadFile(path.join(__dirname, "../renderer/index.html"), {
|
||||
hash,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
window.loadFile(path.join(__dirname, "../renderer/index.html"), {
|
||||
hash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async loadMainWindowURL(hash: string = "") {
|
||||
if (this.mainWindow) {
|
||||
await this.loadWindowURL(this.mainWindow, hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,17 +285,8 @@ export class WindowManager {
|
||||
}
|
||||
|
||||
private static loadNotificationWindowURL() {
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
this.notificationWindow?.loadURL(
|
||||
`${process.env["ELECTRON_RENDERER_URL"]}#/achievement-notification`
|
||||
);
|
||||
} else {
|
||||
this.notificationWindow?.loadFile(
|
||||
path.join(__dirname, "../renderer/index.html"),
|
||||
{
|
||||
hash: "achievement-notification",
|
||||
}
|
||||
);
|
||||
if (this.notificationWindow) {
|
||||
this.loadWindowURL(this.notificationWindow, "achievement-notification");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,19 +458,10 @@ export class WindowManager {
|
||||
|
||||
editorWindow.removeMenu();
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
editorWindow.loadURL(
|
||||
`${process.env["ELECTRON_RENDERER_URL"]}#/theme-editor?themeId=${themeId}`
|
||||
);
|
||||
} else {
|
||||
editorWindow.loadFile(path.join(__dirname, "../renderer/index.html"), {
|
||||
hash: `theme-editor?themeId=${themeId}`,
|
||||
});
|
||||
}
|
||||
this.loadWindowURL(editorWindow, `theme-editor?themeId=${themeId}`);
|
||||
|
||||
editorWindow.once("ready-to-show", () => {
|
||||
editorWindow.show();
|
||||
this.mainWindow?.webContents.openDevTools();
|
||||
if (!app.isPackaged || isStaging) {
|
||||
editorWindow.webContents.openDevTools();
|
||||
}
|
||||
@@ -470,12 +469,11 @@ export class WindowManager {
|
||||
|
||||
editorWindow.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.key === "F12") {
|
||||
this.mainWindow?.webContents.toggleDevTools();
|
||||
editorWindow.webContents.toggleDevTools();
|
||||
}
|
||||
});
|
||||
|
||||
editorWindow.on("close", () => {
|
||||
this.mainWindow?.webContents.closeDevTools();
|
||||
this.editorWindows.delete(themeId);
|
||||
});
|
||||
}
|
||||
|
||||
2
src/main/vite-env.d.ts
vendored
2
src/main/vite-env.d.ts
vendored
@@ -7,6 +7,8 @@ interface ImportMetaEnv {
|
||||
readonly MAIN_VITE_CHECKOUT_URL: string;
|
||||
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
|
||||
readonly MAIN_VITE_WS_URL: string;
|
||||
readonly MAIN_VITE_RENDERER_URL: string;
|
||||
readonly ELECTRON_RENDERER_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { downloadSourcesWorker } from "./workers";
|
||||
import { downloadSourcesTable } from "./dexie";
|
||||
import { useSubscription } from "./hooks/use-subscription";
|
||||
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
|
||||
import { generateUUID } from "./helpers";
|
||||
|
||||
import { injectCustomCss, removeCustomCss } from "./helpers";
|
||||
import "./app.scss";
|
||||
@@ -212,7 +213,7 @@ export function App() {
|
||||
useEffect(() => {
|
||||
updateRepacks();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const id = generateUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||
|
||||
channel.onmessage = async (event: MessageEvent<number>) => {
|
||||
|
||||
@@ -18,10 +18,18 @@ export function Hero() {
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
|
||||
window.electron
|
||||
.getTrendingGames()
|
||||
const language = i18n.language.split("-")[0];
|
||||
|
||||
window.electron.hydraApi
|
||||
.get<TrendingGame[]>("/catalogue/featured", {
|
||||
params: { language },
|
||||
needsAuth: false,
|
||||
})
|
||||
.then((result) => {
|
||||
setFeaturedGameDetails(result);
|
||||
setFeaturedGameDetails(result.slice(0, 1));
|
||||
})
|
||||
.catch(() => {
|
||||
setFeaturedGameDetails([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -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<GameArtifact[]>(`/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]
|
||||
);
|
||||
|
||||
@@ -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<UserBlocks>("/profile/blocks", {
|
||||
params: { take: 12, skip: 0 },
|
||||
})
|
||||
.catch(() => {
|
||||
return { blocks: [] };
|
||||
});
|
||||
setBlockedUsers(blockedUsers.blocks);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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<UserStats>(`/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<UserProfile | null>(`/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<Badge[]>(
|
||||
`/badges?${params.toString()}`,
|
||||
{ needsAuth: false }
|
||||
);
|
||||
setBadges(badges);
|
||||
}, []);
|
||||
}, [i18n]);
|
||||
|
||||
useEffect(() => {
|
||||
setUserProfile(null);
|
||||
|
||||
130
src/renderer/src/declaration.d.ts
vendored
130
src/renderer/src/declaration.d.ts
vendored
@@ -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,
|
||||
DiskUsage,
|
||||
} from "@types";
|
||||
@@ -72,63 +62,22 @@ declare global {
|
||||
) => Promise<Record<string, boolean>>;
|
||||
|
||||
/* Catalogue */
|
||||
searchGames: (
|
||||
payload: CatalogueSearchPayload,
|
||||
take: number,
|
||||
skip: number
|
||||
) => Promise<{ edges: CatalogueSearchResult[]; count: number }>;
|
||||
getCatalogue: (category: CatalogueCategory) => Promise<ShopAssets[]>;
|
||||
getGameShopDetails: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
language: string
|
||||
) => Promise<ShopDetailsWithAssets | null>;
|
||||
getRandomGame: () => Promise<Steam250Game>;
|
||||
getHowLongToBeat: (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<HowLongToBeatCategory[] | null>;
|
||||
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
||||
getGameAssets: (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<ShopAssets | null>;
|
||||
getTrendingGames: () => Promise<TrendingGame[]>;
|
||||
createGameReview: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
reviewHtml: string,
|
||||
score: number
|
||||
) => Promise<void>;
|
||||
getGameReviews: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
take?: number,
|
||||
skip?: number,
|
||||
sortBy?: string
|
||||
) => Promise<any[]>;
|
||||
voteReview: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
reviewId: string,
|
||||
voteType: "upvote" | "downvote"
|
||||
) => Promise<void>;
|
||||
deleteReview: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
reviewId: string
|
||||
) => Promise<void>;
|
||||
checkGameReview: (
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<{ hasReviewed: boolean }>;
|
||||
onUpdateAchievements: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
cb: (achievements: UserAchievement[]) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
getPublishers: () => Promise<string[]>;
|
||||
getDevelopers: () => Promise<string[]>;
|
||||
|
||||
/* Library */
|
||||
toggleAutomaticCloudSync: (
|
||||
@@ -280,14 +229,6 @@ declare global {
|
||||
shop: GameShop,
|
||||
downloadOptionTitle: string | null
|
||||
) => Promise<void>;
|
||||
toggleArtifactFreeze: (
|
||||
gameArtifactId: string,
|
||||
freeze: boolean
|
||||
) => Promise<void>;
|
||||
renameGameArtifact: (
|
||||
gameArtifactId: string,
|
||||
label: string
|
||||
) => Promise<void>;
|
||||
downloadGameArtifact: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
@@ -301,7 +242,6 @@ declare global {
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<LudusaviBackup | null>;
|
||||
deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>;
|
||||
selectGameBackupPath: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
@@ -335,8 +275,48 @@ declare global {
|
||||
options: Electron.OpenDialogOptions
|
||||
) => Promise<Electron.OpenDialogReturnValue>;
|
||||
showItemInFolder: (path: string) => Promise<void>;
|
||||
getFeatures: () => Promise<string[]>;
|
||||
getBadges: () => Promise<Badge[]>;
|
||||
hydraApi: {
|
||||
get: <T = unknown>(
|
||||
url: string,
|
||||
options?: {
|
||||
params?: unknown;
|
||||
needsAuth?: boolean;
|
||||
needsSubscription?: boolean;
|
||||
ifModifiedSince?: Date;
|
||||
}
|
||||
) => Promise<T>;
|
||||
post: <T = unknown>(
|
||||
url: string,
|
||||
options?: {
|
||||
data?: unknown;
|
||||
needsAuth?: boolean;
|
||||
needsSubscription?: boolean;
|
||||
}
|
||||
) => Promise<T>;
|
||||
put: <T = unknown>(
|
||||
url: string,
|
||||
options?: {
|
||||
data?: unknown;
|
||||
needsAuth?: boolean;
|
||||
needsSubscription?: boolean;
|
||||
}
|
||||
) => Promise<T>;
|
||||
patch: <T = unknown>(
|
||||
url: string,
|
||||
options?: {
|
||||
data?: unknown;
|
||||
needsAuth?: boolean;
|
||||
needsSubscription?: boolean;
|
||||
}
|
||||
) => Promise<T>;
|
||||
delete: <T = unknown>(
|
||||
url: string,
|
||||
options?: {
|
||||
needsAuth?: boolean;
|
||||
needsSubscription?: boolean;
|
||||
}
|
||||
) => Promise<T>;
|
||||
};
|
||||
canInstallCommonRedist: () => Promise<boolean>;
|
||||
installCommonRedist: () => Promise<void>;
|
||||
installHydraDeckyPlugin: () => Promise<{
|
||||
@@ -378,27 +358,6 @@ declare global {
|
||||
onSignOut: (cb: () => void) => () => Electron.IpcRenderer;
|
||||
|
||||
/* User */
|
||||
getUser: (userId: string) => Promise<UserProfile | null>;
|
||||
getUserLibrary: (
|
||||
userId: string,
|
||||
take?: number,
|
||||
skip?: number,
|
||||
sortBy?: string
|
||||
) => Promise<UserLibraryResponse>;
|
||||
blockUser: (userId: string) => Promise<void>;
|
||||
unblockUser: (userId: string) => Promise<void>;
|
||||
getUserFriends: (
|
||||
userId: string,
|
||||
take: number,
|
||||
skip: number
|
||||
) => Promise<UserFriends>;
|
||||
getBlockedUsers: (take: number, skip: number) => Promise<UserBlocks>;
|
||||
getUserStats: (userId: string) => Promise<UserStats>;
|
||||
reportUser: (
|
||||
userId: string,
|
||||
reason: string,
|
||||
description: string
|
||||
) => Promise<void>;
|
||||
getComparedUnlockedAchievements: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
@@ -411,7 +370,6 @@ declare global {
|
||||
|
||||
/* Profile */
|
||||
getMe: () => Promise<UserDetails | null>;
|
||||
undoFriendship: (userId: string) => Promise<void>;
|
||||
updateProfile: (
|
||||
updateProfile: UpdateProfileRequest
|
||||
) => Promise<UserProfile>;
|
||||
@@ -419,7 +377,6 @@ declare global {
|
||||
processProfileImage: (
|
||||
path: string
|
||||
) => Promise<{ imagePath: string; mimeType: string }>;
|
||||
getFriendRequests: () => Promise<FriendRequest[]>;
|
||||
syncFriendRequests: () => Promise<void>;
|
||||
onSyncFriendRequests: (
|
||||
cb: (friendRequests: FriendRequestSync) => void
|
||||
@@ -428,7 +385,6 @@ declare global {
|
||||
userId: string,
|
||||
action: FriendRequestAction
|
||||
) => Promise<void>;
|
||||
sendFriendRequest: (userId: string) => Promise<void>;
|
||||
|
||||
/* Notifications */
|
||||
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
import Color from "color";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { THEME_WEB_STORE_URL } from "./constants";
|
||||
|
||||
export const formatDownloadProgress = (
|
||||
@@ -112,3 +113,11 @@ export const formatNumber = (num: number): string => {
|
||||
maximumFractionDigits: 1,
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a UUID v4
|
||||
* @returns A random UUID string
|
||||
*/
|
||||
export const generateUUID = (): string => {
|
||||
return uuidv4();
|
||||
};
|
||||
|
||||
@@ -11,10 +11,12 @@ export function useFeature() {
|
||||
const [features, setFeatures] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.getFeatures().then((features) => {
|
||||
localStorage.setItem("features", JSON.stringify(features || []));
|
||||
setFeatures(features || []);
|
||||
});
|
||||
window.electron.hydraApi
|
||||
.get<string[]>("/features", { needsAuth: false })
|
||||
.then((features) => {
|
||||
localStorage.setItem("features", JSON.stringify(features || []));
|
||||
setFeatures(features || []);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isFeatureEnabled = useCallback(
|
||||
|
||||
@@ -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<FriendRequest[]>("/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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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<string | null>(null);
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
|
||||
// Reviews state management
|
||||
const [reviews, setReviews] = useState<GameReview[]>([]);
|
||||
const [reviewsLoading, setReviewsLoading] = useState(false);
|
||||
const [reviewScore, setReviewScore] = useState<number | null>(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<string, { upvotes: number; downvotes: number }>
|
||||
>(new Map());
|
||||
const [reviewsPage, setReviewsPage] = useState(0);
|
||||
const [hasMoreReviews, setHasMoreReviews] = useState(true);
|
||||
const [visibleBlockedReviews, setVisibleBlockedReviews] = useState<
|
||||
Set<string>
|
||||
>(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<AbortController | null>(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 (
|
||||
<img
|
||||
src={logoImage}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="game-details__game-logo-text">{game?.title}</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// For non-custom games, show logo image if available
|
||||
return logoImage ? (
|
||||
<img
|
||||
src={logoImage}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
|
||||
@@ -587,7 +170,7 @@ export function GameDetailsContent() {
|
||||
style={{ opacity: backdropOpacity }}
|
||||
>
|
||||
<div className="game-details__hero-content">
|
||||
{renderGameLogo()}
|
||||
<GameLogo game={game} shopDetails={shopDetails} />
|
||||
|
||||
<div className="game-details__hero-buttons game-details__hero-buttons--right">
|
||||
{game && (
|
||||
@@ -626,19 +209,6 @@ export function GameDetailsContent() {
|
||||
|
||||
<div className="game-details__description-container">
|
||||
<div className="game-details__description-content">
|
||||
{/* Review Prompt Banner */}
|
||||
{game?.shop !== "custom" &&
|
||||
showReviewPrompt &&
|
||||
userDetails &&
|
||||
!hasUserReviewed &&
|
||||
!reviewCheckLoading &&
|
||||
isGameInLibrary && (
|
||||
<ReviewPromptBanner
|
||||
onYesClick={handleReviewPromptYes}
|
||||
onLaterClick={handleReviewPromptLater}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DescriptionHeader />
|
||||
<GallerySlider />
|
||||
|
||||
@@ -663,404 +233,16 @@ export function GameDetailsContent() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{game?.shop !== "custom" && (
|
||||
<div className="game-details__reviews-section">
|
||||
{showReviewForm && (
|
||||
<>
|
||||
<div className="game-details__reviews-header">
|
||||
<h3 className="game-details__reviews-title">
|
||||
{t("leave_a_review")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="game-details__review-form">
|
||||
<div className="game-details__review-input-container">
|
||||
<div className="game-details__review-input-header">
|
||||
<div className="game-details__review-editor-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleBold().run()
|
||||
}
|
||||
className={`game-details__editor-button ${editor?.isActive("bold") ? "is-active" : ""}`}
|
||||
disabled={!editor}
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleItalic().run()
|
||||
}
|
||||
className={`game-details__editor-button ${editor?.isActive("italic") ? "is-active" : ""}`}
|
||||
disabled={!editor}
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleUnderline().run()
|
||||
}
|
||||
className={`game-details__editor-button ${editor?.isActive("underline") ? "is-active" : ""}`}
|
||||
disabled={!editor}
|
||||
>
|
||||
<u>U</u>
|
||||
</button>
|
||||
</div>
|
||||
<div className="game-details__review-char-counter">
|
||||
<span
|
||||
className={
|
||||
reviewCharCount > MAX_REVIEW_CHARS
|
||||
? "over-limit"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{reviewCharCount}/{MAX_REVIEW_CHARS}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="game-details__review-input">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="game-details__review-form-bottom">
|
||||
<div className="game-details__review-score-container">
|
||||
<div className="game-details__star-rating">
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<button
|
||||
key={starValue}
|
||||
type="button"
|
||||
className={`game-details__star ${
|
||||
reviewScore && starValue <= reviewScore
|
||||
? "game-details__star--filled"
|
||||
: "game-details__star--empty"
|
||||
} ${
|
||||
reviewScore && starValue <= reviewScore
|
||||
? getSelectScoreColorClass(reviewScore)
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setReviewScore(starValue)}
|
||||
title={getRatingText(starValue, t)}
|
||||
>
|
||||
<Star
|
||||
size={18}
|
||||
fill={
|
||||
reviewScore && starValue <= reviewScore
|
||||
? "currentColor"
|
||||
: "none"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={handleSubmitReview}
|
||||
disabled={
|
||||
!editor?.getHTML().trim() ||
|
||||
reviewScore === null ||
|
||||
submittingReview ||
|
||||
reviewCharCount > MAX_REVIEW_CHARS
|
||||
}
|
||||
>
|
||||
{submittingReview
|
||||
? t("submitting")
|
||||
: t("submit_review")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showReviewForm && (
|
||||
<div className="game-details__reviews-separator"></div>
|
||||
)}
|
||||
|
||||
<div className="game-details__reviews-list">
|
||||
<div className="game-details__reviews-list-header">
|
||||
<div className="game-details__reviews-title-group">
|
||||
<h3 className="game-details__reviews-title">
|
||||
{t("reviews")}
|
||||
</h3>
|
||||
<span className="game-details__reviews-badge">
|
||||
{totalReviewCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ReviewSortOptions
|
||||
sortBy={reviewsSortBy as any}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
|
||||
{reviewsLoading && reviews.length === 0 && (
|
||||
<div className="game-details__reviews-loading">
|
||||
{t("loading_reviews")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!reviewsLoading && reviews.length === 0 && (
|
||||
<div className="game-details__reviews-empty">
|
||||
<div className="game-details__reviews-empty-icon">
|
||||
<NoteIcon size={48} />
|
||||
</div>
|
||||
<h4 className="game-details__reviews-empty-title">
|
||||
{t("no_reviews_yet")}
|
||||
</h4>
|
||||
<p className="game-details__reviews-empty-message">
|
||||
{t("be_first_to_review")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: reviewsLoading && reviews.length > 0 ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{reviews.map((review) => (
|
||||
<div
|
||||
key={review.id}
|
||||
className="game-details__review-item"
|
||||
>
|
||||
{review.isBlocked &&
|
||||
!visibleBlockedReviews.has(review.id) ? (
|
||||
<div className="game-details__blocked-review-simple">
|
||||
Review from blocked user —{" "}
|
||||
<button
|
||||
className="game-details__blocked-review-show-link"
|
||||
onClick={() => toggleBlockedReview(review.id)}
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="game-details__review-header">
|
||||
<div className="game-details__review-user">
|
||||
{review.user?.profileImageUrl && (
|
||||
<button
|
||||
className="game-details__review-avatar-button"
|
||||
onClick={() =>
|
||||
review.user?.id &&
|
||||
navigate(`/profile/${review.user.id}`)
|
||||
}
|
||||
title={review.user.displayName || "User"}
|
||||
>
|
||||
<img
|
||||
src={review.user.profileImageUrl}
|
||||
alt={review.user.displayName || "User"}
|
||||
className="game-details__review-avatar"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<div className="game-details__review-user-info">
|
||||
<button
|
||||
className="game-details__review-display-name game-details__review-display-name--clickable"
|
||||
onClick={() =>
|
||||
review.user?.id &&
|
||||
navigate(`/profile/${review.user.id}`)
|
||||
}
|
||||
>
|
||||
{review.user?.displayName || "Anonymous"}
|
||||
</button>
|
||||
<div className="game-details__review-date">
|
||||
<ClockIcon size={12} />
|
||||
{formatDistance(
|
||||
new Date(review.createdAt),
|
||||
new Date(),
|
||||
{ addSuffix: true }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="game-details__review-score-stars"
|
||||
title={getRatingText(review.score, t)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<Star
|
||||
key={starValue}
|
||||
size={20}
|
||||
fill={
|
||||
starValue <= review.score
|
||||
? "currentColor"
|
||||
: "none"
|
||||
}
|
||||
className={`game-details__review-star ${
|
||||
starValue <= review.score
|
||||
? "game-details__review-star--filled"
|
||||
: "game-details__review-star--empty"
|
||||
} ${
|
||||
starValue <= review.score
|
||||
? getScoreColorClass(review.score)
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="game-details__review-content"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(review.reviewHtml),
|
||||
}}
|
||||
/>
|
||||
<div className="game-details__review-actions">
|
||||
<div className="game-details__review-votes">
|
||||
<motion.button
|
||||
className={`game-details__vote-button game-details__vote-button--upvote ${review.hasUpvoted ? "game-details__vote-button--active" : ""}`}
|
||||
onClick={() =>
|
||||
handleVoteReview(review.id, "upvote")
|
||||
}
|
||||
animate={
|
||||
review.hasUpvoted
|
||||
? {
|
||||
scale: [1, 1.2, 1],
|
||||
transition: { duration: 0.3 },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<ThumbsUp size={16} />
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={review.upvotes || 0}
|
||||
custom={
|
||||
(review.upvotes || 0) >
|
||||
(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)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className={`game-details__vote-button game-details__vote-button--downvote ${review.hasDownvoted ? "game-details__vote-button--active" : ""}`}
|
||||
onClick={() =>
|
||||
handleVoteReview(review.id, "downvote")
|
||||
}
|
||||
animate={
|
||||
review.hasDownvoted
|
||||
? {
|
||||
scale: [1, 1.2, 1],
|
||||
transition: { duration: 0.3 },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={review.downvotes || 0}
|
||||
custom={
|
||||
(review.downvotes || 0) >
|
||||
(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)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
</div>
|
||||
{userDetails?.id === review.user?.id && (
|
||||
<button
|
||||
className="game-details__delete-review-button"
|
||||
onClick={() => handleDeleteReview(review.id)}
|
||||
title={t("delete_review")}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
<span>{t("remove_review")}</span>
|
||||
</button>
|
||||
)}
|
||||
{review.isBlocked &&
|
||||
visibleBlockedReviews.has(review.id) && (
|
||||
<button
|
||||
className="game-details__blocked-review-hide-link"
|
||||
onClick={() =>
|
||||
toggleBlockedReview(review.id)
|
||||
}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMoreReviews && !reviewsLoading && (
|
||||
<button
|
||||
className="game-details__load-more-reviews"
|
||||
onClick={loadMoreReviews}
|
||||
>
|
||||
{t("load_more_reviews")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{reviewsLoading && reviews.length > 0 && (
|
||||
<div className="game-details__reviews-loading">
|
||||
{t("loading_more_reviews")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{game?.shop !== "custom" && shop && objectId && (
|
||||
<GameReviews
|
||||
shop={shop}
|
||||
objectId={objectId}
|
||||
game={game}
|
||||
userDetailsId={userDetails?.id}
|
||||
isGameInLibrary={isGameInLibrary}
|
||||
hasUserReviewed={hasUserReviewed}
|
||||
onUserReviewedChange={setHasUserReviewed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1077,15 +259,6 @@ export function GameDetailsContent() {
|
||||
onGameUpdated={handleGameUpdated}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteReviewModal
|
||||
visible={showDeleteReviewModal}
|
||||
onClose={() => {
|
||||
setShowDeleteReviewModal(false);
|
||||
setReviewToDelete(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteReview}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
49
src/renderer/src/pages/game-details/game-logo.tsx
Normal file
49
src/renderer/src/pages/game-details/game-logo.tsx
Normal file
@@ -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<GameLogoProps>) {
|
||||
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 (
|
||||
<img
|
||||
src={logoImage}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <div className="game-details__game-logo-text">{game?.title}</div>;
|
||||
}
|
||||
} else {
|
||||
// For non-custom games, show logo image if available
|
||||
return logoImage ? (
|
||||
<img
|
||||
src={logoImage}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
548
src/renderer/src/pages/game-details/game-reviews.tsx
Normal file
548
src/renderer/src/pages/game-details/game-reviews.tsx
Normal file
@@ -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<GameReviewsProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [reviews, setReviews] = useState<GameReview[]>([]);
|
||||
const [reviewsLoading, setReviewsLoading] = useState(false);
|
||||
const [reviewScore, setReviewScore] = useState<number | null>(null);
|
||||
const [submittingReview, setSubmittingReview] = useState(false);
|
||||
const [reviewCharCount, setReviewCharCount] = useState(0);
|
||||
const [reviewsSortBy, setReviewsSortBy] =
|
||||
useState<ReviewSortOption>("newest");
|
||||
const [reviewsPage, setReviewsPage] = useState(0);
|
||||
const [hasMoreReviews, setHasMoreReviews] = useState(true);
|
||||
const [visibleBlockedReviews, setVisibleBlockedReviews] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [totalReviewCount, setTotalReviewCount] = useState(0);
|
||||
const [showReviewForm, setShowReviewForm] = useState(false);
|
||||
const [votingReviews, setVotingReviews] = useState<Set<string>>(new Set());
|
||||
const [showReviewPrompt, setShowReviewPrompt] = useState(false);
|
||||
|
||||
const previousVotesRef = useRef<
|
||||
Map<string, { upvotes: number; downvotes: number }>
|
||||
>(new Map());
|
||||
const abortControllerRef = useRef<AbortController | null>(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 (
|
||||
<div className="game-details__reviews-section">
|
||||
{showReviewPrompt &&
|
||||
userDetailsId &&
|
||||
!hasUserReviewed &&
|
||||
isGameInLibrary && (
|
||||
<ReviewPromptBanner
|
||||
onYesClick={handleReviewPromptYes}
|
||||
onLaterClick={handleReviewPromptLater}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showReviewForm && (
|
||||
<>
|
||||
<ReviewForm
|
||||
editor={editor}
|
||||
reviewScore={reviewScore}
|
||||
reviewCharCount={reviewCharCount}
|
||||
maxReviewChars={MAX_REVIEW_CHARS}
|
||||
submittingReview={submittingReview}
|
||||
onScoreChange={setReviewScore}
|
||||
onSubmit={handleSubmitReview}
|
||||
/>
|
||||
<div className="game-details__reviews-separator"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="game-details__reviews-list">
|
||||
<div className="game-details__reviews-list-header">
|
||||
<div className="game-details__reviews-title-group">
|
||||
<h3 className="game-details__reviews-title">{t("reviews")}</h3>
|
||||
<span className="game-details__reviews-badge">
|
||||
{totalReviewCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ReviewSortOptions
|
||||
sortBy={reviewsSortBy}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
|
||||
{reviewsLoading && reviews.length === 0 && (
|
||||
<div className="game-details__reviews-loading">
|
||||
{t("loading_reviews")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!reviewsLoading && reviews.length === 0 && (
|
||||
<div className="game-details__reviews-empty">
|
||||
<div className="game-details__reviews-empty-icon">
|
||||
<NoteIcon size={48} />
|
||||
</div>
|
||||
<h4 className="game-details__reviews-empty-title">
|
||||
{t("no_reviews_yet")}
|
||||
</h4>
|
||||
<p className="game-details__reviews-empty-message">
|
||||
{t("be_first_to_review")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: reviewsLoading && reviews.length > 0 ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{reviews.map((review) => (
|
||||
<ReviewItem
|
||||
key={review.id}
|
||||
review={review}
|
||||
userDetailsId={userDetailsId}
|
||||
isBlocked={review.isBlocked}
|
||||
isVisible={visibleBlockedReviews.has(review.id)}
|
||||
isVoting={votingReviews.has(review.id)}
|
||||
previousVotes={
|
||||
previousVotesRef.current.get(review.id) || {
|
||||
upvotes: 0,
|
||||
downvotes: 0,
|
||||
}
|
||||
}
|
||||
onVote={handleVoteReview}
|
||||
onDelete={handleDeleteReview}
|
||||
onToggleVisibility={toggleBlockedReview}
|
||||
onAnimationComplete={handleVoteAnimationComplete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMoreReviews && !reviewsLoading && (
|
||||
<button
|
||||
className="game-details__load-more-reviews"
|
||||
onClick={loadMoreReviews}
|
||||
>
|
||||
{t("load_more_reviews")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{reviewsLoading && reviews.length > 0 && (
|
||||
<div className="game-details__reviews-loading">
|
||||
{t("loading_more_reviews")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
src/renderer/src/pages/game-details/review-form.tsx
Normal file
149
src/renderer/src/pages/game-details/review-form.tsx
Normal file
@@ -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<ReviewFormProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="game-details__reviews-header">
|
||||
<h3 className="game-details__reviews-title">{t("leave_a_review")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="game-details__review-form">
|
||||
<div className="game-details__review-input-container">
|
||||
<div className="game-details__review-input-header">
|
||||
<div className="game-details__review-editor-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
className={`game-details__editor-button ${editor?.isActive("bold") ? "is-active" : ""}`}
|
||||
disabled={!editor}
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
className={`game-details__editor-button ${editor?.isActive("italic") ? "is-active" : ""}`}
|
||||
disabled={!editor}
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
className={`game-details__editor-button ${editor?.isActive("underline") ? "is-active" : ""}`}
|
||||
disabled={!editor}
|
||||
>
|
||||
<u>U</u>
|
||||
</button>
|
||||
</div>
|
||||
<div className="game-details__review-char-counter">
|
||||
<span
|
||||
className={reviewCharCount > maxReviewChars ? "over-limit" : ""}
|
||||
>
|
||||
{reviewCharCount}/{maxReviewChars}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="game-details__review-input">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="game-details__review-form-bottom">
|
||||
<div className="game-details__review-score-container">
|
||||
<div className="game-details__star-rating">
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<button
|
||||
key={starValue}
|
||||
type="button"
|
||||
className={`game-details__star ${
|
||||
reviewScore && starValue <= reviewScore
|
||||
? "game-details__star--filled"
|
||||
: "game-details__star--empty"
|
||||
} ${
|
||||
reviewScore && starValue <= reviewScore
|
||||
? getSelectScoreColorClass(reviewScore)
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => onScoreChange(starValue)}
|
||||
title={getRatingText(starValue, t)}
|
||||
>
|
||||
<Star
|
||||
size={18}
|
||||
fill={
|
||||
reviewScore && starValue <= reviewScore
|
||||
? "currentColor"
|
||||
: "none"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={onSubmit}
|
||||
disabled={
|
||||
!editor?.getHTML().trim() ||
|
||||
reviewScore === null ||
|
||||
submittingReview ||
|
||||
reviewCharCount > maxReviewChars
|
||||
}
|
||||
>
|
||||
{submittingReview ? t("submitting") : t("submit_review")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
264
src/renderer/src/pages/game-details/review-item.tsx
Normal file
264
src/renderer/src/pages/game-details/review-item.tsx
Normal file
@@ -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<ReviewItemProps>) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("game_details");
|
||||
const { formatDistance } = useDate();
|
||||
|
||||
if (isBlocked && !isVisible) {
|
||||
return (
|
||||
<div className="game-details__review-item">
|
||||
<div className="game-details__blocked-review-simple">
|
||||
Review from blocked user —{" "}
|
||||
<button
|
||||
className="game-details__blocked-review-show-link"
|
||||
onClick={() => onToggleVisibility(review.id)}
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-details__review-item">
|
||||
<div className="game-details__review-header">
|
||||
<div className="game-details__review-user">
|
||||
<button
|
||||
onClick={() => navigate(`/profile/${review.user.id}`)}
|
||||
title={review.user.displayName}
|
||||
>
|
||||
<Avatar
|
||||
src={review.user.profileImageUrl}
|
||||
alt={review.user.displayName || "User"}
|
||||
size={40}
|
||||
/>
|
||||
</button>
|
||||
<div className="game-details__review-user-info">
|
||||
<button
|
||||
className="game-details__review-display-name game-details__review-display-name--clickable"
|
||||
onClick={() =>
|
||||
review.user.id && navigate(`/profile/${review.user.id}`)
|
||||
}
|
||||
>
|
||||
{review.user.displayName || "Anonymous"}
|
||||
</button>
|
||||
<div className="game-details__review-date">
|
||||
<ClockIcon size={12} />
|
||||
{formatDistance(new Date(review.createdAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="game-details__review-score-stars"
|
||||
title={getRatingText(review.score, t)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<Star
|
||||
key={starValue}
|
||||
size={20}
|
||||
fill={starValue <= review.score ? "currentColor" : "none"}
|
||||
className={`game-details__review-star ${
|
||||
starValue <= review.score
|
||||
? "game-details__review-star--filled"
|
||||
: "game-details__review-star--empty"
|
||||
} ${
|
||||
starValue <= review.score
|
||||
? getScoreColorClass(review.score)
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="game-details__review-content"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(review.reviewHtml),
|
||||
}}
|
||||
/>
|
||||
<div className="game-details__review-actions">
|
||||
<div className="game-details__review-votes">
|
||||
<motion.button
|
||||
className={`game-details__vote-button game-details__vote-button--upvote ${review.hasUpvoted ? "game-details__vote-button--active" : ""}`}
|
||||
onClick={() => 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 },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<ThumbsUp size={16} />
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={review.upvotes || 0}
|
||||
custom={(review.upvotes || 0) > 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)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className={`game-details__vote-button game-details__vote-button--downvote ${review.hasDownvoted ? "game-details__vote-button--active" : ""}`}
|
||||
onClick={() => 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 },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={review.downvotes || 0}
|
||||
custom={(review.downvotes || 0) > 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)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
</div>
|
||||
{userDetailsId === review.user.id && (
|
||||
<button
|
||||
className="game-details__delete-review-button"
|
||||
onClick={() => onDelete(review.id)}
|
||||
title={t("delete_review")}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
<span>{t("remove_review")}</span>
|
||||
</button>
|
||||
)}
|
||||
{isBlocked && isVisible && (
|
||||
<button
|
||||
className="game-details__blocked-review-hide-link"
|
||||
onClick={() => onToggleVisibility(review.id)}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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<ShopAssets[]>(
|
||||
`/catalogue/${category}?${params.toString()}`,
|
||||
{ needsAuth: false }
|
||||
);
|
||||
|
||||
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
|
||||
} finally {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useForm } from "react-hook-form";
|
||||
import * as yup from "yup";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { useCallback } from "react";
|
||||
import { generateUUID } from "@renderer/helpers";
|
||||
|
||||
import "./modals.scss";
|
||||
|
||||
@@ -79,7 +80,7 @@ export function AddThemeModal({
|
||||
const onSubmit = useCallback(
|
||||
async (values: FormValues) => {
|
||||
const theme: Theme = {
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
name: values.name,
|
||||
isActive: false,
|
||||
author: userDetails?.id,
|
||||
|
||||
@@ -3,7 +3,11 @@ import { Modal } from "@renderer/components/modal/modal";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./modals.scss";
|
||||
import { Theme } from "@types";
|
||||
import { injectCustomCss, removeCustomCss } from "@renderer/helpers";
|
||||
import {
|
||||
injectCustomCss,
|
||||
removeCustomCss,
|
||||
generateUUID,
|
||||
} from "@renderer/helpers";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import { THEME_WEB_STORE_URL } from "@renderer/constants";
|
||||
import { logger } from "@renderer/logger";
|
||||
@@ -30,7 +34,7 @@ export const ImportThemeModal = ({
|
||||
|
||||
const handleImportTheme = async () => {
|
||||
const theme: Theme = {
|
||||
id: crypto.randomUUID(),
|
||||
id: generateUUID(),
|
||||
name: themeName,
|
||||
isActive: false,
|
||||
author: authorId,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import { downloadSourcesWorker } from "@renderer/workers";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { setFilters, clearFilters } from "@renderer/features";
|
||||
import { generateUUID } from "@renderer/helpers";
|
||||
import "./settings-download-sources.scss";
|
||||
|
||||
export function SettingsDownloadSources() {
|
||||
@@ -95,7 +96,7 @@ export function SettingsDownloadSources() {
|
||||
const handleRemoveAllDownloadSources = () => {
|
||||
setIsRemovingDownloadSource(true);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const id = generateUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:delete_all:${id}`);
|
||||
|
||||
downloadSourcesWorker.postMessage(["DELETE_ALL_DOWNLOAD_SOURCES", id]);
|
||||
@@ -120,7 +121,7 @@ export function SettingsDownloadSources() {
|
||||
const syncDownloadSources = async () => {
|
||||
setIsSyncingDownloadSources(true);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const id = generateUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||
|
||||
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -259,7 +259,7 @@ export interface GameReview {
|
||||
id: string;
|
||||
displayName: string;
|
||||
profileImageUrl: string | null;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TrendingGame extends ShopAssets {
|
||||
|
||||
@@ -9599,6 +9599,11 @@ util-deprecate@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||
|
||||
uuid@^13.0.0:
|
||||
version "13.0.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8"
|
||||
integrity sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==
|
||||
|
||||
uuid@^9.0.1:
|
||||
version "9.0.1"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
|
||||
|
||||
Reference in New Issue
Block a user