diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index ae9c2712..6979854b 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -7,7 +7,8 @@ "featured": "Featured", "trending": "Trending", "surprise_me": "Surprise me", - "no_results": "No results found" + "no_results": "No results found", + "start_typing": "Starting typing to search..." }, "sidebar": { "catalogue": "Catalogue", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 1adac376..d3b0f0a4 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -7,7 +7,8 @@ "featured": "Destaques", "trending": "Populares", "surprise_me": "Surpreenda-me", - "no_results": "Nenhum resultado encontrado" + "no_results": "Nenhum resultado encontrado", + "start_typing": "Comece a digitar para pesquisar…" }, "sidebar": { "catalogue": "Catálogo", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 67f99921..3384bdf7 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -7,7 +7,8 @@ "featured": "Destaques", "trending": "Populares", "surprise_me": "Surpreende-me", - "no_results": "Nenhum resultado encontrado" + "no_results": "Nenhum resultado encontrado", + "start_typing": "Comece a digitar para pesquisar…" }, "sidebar": { "catalogue": "Catálogo", diff --git a/src/main/events/catalogue/get-random-game.ts b/src/main/events/catalogue/get-random-game.ts index 69f57800..72b93c33 100644 --- a/src/main/events/catalogue/get-random-game.ts +++ b/src/main/events/catalogue/get-random-game.ts @@ -3,7 +3,7 @@ import { shuffle } from "lodash-es"; import { getSteam250List } from "@main/services"; import { registerEvent } from "../register-event"; -import { searchSteamGames } from "../helpers/search-games"; +import { getSteamGameById } from "../helpers/search-games"; import type { Steam250Game } from "@types"; const state = { games: Array(), index: 0 }; @@ -12,14 +12,10 @@ const filterGames = async (games: Steam250Game[]) => { const results: Steam250Game[] = []; for (const game of games) { - const catalogue = await searchSteamGames({ query: game.title }); + const steamGame = await getSteamGameById(game.objectID); - if (catalogue.length) { - const [steamGame] = catalogue; - - if (steamGame.repacks.length) { - results.push(game); - } + if (steamGame?.repacks.length) { + results.push(game); } } diff --git a/src/main/events/catalogue/search-games.ts b/src/main/events/catalogue/search-games.ts index ec397599..ebe601f2 100644 --- a/src/main/events/catalogue/search-games.ts +++ b/src/main/events/catalogue/search-games.ts @@ -1,10 +1,25 @@ import { registerEvent } from "../register-event"; -import { searchSteamGames } from "../helpers/search-games"; +import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; import { CatalogueEntry } from "@types"; +import { HydraApi, RepacksManager } from "@main/services"; const searchGamesEvent = async ( _event: Electron.IpcMainInvokeEvent, query: string -): Promise => searchSteamGames({ query, limit: 12 }); +): Promise => { + const games = await HydraApi.get< + { objectId: string; title: string; shop: string }[] + >("/games/search", { title: query, take: 12, skip: 0 }, { needsAuth: false }); + + const steamGames = games.map((game) => { + return convertSteamGameToCatalogueEntry({ + id: Number(game.objectId), + name: game.title, + clientIcon: null, + }); + }); + + return RepacksManager.findRepacksForCatalogueEntries(steamGames); +}; registerEvent("searchGames", searchGamesEvent); diff --git a/src/main/events/helpers/search-games.ts b/src/main/events/helpers/search-games.ts index 74be1f07..c5878dcb 100644 --- a/src/main/events/helpers/search-games.ts +++ b/src/main/events/helpers/search-games.ts @@ -1,6 +1,3 @@ -import { orderBy } from "lodash-es"; -import flexSearch from "flexsearch"; - import type { GameShop, CatalogueEntry, SteamGame } from "@types"; import { getSteamAppAsset } from "@main/helpers"; @@ -23,20 +20,18 @@ export const convertSteamGameToCatalogueEntry = ( repacks: [], }); -export const searchSteamGames = async ( - options: flexSearch.SearchOptions -): Promise => { - const steamGames = (await steamGamesWorker.run(options, { - name: "search", - })) as SteamGame[]; +export const getSteamGameById = async ( + objectId: string +): Promise => { + const steamGame = await steamGamesWorker.run(Number(objectId), { + name: "getById", + }); - const result = RepacksManager.findRepacksForCatalogueEntries( - steamGames.map((game) => convertSteamGameToCatalogueEntry(game)) - ); + if (!steamGame) return null; - return orderBy( - result, - [({ repacks }) => repacks.length, "repacks"], - ["desc"] - ); + const catalogueEntry = convertSteamGameToCatalogueEntry(steamGame); + + const result = RepacksManager.findRepacksForCatalogueEntry(catalogueEntry); + + return result; }; diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 120d27ac..bbf390d0 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -7,6 +7,10 @@ import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id"; import { logger } from "./logger"; import { UserNotLoggedInError } from "@shared"; +interface HydraApiOptions { + needsAuth: boolean; +} + export class HydraApi { private static instance: AxiosInstance; @@ -204,50 +208,76 @@ export class HydraApi { throw err; }; - static async get(url: string, params?: any) { - if (!this.isLoggedIn()) throw new UserNotLoggedInError(); + static async get( + url: string, + params?: any, + options?: HydraApiOptions + ) { + if (!options || options.needsAuth) { + if (!this.isLoggedIn()) throw new UserNotLoggedInError(); + await this.revalidateAccessTokenIfExpired(); + } - await this.revalidateAccessTokenIfExpired(); return this.instance .get(url, { params, ...this.getAxiosConfig() }) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async post(url: string, data?: any) { - if (!this.isLoggedIn()) throw new UserNotLoggedInError(); + static async post( + url: string, + data?: any, + options?: HydraApiOptions + ) { + if (!options || options.needsAuth) { + if (!this.isLoggedIn()) throw new UserNotLoggedInError(); + await this.revalidateAccessTokenIfExpired(); + } - await this.revalidateAccessTokenIfExpired(); return this.instance .post(url, data, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async put(url: string, data?: any) { - if (!this.isLoggedIn()) throw new UserNotLoggedInError(); + static async put( + url: string, + data?: any, + options?: HydraApiOptions + ) { + if (!options || options.needsAuth) { + if (!this.isLoggedIn()) throw new UserNotLoggedInError(); + await this.revalidateAccessTokenIfExpired(); + } - await this.revalidateAccessTokenIfExpired(); return this.instance .put(url, data, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async patch(url: string, data?: any) { - if (!this.isLoggedIn()) throw new UserNotLoggedInError(); + static async patch( + url: string, + data?: any, + options?: HydraApiOptions + ) { + if (!options || options.needsAuth) { + if (!this.isLoggedIn()) throw new UserNotLoggedInError(); + await this.revalidateAccessTokenIfExpired(); + } - await this.revalidateAccessTokenIfExpired(); return this.instance .patch(url, data, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async delete(url: string) { - if (!this.isLoggedIn()) throw new UserNotLoggedInError(); + static async delete(url: string, options?: HydraApiOptions) { + if (!options || options.needsAuth) { + if (!this.isLoggedIn()) throw new UserNotLoggedInError(); + await this.revalidateAccessTokenIfExpired(); + } - await this.revalidateAccessTokenIfExpired(); return this.instance .delete(url, this.getAxiosConfig()) .then((response) => response.data) diff --git a/src/main/services/repacks-manager.ts b/src/main/services/repacks-manager.ts index 93157d6c..933d7431 100644 --- a/src/main/services/repacks-manager.ts +++ b/src/main/services/repacks-manager.ts @@ -49,6 +49,11 @@ export class RepacksManager { .map((index) => this.repacks[index]); } + public static findRepacksForCatalogueEntry(entry: CatalogueEntry) { + const repacks = this.search({ query: formatName(entry.title) }); + return { ...entry, repacks }; + } + public static findRepacksForCatalogueEntries(entries: CatalogueEntry[]) { return entries.map((entry) => { const repacks = this.search({ query: formatName(entry.title) }); diff --git a/src/main/workers/steam-games.worker.ts b/src/main/workers/steam-games.worker.ts index ad399943..9085082b 100644 --- a/src/main/workers/steam-games.worker.ts +++ b/src/main/workers/steam-games.worker.ts @@ -1,36 +1,15 @@ import { SteamGame } from "@types"; -import { orderBy, slice } from "lodash-es"; -import flexSearch from "flexsearch"; +import { slice } from "lodash-es"; import fs from "node:fs"; -import { formatName } from "@shared"; import { workerData } from "node:worker_threads"; -const steamGamesIndex = new flexSearch.Index({ - tokenize: "reverse", -}); - const { steamGamesPath } = workerData; const data = fs.readFileSync(steamGamesPath, "utf-8"); const steamGames = JSON.parse(data) as SteamGame[]; -for (let i = 0; i < steamGames.length; i++) { - const steamGame = steamGames[i]; - - const formattedName = formatName(steamGame.name); - - steamGamesIndex.add(i, formattedName); -} - -export const search = (options: flexSearch.SearchOptions) => { - const results = steamGamesIndex.search(options); - const games = results.map((index) => steamGames[index]); - - return orderBy(games, ["name"], ["asc"]); -}; - export const getById = (id: number) => steamGames.find((game) => game.id === id); diff --git a/src/renderer/src/pages/home/search-results.tsx b/src/renderer/src/pages/home/search-results.tsx index 30b3ea68..4ca72487 100644 --- a/src/renderer/src/pages/home/search-results.tsx +++ b/src/renderer/src/pages/home/search-results.tsx @@ -6,7 +6,7 @@ import type { CatalogueEntry } from "@types"; import type { DebouncedFunc } from "lodash"; import { debounce } from "lodash"; -import { InboxIcon } from "@primer/octicons-react"; +import { InboxIcon, SearchIcon } from "@primer/octicons-react"; import { clearSearch } from "@renderer/features"; import { useAppDispatch } from "@renderer/hooks"; import { useEffect, useRef, useState } from "react"; @@ -25,8 +25,10 @@ export function SearchResults() { const [searchResults, setSearchResults] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [showTypingMessage, setShowTypingMessage] = useState(false); const debouncedFunc = useRef void> | null>(null); + const abortControllerRef = useRef(null); const navigate = useNavigate(); @@ -38,21 +40,64 @@ export function SearchResults() { useEffect(() => { setIsLoading(true); if (debouncedFunc.current) debouncedFunc.current.cancel(); + if (abortControllerRef.current) abortControllerRef.current.abort(); + + const abortController = new AbortController(); + abortControllerRef.current = abortController; debouncedFunc.current = debounce(() => { + const query = searchParams.get("query") ?? ""; + + if (query.length < 3) { + setIsLoading(false); + setShowTypingMessage(true); + setSearchResults([]); + return; + } + + setShowTypingMessage(false); window.electron - .searchGames(searchParams.get("query") ?? "") + .searchGames(query) .then((results) => { + if (abortController.signal.aborted) return; + setSearchResults(results); + setIsLoading(false); }) - .finally(() => { + .catch(() => { setIsLoading(false); }); - }, 300); + }, 500); debouncedFunc.current(); }, [searchParams, dispatch]); + const noResultsContent = () => { + if (isLoading) return null; + + if (showTypingMessage) { + return ( +
+ + +

{t("start_typing")}

+
+ ); + } + + if (searchResults.length === 0) { + return ( +
+ + +

{t("no_results")}

+
+ ); + } + + return null; + }; + return (
@@ -75,13 +120,7 @@ export function SearchResults() { )}
- {!isLoading && searchResults.length === 0 && ( -
- - -

{t("no_results")}

-
- )} + {noResultsContent()}
);