Merge pull request #1866 from hydralauncher/feat/search-autosuggest

Feat: search history and auto-suggest
This commit is contained in:
Chubby Granny Chaser
2025-11-30 03:19:57 +00:00
committed by GitHub
11 changed files with 109 additions and 59 deletions

View File

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

View File

@@ -93,8 +93,16 @@
},
"header": {
"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",
"catalogue": "Catálogo",
"library": "Librería",
"downloads": "Descargas",
"search_results": "Resultados de búsqueda",
"settings": "Ajustes",

View File

@@ -93,11 +93,19 @@
},
"header": {
"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",
"library": "Biblioteca",
"downloads": "Downloads",
"search_results": "Resultados da busca",
"settings": "Ajustes",
"home": "Início",
"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."
},

View File

@@ -30,11 +30,19 @@
},
"header": {
"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",
"library": "Biblioteca",
"downloads": "Transferências",
"search_results": "Resultados da pesquisa",
"settings": "Definições",
"home": "Início",
"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."
},

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,6 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { createPortal } from "react-dom";
import {
ClockIcon,
SearchIcon,
TrashIcon,
XIcon,
} from "@primer/octicons-react";
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import cn from "classnames";
import { useTranslation } from "react-i18next";
import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history";
@@ -144,11 +139,10 @@ export function SearchDropdown({
</span>
<button
type="button"
className="search-dropdown__clear-button"
className="search-dropdown__clear-text-button"
onClick={onClearHistory}
title={t("clear_history")}
>
<TrashIcon size={14} />
{t("clear_history")}
</button>
</div>
<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 {
query: string;
@@ -6,22 +7,32 @@ export interface SearchHistoryEntry {
context: "library" | "catalogue";
}
const STORAGE_KEY = "search-history";
const LEVELDB_KEY = "searchHistory";
const MAX_HISTORY_ENTRIES = 15;
export function useSearchHistory() {
const [history, setHistory] = useState<SearchHistoryEntry[]>([]);
const isInitialized = useRef(false);
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const loadHistory = async () => {
if (isInitialized.current) return;
isInitialized.current = true;
try {
const parsed = JSON.parse(stored) as SearchHistoryEntry[];
setHistory(parsed);
const data = (await levelDBService.get(LEVELDB_KEY, null, "json")) as
| SearchHistoryEntry[]
| null;
if (data) {
setHistory(data);
}
} catch {
localStorage.removeItem(STORAGE_KEY);
setHistory([]);
}
}
};
loadHistory();
}, []);
const addToHistory = useCallback(
@@ -39,7 +50,7 @@ export function useSearchHistory() {
(entry) => entry.query.toLowerCase() !== query.toLowerCase().trim()
);
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;
});
},
@@ -49,14 +60,14 @@ export function useSearchHistory() {
const removeFromHistory = useCallback((query: string) => {
setHistory((prev) => {
const updated = prev.filter((entry) => entry.query !== query);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
levelDBService.put(LEVELDB_KEY, updated, null, "json");
return updated;
});
}, []);
const clearHistory = useCallback(() => {
setHistory([]);
localStorage.removeItem(STORAGE_KEY);
levelDBService.del(LEVELDB_KEY, null);
}, []);
const getRecentHistory = useCallback(

View File

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