diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..acb402eb --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + semi: true, + trailingComma: "all", + singleQuote: false, +}; diff --git a/README.md b/README.md index 59497b3b..8c2a5d9b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Hydra ![Discord](https://img.shields.io/discord/1220692017311645737?style=flat&logo=discord&label=Hydra&labelColor=%231c1c1c) +![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. The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using [libtorrent](https://www.libtorrent.org/). diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 668b51a3..3ff72551 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -57,7 +57,10 @@ "release_date": "Released in {{date}}", "publisher": "Published by {{publisher}}", "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": { "title": "Activate Hydra", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index eba09ae9..99bc8209 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -57,7 +57,10 @@ "release_date": "Fecha de lanzamiento {{date}}", "publisher": "Publicado por {{publisher}}", "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": { "title": "Activar Hydra", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index a2548bd3..e68f33e8 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -57,7 +57,10 @@ "release_date": "Lançado em {{date}}", "publisher": "Publicado por {{publisher}}", "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": { "title": "Ativação", 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..e737f391 --- /dev/null +++ b/src/main/events/catalogue/get-how-long-to-beat.ts @@ -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 => { + 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..75393146 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 )?(GOTY|Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited|Game of the Year|Reloaded|[0-9]{4}) 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..62393fdf --- /dev/null +++ b/src/main/services/how-long-to-beat.ts @@ -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 => { + 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, + }; + }); +}; 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/app.tsx b/src/renderer/app.tsx index 89bdaed2..c72ba0a4 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -12,15 +12,12 @@ import { import * as styles from "./app.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 { setSearch, clearSearch, setUserPreferences, setRepackersFriendlyNames, - setSearchResults, } from "@renderer/features"; document.body.classList.add(themeClass); @@ -36,8 +33,6 @@ export function App() { const navigate = useNavigate(); const location = useLocation(); - const debouncedFunc = useRef void | null>>(null); - const search = useAppSelector((state) => state.search.value); useEffect(() => { @@ -61,7 +56,7 @@ export function App() { } addPacket(downloadProgress); - } + }, ); return () => { @@ -72,26 +67,17 @@ export function App() { const handleSearch = useCallback( (query: string) => { dispatch(setSearch(query)); - if (debouncedFunc.current) debouncedFunc.current.cancel(); if (query === "") { navigate(-1); return; } - if (location.pathname !== "/search") { - navigate("/search"); - } - - debouncedFunc.current = debounce(() => { - window.electron.searchGames(query).then((results) => { - dispatch(setSearchResults(results)); - }); - }, 300); - - debouncedFunc.current(); + navigate(`/search/${query}`, { + replace: location.pathname.startsWith("/search"), + }); }, - [dispatch, location.pathname, navigate] + [dispatch, location.pathname, navigate], ); const handleClear = useCallback(() => { 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..f93bd9cc 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"; @@ -17,32 +17,33 @@ export interface HeaderProps { const pathTitle: Record = { "/": "catalogue", "/downloads": "downloads", - "/search": "search_results", "/settings": "settings", }; 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"); const title = useMemo(() => { if (location.pathname.startsWith("/game")) return headerTitle; + if (location.pathname.startsWith("/search")) return t("search_results"); return t(pathTitle[location.pathname]); }, [location.pathname, headerTitle, t]); useEffect(() => { - if (search && location.pathname !== "/search") { + if (search && !location.pathname.startsWith("/search")) { dispatch(clearSearch()); } }, [location.pathname, search, dispatch]); @@ -56,6 +57,10 @@ export function Header({ onSearch, onClear, search }: HeaderProps) { setIsFocused(false); }; + const handleBackButtonClick = () => { + navigate(-1); + }; + return (
-

{title}

+
+ -
+

+ {title} +

+
+ +
-
-
+ +
+ + +

{t("requirements")}

diff --git a/src/renderer/pages/game-details/how-long-to-beat-section.tsx b/src/renderer/pages/game-details/how-long-to-beat-section.tsx new file mode 100644 index 00000000..275f837e --- /dev/null +++ b/src/renderer/pages/game-details/how-long-to-beat-section.tsx @@ -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 = { + 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 ( + +
+

HowLongToBeat

+
+ +
    + {howLongToBeatData + ? howLongToBeatData.map((category) => ( +
  • +

    + {category.title} +

    + +

    + {getDuration(category.duration)} +

    + + {category.accuracy !== "00" && ( + + {t("accuracy", { accuracy: category.accuracy })} + + )} +
  • + )) + : Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+ ); +} diff --git a/src/renderer/pages/game-details/repacks-modal.tsx b/src/renderer/pages/game-details/repacks-modal.tsx index fcefe674..421163d1 100644 --- a/src/renderer/pages/game-details/repacks-modal.tsx +++ b/src/renderer/pages/game-details/repacks-modal.tsx @@ -56,8 +56,8 @@ export function RepacksModal({ gameDetails.repacks.filter((repack) => repack.title .toLowerCase() - .includes(event.target.value.toLocaleLowerCase()) - ) + .includes(event.target.value.toLocaleLowerCase()), + ), ); }; diff --git a/src/types/index.ts b/src/types/index.ts index 42fb7850..2ab27f9a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -102,3 +102,9 @@ export interface UserPreferences { downloadNotificationsEnabled: boolean; repackUpdatesNotificationsEnabled: boolean; } + +export interface HowLongToBeatCategory { + title: string; + duration: string; + accuracy: string; +}