From 5580a9de38663d81739a2e49a4602ef7ac5c0a7f Mon Sep 17 00:00:00 2001 From: Hydra Date: Sun, 14 Apr 2024 07:19:24 +0100 Subject: [PATCH 1/3] 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")}

From 05a31cf6a61118aa93c16d5a0f950eca5590fec2 Mon Sep 17 00:00:00 2001 From: Hydra Date: Sun, 14 Apr 2024 23:00:56 +0100 Subject: [PATCH 2/3] feat: adding better navigation with search results --- .prettierrc.js | 6 ++ src/locales/en/translation.json | 8 ++- src/locales/es/translation.json | 8 ++- src/locales/pt/translation.json | 8 ++- .../events/catalogue/get-how-long-to-beat.ts | 4 +- src/main/helpers/formatters.ts | 2 +- src/main/services/how-long-to-beat.ts | 16 ++--- src/renderer/app.tsx | 24 ++----- src/renderer/components/header/header.tsx | 4 +- src/renderer/declaration.d.ts | 3 +- src/renderer/features/search-slice.ts | 13 +--- src/renderer/main.tsx | 2 +- .../pages/catalogue/search-results.tsx | 36 ++++++++-- .../pages/game-details/game-details.css.ts | 23 ++++++ .../pages/game-details/game-details.tsx | 70 ++++--------------- .../game-details/how-long-to-beat-section.tsx | 65 +++++++++++++++++ src/types/index.ts | 6 ++ 17 files changed, 187 insertions(+), 111 deletions(-) create mode 100644 .prettierrc.js create mode 100644 src/renderer/pages/game-details/how-long-to-beat-section.tsx diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..4bc87a04 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,6 @@ +module.exports = { + semi: true, + trailingComma: "all", + singleQuote: false, + tabWidth: 2, +}; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 207613fd..432d4b0d 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -57,7 +57,13 @@ "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", + "main_story": "Main story", + "main_plus_sides": "Main story + sides", + "completionist": "Completionist", + "all_styles": "All styles", + "hours": "hours", + "minutes": "minutes" }, "activation": { "title": "Activate Hydra", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 0153de88..8e877103 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -57,7 +57,13 @@ "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", + "main_story": "Historia principal", + "main_plus_sides": "Historia principal + extras", + "completionist": "Completista", + "all_styles": "Todo", + "hours": "horas", + "minutes": "minutos" }, "activation": { "title": "Activar Hydra", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 722e7145..8f5a2e7c 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -57,7 +57,13 @@ "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", + "main_story": "História principal", + "main_plus_sides": "Historia principal + extras", + "completionist": "Completista", + "all_styles": "Tudo", + "hours": "horas", + "minutes": "minutos" }, "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 index 7d15a34e..e737f391 100644 --- a/src/main/events/catalogue/get-how-long-to-beat.ts +++ b/src/main/events/catalogue/get-how-long-to-beat.ts @@ -1,4 +1,4 @@ -import type { GameShop } from "@types"; +import type { GameShop, HowLongToBeatCategory } from "@types"; import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services"; import { registerEvent } from "../register-event"; @@ -8,7 +8,7 @@ const getHowLongToBeat = async ( objectID: string, _shop: GameShop, title: string -): Promise | null> => { +): Promise => { const response = await searchHowLongToBeat(title); const game = response.data.find( (game) => game.profile_steam === Number(objectID) diff --git a/src/main/helpers/formatters.ts b/src/main/helpers/formatters.ts index ee2f5238..75393146 100644 --- a/src/main/helpers/formatters.ts +++ b/src/main/helpers/formatters.ts @@ -8,7 +8,7 @@ export const removeSymbolsFromName = (name: string) => export const removeSpecialEditionFromName = (name: string) => name.replace( - /(The |Digital )?(Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited|Game of the Year) 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 index c6b42197..e8471a94 100644 --- a/src/main/services/how-long-to-beat.ts +++ b/src/main/services/how-long-to-beat.ts @@ -52,16 +52,14 @@ export const getHowLongToBeatGame = async (id: string) => { 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 $lis.map(($li) => { + const title = $li.querySelector("h4").textContent; + const [, time] = Array.from(($li as HTMLElement).classList); return { - ...prev, - [name]: { - time: next.querySelector("h5").textContent, - color: classNameColor[time as keyof typeof classNameColor], - }, + title, + duration: $li.querySelector("h5").textContent, + color: classNameColor[time as keyof typeof classNameColor], }; - }, {}); + }); }; 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.tsx b/src/renderer/components/header/header.tsx index 9e07c4a4..f93bd9cc 100644 --- a/src/renderer/components/header/header.tsx +++ b/src/renderer/components/header/header.tsx @@ -17,7 +17,6 @@ export interface HeaderProps { const pathTitle: Record = { "/": "catalogue", "/downloads": "downloads", - "/search": "search_results", "/settings": "settings", }; @@ -38,12 +37,13 @@ export function Header({ onSearch, onClear, search }: HeaderProps) { 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]); diff --git a/src/renderer/declaration.d.ts b/src/renderer/declaration.d.ts index 9ed239e5..00377675 100644 --- a/src/renderer/declaration.d.ts +++ b/src/renderer/declaration.d.ts @@ -6,6 +6,7 @@ import type { TorrentProgress, ShopDetails, UserPreferences, + HowLongToBeatCategory, } from "@types"; import type { DiskSpace } from "check-disk-space"; @@ -43,7 +44,7 @@ declare global { objectID: string, shop: GameShop, title: string - ) => Promise | null>; + ) => Promise; /* Library */ getLibrary: () => Promise; diff --git a/src/renderer/features/search-slice.ts b/src/renderer/features/search-slice.ts index 431a5974..2c064aa2 100644 --- a/src/renderer/features/search-slice.ts +++ b/src/renderer/features/search-slice.ts @@ -1,18 +1,12 @@ import { createSlice } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit"; -import type { CatalogueEntry } from "@types"; - interface SearchState { value: string; - results: CatalogueEntry[]; - isLoading: boolean; } const initialState: SearchState = { value: "", - results: [], - isLoading: false, }; export const searchSlice = createSlice({ @@ -20,17 +14,12 @@ export const searchSlice = createSlice({ initialState, reducers: { setSearch: (state, action: PayloadAction) => { - state.isLoading = true; state.value = action.payload; }, clearSearch: (state) => { state.value = ""; }, - setSearchResults: (state, action: PayloadAction) => { - state.isLoading = false; - state.results = action.payload; - }, }, }); -export const { setSearch, clearSearch, setSearchResults } = searchSlice.actions; +export const { setSearch, clearSearch } = searchSlice.actions; diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 4839740f..3bad1a79 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -45,7 +45,7 @@ const router = createHashRouter([ Component: GameDetails, }, { - path: "/search", + path: "/search/:query", Component: SearchResults, }, { diff --git a/src/renderer/pages/catalogue/search-results.tsx b/src/renderer/pages/catalogue/search-results.tsx index 46a92376..b6ec3856 100644 --- a/src/renderer/pages/catalogue/search-results.tsx +++ b/src/renderer/pages/catalogue/search-results.tsx @@ -3,16 +3,26 @@ import { GameCard } from "@renderer/components"; import type { CatalogueEntry } from "@types"; +import debounce from "lodash/debounce"; +import type { DebouncedFunc } from "lodash"; + import * as styles from "./catalogue.css"; -import { useNavigate } from "react-router-dom"; -import { useAppDispatch, useAppSelector } from "@renderer/hooks"; +import { useNavigate, useParams } from "react-router-dom"; +import { useAppDispatch } from "@renderer/hooks"; import { clearSearch } from "@renderer/features"; import { vars } from "@renderer/theme.css"; +import { useEffect, useRef, useState } from "react"; export function SearchResults() { - const { results, isLoading } = useAppSelector((state) => state.search); const dispatch = useAppDispatch(); + const { query } = useParams(); + + const [searchResults, setSearchResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const debouncedFunc = useRef void | null>>(null); + const navigate = useNavigate(); const handleGameClick = (game: CatalogueEntry) => { @@ -20,6 +30,24 @@ export function SearchResults() { 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 (
@@ -28,7 +56,7 @@ export function SearchResults() { ? Array.from({ length: 12 }).map((_, index) => ( )) - : results.map((game) => ( + : searchResults.map((game) => ( (null); - const [howLongToBeat, setHowLongToBeat] = useState | null>(null); - - console.log(howLongToBeat); + const [howLongToBeat, setHowLongToBeat] = useState< + HowLongToBeatCategory[] | null + >(null); const [game, setGame] = useState(null); const [activeRequirement, setActiveRequirement] = @@ -87,6 +90,7 @@ export function GameDetails() { getGame(); setHowLongToBeat(null); + setClipboardLock(false); }, [getGame, dispatch, navigate, objectID, i18n.language]); const handleCopyToClipboard = () => { @@ -213,55 +217,7 @@ export function GameDetails() {
- {howLongToBeat && ( - <> -
-

HowLongToBeat

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

- {key} -

-

- {value.time} -

-
- ))} -
- - )} +
= { + "Main Story": "main_story", + "Main + Sides": "main_plus_sides", + Completionist: "completionist", + "All Styles": "all_styles", +}; + +const durationTranslation: Record = { + Hours: "hours", + Mins: "minutes", +}; + +export interface HowLongToBeatSectionProps { + howLongToBeatData: HowLongToBeatCategory[] | null; +} + +export function HowLongToBeatSection({ + howLongToBeatData, +}: HowLongToBeatSectionProps) { + const { t } = useTranslation("game_details"); + + if (!howLongToBeatData) return null; + + const getDuration = (duration: string) => { + const [value, unit] = duration.split(" "); + return `${value} ${t(durationTranslation[unit])}`; + }; + + return ( + <> +
+

HowLongToBeat

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

    + {titleTranslation[category.title] + ? t(titleTranslation[category.title]) + : category.title} +

    +

    + {getDuration(category.duration)} +

    +
  • + ))} +
+ + ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 42fb7850..580994ca 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; + color: string; +} From c0274ee7e7ebdc9f436d434a97fb192749d26210 Mon Sep 17 00:00:00 2001 From: Hydra Date: Mon, 15 Apr 2024 00:38:25 +0100 Subject: [PATCH 3/3] feat: adding accuracy as text instead of colours --- .prettierrc.js | 1 - README.md | 2 + src/locales/en/translation.json | 7 +- src/locales/es/translation.json | 7 +- src/locales/pt/translation.json | 7 +- src/main/services/how-long-to-beat.ts | 23 +++--- .../pages/game-details/game-details.css.ts | 18 +++-- .../pages/game-details/game-details.tsx | 22 +++--- .../game-details/how-long-to-beat-section.tsx | 72 ++++++++++--------- .../pages/game-details/repacks-modal.tsx | 4 +- src/types/index.ts | 2 +- 11 files changed, 83 insertions(+), 82 deletions(-) diff --git a/.prettierrc.js b/.prettierrc.js index 4bc87a04..acb402eb 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -2,5 +2,4 @@ module.exports = { semi: true, trailingComma: "all", singleQuote: false, - tabWidth: 2, }; 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 432d4b0d..1f1f7b63 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -58,12 +58,9 @@ "publisher": "Published by {{publisher}}", "copy_link_to_clipboard": "Copy link", "copied_link_to_clipboard": "Link copied", - "main_story": "Main story", - "main_plus_sides": "Main story + sides", - "completionist": "Completionist", - "all_styles": "All styles", "hours": "hours", - "minutes": "minutes" + "minutes": "minutes", + "accuracy": "{{accuracy}}% accuracy" }, "activation": { "title": "Activate Hydra", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 8e877103..d059c08c 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -58,12 +58,9 @@ "publisher": "Publicado por {{publisher}}", "copy_link_to_clipboard": "Copiar enlace", "copied_link_to_clipboard": "Enlace copiado", - "main_story": "Historia principal", - "main_plus_sides": "Historia principal + extras", - "completionist": "Completista", - "all_styles": "Todo", "hours": "horas", - "minutes": "minutos" + "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 8f5a2e7c..0342fb89 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -58,12 +58,9 @@ "publisher": "Publicado por {{publisher}}", "copy_link_to_clipboard": "Copiar link", "copied_link_to_clipboard": "Link copiado", - "main_story": "História principal", - "main_plus_sides": "Historia principal + extras", - "completionist": "Completista", - "all_styles": "Tudo", "hours": "horas", - "minutes": "minutos" + "minutes": "minutos", + "accuracy": "{{accuracy}}% de precisão" }, "activation": { "title": "Ativação", diff --git a/src/main/services/how-long-to-beat.ts b/src/main/services/how-long-to-beat.ts index e8471a94..62393fdf 100644 --- a/src/main/services/how-long-to-beat.ts +++ b/src/main/services/how-long-to-beat.ts @@ -2,6 +2,7 @@ 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; @@ -27,23 +28,15 @@ export const searchHowLongToBeat = async (gameName: string) => { "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) => { +export const getHowLongToBeatGame = async ( + id: string, +): Promise => { const response = await requestWebPage(`https://howlongtobeat.com/game/${id}`); const { window } = new JSDOM(response); @@ -54,12 +47,14 @@ export const getHowLongToBeatGame = async (id: string) => { return $lis.map(($li) => { const title = $li.querySelector("h4").textContent; - const [, time] = Array.from(($li as HTMLElement).classList); + const [, accuracyClassName] = Array.from(($li as HTMLElement).classList); + + const accuracy = accuracyClassName.split("time_").at(1); return { title, duration: $li.querySelector("h5").textContent, - color: classNameColor[time as keyof typeof classNameColor], + accuracy, }; }); }; diff --git a/src/renderer/pages/game-details/game-details.css.ts b/src/renderer/pages/game-details/game-details.css.ts index 49a994b5..a513a5e1 100644 --- a/src/renderer/pages/game-details/game-details.css.ts +++ b/src/renderer/pages/game-details/game-details.css.ts @@ -139,19 +139,19 @@ export const descriptionHeaderInfo = style({ }); export const howLongToBeatCategoriesList = style({ - margin: 0, - padding: 16, + margin: "0", + padding: "16px", display: "flex", flexDirection: "column", - gap: 16, + gap: "16px", }); export const howLongToBeatCategory = style({ display: "flex", flexDirection: "column", - gap: 4, + gap: "4px", backgroundColor: vars.color.background, - borderRadius: 8, + borderRadius: "8px", padding: `8px 16px`, border: `solid 1px ${vars.color.borderColor}`, }); @@ -161,13 +161,19 @@ export const howLongToBeatCategoryLabel = style({ color: "#DADBE1", }); +export const howLongToBeatCategorySkeleton = style({ + border: `solid 1px ${vars.color.borderColor}`, + borderRadius: "8px", + height: "76px", +}); + globalStyle(".bb_tag", { marginTop: `${SPACING_UNIT * 2}px`, marginBottom: `${SPACING_UNIT * 2}px`, }); globalStyle(`${description} img`, { - borderRadius: 5, + borderRadius: "5px", marginTop: `${SPACING_UNIT}px`, marginBottom: `${SPACING_UNIT}px`, marginLeft: "auto", diff --git a/src/renderer/pages/game-details/game-details.tsx b/src/renderer/pages/game-details/game-details.tsx index 38d23e0a..856b3ede 100644 --- a/src/renderer/pages/game-details/game-details.tsx +++ b/src/renderer/pages/game-details/game-details.tsx @@ -31,9 +31,10 @@ export function GameDetails() { const [color, setColor] = useState(""); const [clipboardLock, setClipboardLock] = useState(false); const [gameDetails, setGameDetails] = useState(null); - const [howLongToBeat, setHowLongToBeat] = useState< - HowLongToBeatCategory[] | null - >(null); + const [howLongToBeat, setHowLongToBeat] = useState<{ + isLoading: boolean; + data: HowLongToBeatCategory[] | null; + }>({ isLoading: true, data: null }); const [game, setGame] = useState(null); const [activeRequirement, setActiveRequirement] = @@ -81,7 +82,7 @@ export function GameDetails() { window.electron .getHowLongToBeat(objectID, "steam", result.name) .then((data) => { - setHowLongToBeat(data); + setHowLongToBeat({ isLoading: false, data }); }); setGameDetails(result); @@ -89,7 +90,7 @@ export function GameDetails() { }); getGame(); - setHowLongToBeat(null); + setHowLongToBeat({ isLoading: true, data: null }); setClipboardLock(false); }, [getGame, dispatch, navigate, objectID, i18n.language]); @@ -103,12 +104,12 @@ export function GameDetails() { shop, encodeURIComponent(gameDetails?.name), i18n.language, - ]) + ]), ), }); navigator.clipboard.writeText( - OPEN_HYDRA_URL + `/?${searchParams.toString()}` + OPEN_HYDRA_URL + `/?${searchParams.toString()}`, ); const zero = performance.now(); @@ -134,7 +135,7 @@ export function GameDetails() { repackId, gameDetails.objectID, gameDetails.name, - shop as GameShop + shop as GameShop, ).then(() => { getGame(); setShowRepacksModal(false); @@ -217,7 +218,10 @@ export function GameDetails() {
- +
= { - "Main Story": "main_story", - "Main + Sides": "main_plus_sides", - Completionist: "completionist", - "All Styles": "all_styles", -}; +import { vars } from "@renderer/theme.css"; +import * as styles from "./game-details.css"; const durationTranslation: Record = { Hours: "hours", @@ -17,49 +11,59 @@ const durationTranslation: Record = { export interface HowLongToBeatSectionProps { howLongToBeatData: HowLongToBeatCategory[] | null; + isLoading: boolean; } export function HowLongToBeatSection({ howLongToBeatData, + isLoading, }: HowLongToBeatSectionProps) { const { t } = useTranslation("game_details"); - if (!howLongToBeatData) return null; - const getDuration = (duration: string) => { const [value, unit] = duration.split(" "); return `${value} ${t(durationTranslation[unit])}`; }; + if (!howLongToBeatData && !isLoading) return null; + return ( - <> +

HowLongToBeat

    - {howLongToBeatData.map((category) => ( -
  • -

    - {titleTranslation[category.title] - ? t(titleTranslation[category.title]) - : category.title} -

    -

    - {getDuration(category.duration)} -

    -
  • - ))} + {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 580994ca..2ab27f9a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -106,5 +106,5 @@ export interface UserPreferences { export interface HowLongToBeatCategory { title: string; duration: string; - color: string; + accuracy: string; }