Compare commits

..

13 Commits

Author SHA1 Message Date
Chubby Granny Chaser
256d829a60 feat: adding translations 2025-11-30 03:15:27 +00:00
Chubby Granny Chaser
8cb18578e0 Merge branch 'main' into feat/search-autosuggest 2025-11-30 03:06:00 +00:00
Chubby Granny Chaser
62950297e0 Merge pull request #1874 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-2e94d63b2a
chore(deps): bump tar from 7.5.1 to 7.5.2 in the npm_and_yarn group across 1 directory
2025-11-30 03:05:46 +00:00
Chubby Granny Chaser
3eecc42430 Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-2e94d63b2a 2025-11-30 03:04:37 +00:00
Chubby Granny Chaser
f6edb45628 Merge pull request #1875 from hydralauncher/dependabot/npm_and_yarn/npm_and_yarn-3c67cbb9cd
chore(deps): bump js-yaml from 4.1.0 to 4.1.1 in the npm_and_yarn group across 1 directory
2025-11-30 03:04:30 +00:00
Chubby Granny Chaser
de8797bea6 Merge branch 'main' into dependabot/npm_and_yarn/npm_and_yarn-3c67cbb9cd 2025-11-30 03:04:23 +00:00
Chubby Granny Chaser
828f82f647 Merge pull request #1879 from hydralauncher/feat/adding-level-generic-interface
Feat/adding level generic interface
2025-11-30 03:04:12 +00:00
Moyasee
bb22d9c4dd ci: migration of search history from localStorage to LevelDB and highlighting fix 2025-11-29 05:30:10 +02:00
Moyasee
559bb45acc Merge branch 'feat/adding-level-generic-interface' of https://github.com/hydralauncher/hydra into feat/search-autosuggest 2025-11-29 05:10:54 +02:00
dependabot[bot]
4e92e794be chore(deps): bump js-yaml in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [js-yaml](https://github.com/nodeca/js-yaml).


Updates `js-yaml` from 4.1.0 to 4.1.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 04:55:31 +00:00
dependabot[bot]
de0dbcac35 chore(deps): bump tar in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [tar](https://github.com/isaacs/node-tar).


Updates `tar` from 7.5.1 to 7.5.2
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.1...v7.5.2)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-25 04:55:16 +00:00
Moyasee
07d5a5b3f3 Merge branch 'feat/search-autosuggest' of https://github.com/hydralauncher/hydra into feat/search-autosuggest 2025-11-22 07:31:15 +02:00
Moyasee
a1117c8269 feat: improving suggestion dropdown design 2025-11-22 07:26:48 +02:00
13 changed files with 117 additions and 67 deletions

View File

@@ -84,7 +84,7 @@
"sound-play": "^1.1.0", "sound-play": "^1.1.0",
"steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor", "steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor",
"sudo-prompt": "^9.2.1", "sudo-prompt": "^9.2.1",
"tar": "^7.4.3", "tar": "^7.5.2",
"tough-cookie": "^5.1.1", "tough-cookie": "^5.1.1",
"user-agents": "^1.1.387", "user-agents": "^1.1.387",
"uuid": "^13.0.0", "uuid": "^13.0.0",

View File

@@ -96,7 +96,7 @@
"search_library": "Search library", "search_library": "Search library",
"recent_searches": "Recent Searches", "recent_searches": "Recent Searches",
"suggestions": "Suggestions", "suggestions": "Suggestions",
"clear_history": "Clear history", "clear_history": "Clear",
"remove_from_history": "Remove from history", "remove_from_history": "Remove from history",
"loading": "Loading...", "loading": "Loading...",
"no_results": "No results", "no_results": "No results",

View File

@@ -93,8 +93,16 @@
}, },
"header": { "header": {
"search": "Buscar juegos", "search": "Buscar juegos",
"search_library": "Buscar en la librería",
"recent_searches": "Búsquedas Recientes",
"suggestions": "Sugerencias",
"clear_history": "Limpiar",
"remove_from_history": "Eliminar del historial",
"loading": "Cargando...",
"no_results": "Sin resultados",
"home": "Inicio", "home": "Inicio",
"catalogue": "Catálogo", "catalogue": "Catálogo",
"library": "Librería",
"downloads": "Descargas", "downloads": "Descargas",
"search_results": "Resultados de búsqueda", "search_results": "Resultados de búsqueda",
"settings": "Ajustes", "settings": "Ajustes",

View File

@@ -93,11 +93,19 @@
}, },
"header": { "header": {
"search": "Buscar jogos", "search": "Buscar jogos",
"search_library": "Buscar na biblioteca",
"recent_searches": "Buscas Recentes",
"suggestions": "Sugestões",
"clear_history": "Limpar",
"remove_from_history": "Remover do histórico",
"loading": "Carregando...",
"no_results": "Sem resultados",
"home": "Início",
"catalogue": "Catálogo", "catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Downloads", "downloads": "Downloads",
"search_results": "Resultados da busca", "search_results": "Resultados da busca",
"settings": "Ajustes", "settings": "Ajustes",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.", "version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.",
"version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download." "version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download."
}, },

View File

@@ -30,11 +30,19 @@
}, },
"header": { "header": {
"search": "Procurar jogos", "search": "Procurar jogos",
"search_library": "Procurar na biblioteca",
"recent_searches": "Pesquisas Recentes",
"suggestions": "Sugestões",
"clear_history": "Limpar",
"remove_from_history": "Remover do histórico",
"loading": "A carregar...",
"no_results": "Sem resultados",
"home": "Início",
"catalogue": "Catálogo", "catalogue": "Catálogo",
"library": "Biblioteca",
"downloads": "Transferências", "downloads": "Transferências",
"search_results": "Resultados da pesquisa", "search_results": "Resultados da pesquisa",
"settings": "Definições", "settings": "Definições",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clica aqui para reiniciar e instalar.", "version_available_install": "Versão {{version}} disponível. Clica aqui para reiniciar e instalar.",
"version_available_download": "Versão {{version}} disponível. Clica aqui para fazer o download." "version_available_download": "Versão {{version}} disponível. Clica aqui para fazer o download."
}, },

View File

@@ -93,8 +93,16 @@
}, },
"header": { "header": {
"search": "Поиск", "search": "Поиск",
"search_library": "Поиск в библиотеке",
"recent_searches": "Недавние поиски",
"suggestions": "Предложения",
"clear_history": "Очистить",
"remove_from_history": "Удалить из истории",
"loading": "Загрузка...",
"no_results": "Нет результатов",
"home": "Главная", "home": "Главная",
"catalogue": "Каталог", "catalogue": "Каталог",
"library": "Библиотека",
"downloads": "Загрузки", "downloads": "Загрузки",
"search_results": "Результаты поиска", "search_results": "Результаты поиска",
"settings": "Настройки", "settings": "Настройки",

View File

@@ -15,6 +15,8 @@ import { AutoUpdateSubHeader } from "./auto-update-sub-header";
import { setFilters, setLibrarySearchQuery } from "@renderer/features"; import { setFilters, setLibrarySearchQuery } from "@renderer/features";
import cn from "classnames"; import cn from "classnames";
import { SearchDropdown } from "@renderer/components"; import { SearchDropdown } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import type { GameShop } from "@types";
const pathTitle: Record<string, string> = { const pathTitle: Record<string, string> = {
"/": "home", "/": "home",
@@ -161,11 +163,11 @@ export function Header() {
const handleSelectSuggestion = (suggestion: { const handleSelectSuggestion = (suggestion: {
title: string; title: string;
objectId: string; objectId: string;
shop: string; shop: GameShop;
}) => { }) => {
setIsDropdownVisible(false); setIsDropdownVisible(false);
inputRef.current?.blur(); inputRef.current?.blur();
navigate(`/game/${suggestion.shop}/${suggestion.objectId}`); navigate(buildGameDetailsPath(suggestion));
}; };
const handleClearSearch = () => { const handleClearSearch = () => {

View File

@@ -19,24 +19,25 @@ export function HighlightText({ text, query }: Readonly<HighlightTextProps>) {
return <>{text}</>; return <>{text}</>;
} }
const textWords = text.split(/\b/); const matches: { start: number; end: number }[] = [];
const matches: { start: number; end: number; text: string }[] = []; const textLower = text.toLowerCase();
let currentIndex = 0; queryWords.forEach((queryWord) => {
textWords.forEach((word) => { const escapedQuery = queryWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const wordLower = word.toLowerCase(); const regex = new RegExp(
`(?:^|[\\s])${escapedQuery}(?=[\\s]|$)|^${escapedQuery}$`,
"gi"
);
queryWords.forEach((queryWord) => { let match;
if (wordLower === queryWord) { while ((match = regex.exec(textLower)) !== null) {
matches.push({ const matchedText = match[0];
start: currentIndex, const leadingSpace = matchedText.startsWith(" ") ? 1 : 0;
end: currentIndex + word.length, const start = match.index + leadingSpace;
text: word, const end = start + queryWord.length;
});
}
});
currentIndex += word.length; matches.push({ start, end });
}
}); });
if (matches.length === 0) { if (matches.length === 0) {
@@ -46,16 +47,14 @@ export function HighlightText({ text, query }: Readonly<HighlightTextProps>) {
matches.sort((a, b) => a.start - b.start); matches.sort((a, b) => a.start - b.start);
const mergedMatches: { start: number; end: number }[] = []; const mergedMatches: { start: number; end: number }[] = [];
if (matches.length === 0) {
return <>{text}</>;
}
let current = matches[0]; let current = matches[0];
for (let i = 1; i < matches.length; i++) { for (let i = 1; i < matches.length; i++) {
if (matches[i].start <= current.end) { if (matches[i].start <= current.end) {
current.end = Math.max(current.end, matches[i].end); current = {
start: current.start,
end: Math.max(current.end, matches[i].end),
};
} else { } else {
mergedMatches.push(current); mergedMatches.push(current);
current = matches[i]; current = matches[i];

View File

@@ -24,7 +24,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 8px 12px 4px; padding: 8px 12px 8px;
margin-bottom: 4px;
} }
&__section-title { &__section-title {
@@ -35,19 +36,19 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
&__clear-button { &__clear-text-button {
color: globals.$muted-color; color: globals.$muted-color;
cursor: pointer; cursor: pointer;
padding: 4px; padding: 0;
border-radius: 4px; font-size: 11px;
transition: all ease 0.2s; font-weight: bold;
display: flex; text-transform: uppercase;
align-items: center; transition: color ease 0.2s;
justify-content: center; background: transparent;
border: none;
&:hover { &:hover {
color: #ffffff; color: #ffffff;
background-color: rgba(255, 255, 255, 0.15);
} }
} }
@@ -74,9 +75,8 @@
transform: translateY(-50%); transform: translateY(-50%);
color: globals.$muted-color; color: globals.$muted-color;
padding: 4px; padding: 4px;
border-radius: 4px;
opacity: 0; opacity: 0;
transition: all ease 0.15s; transition: opacity ease 0.15s;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@@ -1,11 +1,6 @@
import { useEffect, useRef, useCallback, useState } from "react"; import { useEffect, useRef, useCallback, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
ClockIcon,
SearchIcon,
TrashIcon,
XIcon,
} from "@primer/octicons-react";
import cn from "classnames"; import cn from "classnames";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history"; import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history";
@@ -144,11 +139,10 @@ export function SearchDropdown({
</span> </span>
<button <button
type="button" type="button"
className="search-dropdown__clear-button" className="search-dropdown__clear-text-button"
onClick={onClearHistory} onClick={onClearHistory}
title={t("clear_history")}
> >
<TrashIcon size={14} /> {t("clear_history")}
</button> </button>
</div> </div>
<ul className="search-dropdown__list"> <ul className="search-dropdown__list">

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { levelDBService } from "@renderer/services/leveldb.service";
export interface SearchHistoryEntry { export interface SearchHistoryEntry {
query: string; query: string;
@@ -6,22 +7,32 @@ export interface SearchHistoryEntry {
context: "library" | "catalogue"; context: "library" | "catalogue";
} }
const STORAGE_KEY = "search-history"; const LEVELDB_KEY = "searchHistory";
const MAX_HISTORY_ENTRIES = 15; const MAX_HISTORY_ENTRIES = 15;
export function useSearchHistory() { export function useSearchHistory() {
const [history, setHistory] = useState<SearchHistoryEntry[]>([]); const [history, setHistory] = useState<SearchHistoryEntry[]>([]);
const isInitialized = useRef(false);
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY); const loadHistory = async () => {
if (stored) { if (isInitialized.current) return;
isInitialized.current = true;
try { try {
const parsed = JSON.parse(stored) as SearchHistoryEntry[]; const data = (await levelDBService.get(LEVELDB_KEY, null, "json")) as
setHistory(parsed); | SearchHistoryEntry[]
| null;
if (data) {
setHistory(data);
}
} catch { } catch {
localStorage.removeItem(STORAGE_KEY); setHistory([]);
} }
} };
loadHistory();
}, []); }, []);
const addToHistory = useCallback( const addToHistory = useCallback(
@@ -39,7 +50,7 @@ export function useSearchHistory() {
(entry) => entry.query.toLowerCase() !== query.toLowerCase().trim() (entry) => entry.query.toLowerCase() !== query.toLowerCase().trim()
); );
const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES); const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); levelDBService.put(LEVELDB_KEY, updated, null, "json");
return updated; return updated;
}); });
}, },
@@ -49,14 +60,14 @@ export function useSearchHistory() {
const removeFromHistory = useCallback((query: string) => { const removeFromHistory = useCallback((query: string) => {
setHistory((prev) => { setHistory((prev) => {
const updated = prev.filter((entry) => entry.query !== query); const updated = prev.filter((entry) => entry.query !== query);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); levelDBService.put(LEVELDB_KEY, updated, null, "json");
return updated; return updated;
}); });
}, []); }, []);
const clearHistory = useCallback(() => { const clearHistory = useCallback(() => {
setHistory([]); setHistory([]);
localStorage.removeItem(STORAGE_KEY); levelDBService.del(LEVELDB_KEY, null);
}, []); }, []);
const getRecentHistory = useCallback( const getRecentHistory = useCallback(

View File

@@ -2,11 +2,12 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { useAppSelector } from "./redux"; import { useAppSelector } from "./redux";
import { debounce } from "lodash-es"; import { debounce } from "lodash-es";
import { logger } from "@renderer/logger"; import { logger } from "@renderer/logger";
import type { GameShop } from "@types";
export interface SearchSuggestion { export interface SearchSuggestion {
title: string; title: string;
objectId: string; objectId: string;
shop: string; shop: GameShop;
iconUrl: string | null; iconUrl: string | null;
source: "library" | "catalogue"; source: "library" | "catalogue";
} }
@@ -20,6 +21,7 @@ export function useSearchSuggestions(
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const library = useAppSelector((state) => state.library.value); const library = useAppSelector((state) => state.library.value);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const cacheRef = useRef<Map<string, SearchSuggestion[]>>(new Map());
const getLibrarySuggestions = useCallback( const getLibrarySuggestions = useCallback(
(searchQuery: string, limit: number = 3): SearchSuggestion[] => { (searchQuery: string, limit: number = 3): SearchSuggestion[] => {
@@ -68,6 +70,15 @@ export function useSearchSuggestions(
return; return;
} }
const cacheKey = `${searchQuery.toLowerCase()}_${limit}`;
const cachedResults = cacheRef.current.get(cacheKey);
if (cachedResults) {
setSuggestions(cachedResults);
setIsLoading(false);
return;
}
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
const abortController = new AbortController(); const abortController = new AbortController();
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
@@ -79,7 +90,7 @@ export function useSearchSuggestions(
{ {
title: string; title: string;
objectId: string; objectId: string;
shop: string; shop: GameShop;
iconUrl: string | null; iconUrl: string | null;
}[] }[]
>("/catalogue/search/suggestions", { >("/catalogue/search/suggestions", {
@@ -99,6 +110,7 @@ export function useSearchSuggestions(
}) })
); );
cacheRef.current.set(cacheKey, catalogueSuggestions);
setSuggestions(catalogueSuggestions); setSuggestions(catalogueSuggestions);
} catch (error) { } catch (error) {
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {

View File

@@ -6205,9 +6205,9 @@ jiti@^2.6.1:
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@^4.1.0: js-yaml@^4.1.0:
version "4.1.0" version "4.1.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
dependencies: dependencies:
argparse "^2.0.1" argparse "^2.0.1"
@@ -8518,10 +8518,10 @@ tar@^6.0.5, tar@^6.1.11, tar@^6.1.12, tar@^6.2.1:
mkdirp "^1.0.3" mkdirp "^1.0.3"
yallist "^4.0.0" yallist "^4.0.0"
tar@^7.4.3: tar@^7.5.2:
version "7.5.1" version "7.5.2"
resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.1.tgz#750a8bd63b7c44c1848e7bf982260a083cf747c9" resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.2.tgz#115c061495ec51ff3c6745ff8f6d0871c5b1dedc"
integrity sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g== integrity sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==
dependencies: dependencies:
"@isaacs/fs-minipass" "^4.0.0" "@isaacs/fs-minipass" "^4.0.0"
chownr "^3.0.0" chownr "^3.0.0"