mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-09 03:37:45 +03:00
feat: adding how long to beat integration
This commit is contained in:
parent
034ea6d817
commit
5580a9de38
25
src/main/events/catalogue/get-how-long-to-beat.ts
Normal file
25
src/main/events/catalogue/get-how-long-to-beat.ts
Normal file
@ -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,
|
||||
});
|
@ -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());
|
||||
|
@ -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,
|
||||
""
|
||||
);
|
||||
|
||||
|
67
src/main/services/how-long-to-beat.ts
Normal file
67
src/main/services/how-long-to-beat.ts
Normal file
@ -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],
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
};
|
@ -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";
|
||||
|
@ -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"),
|
||||
|
@ -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)",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -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<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");
|
||||
@ -56,6 +57,10 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
const handleBackButtonClick = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={styles.header({
|
||||
@ -63,9 +68,26 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||
isWindows: window.electron.platform === "win32",
|
||||
})}
|
||||
>
|
||||
<h3>{title}</h3>
|
||||
<div className={styles.section}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton({ enabled: location.key !== "default" })}
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={location.key === "default"}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
|
||||
<section className={styles.leftContent}>
|
||||
<h3
|
||||
className={styles.title({
|
||||
hasBackButton: location.key !== "default",
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className={styles.section}>
|
||||
<div className={styles.search({ focused: isFocused })}>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -115,6 +115,12 @@ export function Sidebar() {
|
||||
return game.title;
|
||||
};
|
||||
|
||||
const handleSidebarItemClick = (path: string) => {
|
||||
if (path !== location.pathname) {
|
||||
navigate(path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
@ -146,7 +152,7 @@ export function Sidebar() {
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItemButton}
|
||||
onClick={() => navigate(path)}
|
||||
onClick={() => handleSidebarItemClick(path)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{t(nameKey)}</span>
|
||||
@ -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} />
|
||||
|
5
src/renderer/declaration.d.ts
vendored
5
src/renderer/declaration.d.ts
vendored
@ -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[]>;
|
||||
|
@ -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;
|
||||
|
@ -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 (
|
||||
|
@ -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%",
|
||||
@ -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({
|
||||
@ -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",
|
||||
|
@ -15,6 +15,7 @@ import { RepacksModal } from "./repacks-modal";
|
||||
import { HeroPanel } from "./hero-panel";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShareAndroidIcon } from "@primer/octicons-react";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
|
||||
const OPEN_HYDRA_URL = "https://open.hydralauncher.site";
|
||||
|
||||
@ -24,6 +25,13 @@ export function GameDetails() {
|
||||
const [color, setColor] = useState("");
|
||||
const [clipboardLock, setClipboardLock] = useState(false);
|
||||
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
|
||||
const [howLongToBeat, setHowLongToBeat] = useState<Record<
|
||||
string,
|
||||
{ time: string; color: string }
|
||||
> | null>(null);
|
||||
|
||||
console.log(howLongToBeat);
|
||||
|
||||
const [game, setGame] = useState<Game | null>(null);
|
||||
const [activeRequirement, setActiveRequirement] =
|
||||
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
||||
@ -67,11 +75,18 @@ export function GameDetails() {
|
||||
return;
|
||||
}
|
||||
|
||||
window.electron
|
||||
.getHowLongToBeat(objectID, "steam", result.name)
|
||||
.then((data) => {
|
||||
setHowLongToBeat(data);
|
||||
});
|
||||
|
||||
setGameDetails(result);
|
||||
dispatch(setHeaderTitle(result.name));
|
||||
});
|
||||
|
||||
getGame();
|
||||
setHowLongToBeat(null);
|
||||
}, [getGame, dispatch, navigate, objectID, i18n.language]);
|
||||
|
||||
const handleCopyToClipboard = () => {
|
||||
@ -196,8 +211,62 @@ export function GameDetails() {
|
||||
className={styles.description}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.requirements}>
|
||||
<div className={styles.requirementsHeader}>
|
||||
|
||||
<div className={styles.contentSidebar}>
|
||||
{howLongToBeat && (
|
||||
<>
|
||||
<div className={styles.contentSidebarTitle}>
|
||||
<h3>HowLongToBeat</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{Object.entries(howLongToBeat).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
backgroundColor: value.color ?? vars.color.background,
|
||||
borderRadius: 8,
|
||||
padding: `8px 16px`,
|
||||
border: `solid 1px ${vars.color.borderColor}`,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: vars.size.bodyFontSize,
|
||||
color: "#DADBE1",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: vars.size.bodyFontSize,
|
||||
color: "#DADBE1",
|
||||
}}
|
||||
>
|
||||
{value.time}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={styles.contentSidebarTitle}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<h3>{t("requirements")}</h3>
|
||||
</div>
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user