Skip to content

Commit

Permalink
feat: adding how long to beat integration
Browse files Browse the repository at this point in the history
  • Loading branch information
thegrannychaseroperation committed Apr 14, 2024
1 parent 034ea6d commit 5580a9d
Show file tree
Hide file tree
Showing 14 changed files with 282 additions and 24 deletions.
25 changes: 25 additions & 0 deletions src/main/events/catalogue/get-how-long-to-beat.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, { time: string; color: string }> | 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,
});
1 change: 1 addition & 0 deletions src/main/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
8 changes: 5 additions & 3 deletions src/main/helpers/formatters.ts
Original file line number Diff line number Diff line change
@@ -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,
""
);

Expand Down
67 changes: 67 additions & 0 deletions src/main/services/how-long-to-beat.ts
Original file line number Diff line number Diff line change
@@ -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],
},
};
}, {});
};
1 change: 1 addition & 0 deletions src/main/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
2 changes: 2 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
63 changes: 60 additions & 3 deletions src/renderer/components/header/header.css.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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)",
},
},
},
});
36 changes: 29 additions & 7 deletions src/renderer/components/header/header.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -24,13 +24,14 @@ const pathTitle: Record<string, string> = {
export function Header({ onSearch, onClear, search }: HeaderProps) {
const inputRef = useRef<HTMLInputElement>(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");
Expand All @@ -56,16 +57,37 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
setIsFocused(false);
};

const handleBackButtonClick = () => {
navigate(-1);
};

return (
<header
className={styles.header({
draggingDisabled,
isWindows: window.electron.platform === "win32",
})}
>
<h3>{title}</h3>

<section className={styles.leftContent}>
<div className={styles.section}>
<button
type="button"
className={styles.backButton({ enabled: location.key !== "default" })}
onClick={handleBackButtonClick}
disabled={location.key === "default"}
>
<ArrowLeftIcon />
</button>

<h3
className={styles.title({
hasBackButton: location.key !== "default",
})}
>
{title}
</h3>
</div>

<section className={styles.section}>
<div className={styles.search({ focused: isFocused })}>
<button
type="button"
Expand Down
12 changes: 10 additions & 2 deletions src/renderer/components/sidebar/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ export function Sidebar() {
return game.title;
};

const handleSidebarItemClick = (path: string) => {
if (path !== location.pathname) {
navigate(path);
}
};

return (
<aside
ref={sidebarRef}
Expand Down Expand Up @@ -146,7 +152,7 @@ export function Sidebar() {
<button
type="button"
className={styles.menuItemButton}
onClick={() => navigate(path)}
onClick={() => handleSidebarItemClick(path)}
>
<Icon />
<span>{t(nameKey)}</span>
Expand Down Expand Up @@ -179,7 +185,9 @@ export function Sidebar() {
type="button"
className={styles.menuItemButton}
onClick={() =>
navigate(`/game/${game.shop}/${game.objectID}`)
handleSidebarItemClick(
`/game/${game.shop}/${game.objectID}`
)
}
>
<AsyncImage className={styles.gameIcon} src={game.iconUrl} />
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/declaration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ declare global {
language: string
) => Promise<ShopDetails | null>;
getRandomGame: () => Promise<string>;
getHowLongToBeat: (
objectID: string,
shop: GameShop,
title: string
) => Promise<Record<string, { time: string; color: string }> | null>;

/* Library */
getLibrary: () => Promise<Game[]>;
Expand Down
2 changes: 0 additions & 2 deletions src/renderer/features/search-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ export const searchSlice = createSlice({
},
clearSearch: (state) => {
state.value = "";
state.results = [];
state.isLoading = false;
},
setSearchResults: (state, action: PayloadAction<CatalogueEntry[]>) => {
state.isLoading = false;
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/pages/catalogue/search-results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function SearchResults() {

const handleGameClick = (game: CatalogueEntry) => {
dispatch(clearSearch());
navigate(`/game/${game.shop}/${game.objectID}`, { replace: true });
navigate(`/game/${game.shop}/${game.objectID}`);
};

return (
Expand Down
9 changes: 5 additions & 4 deletions src/renderer/pages/game-details/game-details.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const descriptionContent = style({
height: "100%",
});

export const requirements = style({
export const contentSidebar = style({
borderLeft: `solid 1px ${vars.color.borderColor};`,
width: "100%",
height: "100%",
Expand All @@ -83,12 +83,13 @@ export const requirements = style({
},
});

export const requirementsHeader = style({
height: "71px",
export const contentSidebarTitle = style({
height: "72px",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
display: "flex",
alignItems: "center",
backgroundColor: vars.color.background,
borderBottom: `solid 1px ${vars.color.borderColor}`,
});

export const requirementButtonContainer = style({
Expand All @@ -105,7 +106,7 @@ export const requirementButton = style({
});

export const requirementsDetails = style({
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
padding: `${SPACING_UNIT * 2}px`,
lineHeight: "22px",
fontFamily: "'Fira Sans', sans-serif",
fontSize: "16px",
Expand Down
Loading

0 comments on commit 5580a9d

Please sign in to comment.