mirror of
				https://github.com/hydralauncher/hydra.git
				synced 2025-03-09 15:40:26 +00:00 
			
		
		
		
	feat: adding bypass for region blocks
This commit is contained in:
		
							parent
							
								
									a2790190e6
								
							
						
					
					
						commit
						1c7911c531
					
				
					 38 changed files with 942 additions and 979 deletions
				
			
		| 
						 | 
				
			
			@ -96,7 +96,8 @@
 | 
			
		|||
    "dont_show_it_again": "Don't show it again",
 | 
			
		||||
    "copy_to_clipboard": "Copy",
 | 
			
		||||
    "copied_to_clipboard": "Copied",
 | 
			
		||||
    "got_it": "Got it"
 | 
			
		||||
    "got_it": "Got it",
 | 
			
		||||
    "no_shop_details": "Could not retrieve shop details."
 | 
			
		||||
  },
 | 
			
		||||
  "activation": {
 | 
			
		||||
    "title": "Activate Hydra",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -172,4 +172,3 @@
 | 
			
		|||
    "close": "Knop Sluiten"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
  
 | 
			
		||||
| 
						 | 
				
			
			@ -162,4 +162,3 @@
 | 
			
		|||
    "close": "Kapat tuşu"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
  
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,7 +4,29 @@ import { getSteamAppDetails } from "@main/services";
 | 
			
		|||
import type { ShopDetails, GameShop, SteamAppDetails } from "@types";
 | 
			
		||||
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
import { searchRepacks } from "../helpers/search-games";
 | 
			
		||||
 | 
			
		||||
const getLocalizedSteamAppDetails = (
 | 
			
		||||
  objectID: string,
 | 
			
		||||
  language: string
 | 
			
		||||
): Promise<ShopDetails | null> => {
 | 
			
		||||
  const englishAppDetails = getSteamAppDetails(objectID, "english");
 | 
			
		||||
 | 
			
		||||
  if (language === "english") return englishAppDetails;
 | 
			
		||||
 | 
			
		||||
  return Promise.all([
 | 
			
		||||
    englishAppDetails,
 | 
			
		||||
    getSteamAppDetails(objectID, language),
 | 
			
		||||
  ]).then(([appDetails, localizedAppDetails]) => {
 | 
			
		||||
    if (appDetails && localizedAppDetails) {
 | 
			
		||||
      return {
 | 
			
		||||
        ...localizedAppDetails,
 | 
			
		||||
        name: appDetails.name,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return null;
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getGameShopDetails = async (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
| 
						 | 
				
			
			@ -17,27 +39,21 @@ const getGameShopDetails = async (
 | 
			
		|||
      where: { objectID, language },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const result = Promise.all([
 | 
			
		||||
      getSteamAppDetails(objectID, "english"),
 | 
			
		||||
      getSteamAppDetails(objectID, language),
 | 
			
		||||
    ]).then(([appDetails, localizedAppDetails]) => {
 | 
			
		||||
      if (appDetails && localizedAppDetails) {
 | 
			
		||||
    const appDetails = getLocalizedSteamAppDetails(objectID, language).then(
 | 
			
		||||
      (result) => {
 | 
			
		||||
        gameShopCacheRepository.upsert(
 | 
			
		||||
          {
 | 
			
		||||
            objectID,
 | 
			
		||||
            shop: "steam",
 | 
			
		||||
            language,
 | 
			
		||||
            serializedData: JSON.stringify({
 | 
			
		||||
              ...localizedAppDetails,
 | 
			
		||||
              name: appDetails.name,
 | 
			
		||||
            }),
 | 
			
		||||
            serializedData: JSON.stringify(result),
 | 
			
		||||
          },
 | 
			
		||||
          ["objectID"]
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return [appDetails, localizedAppDetails];
 | 
			
		||||
    });
 | 
			
		||||
        return result;
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const cachedGame = cachedData?.serializedData
 | 
			
		||||
      ? (JSON.parse(cachedData?.serializedData) as SteamAppDetails)
 | 
			
		||||
| 
						 | 
				
			
			@ -46,21 +62,11 @@ const getGameShopDetails = async (
 | 
			
		|||
    if (cachedGame) {
 | 
			
		||||
      return {
 | 
			
		||||
        ...cachedGame,
 | 
			
		||||
        repacks: searchRepacks(cachedGame.name),
 | 
			
		||||
        objectID,
 | 
			
		||||
      } as ShopDetails;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return result.then(([appDetails, localizedAppDetails]) => {
 | 
			
		||||
      if (!appDetails || !localizedAppDetails) return null;
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        ...localizedAppDetails,
 | 
			
		||||
        name: appDetails.name,
 | 
			
		||||
        repacks: searchRepacks(appDetails.name),
 | 
			
		||||
        objectID,
 | 
			
		||||
      } as ShopDetails;
 | 
			
		||||
    });
 | 
			
		||||
    return Promise.resolve(appDetails);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  throw new Error("Not implemented");
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										14
									
								
								src/main/events/catalogue/search-game-repacks.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/main/events/catalogue/search-game-repacks.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
import { searchRepacks } from "../helpers/search-games";
 | 
			
		||||
import { registerEvent } from "../register-event";
 | 
			
		||||
 | 
			
		||||
const searchGameRepacks = (
 | 
			
		||||
  _event: Electron.IpcMainInvokeEvent,
 | 
			
		||||
  query: string
 | 
			
		||||
) => {
 | 
			
		||||
  return searchRepacks(query);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
registerEvent(searchGameRepacks, {
 | 
			
		||||
  name: "searchGameRepacks",
 | 
			
		||||
  memoize: true,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -7,6 +7,7 @@ import "./catalogue/get-games";
 | 
			
		|||
import "./catalogue/get-how-long-to-beat";
 | 
			
		||||
import "./catalogue/get-random-game";
 | 
			
		||||
import "./catalogue/search-games";
 | 
			
		||||
import "./catalogue/search-game-repacks";
 | 
			
		||||
import "./hardware/get-disk-free-space";
 | 
			
		||||
import "./library/add-game-to-library";
 | 
			
		||||
import "./library/close-game";
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,8 +12,7 @@ export class RealDebridClient {
 | 
			
		|||
  private static instance: AxiosInstance;
 | 
			
		||||
 | 
			
		||||
  static async addMagnet(magnet: string) {
 | 
			
		||||
    const searchParams = new URLSearchParams();
 | 
			
		||||
    searchParams.append("magnet", magnet);
 | 
			
		||||
    const searchParams = new URLSearchParams({ magnet });
 | 
			
		||||
 | 
			
		||||
    const response = await this.instance.post<RealDebridAddMagnet>(
 | 
			
		||||
      "/torrents/addMagnet",
 | 
			
		||||
| 
						 | 
				
			
			@ -31,8 +30,7 @@ export class RealDebridClient {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  static async selectAllFiles(id: string) {
 | 
			
		||||
    const searchParams = new URLSearchParams();
 | 
			
		||||
    searchParams.append("files", "all");
 | 
			
		||||
    const searchParams = new URLSearchParams({ files: "all" });
 | 
			
		||||
 | 
			
		||||
    await this.instance.post(
 | 
			
		||||
      `/torrents/selectFiles/${id}`,
 | 
			
		||||
| 
						 | 
				
			
			@ -41,8 +39,7 @@ export class RealDebridClient {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  static async unrestrictLink(link: string) {
 | 
			
		||||
    const searchParams = new URLSearchParams();
 | 
			
		||||
    searchParams.append("link", link);
 | 
			
		||||
    const searchParams = new URLSearchParams({ link });
 | 
			
		||||
 | 
			
		||||
    const response = await this.instance.post<RealDebridUnrestrictLink>(
 | 
			
		||||
      "/unrestrict/link",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										105
									
								
								src/preload/index.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										105
									
								
								src/preload/index.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -1,105 +0,0 @@
 | 
			
		|||
// See the Electron documentation for details on how to use preload scripts:
 | 
			
		||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
 | 
			
		||||
import { contextBridge, ipcRenderer } from "electron";
 | 
			
		||||
 | 
			
		||||
import type {
 | 
			
		||||
  CatalogueCategory,
 | 
			
		||||
  GameShop,
 | 
			
		||||
  TorrentProgress,
 | 
			
		||||
  UserPreferences,
 | 
			
		||||
} from "@types";
 | 
			
		||||
 | 
			
		||||
contextBridge.exposeInMainWorld("electron", {
 | 
			
		||||
  /* Torrenting */
 | 
			
		||||
  startGameDownload: (
 | 
			
		||||
    repackId: number,
 | 
			
		||||
    objectID: string,
 | 
			
		||||
    title: string,
 | 
			
		||||
    shop: GameShop
 | 
			
		||||
  ) => ipcRenderer.invoke("startGameDownload", repackId, objectID, title, shop),
 | 
			
		||||
  cancelGameDownload: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("cancelGameDownload", gameId),
 | 
			
		||||
  pauseGameDownload: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("pauseGameDownload", gameId),
 | 
			
		||||
  resumeGameDownload: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("resumeGameDownload", gameId),
 | 
			
		||||
  onDownloadProgress: (cb: (value: TorrentProgress) => void) => {
 | 
			
		||||
    const listener = (
 | 
			
		||||
      _event: Electron.IpcRendererEvent,
 | 
			
		||||
      value: TorrentProgress
 | 
			
		||||
    ) => cb(value);
 | 
			
		||||
    ipcRenderer.on("on-download-progress", listener);
 | 
			
		||||
    return () => ipcRenderer.removeListener("on-download-progress", listener);
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /* Catalogue */
 | 
			
		||||
  searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
 | 
			
		||||
  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, title: string) =>
 | 
			
		||||
    ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
 | 
			
		||||
  getGames: (take?: number, prevCursor?: number) =>
 | 
			
		||||
    ipcRenderer.invoke("getGames", take, prevCursor),
 | 
			
		||||
 | 
			
		||||
  /* User preferences */
 | 
			
		||||
  getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
 | 
			
		||||
  updateUserPreferences: (preferences: UserPreferences) =>
 | 
			
		||||
    ipcRenderer.invoke("updateUserPreferences", preferences),
 | 
			
		||||
  autoLaunch: (enabled: boolean) => ipcRenderer.invoke("autoLaunch", enabled),
 | 
			
		||||
 | 
			
		||||
  /* Library */
 | 
			
		||||
  addGameToLibrary: (
 | 
			
		||||
    objectID: string,
 | 
			
		||||
    title: string,
 | 
			
		||||
    shop: GameShop,
 | 
			
		||||
    executablePath: string
 | 
			
		||||
  ) =>
 | 
			
		||||
    ipcRenderer.invoke(
 | 
			
		||||
      "addGameToLibrary",
 | 
			
		||||
      objectID,
 | 
			
		||||
      title,
 | 
			
		||||
      shop,
 | 
			
		||||
      executablePath
 | 
			
		||||
    ),
 | 
			
		||||
  getLibrary: () => ipcRenderer.invoke("getLibrary"),
 | 
			
		||||
  getRepackersFriendlyNames: () =>
 | 
			
		||||
    ipcRenderer.invoke("getRepackersFriendlyNames"),
 | 
			
		||||
  openGameInstaller: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("openGameInstaller", gameId),
 | 
			
		||||
  openGame: (gameId: number, executablePath: string) =>
 | 
			
		||||
    ipcRenderer.invoke("openGame", gameId, executablePath),
 | 
			
		||||
  closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId),
 | 
			
		||||
  removeGameFromLibrary: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("removeGameFromLibrary", gameId),
 | 
			
		||||
  deleteGameFolder: (gameId: number) =>
 | 
			
		||||
    ipcRenderer.invoke("deleteGameFolder", gameId),
 | 
			
		||||
  getGameByObjectID: (objectID: string) =>
 | 
			
		||||
    ipcRenderer.invoke("getGameByObjectID", objectID),
 | 
			
		||||
  onPlaytime: (cb: (gameId: number) => void) => {
 | 
			
		||||
    const listener = (_event: Electron.IpcRendererEvent, gameId: number) =>
 | 
			
		||||
      cb(gameId);
 | 
			
		||||
    ipcRenderer.on("on-playtime", listener);
 | 
			
		||||
    return () => ipcRenderer.removeListener("on-playtime", listener);
 | 
			
		||||
  },
 | 
			
		||||
  onGameClose: (cb: (gameId: number) => void) => {
 | 
			
		||||
    const listener = (_event: Electron.IpcRendererEvent, gameId: number) =>
 | 
			
		||||
      cb(gameId);
 | 
			
		||||
    ipcRenderer.on("on-game-close", listener);
 | 
			
		||||
    return () => ipcRenderer.removeListener("on-game-close", listener);
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  /* Hardware */
 | 
			
		||||
  getDiskFreeSpace: () => ipcRenderer.invoke("getDiskFreeSpace"),
 | 
			
		||||
 | 
			
		||||
  /* Misc */
 | 
			
		||||
  ping: () => ipcRenderer.invoke("ping"),
 | 
			
		||||
  getVersion: () => ipcRenderer.invoke("getVersion"),
 | 
			
		||||
  getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
 | 
			
		||||
  openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
 | 
			
		||||
  showOpenDialog: (options: Electron.OpenDialogOptions) =>
 | 
			
		||||
    ipcRenderer.invoke("showOpenDialog", options),
 | 
			
		||||
  platform: process.platform,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -52,6 +52,8 @@ contextBridge.exposeInMainWorld("electron", {
 | 
			
		|||
    ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
 | 
			
		||||
  getGames: (take?: number, prevCursor?: number) =>
 | 
			
		||||
    ipcRenderer.invoke("getGames", take, prevCursor),
 | 
			
		||||
  searchGameRepacks: (query: string) =>
 | 
			
		||||
    ipcRenderer.invoke("searchGameRepacks", query),
 | 
			
		||||
 | 
			
		||||
  /* User preferences */
 | 
			
		||||
  getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,9 +1,7 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
import { recipe } from "@vanilla-extract/recipes";
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const card = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
export const card = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "180px",
 | 
			
		||||
  boxShadow: "0px 0px 15px 0px #000000",
 | 
			
		||||
| 
						 | 
				
			
			@ -16,17 +14,6 @@ export const card = recipe({
 | 
			
		|||
  ":active": {
 | 
			
		||||
    opacity: vars.opacity.active,
 | 
			
		||||
  },
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    disabled: {
 | 
			
		||||
      true: {
 | 
			
		||||
        pointerEvents: "none",
 | 
			
		||||
        boxShadow: "none",
 | 
			
		||||
        opacity: vars.opacity.disabled,
 | 
			
		||||
        filter: "grayscale(50%)",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const backdrop = style({
 | 
			
		||||
| 
						 | 
				
			
			@ -48,7 +35,7 @@ export const cover = style({
 | 
			
		|||
  zIndex: "-1",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  selectors: {
 | 
			
		||||
    [`${card({})}:hover &`]: {
 | 
			
		||||
    [`${card}:hover &`]: {
 | 
			
		||||
      transform: "scale(1.05)",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			@ -64,7 +51,7 @@ export const content = style({
 | 
			
		|||
  transition: "all ease 0.2s",
 | 
			
		||||
  transform: "translateY(24px)",
 | 
			
		||||
  selectors: {
 | 
			
		||||
    [`${card({})}:hover &`]: {
 | 
			
		||||
    [`${card}:hover &`]: {
 | 
			
		||||
      transform: "translateY(0px)",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,7 +14,6 @@ export interface GameCardProps
 | 
			
		|||
    HTMLButtonElement
 | 
			
		||||
  > {
 | 
			
		||||
  game: CatalogueEntry;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const shopIcon = {
 | 
			
		||||
| 
						 | 
				
			
			@ -22,7 +21,7 @@ const shopIcon = {
 | 
			
		|||
  steam: <SteamLogo className={styles.shopIcon} />,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function GameCard({ game, disabled, ...props }: GameCardProps) {
 | 
			
		||||
export function GameCard({ game, ...props }: GameCardProps) {
 | 
			
		||||
  const { t } = useTranslation("game_card");
 | 
			
		||||
 | 
			
		||||
  const repackersFriendlyNames = useAppSelector(
 | 
			
		||||
| 
						 | 
				
			
			@ -34,12 +33,7 @@ export function GameCard({ game, disabled, ...props }: GameCardProps) {
 | 
			
		|||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <button
 | 
			
		||||
      {...props}
 | 
			
		||||
      type="button"
 | 
			
		||||
      className={styles.card({ disabled })}
 | 
			
		||||
      disabled={disabled}
 | 
			
		||||
    >
 | 
			
		||||
    <button {...props} type="button" className={styles.card}>
 | 
			
		||||
      <div className={styles.backdrop}>
 | 
			
		||||
        <img src={game.cover} alt={game.title} className={styles.cover} />
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,7 +6,7 @@ export const hero = style({
 | 
			
		|||
  height: "280px",
 | 
			
		||||
  minHeight: "280px",
 | 
			
		||||
  maxHeight: "280px",
 | 
			
		||||
  borderRadius: "8px",
 | 
			
		||||
  borderRadius: "4px",
 | 
			
		||||
  color: "#DADBE1",
 | 
			
		||||
  overflow: "hidden",
 | 
			
		||||
  boxShadow: "0px 0px 15px 0px #000000",
 | 
			
		||||
| 
						 | 
				
			
			@ -45,6 +45,7 @@ export const description = style({
 | 
			
		|||
  textAlign: "left",
 | 
			
		||||
  fontFamily: "'Fira Sans', sans-serif",
 | 
			
		||||
  lineHeight: "20px",
 | 
			
		||||
  marginTop: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const content = style({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,20 +2,28 @@ import { useNavigate } from "react-router-dom";
 | 
			
		|||
import * as styles from "./hero.css";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { ShopDetails } from "@types";
 | 
			
		||||
import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers";
 | 
			
		||||
import {
 | 
			
		||||
  buildGameDetailsPath,
 | 
			
		||||
  getSteamLanguage,
 | 
			
		||||
  steamUrlBuilder,
 | 
			
		||||
} from "@renderer/helpers";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
const FEATURED_GAME_TITLE = "Horizon Forbidden West™ Complete Edition";
 | 
			
		||||
const FEATURED_GAME_ID = "2420110";
 | 
			
		||||
 | 
			
		||||
export function Hero() {
 | 
			
		||||
  const [featuredGameDetails, setFeaturedGameDetails] =
 | 
			
		||||
    useState<ShopDetails | null>(null);
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const { i18n } = useTranslation();
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setIsLoading(true);
 | 
			
		||||
 | 
			
		||||
    window.electron
 | 
			
		||||
      .getGameShopDetails(
 | 
			
		||||
        FEATURED_GAME_ID,
 | 
			
		||||
| 
						 | 
				
			
			@ -24,19 +32,30 @@ export function Hero() {
 | 
			
		|||
      )
 | 
			
		||||
      .then((result) => {
 | 
			
		||||
        setFeaturedGameDetails(result);
 | 
			
		||||
      })
 | 
			
		||||
      .finally(() => {
 | 
			
		||||
        setIsLoading(false);
 | 
			
		||||
      });
 | 
			
		||||
  }, [i18n.language]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <button
 | 
			
		||||
      type="button"
 | 
			
		||||
      onClick={() => navigate(`/game/steam/${FEATURED_GAME_ID}`)}
 | 
			
		||||
      onClick={() =>
 | 
			
		||||
        navigate(
 | 
			
		||||
          buildGameDetailsPath({
 | 
			
		||||
            title: FEATURED_GAME_TITLE,
 | 
			
		||||
            objectID: FEATURED_GAME_ID,
 | 
			
		||||
            shop: "steam",
 | 
			
		||||
          })
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
      className={styles.hero}
 | 
			
		||||
    >
 | 
			
		||||
      <div className={styles.backdrop}>
 | 
			
		||||
        <img
 | 
			
		||||
          src="https://cdn2.steamgriddb.com/hero/4ef10445b952a8b3c93a9379d581146a.jpg"
 | 
			
		||||
          alt={featuredGameDetails?.name}
 | 
			
		||||
          alt={FEATURED_GAME_TITLE}
 | 
			
		||||
          className={styles.heroMedia}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -44,13 +63,14 @@ export function Hero() {
 | 
			
		|||
          <img
 | 
			
		||||
            src={steamUrlBuilder.logo(FEATURED_GAME_ID)}
 | 
			
		||||
            width="250px"
 | 
			
		||||
            alt={featuredGameDetails?.name}
 | 
			
		||||
            style={{ marginBottom: 16 }}
 | 
			
		||||
            alt={FEATURED_GAME_TITLE}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          {!isLoading && featuredGameDetails && (
 | 
			
		||||
            <p className={styles.description}>
 | 
			
		||||
              {featuredGameDetails?.short_description}
 | 
			
		||||
            </p>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </button>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,6 +15,7 @@ import XLogo from "@renderer/assets/x-icon.svg?react";
 | 
			
		|||
 | 
			
		||||
import * as styles from "./sidebar.css";
 | 
			
		||||
import { GameStatus, GameStatusHelper } from "@shared";
 | 
			
		||||
import { buildGameDetailsPath } from "@renderer/helpers";
 | 
			
		||||
 | 
			
		||||
const SIDEBAR_MIN_WIDTH = 200;
 | 
			
		||||
const SIDEBAR_INITIAL_WIDTH = 250;
 | 
			
		||||
| 
						 | 
				
			
			@ -209,9 +210,7 @@ export function Sidebar() {
 | 
			
		|||
                  type="button"
 | 
			
		||||
                  className={styles.menuItemButton}
 | 
			
		||||
                  onClick={() =>
 | 
			
		||||
                    handleSidebarItemClick(
 | 
			
		||||
                      `/game/${game.shop}/${game.objectID}`
 | 
			
		||||
                    )
 | 
			
		||||
                    handleSidebarItemClick(buildGameDetailsPath(game))
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  <img
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										2
									
								
								src/renderer/src/declaration.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								src/renderer/src/declaration.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -2,6 +2,7 @@ import type {
 | 
			
		|||
  CatalogueCategory,
 | 
			
		||||
  CatalogueEntry,
 | 
			
		||||
  Game,
 | 
			
		||||
  GameRepack,
 | 
			
		||||
  GameShop,
 | 
			
		||||
  HowLongToBeatCategory,
 | 
			
		||||
  ShopDetails,
 | 
			
		||||
| 
						 | 
				
			
			@ -50,6 +51,7 @@ declare global {
 | 
			
		|||
      take?: number,
 | 
			
		||||
      prevCursor?: number
 | 
			
		||||
    ) => Promise<{ results: CatalogueEntry[]; cursor: number }>;
 | 
			
		||||
    searchGameRepacks: (query: string) => Promise<GameRepack[]>;
 | 
			
		||||
 | 
			
		||||
    /* Library */
 | 
			
		||||
    addGameToLibrary: (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,5 @@
 | 
			
		|||
import type { CatalogueEntry } from "@types";
 | 
			
		||||
 | 
			
		||||
export const steamUrlBuilder = {
 | 
			
		||||
  library: (objectID: string) =>
 | 
			
		||||
    `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
 | 
			
		||||
| 
						 | 
				
			
			@ -28,3 +30,11 @@ export const getSteamLanguage = (language: string) => {
 | 
			
		|||
 | 
			
		||||
  return "english";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const buildGameDetailsPath = (
 | 
			
		||||
  game: Pick<CatalogueEntry, "title" | "shop" | "objectID">,
 | 
			
		||||
  params: Record<string, string> = {}
 | 
			
		||||
) => {
 | 
			
		||||
  const searchParams = new URLSearchParams({ title: game.title, ...params });
 | 
			
		||||
  return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,7 @@ import { useEffect, useRef, useState } from "react";
 | 
			
		|||
import { useNavigate, useSearchParams } from "react-router-dom";
 | 
			
		||||
import * as styles from "../home/home.css";
 | 
			
		||||
import { ArrowLeftIcon, ArrowRightIcon } from "@primer/octicons-react";
 | 
			
		||||
import { buildGameDetailsPath } from "@renderer/helpers";
 | 
			
		||||
 | 
			
		||||
export function Catalogue() {
 | 
			
		||||
  const dispatch = useAppDispatch();
 | 
			
		||||
| 
						 | 
				
			
			@ -31,7 +32,7 @@ export function Catalogue() {
 | 
			
		|||
 | 
			
		||||
  const handleGameClick = (game: CatalogueEntry) => {
 | 
			
		||||
    dispatch(clearSearch());
 | 
			
		||||
    navigate(`/game/${game.shop}/${game.objectID}`);
 | 
			
		||||
    navigate(buildGameDetailsPath(game));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,7 +11,7 @@ import * as styles from "./game-details.css";
 | 
			
		|||
const OPEN_HYDRA_URL = "https://open.hydralauncher.site";
 | 
			
		||||
 | 
			
		||||
export interface DescriptionHeaderProps {
 | 
			
		||||
  gameDetails: ShopDetails | null;
 | 
			
		||||
  gameDetails: ShopDetails;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
 | 
			
		||||
| 
						 | 
				
			
			@ -64,7 +64,7 @@ export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
 | 
			
		|||
            date: gameDetails?.release_date.date,
 | 
			
		||||
          })}
 | 
			
		||||
        </p>
 | 
			
		||||
        <p>{t("publisher", { publisher: gameDetails?.publishers[0] })}</p>
 | 
			
		||||
        <p>{t("publisher", { publisher: gameDetails.publishers[0] })}</p>
 | 
			
		||||
      </section>
 | 
			
		||||
 | 
			
		||||
      <Button
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,14 +4,13 @@ import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
 | 
			
		|||
import * as styles from "./gallery-slider.css";
 | 
			
		||||
 | 
			
		||||
export interface GallerySliderProps {
 | 
			
		||||
  gameDetails: ShopDetails | null;
 | 
			
		||||
  gameDetails: ShopDetails;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function GallerySlider({ gameDetails }: GallerySliderProps) {
 | 
			
		||||
  const scrollContainerRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
 | 
			
		||||
  const [mediaCount] = useState<number>(() => {
 | 
			
		||||
    if (gameDetails) {
 | 
			
		||||
    if (gameDetails.screenshots && gameDetails.movies) {
 | 
			
		||||
      return gameDetails.screenshots.length + gameDetails.movies.length;
 | 
			
		||||
    } else if (gameDetails.movies) {
 | 
			
		||||
| 
						 | 
				
			
			@ -19,7 +18,6 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
 | 
			
		|||
    } else if (gameDetails.screenshots) {
 | 
			
		||||
      return gameDetails.screenshots.length;
 | 
			
		||||
    }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return 0;
 | 
			
		||||
  });
 | 
			
		||||
| 
						 | 
				
			
			@ -57,8 +55,8 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
 | 
			
		|||
    }
 | 
			
		||||
  }, [gameDetails, mediaIndex, mediaCount]);
 | 
			
		||||
 | 
			
		||||
  const hasScreenshots = gameDetails && gameDetails.screenshots.length;
 | 
			
		||||
  const hasMovies = gameDetails && gameDetails.movies?.length;
 | 
			
		||||
  const hasScreenshots = gameDetails.screenshots.length;
 | 
			
		||||
  const hasMovies = gameDetails.movies?.length;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
| 
						 | 
				
			
			@ -84,7 +82,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
 | 
			
		|||
                  <source src={video.webm.max.replace("http", "https")} />
 | 
			
		||||
                </video>
 | 
			
		||||
              ))}
 | 
			
		||||
            {gameDetails.screenshots &&
 | 
			
		||||
            {hasScreenshots &&
 | 
			
		||||
              gameDetails.screenshots.map(
 | 
			
		||||
                (image: SteamScreenshot, i: number) => (
 | 
			
		||||
                  <img
 | 
			
		||||
| 
						 | 
				
			
			@ -128,7 +126,8 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
 | 
			
		|||
                  className={`${styles.gallerySliderMediaPreview} ${mediaIndex === i ? styles.gallerySliderMediaPreviewActive : ""}`}
 | 
			
		||||
                />
 | 
			
		||||
              ))}
 | 
			
		||||
            {gameDetails.screenshots &&
 | 
			
		||||
 | 
			
		||||
            {hasScreenshots &&
 | 
			
		||||
              gameDetails.screenshots.map(
 | 
			
		||||
                (image: SteamScreenshot, i: number) => (
 | 
			
		||||
                  <img
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,10 @@
 | 
			
		|||
import Skeleton from "react-loading-skeleton";
 | 
			
		||||
 | 
			
		||||
import { Button } from "@renderer/components";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./game-details.css";
 | 
			
		||||
import * as sidebarStyles from "./sidebar/sidebar.css";
 | 
			
		||||
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { ShareAndroidIcon } from "@primer/octicons-react";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -43,41 +46,41 @@ export function GameDetailsSkeleton() {
 | 
			
		|||
            <Skeleton />
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className={styles.contentSidebar}>
 | 
			
		||||
          <div className={styles.contentSidebarTitle}>
 | 
			
		||||
        <div className={sidebarStyles.contentSidebar}>
 | 
			
		||||
          <div className={sidebarStyles.contentSidebarTitle}>
 | 
			
		||||
            <h3>HowLongToBeat</h3>
 | 
			
		||||
          </div>
 | 
			
		||||
          <ul className={styles.howLongToBeatCategoriesList}>
 | 
			
		||||
          <ul className={sidebarStyles.howLongToBeatCategoriesList}>
 | 
			
		||||
            {Array.from({ length: 3 }).map((_, index) => (
 | 
			
		||||
              <Skeleton
 | 
			
		||||
                key={index}
 | 
			
		||||
                className={styles.howLongToBeatCategorySkeleton}
 | 
			
		||||
                className={sidebarStyles.howLongToBeatCategorySkeleton}
 | 
			
		||||
              />
 | 
			
		||||
            ))}
 | 
			
		||||
          </ul>
 | 
			
		||||
          <div
 | 
			
		||||
            className={styles.contentSidebarTitle}
 | 
			
		||||
            className={sidebarStyles.contentSidebarTitle}
 | 
			
		||||
            style={{ border: "none" }}
 | 
			
		||||
          >
 | 
			
		||||
            <h3>{t("requirements")}</h3>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className={styles.requirementButtonContainer}>
 | 
			
		||||
          <div className={sidebarStyles.requirementButtonContainer}>
 | 
			
		||||
            <Button
 | 
			
		||||
              className={styles.requirementButton}
 | 
			
		||||
              className={sidebarStyles.requirementButton}
 | 
			
		||||
              theme="primary"
 | 
			
		||||
              disabled
 | 
			
		||||
            >
 | 
			
		||||
              {t("minimum")}
 | 
			
		||||
            </Button>
 | 
			
		||||
            <Button
 | 
			
		||||
              className={styles.requirementButton}
 | 
			
		||||
              className={sidebarStyles.requirementButton}
 | 
			
		||||
              theme="outline"
 | 
			
		||||
              disabled
 | 
			
		||||
            >
 | 
			
		||||
              {t("recommended")}
 | 
			
		||||
            </Button>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className={styles.requirementsDetailsSkeleton}>
 | 
			
		||||
          <div className={sidebarStyles.requirementsDetailsSkeleton}>
 | 
			
		||||
            {Array.from({ length: 6 }).map((_, index) => (
 | 
			
		||||
              <Skeleton key={index} height={20} />
 | 
			
		||||
            ))}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -79,62 +79,6 @@ export const descriptionContent = style({
 | 
			
		|||
  height: "100%",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const contentSidebar = style({
 | 
			
		||||
  borderLeft: `solid 1px ${vars.color.border};`,
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  "@media": {
 | 
			
		||||
    "(min-width: 768px)": {
 | 
			
		||||
      width: "100%",
 | 
			
		||||
      maxWidth: "200px",
 | 
			
		||||
    },
 | 
			
		||||
    "(min-width: 1024px)": {
 | 
			
		||||
      maxWidth: "300px",
 | 
			
		||||
      width: "100%",
 | 
			
		||||
    },
 | 
			
		||||
    "(min-width: 1280px)": {
 | 
			
		||||
      width: "100%",
 | 
			
		||||
      maxWidth: "400px",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const contentSidebarTitle = style({
 | 
			
		||||
  height: "72px",
 | 
			
		||||
  padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  backgroundColor: vars.color.background,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const requirementButtonContainer = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const requirementButton = style({
 | 
			
		||||
  border: `solid 1px ${vars.color.border};`,
 | 
			
		||||
  borderLeft: "none",
 | 
			
		||||
  borderRight: "none",
 | 
			
		||||
  borderRadius: "0",
 | 
			
		||||
  width: "100%",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const requirementsDetails = style({
 | 
			
		||||
  padding: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
  lineHeight: "22px",
 | 
			
		||||
  fontFamily: "'Fira Sans', sans-serif",
 | 
			
		||||
  fontSize: "16px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const requirementsDetailsSkeleton = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  gap: "8px",
 | 
			
		||||
  padding: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
  fontSize: "16px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const description = style({
 | 
			
		||||
  userSelect: "text",
 | 
			
		||||
  lineHeight: "22px",
 | 
			
		||||
| 
						 | 
				
			
			@ -183,34 +127,6 @@ export const descriptionHeaderInfo = style({
 | 
			
		|||
  flexDirection: "column",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const howLongToBeatCategoriesList = style({
 | 
			
		||||
  margin: "0",
 | 
			
		||||
  padding: "16px",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  gap: "16px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const howLongToBeatCategory = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  gap: "4px",
 | 
			
		||||
  backgroundColor: vars.color.background,
 | 
			
		||||
  borderRadius: "8px",
 | 
			
		||||
  padding: `8px 16px`,
 | 
			
		||||
  border: `solid 1px ${vars.color.border}`,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const howLongToBeatCategoryLabel = style({
 | 
			
		||||
  color: vars.color.muted,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const howLongToBeatCategorySkeleton = style({
 | 
			
		||||
  border: `solid 1px ${vars.color.border}`,
 | 
			
		||||
  borderRadius: "8px",
 | 
			
		||||
  height: "76px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const randomizerButton = style({
 | 
			
		||||
  animationName: slideIn,
 | 
			
		||||
  animationDuration: "0.2s",
 | 
			
		||||
| 
						 | 
				
			
			@ -260,8 +176,3 @@ globalStyle(`${description} img`, {
 | 
			
		|||
globalStyle(`${description} a`, {
 | 
			
		||||
  color: vars.color.bodyText,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle(`${requirementsDetails} a`, {
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  color: vars.color.bodyText,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,14 +3,7 @@ import { average } from "color.js";
 | 
			
		|||
import { useCallback, useEffect, useState } from "react";
 | 
			
		||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
 | 
			
		||||
 | 
			
		||||
import type {
 | 
			
		||||
  Game,
 | 
			
		||||
  GameRepack,
 | 
			
		||||
  GameShop,
 | 
			
		||||
  HowLongToBeatCategory,
 | 
			
		||||
  ShopDetails,
 | 
			
		||||
  SteamAppDetails,
 | 
			
		||||
} from "@types";
 | 
			
		||||
import type { Game, GameRepack, GameShop, ShopDetails } from "@types";
 | 
			
		||||
 | 
			
		||||
import { Button } from "@renderer/components";
 | 
			
		||||
import { setHeaderTitle } from "@renderer/features";
 | 
			
		||||
| 
						 | 
				
			
			@ -26,7 +19,6 @@ import { DescriptionHeader } from "./description-header";
 | 
			
		|||
import { GameDetailsSkeleton } from "./game-details-skeleton";
 | 
			
		||||
import * as styles from "./game-details.css";
 | 
			
		||||
import { HeroPanel } from "./hero";
 | 
			
		||||
import { HowLongToBeatSection } from "./how-long-to-beat-section";
 | 
			
		||||
import { RepacksModal } from "./repacks-modal";
 | 
			
		||||
 | 
			
		||||
import { vars } from "../../theme.css";
 | 
			
		||||
| 
						 | 
				
			
			@ -37,6 +29,7 @@ import {
 | 
			
		|||
  OnlineFixInstallationGuide,
 | 
			
		||||
} from "./installation-guides";
 | 
			
		||||
import { GallerySlider } from "./gallery-slider";
 | 
			
		||||
import { Sidebar } from "./sidebar/sidebar";
 | 
			
		||||
 | 
			
		||||
export function GameDetails() {
 | 
			
		||||
  const { objectID, shop } = useParams();
 | 
			
		||||
| 
						 | 
				
			
			@ -45,10 +38,7 @@ export function GameDetails() {
 | 
			
		|||
  const [isLoadingRandomGame, setIsLoadingRandomGame] = useState(false);
 | 
			
		||||
  const [color, setColor] = useState({ dark: "", light: "" });
 | 
			
		||||
  const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
 | 
			
		||||
  const [howLongToBeat, setHowLongToBeat] = useState<{
 | 
			
		||||
    isLoading: boolean;
 | 
			
		||||
    data: HowLongToBeatCategory[] | null;
 | 
			
		||||
  }>({ isLoading: true, data: null });
 | 
			
		||||
  const [repacks, setRepacks] = useState<GameRepack[]>([]);
 | 
			
		||||
 | 
			
		||||
  const [game, setGame] = useState<Game | null>(null);
 | 
			
		||||
  const [isGamePlaying, setIsGamePlaying] = useState(false);
 | 
			
		||||
| 
						 | 
				
			
			@ -56,12 +46,12 @@ export function GameDetails() {
 | 
			
		|||
    null | "onlinefix" | "DODI"
 | 
			
		||||
  >(null);
 | 
			
		||||
 | 
			
		||||
  const [activeRequirement, setActiveRequirement] =
 | 
			
		||||
    useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
  const [searchParams] = useSearchParams();
 | 
			
		||||
 | 
			
		||||
  const fromRandomizer = searchParams.get("fromRandomizer");
 | 
			
		||||
  const title = searchParams.get("title")!;
 | 
			
		||||
 | 
			
		||||
  const { t, i18n } = useTranslation("game_details");
 | 
			
		||||
 | 
			
		||||
  const [showRepacksModal, setShowRepacksModal] = useState(false);
 | 
			
		||||
| 
						 | 
				
			
			@ -90,28 +80,24 @@ export function GameDetails() {
 | 
			
		|||
  useEffect(() => {
 | 
			
		||||
    getGame();
 | 
			
		||||
  }, [getGame, gameDownloading?.id]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setGame(null);
 | 
			
		||||
    setIsLoading(true);
 | 
			
		||||
    setIsGamePlaying(false);
 | 
			
		||||
    dispatch(setHeaderTitle(""));
 | 
			
		||||
    dispatch(setHeaderTitle(title));
 | 
			
		||||
 | 
			
		||||
    window.electron
 | 
			
		||||
      .getGameShopDetails(objectID!, "steam", getSteamLanguage(i18n.language))
 | 
			
		||||
      .then((result) => {
 | 
			
		||||
        if (!result) {
 | 
			
		||||
          navigate(-1);
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        window.electron
 | 
			
		||||
          .getHowLongToBeat(objectID!, "steam", result.name)
 | 
			
		||||
          .then((data) => {
 | 
			
		||||
            setHowLongToBeat({ isLoading: false, data });
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
        setGameDetails(result);
 | 
			
		||||
        dispatch(setHeaderTitle(result.name));
 | 
			
		||||
    Promise.all([
 | 
			
		||||
      window.electron.getGameShopDetails(
 | 
			
		||||
        objectID!,
 | 
			
		||||
        "steam",
 | 
			
		||||
        getSteamLanguage(i18n.language)
 | 
			
		||||
      ),
 | 
			
		||||
      window.electron.searchGameRepacks(title),
 | 
			
		||||
    ])
 | 
			
		||||
      .then(([appDetails, repacks]) => {
 | 
			
		||||
        if (appDetails) setGameDetails(appDetails);
 | 
			
		||||
        setRepacks(repacks);
 | 
			
		||||
        setIsLoadingRandomGame(false);
 | 
			
		||||
      })
 | 
			
		||||
      .finally(() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -119,8 +105,7 @@ export function GameDetails() {
 | 
			
		|||
      });
 | 
			
		||||
 | 
			
		||||
    getGame();
 | 
			
		||||
    setHowLongToBeat({ isLoading: true, data: null });
 | 
			
		||||
  }, [getGame, dispatch, navigate, objectID, i18n.language]);
 | 
			
		||||
  }, [getGame, dispatch, navigate, title, objectID, i18n.language]);
 | 
			
		||||
 | 
			
		||||
  const isGameDownloading = gameDownloading?.id === game?.id;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -154,11 +139,10 @@ export function GameDetails() {
 | 
			
		|||
    repack: GameRepack,
 | 
			
		||||
    downloadPath: string
 | 
			
		||||
  ) => {
 | 
			
		||||
    if (gameDetails) {
 | 
			
		||||
    return startDownload(
 | 
			
		||||
      repack.id,
 | 
			
		||||
        gameDetails.objectID,
 | 
			
		||||
        gameDetails.name,
 | 
			
		||||
      objectID!,
 | 
			
		||||
      title,
 | 
			
		||||
      shop as GameShop,
 | 
			
		||||
      downloadPath
 | 
			
		||||
    ).then(() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -177,7 +161,6 @@ export function GameDetails() {
 | 
			
		|||
        setShowInstructionsModal("DODI");
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleRandomizerClick = async () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -191,18 +174,14 @@ export function GameDetails() {
 | 
			
		|||
    navigate(`/game/steam/${randomGameObjectID}?${searchParams.toString()}`);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const fromRandomizer = searchParams.get("fromRandomizer");
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
 | 
			
		||||
      {gameDetails && (
 | 
			
		||||
      <RepacksModal
 | 
			
		||||
        visible={showRepacksModal}
 | 
			
		||||
          gameDetails={gameDetails}
 | 
			
		||||
        repacks={repacks}
 | 
			
		||||
        startDownload={handleStartDownload}
 | 
			
		||||
        onClose={() => setShowRepacksModal(false)}
 | 
			
		||||
      />
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <OnlineFixInstallationGuide
 | 
			
		||||
        visible={showInstructionsModal === "onlinefix"}
 | 
			
		||||
| 
						 | 
				
			
			@ -239,7 +218,9 @@ export function GameDetails() {
 | 
			
		|||
          <HeroPanel
 | 
			
		||||
            game={game}
 | 
			
		||||
            color={color.dark}
 | 
			
		||||
            gameDetails={gameDetails}
 | 
			
		||||
            objectID={objectID!}
 | 
			
		||||
            title={title}
 | 
			
		||||
            repacks={repacks}
 | 
			
		||||
            openRepacksModal={() => setShowRepacksModal(true)}
 | 
			
		||||
            getGame={getGame}
 | 
			
		||||
            isGamePlaying={isGamePlaying}
 | 
			
		||||
| 
						 | 
				
			
			@ -247,63 +228,22 @@ export function GameDetails() {
 | 
			
		|||
 | 
			
		||||
          <div className={styles.descriptionContainer}>
 | 
			
		||||
            <div className={styles.descriptionContent}>
 | 
			
		||||
              <DescriptionHeader gameDetails={gameDetails} />
 | 
			
		||||
 | 
			
		||||
              <GallerySlider gameDetails={gameDetails} />
 | 
			
		||||
              {gameDetails && <DescriptionHeader gameDetails={gameDetails} />}
 | 
			
		||||
              {gameDetails && <GallerySlider gameDetails={gameDetails} />}
 | 
			
		||||
 | 
			
		||||
              <div
 | 
			
		||||
                dangerouslySetInnerHTML={{
 | 
			
		||||
                  __html: gameDetails?.about_the_game ?? "",
 | 
			
		||||
                  __html: gameDetails?.about_the_game ?? t("no_shop_details"),
 | 
			
		||||
                }}
 | 
			
		||||
                className={styles.description}
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div className={styles.contentSidebar}>
 | 
			
		||||
              <HowLongToBeatSection
 | 
			
		||||
                howLongToBeatData={howLongToBeat.data}
 | 
			
		||||
                isLoading={howLongToBeat.isLoading}
 | 
			
		||||
            <Sidebar
 | 
			
		||||
              objectID={objectID!}
 | 
			
		||||
              title={title}
 | 
			
		||||
              gameDetails={gameDetails}
 | 
			
		||||
            />
 | 
			
		||||
 | 
			
		||||
              <div
 | 
			
		||||
                className={styles.contentSidebarTitle}
 | 
			
		||||
                style={{ border: "none" }}
 | 
			
		||||
              >
 | 
			
		||||
                <h3>{t("requirements")}</h3>
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <div className={styles.requirementButtonContainer}>
 | 
			
		||||
                <Button
 | 
			
		||||
                  className={styles.requirementButton}
 | 
			
		||||
                  onClick={() => setActiveRequirement("minimum")}
 | 
			
		||||
                  theme={
 | 
			
		||||
                    activeRequirement === "minimum" ? "primary" : "outline"
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  {t("minimum")}
 | 
			
		||||
                </Button>
 | 
			
		||||
                <Button
 | 
			
		||||
                  className={styles.requirementButton}
 | 
			
		||||
                  onClick={() => setActiveRequirement("recommended")}
 | 
			
		||||
                  theme={
 | 
			
		||||
                    activeRequirement === "recommended" ? "primary" : "outline"
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  {t("recommended")}
 | 
			
		||||
                </Button>
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <div
 | 
			
		||||
                className={styles.requirementsDetails}
 | 
			
		||||
                dangerouslySetInnerHTML={{
 | 
			
		||||
                  __html:
 | 
			
		||||
                    gameDetails?.pc_requirements?.[activeRequirement] ??
 | 
			
		||||
                    t(`no_${activeRequirement}_requirements`, {
 | 
			
		||||
                      title: gameDetails?.name,
 | 
			
		||||
                    }),
 | 
			
		||||
                }}
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </section>
 | 
			
		||||
      )}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,7 @@ import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
 | 
			
		|||
 | 
			
		||||
import { Button } from "@renderer/components";
 | 
			
		||||
import { useDownload, useLibrary } from "@renderer/hooks";
 | 
			
		||||
import type { Game, ShopDetails } from "@types";
 | 
			
		||||
import type { Game, GameRepack } from "@types";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -11,9 +11,11 @@ import * as styles from "./hero-panel-actions.css";
 | 
			
		|||
 | 
			
		||||
export interface HeroPanelActionsProps {
 | 
			
		||||
  game: Game | null;
 | 
			
		||||
  gameDetails: ShopDetails | null;
 | 
			
		||||
  repacks: GameRepack[];
 | 
			
		||||
  isGamePlaying: boolean;
 | 
			
		||||
  isGameDownloading: boolean;
 | 
			
		||||
  objectID: string;
 | 
			
		||||
  title: string;
 | 
			
		||||
  openRepacksModal: () => void;
 | 
			
		||||
  openBinaryNotFoundModal: () => void;
 | 
			
		||||
  getGame: () => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -21,9 +23,11 @@ export interface HeroPanelActionsProps {
 | 
			
		|||
 | 
			
		||||
export function HeroPanelActions({
 | 
			
		||||
  game,
 | 
			
		||||
  gameDetails,
 | 
			
		||||
  isGamePlaying,
 | 
			
		||||
  isGameDownloading,
 | 
			
		||||
  repacks,
 | 
			
		||||
  objectID,
 | 
			
		||||
  title,
 | 
			
		||||
  openRepacksModal,
 | 
			
		||||
  openBinaryNotFoundModal,
 | 
			
		||||
  getGame,
 | 
			
		||||
| 
						 | 
				
			
			@ -69,12 +73,12 @@ export function HeroPanelActions({
 | 
			
		|||
    try {
 | 
			
		||||
      if (game) {
 | 
			
		||||
        await removeGameFromLibrary(game.id);
 | 
			
		||||
      } else if (gameDetails) {
 | 
			
		||||
      } else {
 | 
			
		||||
        const gameExecutablePath = await selectGameExecutable();
 | 
			
		||||
 | 
			
		||||
        await window.electron.addGameToLibrary(
 | 
			
		||||
          gameDetails.objectID,
 | 
			
		||||
          gameDetails.name,
 | 
			
		||||
          objectID,
 | 
			
		||||
          title,
 | 
			
		||||
          "steam",
 | 
			
		||||
          gameExecutablePath
 | 
			
		||||
        );
 | 
			
		||||
| 
						 | 
				
			
			@ -123,7 +127,7 @@ export function HeroPanelActions({
 | 
			
		|||
  const toggleGameOnLibraryButton = (
 | 
			
		||||
    <Button
 | 
			
		||||
      theme="outline"
 | 
			
		||||
      disabled={!gameDetails || toggleLibraryGameDisabled}
 | 
			
		||||
      disabled={toggleLibraryGameDisabled}
 | 
			
		||||
      onClick={toggleGameOnLibrary}
 | 
			
		||||
      className={styles.heroPanelAction}
 | 
			
		||||
    >
 | 
			
		||||
| 
						 | 
				
			
			@ -239,7 +243,7 @@ export function HeroPanelActions({
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (gameDetails && gameDetails.repacks.length) {
 | 
			
		||||
  if (repacks.length) {
 | 
			
		||||
    return (
 | 
			
		||||
      <>
 | 
			
		||||
        {toggleGameOnLibraryButton}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,13 +4,13 @@ import { SPACING_UNIT, vars } from "../../../theme.css";
 | 
			
		|||
export const panel = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "72px",
 | 
			
		||||
  minHeight: "72px",
 | 
			
		||||
  padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  justifyContent: "space-between",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  borderBottom: `solid 1px ${vars.color.border}`,
 | 
			
		||||
  boxShadow: "0px 0px 15px 0px #000000",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const content = style({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,7 @@ import { useMemo, useState } from "react";
 | 
			
		|||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
import { useDownload } from "@renderer/hooks";
 | 
			
		||||
import type { Game, ShopDetails } from "@types";
 | 
			
		||||
import type { Game, GameRepack } from "@types";
 | 
			
		||||
 | 
			
		||||
import { formatDownloadProgress } from "@renderer/helpers";
 | 
			
		||||
import { HeroPanelActions } from "./hero-panel-actions";
 | 
			
		||||
| 
						 | 
				
			
			@ -15,20 +15,24 @@ import { HeroPanelPlaytime } from "./hero-panel-playtime";
 | 
			
		|||
 | 
			
		||||
export interface HeroPanelProps {
 | 
			
		||||
  game: Game | null;
 | 
			
		||||
  gameDetails: ShopDetails | null;
 | 
			
		||||
  color: string;
 | 
			
		||||
  isGamePlaying: boolean;
 | 
			
		||||
  objectID: string;
 | 
			
		||||
  title: string;
 | 
			
		||||
  repacks: GameRepack[];
 | 
			
		||||
  openRepacksModal: () => void;
 | 
			
		||||
  getGame: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function HeroPanel({
 | 
			
		||||
  game,
 | 
			
		||||
  gameDetails,
 | 
			
		||||
  color,
 | 
			
		||||
  repacks,
 | 
			
		||||
  objectID,
 | 
			
		||||
  title,
 | 
			
		||||
  isGamePlaying,
 | 
			
		||||
  openRepacksModal,
 | 
			
		||||
  getGame,
 | 
			
		||||
  isGamePlaying,
 | 
			
		||||
}: HeroPanelProps) {
 | 
			
		||||
  const { t } = useTranslation("game_details");
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -58,7 +62,7 @@ export function HeroPanel({
 | 
			
		|||
  }, [game, isGameDownloading, gameDownloading]);
 | 
			
		||||
 | 
			
		||||
  const getInfo = () => {
 | 
			
		||||
    if (!gameDetails) return null;
 | 
			
		||||
    if (!repacks.length) return null;
 | 
			
		||||
 | 
			
		||||
    if (isGameDeleting(game?.id ?? -1)) {
 | 
			
		||||
      return <p>{t("deleting")}</p>;
 | 
			
		||||
| 
						 | 
				
			
			@ -110,11 +114,11 @@ export function HeroPanel({
 | 
			
		|||
      return <HeroPanelPlaytime game={game} isGamePlaying={isGamePlaying} />;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const [latestRepack] = gameDetails.repacks;
 | 
			
		||||
    const [latestRepack] = repacks;
 | 
			
		||||
 | 
			
		||||
    if (latestRepack) {
 | 
			
		||||
      const lastUpdate = format(latestRepack.uploadDate!, "dd/MM/yyyy");
 | 
			
		||||
      const repacksCount = gameDetails.repacks.length;
 | 
			
		||||
      const repacksCount = repacks.length;
 | 
			
		||||
 | 
			
		||||
      return (
 | 
			
		||||
        <>
 | 
			
		||||
| 
						 | 
				
			
			@ -139,7 +143,9 @@ export function HeroPanel({
 | 
			
		|||
        <div className={styles.actions}>
 | 
			
		||||
          <HeroPanelActions
 | 
			
		||||
            game={game}
 | 
			
		||||
            gameDetails={gameDetails}
 | 
			
		||||
            repacks={repacks}
 | 
			
		||||
            objectID={objectID}
 | 
			
		||||
            title={title}
 | 
			
		||||
            getGame={getGame}
 | 
			
		||||
            openRepacksModal={openRepacksModal}
 | 
			
		||||
            openBinaryNotFoundModal={() => setShowBinaryNotFoundModal(true)}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
 | 
			
		|||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
import { Button, Modal, TextField } from "@renderer/components";
 | 
			
		||||
import type { GameRepack, ShopDetails } from "@types";
 | 
			
		||||
import type { GameRepack } from "@types";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./repacks-modal.css";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -13,14 +13,14 @@ import { SelectFolderModal } from "./select-folder-modal";
 | 
			
		|||
 | 
			
		||||
export interface RepacksModalProps {
 | 
			
		||||
  visible: boolean;
 | 
			
		||||
  gameDetails: ShopDetails;
 | 
			
		||||
  repacks: GameRepack[];
 | 
			
		||||
  startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function RepacksModal({
 | 
			
		||||
  visible,
 | 
			
		||||
  gameDetails,
 | 
			
		||||
  repacks,
 | 
			
		||||
  startDownload,
 | 
			
		||||
  onClose,
 | 
			
		||||
}: RepacksModalProps) {
 | 
			
		||||
| 
						 | 
				
			
			@ -35,8 +35,8 @@ export function RepacksModal({
 | 
			
		|||
  const { t } = useTranslation("game_details");
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setFilteredRepacks(gameDetails.repacks);
 | 
			
		||||
  }, [gameDetails.repacks, visible]);
 | 
			
		||||
    setFilteredRepacks(repacks);
 | 
			
		||||
  }, [repacks, visible]);
 | 
			
		||||
 | 
			
		||||
  const handleRepackClick = (repack: GameRepack) => {
 | 
			
		||||
    setRepack(repack);
 | 
			
		||||
| 
						 | 
				
			
			@ -47,7 +47,7 @@ export function RepacksModal({
 | 
			
		|||
    const term = event.target.value.toLocaleLowerCase();
 | 
			
		||||
 | 
			
		||||
    setFilteredRepacks(
 | 
			
		||||
      gameDetails.repacks.filter((repack) => {
 | 
			
		||||
      repacks.filter((repack) => {
 | 
			
		||||
        const lowerCaseTitle = repack.title.toLowerCase();
 | 
			
		||||
        const lowerCaseRepacker = repack.repacker.toLowerCase();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -63,14 +63,13 @@ export function RepacksModal({
 | 
			
		|||
      <SelectFolderModal
 | 
			
		||||
        visible={showSelectFolderModal}
 | 
			
		||||
        onClose={() => setShowSelectFolderModal(false)}
 | 
			
		||||
        gameDetails={gameDetails}
 | 
			
		||||
        startDownload={startDownload}
 | 
			
		||||
        repack={repack}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <Modal
 | 
			
		||||
        visible={visible}
 | 
			
		||||
        title={`${gameDetails.name} Repacks`}
 | 
			
		||||
        title={`Download options`}
 | 
			
		||||
        description={t("repacks_modal_description")}
 | 
			
		||||
        onClose={onClose}
 | 
			
		||||
      >
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import { Button, Link, Modal, TextField } from "@renderer/components";
 | 
			
		||||
import { GameRepack, ShopDetails } from "@types";
 | 
			
		||||
import type { GameRepack } from "@types";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { Trans, useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -10,7 +10,6 @@ import { formatBytes } from "@shared";
 | 
			
		|||
 | 
			
		||||
export interface SelectFolderModalProps {
 | 
			
		||||
  visible: boolean;
 | 
			
		||||
  gameDetails: ShopDetails;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
 | 
			
		||||
  repack: GameRepack | null;
 | 
			
		||||
| 
						 | 
				
			
			@ -18,7 +17,6 @@ export interface SelectFolderModalProps {
 | 
			
		|||
 | 
			
		||||
export function SelectFolderModal({
 | 
			
		||||
  visible,
 | 
			
		||||
  gameDetails,
 | 
			
		||||
  onClose,
 | 
			
		||||
  startDownload,
 | 
			
		||||
  repack,
 | 
			
		||||
| 
						 | 
				
			
			@ -74,7 +72,7 @@ export function SelectFolderModal({
 | 
			
		|||
  return (
 | 
			
		||||
    <Modal
 | 
			
		||||
      visible={visible}
 | 
			
		||||
      title={t("installation_folder", { name: gameDetails.name })}
 | 
			
		||||
      title={t("installation_folder")}
 | 
			
		||||
      description={t("space_left_on_disk", {
 | 
			
		||||
        space: formatBytes(diskFreeSpace?.free ?? 0),
 | 
			
		||||
      })}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,8 +1,8 @@
 | 
			
		|||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import type { HowLongToBeatCategory } from "@types";
 | 
			
		||||
import { vars } from "../../theme.css";
 | 
			
		||||
import * as styles from "./game-details.css";
 | 
			
		||||
import { vars } from "../../../theme.css";
 | 
			
		||||
import * as styles from "./sidebar.css";
 | 
			
		||||
 | 
			
		||||
const durationTranslation: Record<string, string> = {
 | 
			
		||||
  Hours: "hours",
 | 
			
		||||
							
								
								
									
										92
									
								
								src/renderer/src/pages/game-details/sidebar/sidebar.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/renderer/src/pages/game-details/sidebar/sidebar.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,92 @@
 | 
			
		|||
import { globalStyle, style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const contentSidebar = style({
 | 
			
		||||
  borderLeft: `solid 1px ${vars.color.border};`,
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  "@media": {
 | 
			
		||||
    "(min-width: 768px)": {
 | 
			
		||||
      width: "100%",
 | 
			
		||||
      maxWidth: "200px",
 | 
			
		||||
    },
 | 
			
		||||
    "(min-width: 1024px)": {
 | 
			
		||||
      maxWidth: "300px",
 | 
			
		||||
      width: "100%",
 | 
			
		||||
    },
 | 
			
		||||
    "(min-width: 1280px)": {
 | 
			
		||||
      width: "100%",
 | 
			
		||||
      maxWidth: "400px",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const contentSidebarTitle = style({
 | 
			
		||||
  height: "72px",
 | 
			
		||||
  padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  backgroundColor: vars.color.background,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const requirementButtonContainer = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const requirementButton = style({
 | 
			
		||||
  border: `solid 1px ${vars.color.border};`,
 | 
			
		||||
  borderLeft: "none",
 | 
			
		||||
  borderRight: "none",
 | 
			
		||||
  borderRadius: "0",
 | 
			
		||||
  width: "100%",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const requirementsDetails = style({
 | 
			
		||||
  padding: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
  lineHeight: "22px",
 | 
			
		||||
  fontFamily: "'Fira Sans', sans-serif",
 | 
			
		||||
  fontSize: "16px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const requirementsDetailsSkeleton = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  gap: "8px",
 | 
			
		||||
  padding: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
  fontSize: "16px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const howLongToBeatCategoriesList = style({
 | 
			
		||||
  margin: "0",
 | 
			
		||||
  padding: "16px",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  gap: "16px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const howLongToBeatCategory = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  gap: "4px",
 | 
			
		||||
  backgroundColor: vars.color.background,
 | 
			
		||||
  borderRadius: "8px",
 | 
			
		||||
  padding: `8px 16px`,
 | 
			
		||||
  border: `solid 1px ${vars.color.border}`,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const howLongToBeatCategoryLabel = style({
 | 
			
		||||
  color: vars.color.muted,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const howLongToBeatCategorySkeleton = style({
 | 
			
		||||
  border: `solid 1px ${vars.color.border}`,
 | 
			
		||||
  borderRadius: "8px",
 | 
			
		||||
  height: "76px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle(`${requirementsDetails} a`, {
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  color: vars.color.bodyText,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										84
									
								
								src/renderer/src/pages/game-details/sidebar/sidebar.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/renderer/src/pages/game-details/sidebar/sidebar.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,84 @@
 | 
			
		|||
import { useEffect, useState } from "react";
 | 
			
		||||
import { HowLongToBeatSection } from "./how-long-to-beat-section";
 | 
			
		||||
import type {
 | 
			
		||||
  HowLongToBeatCategory,
 | 
			
		||||
  ShopDetails,
 | 
			
		||||
  SteamAppDetails,
 | 
			
		||||
} from "@types";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { Button } from "@renderer/components";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./sidebar.css";
 | 
			
		||||
 | 
			
		||||
export interface SidebarProps {
 | 
			
		||||
  objectID: string;
 | 
			
		||||
  title: string;
 | 
			
		||||
  gameDetails: ShopDetails | null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
 | 
			
		||||
  const [howLongToBeat, setHowLongToBeat] = useState<{
 | 
			
		||||
    isLoading: boolean;
 | 
			
		||||
    data: HowLongToBeatCategory[] | null;
 | 
			
		||||
  }>({ isLoading: true, data: null });
 | 
			
		||||
 | 
			
		||||
  const [activeRequirement, setActiveRequirement] =
 | 
			
		||||
    useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
 | 
			
		||||
 | 
			
		||||
  const { t } = useTranslation("game_details");
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setHowLongToBeat({ isLoading: true, data: null });
 | 
			
		||||
 | 
			
		||||
    window.electron
 | 
			
		||||
      .getHowLongToBeat(objectID, "steam", title)
 | 
			
		||||
      .then((howLongToBeat) => {
 | 
			
		||||
        setHowLongToBeat({ isLoading: false, data: howLongToBeat });
 | 
			
		||||
      })
 | 
			
		||||
      .catch(() => {
 | 
			
		||||
        setHowLongToBeat({ isLoading: false, data: null });
 | 
			
		||||
      });
 | 
			
		||||
  }, [objectID, title]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <aside className={styles.contentSidebar}>
 | 
			
		||||
      <HowLongToBeatSection
 | 
			
		||||
        howLongToBeatData={howLongToBeat.data}
 | 
			
		||||
        isLoading={howLongToBeat.isLoading}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <div className={styles.contentSidebarTitle} style={{ border: "none" }}>
 | 
			
		||||
        <h3>{t("requirements")}</h3>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className={styles.requirementButtonContainer}>
 | 
			
		||||
        <Button
 | 
			
		||||
          className={styles.requirementButton}
 | 
			
		||||
          onClick={() => setActiveRequirement("minimum")}
 | 
			
		||||
          theme={activeRequirement === "minimum" ? "primary" : "outline"}
 | 
			
		||||
        >
 | 
			
		||||
          {t("minimum")}
 | 
			
		||||
        </Button>
 | 
			
		||||
 | 
			
		||||
        <Button
 | 
			
		||||
          className={styles.requirementButton}
 | 
			
		||||
          onClick={() => setActiveRequirement("recommended")}
 | 
			
		||||
          theme={activeRequirement === "recommended" ? "primary" : "outline"}
 | 
			
		||||
        >
 | 
			
		||||
          {t("recommended")}
 | 
			
		||||
        </Button>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div
 | 
			
		||||
        className={styles.requirementsDetails}
 | 
			
		||||
        dangerouslySetInnerHTML={{
 | 
			
		||||
          __html:
 | 
			
		||||
            gameDetails?.pc_requirements?.[activeRequirement] ??
 | 
			
		||||
            t(`no_${activeRequirement}_requirements`, {
 | 
			
		||||
              title,
 | 
			
		||||
            }),
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    </aside>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -12,6 +12,7 @@ import starsAnimation from "@renderer/assets/lottie/stars.json";
 | 
			
		|||
import * as styles from "./home.css";
 | 
			
		||||
import { vars } from "../../theme.css";
 | 
			
		||||
import Lottie from "lottie-react";
 | 
			
		||||
import { buildGameDetailsPath } from "@renderer/helpers";
 | 
			
		||||
 | 
			
		||||
const categories: CatalogueCategory[] = ["trending", "recently_added"];
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -129,9 +130,7 @@ export function Home() {
 | 
			
		|||
                <GameCard
 | 
			
		||||
                  key={result.objectID}
 | 
			
		||||
                  game={result}
 | 
			
		||||
                  onClick={() =>
 | 
			
		||||
                    navigate(`/game/${result.shop}/${result.objectID}`)
 | 
			
		||||
                  }
 | 
			
		||||
                  onClick={() => navigate(buildGameDetailsPath(result))}
 | 
			
		||||
                />
 | 
			
		||||
              ))}
 | 
			
		||||
        </section>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,6 +14,7 @@ import { useEffect, useRef, useState } from "react";
 | 
			
		|||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { useNavigate, useSearchParams } from "react-router-dom";
 | 
			
		||||
import * as styles from "./home.css";
 | 
			
		||||
import { buildGameDetailsPath } from "@renderer/helpers";
 | 
			
		||||
 | 
			
		||||
export function SearchResults() {
 | 
			
		||||
  const dispatch = useAppDispatch();
 | 
			
		||||
| 
						 | 
				
			
			@ -30,7 +31,7 @@ export function SearchResults() {
 | 
			
		|||
 | 
			
		||||
  const handleGameClick = (game: CatalogueEntry) => {
 | 
			
		||||
    dispatch(clearSearch());
 | 
			
		||||
    navigate(`/game/${game.shop}/${game.objectID}`);
 | 
			
		||||
    navigate(buildGameDetailsPath(game));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -69,7 +69,6 @@ export interface GameRepack {
 | 
			
		|||
 | 
			
		||||
export type ShopDetails = SteamAppDetails & {
 | 
			
		||||
  objectID: string;
 | 
			
		||||
  repacks: GameRepack[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface TorrentFile {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue