feat: adding better navigation with search results

This commit is contained in:
Hydra 2024-04-14 23:00:56 +01:00
parent 5580a9de38
commit 05a31cf6a6
No known key found for this signature in database
17 changed files with 187 additions and 111 deletions

6
.prettierrc.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
semi: true,
trailingComma: "all",
singleQuote: false,
tabWidth: 2,
};

View file

@ -57,7 +57,13 @@
"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",
"main_story": "Main story",
"main_plus_sides": "Main story + sides",
"completionist": "Completionist",
"all_styles": "All styles",
"hours": "hours",
"minutes": "minutes"
}, },
"activation": { "activation": {
"title": "Activate Hydra", "title": "Activate Hydra",

View file

@ -57,7 +57,13 @@
"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",
"main_story": "Historia principal",
"main_plus_sides": "Historia principal + extras",
"completionist": "Completista",
"all_styles": "Todo",
"hours": "horas",
"minutes": "minutos"
}, },
"activation": { "activation": {
"title": "Activar Hydra", "title": "Activar Hydra",

View file

@ -57,7 +57,13 @@
"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",
"main_story": "História principal",
"main_plus_sides": "Historia principal + extras",
"completionist": "Completista",
"all_styles": "Tudo",
"hours": "horas",
"minutes": "minutos"
}, },
"activation": { "activation": {
"title": "Ativação", "title": "Ativação",

View file

@ -1,4 +1,4 @@
import type { GameShop } from "@types"; import type { GameShop, HowLongToBeatCategory } from "@types";
import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services"; import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
@ -8,7 +8,7 @@ const getHowLongToBeat = async (
objectID: string, objectID: string,
_shop: GameShop, _shop: GameShop,
title: string title: string
): Promise<Record<string, { time: string; color: string }> | null> => { ): Promise<HowLongToBeatCategory[] | null> => {
const response = await searchHowLongToBeat(title); const response = await searchHowLongToBeat(title);
const game = response.data.find( const game = response.data.find(
(game) => game.profile_steam === Number(objectID) (game) => game.profile_steam === Number(objectID)

View file

@ -8,7 +8,7 @@ export const removeSymbolsFromName = (name: string) =>
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|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,
"" ""
); );

View file

@ -52,16 +52,14 @@ export const getHowLongToBeatGame = async (id: string) => {
const $ul = document.querySelector(".shadow_shadow ul"); const $ul = document.querySelector(".shadow_shadow ul");
const $lis = Array.from($ul.children); const $lis = Array.from($ul.children);
return $lis.reduce((prev, next) => { return $lis.map(($li) => {
const name = next.querySelector("h4").textContent; const title = $li.querySelector("h4").textContent;
const [, time] = Array.from((next as HTMLElement).classList); const [, time] = Array.from(($li as HTMLElement).classList);
return { return {
...prev, title,
[name]: { duration: $li.querySelector("h5").textContent,
time: next.querySelector("h5").textContent, color: classNameColor[time as keyof typeof classNameColor],
color: classNameColor[time as keyof typeof classNameColor],
},
}; };
}, {}); });
}; };

View file

@ -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(() => {

View file

@ -17,7 +17,6 @@ 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",
}; };
@ -38,12 +37,13 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
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]);

View file

@ -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";
@ -43,7 +44,7 @@ declare global {
objectID: string, objectID: string,
shop: GameShop, shop: GameShop,
title: string title: string
) => Promise<Record<string, { time: string; color: string }> | null>; ) => Promise<HowLongToBeatCategory[] | null>;
/* Library */ /* Library */
getLibrary: () => Promise<Game[]>; getLibrary: () => Promise<Game[]>;

View file

@ -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,17 +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 = "";
}, },
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;

View file

@ -45,7 +45,7 @@ const router = createHashRouter([
Component: GameDetails, Component: GameDetails,
}, },
{ {
path: "/search", path: "/search/:query",
Component: SearchResults, Component: SearchResults,
}, },
{ {

View file

@ -3,16 +3,26 @@ 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) => {
@ -20,6 +30,24 @@ export function SearchResults() {
navigate(`/game/${game.shop}/${game.objectID}`); 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}

View file

@ -138,6 +138,29 @@ export const descriptionHeaderInfo = style({
fontSize: vars.size.bodyFontSize, fontSize: vars.size.bodyFontSize,
}); });
export const howLongToBeatCategoriesList = style({
margin: 0,
padding: 16,
display: "flex",
flexDirection: "column",
gap: 16,
});
export const howLongToBeatCategory = style({
display: "flex",
flexDirection: "column",
gap: 4,
backgroundColor: vars.color.background,
borderRadius: 8,
padding: `8px 16px`,
border: `solid 1px ${vars.color.borderColor}`,
});
export const howLongToBeatCategoryLabel = style({
fontSize: vars.size.bodyFontSize,
color: "#DADBE1",
});
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`,

View file

@ -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,7 +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 { vars } from "@renderer/theme.css"; import { HowLongToBeatSection } from "./how-long-to-beat-section";
const OPEN_HYDRA_URL = "https://open.hydralauncher.site"; const OPEN_HYDRA_URL = "https://open.hydralauncher.site";
@ -25,12 +31,9 @@ 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<Record< const [howLongToBeat, setHowLongToBeat] = useState<
string, HowLongToBeatCategory[] | null
{ time: string; color: string } >(null);
> | null>(null);
console.log(howLongToBeat);
const [game, setGame] = useState<Game | null>(null); const [game, setGame] = useState<Game | null>(null);
const [activeRequirement, setActiveRequirement] = const [activeRequirement, setActiveRequirement] =
@ -87,6 +90,7 @@ export function GameDetails() {
getGame(); getGame();
setHowLongToBeat(null); setHowLongToBeat(null);
setClipboardLock(false);
}, [getGame, dispatch, navigate, objectID, i18n.language]); }, [getGame, dispatch, navigate, objectID, i18n.language]);
const handleCopyToClipboard = () => { const handleCopyToClipboard = () => {
@ -213,55 +217,7 @@ export function GameDetails() {
</div> </div>
<div className={styles.contentSidebar}> <div className={styles.contentSidebar}>
{howLongToBeat && ( <HowLongToBeatSection howLongToBeatData={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 <div
className={styles.contentSidebarTitle} className={styles.contentSidebarTitle}

View file

@ -0,0 +1,65 @@
import type { HowLongToBeatCategory } from "@types";
import * as styles from "./game-details.css";
import { vars } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
const titleTranslation: Record<string, string> = {
"Main Story": "main_story",
"Main + Sides": "main_plus_sides",
Completionist: "completionist",
"All Styles": "all_styles",
};
const durationTranslation: Record<string, string> = {
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 (
<>
<div className={styles.contentSidebarTitle}>
<h3>HowLongToBeat</h3>
</div>
<ul className={styles.howLongToBeatCategoriesList}>
{howLongToBeatData.map((category) => (
<li
key={category.title}
className={styles.howLongToBeatCategory}
style={{ backgroundColor: category.color ?? vars.color.background }}
>
<p
className={styles.howLongToBeatCategoryLabel}
style={{
fontWeight: "bold",
}}
>
{titleTranslation[category.title]
? t(titleTranslation[category.title])
: category.title}
</p>
<p className={styles.howLongToBeatCategoryLabel}>
{getDuration(category.duration)}
</p>
</li>
))}
</ul>
</>
);
}

View file

@ -102,3 +102,9 @@ export interface UserPreferences {
downloadNotificationsEnabled: boolean; downloadNotificationsEnabled: boolean;
repackUpdatesNotificationsEnabled: boolean; repackUpdatesNotificationsEnabled: boolean;
} }
export interface HowLongToBeatCategory {
title: string;
duration: string;
color: string;
}