From 5580a9de38663d81739a2e49a4602ef7ac5c0a7f Mon Sep 17 00:00:00 2001 From: Hydra Date: Sun, 14 Apr 2024 07:19:24 +0100 Subject: [PATCH] feat: adding how long to beat integration --- .../events/catalogue/get-how-long-to-beat.ts | 25 +++++++ src/main/events/index.ts | 1 + src/main/helpers/formatters.ts | 8 +- src/main/services/how-long-to-beat.ts | 67 +++++++++++++++++ src/main/services/index.ts | 1 + src/preload.ts | 2 + src/renderer/components/header/header.css.ts | 63 +++++++++++++++- src/renderer/components/header/header.tsx | 34 +++++++-- src/renderer/components/sidebar/sidebar.tsx | 12 ++- src/renderer/declaration.d.ts | 5 ++ src/renderer/features/search-slice.ts | 2 - .../pages/catalogue/search-results.tsx | 2 +- .../pages/game-details/game-details.css.ts | 9 ++- .../pages/game-details/game-details.tsx | 73 ++++++++++++++++++- 14 files changed, 281 insertions(+), 23 deletions(-) create mode 100644 src/main/events/catalogue/get-how-long-to-beat.ts create mode 100644 src/main/services/how-long-to-beat.ts diff --git a/src/main/events/catalogue/get-how-long-to-beat.ts b/src/main/events/catalogue/get-how-long-to-beat.ts new file mode 100644 index 00000000..7d15a34e --- /dev/null +++ b/src/main/events/catalogue/get-how-long-to-beat.ts @@ -0,0 +1,25 @@ +import type { GameShop } from "@types"; +import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services"; + +import { registerEvent } from "../register-event"; + +const getHowLongToBeat = async ( + _event: Electron.IpcMainInvokeEvent, + objectID: string, + _shop: GameShop, + title: string +): Promise | null> => { + const response = await searchHowLongToBeat(title); + const game = response.data.find( + (game) => game.profile_steam === Number(objectID) + ); + + if (!game) return null; + const howLongToBeat = await getHowLongToBeatGame(String(game.game_id)); + return howLongToBeat; +}; + +registerEvent(getHowLongToBeat, { + name: "getHowLongToBeat", + memoize: true, +}); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 8f10a260..4910fbc5 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -20,6 +20,7 @@ import "./misc/show-open-dialog"; import "./library/remove-game"; import "./library/delete-game-folder"; import "./catalogue/get-random-game"; +import "./catalogue/get-how-long-to-beat"; ipcMain.handle("ping", () => "pong"); ipcMain.handle("getVersion", () => app.getVersion()); diff --git a/src/main/helpers/formatters.ts b/src/main/helpers/formatters.ts index a7eade07..ee2f5238 100644 --- a/src/main/helpers/formatters.ts +++ b/src/main/helpers/formatters.ts @@ -1,12 +1,14 @@ /* String formatting */ -export const removeReleaseYearFromName = (name: string) => name; +export const removeReleaseYearFromName = (name: string) => + name.replace(/\([0-9]{4}\)/g, ""); -export const removeSymbolsFromName = (name: string) => name; +export const removeSymbolsFromName = (name: string) => + name.replace(/[^A-Za-z 0-9]/g, ""); export const removeSpecialEditionFromName = (name: string) => name.replace( - /(The |Digital )?(Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited) Edition/g, + /(The |Digital )?(Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited|Game of the Year) Edition/g, "" ); diff --git a/src/main/services/how-long-to-beat.ts b/src/main/services/how-long-to-beat.ts new file mode 100644 index 00000000..c6b42197 --- /dev/null +++ b/src/main/services/how-long-to-beat.ts @@ -0,0 +1,67 @@ +import { formatName } from "@main/helpers"; +import axios from "axios"; +import { JSDOM } from "jsdom"; +import { requestWebPage } from "./repack-tracker/helpers"; + +export interface HowLongToBeatResult { + game_id: number; + profile_steam: number; +} + +export interface HowLongToBeatSearchResponse { + data: HowLongToBeatResult[]; +} + +export const searchHowLongToBeat = async (gameName: string) => { + const response = await axios.post( + "https://howlongtobeat.com/api/search", + { + searchType: "games", + searchTerms: formatName(gameName).split(" "), + searchPage: 1, + size: 100, + }, + { + headers: { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + Referer: "https://howlongtobeat.com/", + }, + } + ); + + return response.data as HowLongToBeatSearchResponse; +}; + +export const classNameColor = { + time_40: "#ff3a3a", + time_50: "#cc3b51", + time_60: "#824985", + time_70: "#5650a1", + time_80: "#485cab", + time_90: "#3a6db5", + time_100: "#287fc2", +}; + +export const getHowLongToBeatGame = async (id: string) => { + const response = await requestWebPage(`https://howlongtobeat.com/game/${id}`); + + const { window } = new JSDOM(response); + const { document } = window; + + const $ul = document.querySelector(".shadow_shadow ul"); + const $lis = Array.from($ul.children); + + return $lis.reduce((prev, next) => { + const name = next.querySelector("h4").textContent; + const [, time] = Array.from((next as HTMLElement).classList); + + return { + ...prev, + [name]: { + time: next.querySelector("h5").textContent, + color: classNameColor[time as keyof typeof classNameColor], + }, + }; + }, {}); +}; diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 33de99a7..59b5c034 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -8,3 +8,4 @@ export * from "./update-resolver"; export * from "./window-manager"; export * from "./fifo"; export * from "./torrent-client"; +export * from "./how-long-to-beat"; diff --git a/src/preload.ts b/src/preload.ts index 550c2c31..939f87ca 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -39,6 +39,8 @@ contextBridge.exposeInMainWorld("electron", { 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), /* User preferences */ getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"), diff --git a/src/renderer/components/header/header.css.ts b/src/renderer/components/header/header.css.ts index c7e08f0c..1a9d5073 100644 --- a/src/renderer/components/header/header.css.ts +++ b/src/renderer/components/header/header.css.ts @@ -1,7 +1,24 @@ import type { ComplexStyleRule } from "@vanilla-extract/css"; -import { style } from "@vanilla-extract/css"; +import { keyframes, style } from "@vanilla-extract/css"; import { recipe } from "@vanilla-extract/recipes"; -import { SPACING_UNIT, vars } from "../../theme.css"; + +import { SPACING_UNIT, vars } from "@renderer/theme.css"; + +export const slideIn = keyframes({ + "0%": { transform: "translateX(20px)", opacity: "0" }, + "100%": { + transform: "translateX(0)", + opacity: "1", + }, +}); + +export const slideOut = keyframes({ + "0%": { transform: "translateX(0px)", opacity: "1" }, + "100%": { + transform: "translateX(20px)", + opacity: "0", + }, +}); export const header = recipe({ base: { @@ -83,9 +100,49 @@ export const actionButton = style({ }, }); -export const leftContent = style({ +export const section = style({ display: "flex", alignItems: "center", gap: `${SPACING_UNIT * 2}px`, height: "100%", }); + +export const backButton = recipe({ + base: { + color: vars.color.bodyText, + cursor: "pointer", + WebkitAppRegion: "no-drag", + position: "absolute", + transition: "transform ease 0.2s", + animationDuration: "0.2s", + width: "16px", + height: "16px", + display: "flex", + alignItems: "center", + } as ComplexStyleRule, + variants: { + enabled: { + true: { + animationName: slideIn, + }, + false: { + opacity: "0", + pointerEvents: "none", + animationName: slideOut, + }, + }, + }, +}); + +export const title = recipe({ + base: { + transition: "all ease 0.2s", + }, + variants: { + hasBackButton: { + true: { + transform: "translateX(28px)", + }, + }, + }, +}); diff --git a/src/renderer/components/header/header.tsx b/src/renderer/components/header/header.tsx index 9a0d597d..9e07c4a4 100644 --- a/src/renderer/components/header/header.tsx +++ b/src/renderer/components/header/header.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { useEffect, useMemo, useRef, useState } from "react"; -import { useLocation } from "react-router-dom"; -import { SearchIcon, XIcon } from "@primer/octicons-react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react"; import { useAppDispatch, useAppSelector } from "@renderer/hooks"; @@ -24,13 +24,14 @@ const pathTitle: Record = { export function Header({ onSearch, onClear, search }: HeaderProps) { const inputRef = useRef(null); + const navigate = useNavigate(); + const location = useLocation(); + const { headerTitle, draggingDisabled } = useAppSelector( (state) => state.window ); const dispatch = useAppDispatch(); - const location = useLocation(); - const [isFocused, setIsFocused] = useState(false); const { t } = useTranslation("header"); @@ -56,6 +57,10 @@ export function Header({ onSearch, onClear, search }: HeaderProps) { setIsFocused(false); }; + const handleBackButtonClick = () => { + navigate(-1); + }; + return (
-

{title}

+
+ -
+

+ {title} +

+
+ +
-
-
+ +
+ {howLongToBeat && ( + <> +
+

HowLongToBeat

+
+ +
+ {Object.entries(howLongToBeat).map(([key, value]) => ( +
+

+ {key} +

+

+ {value.time} +

+
+ ))} +
+ + )} + +

{t("requirements")}