mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
feat: adding better navigation with search results
This commit is contained in:
parent
5580a9de38
commit
05a31cf6a6
17 changed files with 187 additions and 111 deletions
6
.prettierrc.js
Normal file
6
.prettierrc.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
semi: true,
|
||||
trailingComma: "all",
|
||||
singleQuote: false,
|
||||
tabWidth: 2,
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<Record<string, { time: string; color: string }> | null> => {
|
||||
): Promise<HowLongToBeatCategory[] | null> => {
|
||||
const response = await searchHowLongToBeat(title);
|
||||
const game = response.data.find(
|
||||
(game) => game.profile_steam === Number(objectID)
|
||||
|
|
|
@ -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,
|
||||
""
|
||||
);
|
||||
|
||||
|
|
|
@ -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],
|
||||
};
|
||||
}, {});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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<DebouncedFunc<() => 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(() => {
|
||||
|
|
|
@ -17,7 +17,6 @@ export interface HeaderProps {
|
|||
const pathTitle: Record<string, string> = {
|
||||
"/": "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]);
|
||||
|
|
3
src/renderer/declaration.d.ts
vendored
3
src/renderer/declaration.d.ts
vendored
|
@ -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<Record<string, { time: string; color: string }> | null>;
|
||||
) => Promise<HowLongToBeatCategory[] | null>;
|
||||
|
||||
/* Library */
|
||||
getLibrary: () => Promise<Game[]>;
|
||||
|
|
|
@ -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<string>) => {
|
||||
state.isLoading = true;
|
||||
state.value = action.payload;
|
||||
},
|
||||
clearSearch: (state) => {
|
||||
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;
|
||||
|
|
|
@ -45,7 +45,7 @@ const router = createHashRouter([
|
|||
Component: GameDetails,
|
||||
},
|
||||
{
|
||||
path: "/search",
|
||||
path: "/search/:query",
|
||||
Component: SearchResults,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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<CatalogueEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const debouncedFunc = useRef<DebouncedFunc<() => 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 (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<section className={styles.content}>
|
||||
|
@ -28,7 +56,7 @@ export function SearchResults() {
|
|||
? Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton key={index} className={styles.cardSkeleton} />
|
||||
))
|
||||
: results.map((game) => (
|
||||
: searchResults.map((game) => (
|
||||
<GameCard
|
||||
key={game.objectID}
|
||||
game={game}
|
||||
|
|
|
@ -138,6 +138,29 @@ export const descriptionHeaderInfo = style({
|
|||
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", {
|
||||
marginTop: `${SPACING_UNIT * 2}px`,
|
||||
marginBottom: `${SPACING_UNIT * 2}px`,
|
||||
|
|
|
@ -3,7 +3,13 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||
import Color from "color";
|
||||
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 { setHeaderTitle } from "@renderer/features";
|
||||
|
@ -15,7 +21,7 @@ import { RepacksModal } from "./repacks-modal";
|
|||
import { HeroPanel } from "./hero-panel";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShareAndroidIcon } from "@primer/octicons-react";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
||||
|
||||
const OPEN_HYDRA_URL = "https://open.hydralauncher.site";
|
||||
|
||||
|
@ -25,12 +31,9 @@ export function GameDetails() {
|
|||
const [color, setColor] = useState("");
|
||||
const [clipboardLock, setClipboardLock] = useState(false);
|
||||
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
|
||||
const [howLongToBeat, setHowLongToBeat] = useState<Record<
|
||||
string,
|
||||
{ time: string; color: string }
|
||||
> | null>(null);
|
||||
|
||||
console.log(howLongToBeat);
|
||||
const [howLongToBeat, setHowLongToBeat] = useState<
|
||||
HowLongToBeatCategory[] | null
|
||||
>(null);
|
||||
|
||||
const [game, setGame] = useState<Game | null>(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() {
|
|||
</div>
|
||||
|
||||
<div className={styles.contentSidebar}>
|
||||
{howLongToBeat && (
|
||||
<>
|
||||
<div className={styles.contentSidebarTitle}>
|
||||
<h3>HowLongToBeat</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{Object.entries(howLongToBeat).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
backgroundColor: value.color ?? vars.color.background,
|
||||
borderRadius: 8,
|
||||
padding: `8px 16px`,
|
||||
border: `solid 1px ${vars.color.borderColor}`,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: vars.size.bodyFontSize,
|
||||
color: "#DADBE1",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: vars.size.bodyFontSize,
|
||||
color: "#DADBE1",
|
||||
}}
|
||||
>
|
||||
{value.time}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<HowLongToBeatSection howLongToBeatData={howLongToBeat} />
|
||||
|
||||
<div
|
||||
className={styles.contentSidebarTitle}
|
||||
|
|
65
src/renderer/pages/game-details/how-long-to-beat-section.tsx
Normal file
65
src/renderer/pages/game-details/how-long-to-beat-section.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -102,3 +102,9 @@ export interface UserPreferences {
|
|||
downloadNotificationsEnabled: boolean;
|
||||
repackUpdatesNotificationsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface HowLongToBeatCategory {
|
||||
title: string;
|
||||
duration: string;
|
||||
color: string;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue