mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-09 11:42:21 +03:00
Merge pull request #10 from hydralauncher/feature/adding-how-long-to-beat-integration
feat: adding how long to beat integration
This commit is contained in:
commit
ef035e46f8
5
.prettierrc.js
Normal file
5
.prettierrc.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
semi: true,
|
||||||
|
trailingComma: "all",
|
||||||
|
singleQuote: false,
|
||||||
|
};
|
@ -1,6 +1,8 @@
|
|||||||
# Hydra
|
# Hydra
|
||||||
|
|
||||||
<a href="https://discord.gg/hydralauncher" target="_blank">![Discord](https://img.shields.io/discord/1220692017311645737?style=flat&logo=discord&label=Hydra&labelColor=%231c1c1c)</a>
|
<a href="https://discord.gg/hydralauncher" target="_blank">![Discord](https://img.shields.io/discord/1220692017311645737?style=flat&logo=discord&label=Hydra&labelColor=%231c1c1c)</a>
|
||||||
|
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)
|
||||||
|
![GitHub package.json version](https://img.shields.io/github/package-json/v/hydralauncher/hydra)
|
||||||
|
|
||||||
Hydra is a game launcher with its own embedded bittorrent client and a self-managed repack scraper.
|
Hydra is a game launcher with its own embedded bittorrent client and a self-managed repack scraper.
|
||||||
The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using [libtorrent](https://www.libtorrent.org/).
|
The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using [libtorrent](https://www.libtorrent.org/).
|
||||||
|
@ -57,7 +57,10 @@
|
|||||||
"release_date": "Released in {{date}}",
|
"release_date": "Released in {{date}}",
|
||||||
"publisher": "Published by {{publisher}}",
|
"publisher": "Published by {{publisher}}",
|
||||||
"copy_link_to_clipboard": "Copy link",
|
"copy_link_to_clipboard": "Copy link",
|
||||||
"copied_link_to_clipboard": "Link copied"
|
"copied_link_to_clipboard": "Link copied",
|
||||||
|
"hours": "hours",
|
||||||
|
"minutes": "minutes",
|
||||||
|
"accuracy": "{{accuracy}}% accuracy"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Activate Hydra",
|
"title": "Activate Hydra",
|
||||||
|
@ -57,7 +57,10 @@
|
|||||||
"release_date": "Fecha de lanzamiento {{date}}",
|
"release_date": "Fecha de lanzamiento {{date}}",
|
||||||
"publisher": "Publicado por {{publisher}}",
|
"publisher": "Publicado por {{publisher}}",
|
||||||
"copy_link_to_clipboard": "Copiar enlace",
|
"copy_link_to_clipboard": "Copiar enlace",
|
||||||
"copied_link_to_clipboard": "Enlace copiado"
|
"copied_link_to_clipboard": "Enlace copiado",
|
||||||
|
"hours": "horas",
|
||||||
|
"minutes": "minutos",
|
||||||
|
"accuracy": "{{accuracy}}% precisión"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Activar Hydra",
|
"title": "Activar Hydra",
|
||||||
|
@ -57,7 +57,10 @@
|
|||||||
"release_date": "Lançado em {{date}}",
|
"release_date": "Lançado em {{date}}",
|
||||||
"publisher": "Publicado por {{publisher}}",
|
"publisher": "Publicado por {{publisher}}",
|
||||||
"copy_link_to_clipboard": "Copiar link",
|
"copy_link_to_clipboard": "Copiar link",
|
||||||
"copied_link_to_clipboard": "Link copiado"
|
"copied_link_to_clipboard": "Link copiado",
|
||||||
|
"hours": "horas",
|
||||||
|
"minutes": "minutos",
|
||||||
|
"accuracy": "{{accuracy}}% de precisão"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Ativação",
|
"title": "Ativação",
|
||||||
|
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, HowLongToBeatCategory } 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<HowLongToBeatCategory[] | 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/remove-game";
|
||||||
import "./library/delete-game-folder";
|
import "./library/delete-game-folder";
|
||||||
import "./catalogue/get-random-game";
|
import "./catalogue/get-random-game";
|
||||||
|
import "./catalogue/get-how-long-to-beat";
|
||||||
|
|
||||||
ipcMain.handle("ping", () => "pong");
|
ipcMain.handle("ping", () => "pong");
|
||||||
ipcMain.handle("getVersion", () => app.getVersion());
|
ipcMain.handle("getVersion", () => app.getVersion());
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
/* String formatting */
|
/* 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) =>
|
export const removeSpecialEditionFromName = (name: string) =>
|
||||||
name.replace(
|
name.replace(
|
||||||
/(The |Digital )?(Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited) Edition/g,
|
/(The |Digital )?(GOTY|Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited|Game of the Year|Reloaded|[0-9]{4}) Edition/g,
|
||||||
""
|
""
|
||||||
);
|
);
|
||||||
|
|
||||||
|
60
src/main/services/how-long-to-beat.ts
Normal file
60
src/main/services/how-long-to-beat.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { formatName } from "@main/helpers";
|
||||||
|
import axios from "axios";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { requestWebPage } from "./repack-tracker/helpers";
|
||||||
|
import { HowLongToBeatCategory } from "@types";
|
||||||
|
|
||||||
|
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 getHowLongToBeatGame = async (
|
||||||
|
id: string,
|
||||||
|
): Promise<HowLongToBeatCategory[]> => {
|
||||||
|
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.map(($li) => {
|
||||||
|
const title = $li.querySelector("h4").textContent;
|
||||||
|
const [, accuracyClassName] = Array.from(($li as HTMLElement).classList);
|
||||||
|
|
||||||
|
const accuracy = accuracyClassName.split("time_").at(1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
duration: $li.querySelector("h5").textContent,
|
||||||
|
accuracy,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
@ -8,3 +8,4 @@ export * from "./update-resolver";
|
|||||||
export * from "./window-manager";
|
export * from "./window-manager";
|
||||||
export * from "./fifo";
|
export * from "./fifo";
|
||||||
export * from "./torrent-client";
|
export * from "./torrent-client";
|
||||||
|
export * from "./how-long-to-beat";
|
||||||
|
@ -39,6 +39,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
|
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
|
||||||
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
|
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
|
||||||
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
||||||
|
getHowLongToBeat: (objectID: string, shop: GameShop, title: string) =>
|
||||||
|
ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
|
||||||
|
|
||||||
/* User preferences */
|
/* User preferences */
|
||||||
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
|
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
|
||||||
|
@ -12,15 +12,12 @@ import {
|
|||||||
import * as styles from "./app.css";
|
import * as styles from "./app.css";
|
||||||
import { themeClass } from "./theme.css";
|
import { themeClass } from "./theme.css";
|
||||||
|
|
||||||
import debounce from "lodash/debounce";
|
|
||||||
import type { DebouncedFunc } from "lodash";
|
|
||||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
setSearch,
|
setSearch,
|
||||||
clearSearch,
|
clearSearch,
|
||||||
setUserPreferences,
|
setUserPreferences,
|
||||||
setRepackersFriendlyNames,
|
setRepackersFriendlyNames,
|
||||||
setSearchResults,
|
|
||||||
} from "@renderer/features";
|
} from "@renderer/features";
|
||||||
|
|
||||||
document.body.classList.add(themeClass);
|
document.body.classList.add(themeClass);
|
||||||
@ -36,8 +33,6 @@ export function App() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const debouncedFunc = useRef<DebouncedFunc<() => void | null>>(null);
|
|
||||||
|
|
||||||
const search = useAppSelector((state) => state.search.value);
|
const search = useAppSelector((state) => state.search.value);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -61,7 +56,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addPacket(downloadProgress);
|
addPacket(downloadProgress);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -72,26 +67,17 @@ export function App() {
|
|||||||
const handleSearch = useCallback(
|
const handleSearch = useCallback(
|
||||||
(query: string) => {
|
(query: string) => {
|
||||||
dispatch(setSearch(query));
|
dispatch(setSearch(query));
|
||||||
if (debouncedFunc.current) debouncedFunc.current.cancel();
|
|
||||||
|
|
||||||
if (query === "") {
|
if (query === "") {
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (location.pathname !== "/search") {
|
navigate(`/search/${query}`, {
|
||||||
navigate("/search");
|
replace: location.pathname.startsWith("/search"),
|
||||||
}
|
|
||||||
|
|
||||||
debouncedFunc.current = debounce(() => {
|
|
||||||
window.electron.searchGames(query).then((results) => {
|
|
||||||
dispatch(setSearchResults(results));
|
|
||||||
});
|
});
|
||||||
}, 300);
|
|
||||||
|
|
||||||
debouncedFunc.current();
|
|
||||||
},
|
},
|
||||||
[dispatch, location.pathname, navigate]
|
[dispatch, location.pathname, navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
|
@ -1,7 +1,24 @@
|
|||||||
import type { ComplexStyleRule } from "@vanilla-extract/css";
|
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 { 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({
|
export const header = recipe({
|
||||||
base: {
|
base: {
|
||||||
@ -83,9 +100,49 @@ export const actionButton = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const leftContent = style({
|
export const section = style({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: `${SPACING_UNIT * 2}px`,
|
gap: `${SPACING_UNIT * 2}px`,
|
||||||
height: "100%",
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { SearchIcon, XIcon } from "@primer/octicons-react";
|
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||||
|
|
||||||
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
|
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
|
||||||
|
|
||||||
@ -17,32 +17,33 @@ export interface HeaderProps {
|
|||||||
const pathTitle: Record<string, string> = {
|
const pathTitle: Record<string, string> = {
|
||||||
"/": "catalogue",
|
"/": "catalogue",
|
||||||
"/downloads": "downloads",
|
"/downloads": "downloads",
|
||||||
"/search": "search_results",
|
|
||||||
"/settings": "settings",
|
"/settings": "settings",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Header({ onSearch, onClear, search }: HeaderProps) {
|
export function Header({ onSearch, onClear, search }: HeaderProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const { headerTitle, draggingDisabled } = useAppSelector(
|
const { headerTitle, draggingDisabled } = useAppSelector(
|
||||||
(state) => state.window
|
(state) => state.window
|
||||||
);
|
);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation("header");
|
const { t } = useTranslation("header");
|
||||||
|
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
if (location.pathname.startsWith("/game")) return headerTitle;
|
if (location.pathname.startsWith("/game")) return headerTitle;
|
||||||
|
if (location.pathname.startsWith("/search")) return t("search_results");
|
||||||
|
|
||||||
return t(pathTitle[location.pathname]);
|
return t(pathTitle[location.pathname]);
|
||||||
}, [location.pathname, headerTitle, t]);
|
}, [location.pathname, headerTitle, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (search && location.pathname !== "/search") {
|
if (search && !location.pathname.startsWith("/search")) {
|
||||||
dispatch(clearSearch());
|
dispatch(clearSearch());
|
||||||
}
|
}
|
||||||
}, [location.pathname, search, dispatch]);
|
}, [location.pathname, search, dispatch]);
|
||||||
@ -56,6 +57,10 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
|||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBackButtonClick = () => {
|
||||||
|
navigate(-1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={styles.header({
|
className={styles.header({
|
||||||
@ -63,9 +68,26 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
|
|||||||
isWindows: window.electron.platform === "win32",
|
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 })}>
|
<div className={styles.search({ focused: isFocused })}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -115,6 +115,12 @@ export function Sidebar() {
|
|||||||
return game.title;
|
return game.title;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSidebarItemClick = (path: string) => {
|
||||||
|
if (path !== location.pathname) {
|
||||||
|
navigate(path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
@ -146,7 +152,7 @@ export function Sidebar() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.menuItemButton}
|
className={styles.menuItemButton}
|
||||||
onClick={() => navigate(path)}
|
onClick={() => handleSidebarItemClick(path)}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon />
|
||||||
<span>{t(nameKey)}</span>
|
<span>{t(nameKey)}</span>
|
||||||
@ -179,7 +185,9 @@ export function Sidebar() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={styles.menuItemButton}
|
className={styles.menuItemButton}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/game/${game.shop}/${game.objectID}`)
|
handleSidebarItemClick(
|
||||||
|
`/game/${game.shop}/${game.objectID}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AsyncImage className={styles.gameIcon} src={game.iconUrl} />
|
<AsyncImage className={styles.gameIcon} src={game.iconUrl} />
|
||||||
|
6
src/renderer/declaration.d.ts
vendored
6
src/renderer/declaration.d.ts
vendored
@ -6,6 +6,7 @@ import type {
|
|||||||
TorrentProgress,
|
TorrentProgress,
|
||||||
ShopDetails,
|
ShopDetails,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
|
HowLongToBeatCategory,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { DiskSpace } from "check-disk-space";
|
import type { DiskSpace } from "check-disk-space";
|
||||||
|
|
||||||
@ -39,6 +40,11 @@ declare global {
|
|||||||
language: string
|
language: string
|
||||||
) => Promise<ShopDetails | null>;
|
) => Promise<ShopDetails | null>;
|
||||||
getRandomGame: () => Promise<string>;
|
getRandomGame: () => Promise<string>;
|
||||||
|
getHowLongToBeat: (
|
||||||
|
objectID: string,
|
||||||
|
shop: GameShop,
|
||||||
|
title: string
|
||||||
|
) => Promise<HowLongToBeatCategory[] | null>;
|
||||||
|
|
||||||
/* Library */
|
/* Library */
|
||||||
getLibrary: () => Promise<Game[]>;
|
getLibrary: () => Promise<Game[]>;
|
||||||
|
@ -1,18 +1,12 @@
|
|||||||
import { createSlice } from "@reduxjs/toolkit";
|
import { createSlice } from "@reduxjs/toolkit";
|
||||||
import type { PayloadAction } from "@reduxjs/toolkit";
|
import type { PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
import type { CatalogueEntry } from "@types";
|
|
||||||
|
|
||||||
interface SearchState {
|
interface SearchState {
|
||||||
value: string;
|
value: string;
|
||||||
results: CatalogueEntry[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: SearchState = {
|
const initialState: SearchState = {
|
||||||
value: "",
|
value: "",
|
||||||
results: [],
|
|
||||||
isLoading: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const searchSlice = createSlice({
|
export const searchSlice = createSlice({
|
||||||
@ -20,19 +14,12 @@ export const searchSlice = createSlice({
|
|||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setSearch: (state, action: PayloadAction<string>) => {
|
setSearch: (state, action: PayloadAction<string>) => {
|
||||||
state.isLoading = true;
|
|
||||||
state.value = action.payload;
|
state.value = action.payload;
|
||||||
},
|
},
|
||||||
clearSearch: (state) => {
|
clearSearch: (state) => {
|
||||||
state.value = "";
|
state.value = "";
|
||||||
state.results = [];
|
|
||||||
state.isLoading = false;
|
|
||||||
},
|
|
||||||
setSearchResults: (state, action: PayloadAction<CatalogueEntry[]>) => {
|
|
||||||
state.isLoading = false;
|
|
||||||
state.results = action.payload;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { setSearch, clearSearch, setSearchResults } = searchSlice.actions;
|
export const { setSearch, clearSearch } = searchSlice.actions;
|
||||||
|
@ -45,7 +45,7 @@ const router = createHashRouter([
|
|||||||
Component: GameDetails,
|
Component: GameDetails,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/search",
|
path: "/search/:query",
|
||||||
Component: SearchResults,
|
Component: SearchResults,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -3,23 +3,51 @@ import { GameCard } from "@renderer/components";
|
|||||||
|
|
||||||
import type { CatalogueEntry } from "@types";
|
import type { CatalogueEntry } from "@types";
|
||||||
|
|
||||||
|
import debounce from "lodash/debounce";
|
||||||
|
import type { DebouncedFunc } from "lodash";
|
||||||
|
|
||||||
import * as styles from "./catalogue.css";
|
import * as styles from "./catalogue.css";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
|
import { useAppDispatch } from "@renderer/hooks";
|
||||||
import { clearSearch } from "@renderer/features";
|
import { clearSearch } from "@renderer/features";
|
||||||
import { vars } from "@renderer/theme.css";
|
import { vars } from "@renderer/theme.css";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export function SearchResults() {
|
export function SearchResults() {
|
||||||
const { results, isLoading } = useAppSelector((state) => state.search);
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const { query } = useParams();
|
||||||
|
|
||||||
|
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const debouncedFunc = useRef<DebouncedFunc<() => void | null>>(null);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleGameClick = (game: CatalogueEntry) => {
|
const handleGameClick = (game: CatalogueEntry) => {
|
||||||
dispatch(clearSearch());
|
dispatch(clearSearch());
|
||||||
navigate(`/game/${game.shop}/${game.objectID}`, { replace: true });
|
navigate(`/game/${game.shop}/${game.objectID}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
if (debouncedFunc.current) debouncedFunc.current.cancel();
|
||||||
|
|
||||||
|
debouncedFunc.current = debounce(() => {
|
||||||
|
window.electron
|
||||||
|
.searchGames(query)
|
||||||
|
.then((results) => {
|
||||||
|
setSearchResults(results);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
debouncedFunc.current();
|
||||||
|
}, [query, dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||||
<section className={styles.content}>
|
<section className={styles.content}>
|
||||||
@ -28,7 +56,7 @@ export function SearchResults() {
|
|||||||
? Array.from({ length: 12 }).map((_, index) => (
|
? Array.from({ length: 12 }).map((_, index) => (
|
||||||
<Skeleton key={index} className={styles.cardSkeleton} />
|
<Skeleton key={index} className={styles.cardSkeleton} />
|
||||||
))
|
))
|
||||||
: results.map((game) => (
|
: searchResults.map((game) => (
|
||||||
<GameCard
|
<GameCard
|
||||||
key={game.objectID}
|
key={game.objectID}
|
||||||
game={game}
|
game={game}
|
||||||
|
@ -65,7 +65,7 @@ export const descriptionContent = style({
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const requirements = style({
|
export const contentSidebar = style({
|
||||||
borderLeft: `solid 1px ${vars.color.borderColor};`,
|
borderLeft: `solid 1px ${vars.color.borderColor};`,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
@ -83,12 +83,13 @@ export const requirements = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const requirementsHeader = style({
|
export const contentSidebarTitle = style({
|
||||||
height: "71px",
|
height: "72px",
|
||||||
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
|
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: vars.color.background,
|
backgroundColor: vars.color.background,
|
||||||
|
borderBottom: `solid 1px ${vars.color.borderColor}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const requirementButtonContainer = style({
|
export const requirementButtonContainer = style({
|
||||||
@ -105,7 +106,7 @@ export const requirementButton = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const requirementsDetails = style({
|
export const requirementsDetails = style({
|
||||||
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
|
padding: `${SPACING_UNIT * 2}px`,
|
||||||
lineHeight: "22px",
|
lineHeight: "22px",
|
||||||
fontFamily: "'Fira Sans', sans-serif",
|
fontFamily: "'Fira Sans', sans-serif",
|
||||||
fontSize: "16px",
|
fontSize: "16px",
|
||||||
@ -137,13 +138,42 @@ export const descriptionHeaderInfo = style({
|
|||||||
fontSize: vars.size.bodyFontSize,
|
fontSize: vars.size.bodyFontSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.borderColor}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const howLongToBeatCategoryLabel = style({
|
||||||
|
fontSize: vars.size.bodyFontSize,
|
||||||
|
color: "#DADBE1",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const howLongToBeatCategorySkeleton = style({
|
||||||
|
border: `solid 1px ${vars.color.borderColor}`,
|
||||||
|
borderRadius: "8px",
|
||||||
|
height: "76px",
|
||||||
|
});
|
||||||
|
|
||||||
globalStyle(".bb_tag", {
|
globalStyle(".bb_tag", {
|
||||||
marginTop: `${SPACING_UNIT * 2}px`,
|
marginTop: `${SPACING_UNIT * 2}px`,
|
||||||
marginBottom: `${SPACING_UNIT * 2}px`,
|
marginBottom: `${SPACING_UNIT * 2}px`,
|
||||||
});
|
});
|
||||||
|
|
||||||
globalStyle(`${description} img`, {
|
globalStyle(`${description} img`, {
|
||||||
borderRadius: 5,
|
borderRadius: "5px",
|
||||||
marginTop: `${SPACING_UNIT}px`,
|
marginTop: `${SPACING_UNIT}px`,
|
||||||
marginBottom: `${SPACING_UNIT}px`,
|
marginBottom: `${SPACING_UNIT}px`,
|
||||||
marginLeft: "auto",
|
marginLeft: "auto",
|
||||||
|
@ -3,7 +3,13 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import Color from "color";
|
import Color from "color";
|
||||||
import { average } from "color.js";
|
import { average } from "color.js";
|
||||||
|
|
||||||
import type { Game, GameShop, ShopDetails, SteamAppDetails } from "@types";
|
import type {
|
||||||
|
Game,
|
||||||
|
GameShop,
|
||||||
|
HowLongToBeatCategory,
|
||||||
|
ShopDetails,
|
||||||
|
SteamAppDetails,
|
||||||
|
} from "@types";
|
||||||
|
|
||||||
import { AsyncImage, Button } from "@renderer/components";
|
import { AsyncImage, Button } from "@renderer/components";
|
||||||
import { setHeaderTitle } from "@renderer/features";
|
import { setHeaderTitle } from "@renderer/features";
|
||||||
@ -15,6 +21,7 @@ import { RepacksModal } from "./repacks-modal";
|
|||||||
import { HeroPanel } from "./hero-panel";
|
import { HeroPanel } from "./hero-panel";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ShareAndroidIcon } from "@primer/octicons-react";
|
import { ShareAndroidIcon } from "@primer/octicons-react";
|
||||||
|
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
||||||
|
|
||||||
const OPEN_HYDRA_URL = "https://open.hydralauncher.site";
|
const OPEN_HYDRA_URL = "https://open.hydralauncher.site";
|
||||||
|
|
||||||
@ -24,6 +31,11 @@ export function GameDetails() {
|
|||||||
const [color, setColor] = useState("");
|
const [color, setColor] = useState("");
|
||||||
const [clipboardLock, setClipboardLock] = useState(false);
|
const [clipboardLock, setClipboardLock] = useState(false);
|
||||||
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
|
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
|
||||||
|
const [howLongToBeat, setHowLongToBeat] = useState<{
|
||||||
|
isLoading: boolean;
|
||||||
|
data: HowLongToBeatCategory[] | null;
|
||||||
|
}>({ isLoading: true, data: null });
|
||||||
|
|
||||||
const [game, setGame] = useState<Game | null>(null);
|
const [game, setGame] = useState<Game | null>(null);
|
||||||
const [activeRequirement, setActiveRequirement] =
|
const [activeRequirement, setActiveRequirement] =
|
||||||
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
||||||
@ -67,11 +79,19 @@ export function GameDetails() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.electron
|
||||||
|
.getHowLongToBeat(objectID, "steam", result.name)
|
||||||
|
.then((data) => {
|
||||||
|
setHowLongToBeat({ isLoading: false, data });
|
||||||
|
});
|
||||||
|
|
||||||
setGameDetails(result);
|
setGameDetails(result);
|
||||||
dispatch(setHeaderTitle(result.name));
|
dispatch(setHeaderTitle(result.name));
|
||||||
});
|
});
|
||||||
|
|
||||||
getGame();
|
getGame();
|
||||||
|
setHowLongToBeat({ isLoading: true, data: null });
|
||||||
|
setClipboardLock(false);
|
||||||
}, [getGame, dispatch, navigate, objectID, i18n.language]);
|
}, [getGame, dispatch, navigate, objectID, i18n.language]);
|
||||||
|
|
||||||
const handleCopyToClipboard = () => {
|
const handleCopyToClipboard = () => {
|
||||||
@ -84,12 +104,12 @@ export function GameDetails() {
|
|||||||
shop,
|
shop,
|
||||||
encodeURIComponent(gameDetails?.name),
|
encodeURIComponent(gameDetails?.name),
|
||||||
i18n.language,
|
i18n.language,
|
||||||
])
|
]),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
OPEN_HYDRA_URL + `/?${searchParams.toString()}`
|
OPEN_HYDRA_URL + `/?${searchParams.toString()}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const zero = performance.now();
|
const zero = performance.now();
|
||||||
@ -115,7 +135,7 @@ export function GameDetails() {
|
|||||||
repackId,
|
repackId,
|
||||||
gameDetails.objectID,
|
gameDetails.objectID,
|
||||||
gameDetails.name,
|
gameDetails.name,
|
||||||
shop as GameShop
|
shop as GameShop,
|
||||||
).then(() => {
|
).then(() => {
|
||||||
getGame();
|
getGame();
|
||||||
setShowRepacksModal(false);
|
setShowRepacksModal(false);
|
||||||
@ -196,8 +216,17 @@ export function GameDetails() {
|
|||||||
className={styles.description}
|
className={styles.description}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.requirements}>
|
|
||||||
<div className={styles.requirementsHeader}>
|
<div className={styles.contentSidebar}>
|
||||||
|
<HowLongToBeatSection
|
||||||
|
howLongToBeatData={howLongToBeat.data}
|
||||||
|
isLoading={howLongToBeat.isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.contentSidebarTitle}
|
||||||
|
style={{ border: "none" }}
|
||||||
|
>
|
||||||
<h3>{t("requirements")}</h3>
|
<h3>{t("requirements")}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
69
src/renderer/pages/game-details/how-long-to-beat-section.tsx
Normal file
69
src/renderer/pages/game-details/how-long-to-beat-section.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||||
|
import type { HowLongToBeatCategory } from "@types";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { vars } from "@renderer/theme.css";
|
||||||
|
import * as styles from "./game-details.css";
|
||||||
|
|
||||||
|
const durationTranslation: Record<string, string> = {
|
||||||
|
Hours: "hours",
|
||||||
|
Mins: "minutes",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface HowLongToBeatSectionProps {
|
||||||
|
howLongToBeatData: HowLongToBeatCategory[] | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HowLongToBeatSection({
|
||||||
|
howLongToBeatData,
|
||||||
|
isLoading,
|
||||||
|
}: HowLongToBeatSectionProps) {
|
||||||
|
const { t } = useTranslation("game_details");
|
||||||
|
|
||||||
|
const getDuration = (duration: string) => {
|
||||||
|
const [value, unit] = duration.split(" ");
|
||||||
|
return `${value} ${t(durationTranslation[unit])}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!howLongToBeatData && !isLoading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||||
|
<div className={styles.contentSidebarTitle}>
|
||||||
|
<h3>HowLongToBeat</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className={styles.howLongToBeatCategoriesList}>
|
||||||
|
{howLongToBeatData
|
||||||
|
? howLongToBeatData.map((category) => (
|
||||||
|
<li key={category.title} className={styles.howLongToBeatCategory}>
|
||||||
|
<p
|
||||||
|
className={styles.howLongToBeatCategoryLabel}
|
||||||
|
style={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category.title}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className={styles.howLongToBeatCategoryLabel}>
|
||||||
|
{getDuration(category.duration)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{category.accuracy !== "00" && (
|
||||||
|
<small>
|
||||||
|
{t("accuracy", { accuracy: category.accuracy })}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
: Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<Skeleton
|
||||||
|
key={index}
|
||||||
|
className={styles.howLongToBeatCategorySkeleton}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</SkeletonTheme>
|
||||||
|
);
|
||||||
|
}
|
@ -56,8 +56,8 @@ export function RepacksModal({
|
|||||||
gameDetails.repacks.filter((repack) =>
|
gameDetails.repacks.filter((repack) =>
|
||||||
repack.title
|
repack.title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(event.target.value.toLocaleLowerCase())
|
.includes(event.target.value.toLocaleLowerCase()),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -102,3 +102,9 @@ export interface UserPreferences {
|
|||||||
downloadNotificationsEnabled: boolean;
|
downloadNotificationsEnabled: boolean;
|
||||||
repackUpdatesNotificationsEnabled: boolean;
|
repackUpdatesNotificationsEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HowLongToBeatCategory {
|
||||||
|
title: string;
|
||||||
|
duration: string;
|
||||||
|
accuracy: string;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user