From be3ce6e2db685f3897c23090abc8ef9efc2dfbee Mon Sep 17 00:00:00 2001 From: Snehit Sah Date: Fri, 14 Nov 2025 20:20:26 +0530 Subject: [PATCH 01/22] ci: fix version name in aur commit omit extra 'v' in commit message --- .github/workflows/update-aur.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-aur.yml b/.github/workflows/update-aur.yml index fa12b500..22fcc49a 100644 --- a/.github/workflows/update-aur.yml +++ b/.github/workflows/update-aur.yml @@ -137,7 +137,7 @@ jobs: if git diff --staged --quiet; then echo "No changes to commit" else - COMMIT_MSG="v${{ steps.get-version.outputs.version }}" + COMMIT_MSG="${{ steps.get-version.outputs.version }}" git commit -m "$COMMIT_MSG" From 28bf7b8764d3976c1cc3fbbdb2775f6e3ae683e5 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 15 Nov 2025 21:02:28 +0200 Subject: [PATCH 02/22] feat: search history and suggestions --- src/locales/en/translation.json | 5 + src/main/services/window-manager.ts | 2 +- src/renderer/src/components/header/header.tsx | 182 +++++++++++++- src/renderer/src/components/index.ts | 1 + .../search-dropdown/highlight-text.tsx | 106 +++++++++ .../search-dropdown/search-dropdown.scss | 122 ++++++++++ .../search-dropdown/search-dropdown.tsx | 225 ++++++++++++++++++ src/renderer/src/hooks/index.ts | 2 + src/renderer/src/hooks/use-search-history.ts | 78 ++++++ .../src/hooks/use-search-suggestions.ts | 149 ++++++++++++ 10 files changed, 864 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/search-dropdown/highlight-text.tsx create mode 100644 src/renderer/src/components/search-dropdown/search-dropdown.scss create mode 100644 src/renderer/src/components/search-dropdown/search-dropdown.tsx create mode 100644 src/renderer/src/hooks/use-search-history.ts create mode 100644 src/renderer/src/hooks/use-search-suggestions.ts diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 5084a4a0..53153a93 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -94,6 +94,11 @@ "header": { "search": "Search games", "search_library": "Search library", + "recent_searches": "Recent Searches", + "suggestions": "Suggestions", + "clear_history": "Clear history", + "loading": "Loading...", + "no_results": "No results", "home": "Home", "catalogue": "Catalogue", "library": "Library", diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index b11b4a9b..2475e485 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -198,7 +198,7 @@ export class WindowManager { this.mainWindow.on("ready-to-show", () => { if (!app.isPackaged || isStaging) - WindowManager.mainWindow?.webContents.openDevTools(); + WindowManager.mainWindow?.webContents.openDevTools({ mode: "detach" }); WindowManager.mainWindow?.show(); }); diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index d3164ced..435c049b 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -3,12 +3,18 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react"; -import { useAppDispatch, useAppSelector } from "@renderer/hooks"; +import { + useAppDispatch, + useAppSelector, + useSearchHistory, + useSearchSuggestions, +} from "@renderer/hooks"; import "./header.scss"; import { AutoUpdateSubHeader } from "./auto-update-sub-header"; import { setFilters, setLibrarySearchQuery } from "@renderer/features"; import cn from "classnames"; +import { SearchDropdown } from "@renderer/components"; const pathTitle: Record = { "/": "home", @@ -20,6 +26,7 @@ const pathTitle: Record = { export function Header() { const inputRef = useRef(null); + const searchContainerRef = useRef(null); const navigate = useNavigate(); const location = useLocation(); @@ -37,6 +44,7 @@ export function Header() { ); const isOnLibraryPage = location.pathname.startsWith("/library"); + const isOnCataloguePage = location.pathname.startsWith("/catalogue"); const searchValue = isOnLibraryPage ? librarySearchValue @@ -45,9 +53,29 @@ export function Header() { const dispatch = useAppDispatch(); const [isFocused, setIsFocused] = useState(false); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const [dropdownPosition, setDropdownPosition] = useState({ + x: 0, + y: 0, + }); const { t } = useTranslation("header"); + const { addToHistory, removeFromHistory, clearHistory, getRecentHistory } = + useSearchHistory(); + + const { suggestions, isLoading: isLoadingSuggestions } = useSearchSuggestions( + searchValue, + isOnLibraryPage, + isDropdownVisible && isFocused && !isOnCataloguePage + ); + + const historyItems = getRecentHistory( + isOnLibraryPage ? "library" : "catalogue", + 3 + ); + const title = useMemo(() => { if (location.pathname.startsWith("/game")) return headerTitle; if (location.pathname.startsWith("/achievements")) return headerTitle; @@ -59,13 +87,43 @@ export function Header() { return t(pathTitle[location.pathname]); }, [location.pathname, headerTitle, t]); + const totalItems = historyItems.length + suggestions.length; + + const updateDropdownPosition = () => { + if (searchContainerRef.current) { + const rect = searchContainerRef.current.getBoundingClientRect(); + setDropdownPosition({ + x: rect.left, + y: rect.bottom, + }); + } + }; + const focusInput = () => { setIsFocused(true); inputRef.current?.focus(); }; + const handleFocus = () => { + if (isFocused && isDropdownVisible) { + updateDropdownPosition(); + return; + } + + setIsFocused(true); + setActiveIndex(-1); + setTimeout(() => { + updateDropdownPosition(); + setIsDropdownVisible(true); + }, 220); + }; + const handleBlur = () => { - setIsFocused(false); + setTimeout(() => { + setIsFocused(false); + setIsDropdownVisible(false); + setActiveIndex(-1); + }, 200); }; const handleBackButtonClick = () => { @@ -77,10 +135,37 @@ export function Header() { dispatch(setLibrarySearchQuery(value.slice(0, 255))); } else { dispatch(setFilters({ title: value.slice(0, 255) })); - if (!location.pathname.startsWith("/catalogue")) { - navigate("/catalogue"); - } } + setActiveIndex(-1); + }; + + const executeSearch = (query: string) => { + const context = isOnLibraryPage ? "library" : "catalogue"; + if (query.trim()) { + addToHistory(query, context); + } + handleSearch(query); + + if (!isOnLibraryPage && !location.pathname.startsWith("/catalogue")) { + navigate("/catalogue"); + } + + setIsDropdownVisible(false); + inputRef.current?.blur(); + }; + + const handleSelectHistory = (query: string) => { + executeSearch(query); + }; + + const handleSelectSuggestion = (suggestion: { + title: string; + objectId: string; + shop: string; + }) => { + setIsDropdownVisible(false); + inputRef.current?.blur(); + navigate(`/game/${suggestion.shop}/${suggestion.objectId}`); }; const handleClearSearch = () => { @@ -89,14 +174,75 @@ export function Header() { } else { dispatch(setFilters({ title: "" })); } + setActiveIndex(-1); + }; + + const handleClearHistory = () => { + clearHistory(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + if (activeIndex >= 0 && activeIndex < totalItems) { + if (activeIndex < historyItems.length) { + handleSelectHistory(historyItems[activeIndex].query); + } else { + const suggestionIndex = activeIndex - historyItems.length; + handleSelectSuggestion(suggestions[suggestionIndex]); + } + } else if (searchValue.trim()) { + executeSearch(searchValue); + } + } else if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev)); + if (!isDropdownVisible) { + setIsDropdownVisible(true); + updateDropdownPosition(); + } + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((prev) => (prev > -1 ? prev - 1 : -1)); + } else if (event.key === "Escape") { + event.preventDefault(); + setIsDropdownVisible(false); + setActiveIndex(-1); + inputRef.current?.blur(); + } + }; + + const handleCloseDropdown = () => { + setIsDropdownVisible(false); + setActiveIndex(-1); }; useEffect(() => { - if (!location.pathname.startsWith("/catalogue") && catalogueSearchValue) { + const prevPath = sessionStorage.getItem("prevPath"); + const currentPath = location.pathname; + + if ( + prevPath?.startsWith("/catalogue") && + !currentPath.startsWith("/catalogue") && + catalogueSearchValue + ) { dispatch(setFilters({ title: "" })); } + + sessionStorage.setItem("prevPath", currentPath); }, [location.pathname, catalogueSearchValue, dispatch]); + useEffect(() => { + if (!isDropdownVisible) return; + + const handleResize = () => { + updateDropdownPosition(); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [isDropdownVisible]); + return ( <>
handleSearch(event.target.value)} - onFocus={() => setIsFocused(true)} + onFocus={handleFocus} onBlur={handleBlur} + onKeyDown={handleKeyDown} /> {searchValue && ( @@ -165,6 +313,26 @@ export function Header() {
+ + 0 || + suggestions.length > 0 || + isLoadingSuggestions) + } + position={dropdownPosition} + historyItems={historyItems} + suggestions={suggestions} + isLoadingSuggestions={isLoadingSuggestions} + onSelectHistory={handleSelectHistory} + onSelectSuggestion={handleSelectSuggestion} + onClearHistory={handleClearHistory} + onClose={handleCloseDropdown} + activeIndex={activeIndex} + currentQuery={searchValue} + searchContainerRef={searchContainerRef} + /> ); } diff --git a/src/renderer/src/components/index.ts b/src/renderer/src/components/index.ts index 89dccdbc..e8876fcb 100644 --- a/src/renderer/src/components/index.ts +++ b/src/renderer/src/components/index.ts @@ -19,3 +19,4 @@ export * from "./context-menu/context-menu"; export * from "./game-context-menu/game-context-menu"; export * from "./game-context-menu/use-game-actions"; export * from "./star-rating/star-rating"; +export * from "./search-dropdown/search-dropdown"; diff --git a/src/renderer/src/components/search-dropdown/highlight-text.tsx b/src/renderer/src/components/search-dropdown/highlight-text.tsx new file mode 100644 index 00000000..9f8f0121 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/highlight-text.tsx @@ -0,0 +1,106 @@ +import React from "react"; + +interface HighlightTextProps { + text: string; + query: string; +} + +export function HighlightText({ text, query }: HighlightTextProps) { + if (!query.trim()) { + return <>{text}; + } + + const queryWords = query + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 0); + + if (queryWords.length === 0) { + return <>{text}; + } + + const textLower = text.toLowerCase(); + const textWords = text.split(/\b/); + const matches: Array<{ start: number; end: number; text: string }> = []; + + let currentIndex = 0; + textWords.forEach((word) => { + const wordLower = word.toLowerCase(); + + queryWords.forEach((queryWord) => { + if (wordLower === queryWord) { + matches.push({ + start: currentIndex, + end: currentIndex + word.length, + text: word, + }); + } + }); + + currentIndex += word.length; + }); + + if (matches.length === 0) { + return <>{text}; + } + + matches.sort((a, b) => a.start - b.start); + + const mergedMatches: Array<{ 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); + } else { + mergedMatches.push(current); + current = matches[i]; + } + } + mergedMatches.push(current); + + const parts: Array<{ text: string; highlight: boolean }> = []; + let lastIndex = 0; + + mergedMatches.forEach((match) => { + if (match.start > lastIndex) { + parts.push({ + text: text.slice(lastIndex, match.start), + highlight: false, + }); + } + + parts.push({ + text: text.slice(match.start, match.end), + highlight: true, + }); + + lastIndex = match.end; + }); + + if (lastIndex < text.length) { + parts.push({ + text: text.slice(lastIndex), + highlight: false, + }); + } + + return ( + <> + {parts.map((part, index) => + part.highlight ? ( + + {part.text} + + ) : ( + {part.text} + ) + )} + + ); +} diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.scss b/src/renderer/src/components/search-dropdown/search-dropdown.scss new file mode 100644 index 00000000..276619c2 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/search-dropdown.scss @@ -0,0 +1,122 @@ +@use "../../scss/globals.scss"; + +.search-dropdown { + position: fixed; + background-color: globals.$dark-background-color; + border: 1px solid globals.$border-color; + border-radius: 8px; + max-height: 300px; + overflow-y: auto; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 1000; + margin-top: 4px; + width: 250px; + + &__section { + padding: 4px 0; + + &:not(:last-child) { + border-bottom: 1px solid globals.$border-color; + } + } + + &__section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px 4px; + } + + &__section-title { + color: globals.$muted-color; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + &__clear-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; + + &:hover { + color: #dadbe1; + background-color: rgba(255, 255, 255, 0.1); + } + } + + &__list { + list-style: none; + padding: 0; + margin: 0; + } + + &__item { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.1s ease; + color: #dadbe1; + text-align: left; + border: none; + background: transparent; + + &:hover, + &--active { + background-color: globals.$background-color; + } + + &:focus { + outline: none; + } + } + + &__item-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + color: globals.$muted-color; + + &--image { + border-radius: 2px; + object-fit: cover; + } + } + + &__item-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + } + + &__loading, + &__empty { + padding: 16px 12px; + text-align: center; + color: globals.$muted-color; + font-size: 14px; + } + + &__empty { + font-style: italic; + } + + &__highlight { + background-color: rgba(255, 193, 7, 0.3); + color: #ffc107; + font-weight: 600; + padding: 0 2px; + border-radius: 2px; + } +} diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.tsx b/src/renderer/src/components/search-dropdown/search-dropdown.tsx new file mode 100644 index 00000000..a8f08401 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/search-dropdown.tsx @@ -0,0 +1,225 @@ +import { useEffect, useRef, useCallback, useState } from "react"; +import { createPortal } from "react-dom"; +import { ClockIcon, SearchIcon, TrashIcon } from "@primer/octicons-react"; +import cn from "classnames"; +import { useTranslation } from "react-i18next"; +import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history"; +import type { SearchSuggestion } from "@renderer/hooks/use-search-suggestions"; +import { HighlightText } from "./highlight-text"; +import "./search-dropdown.scss"; + +export interface SearchDropdownProps { + visible: boolean; + position: { x: number; y: number }; + historyItems: SearchHistoryEntry[]; + suggestions: SearchSuggestion[]; + isLoadingSuggestions: boolean; + onSelectHistory: (query: string) => void; + onSelectSuggestion: (suggestion: SearchSuggestion) => void; + onClearHistory: () => void; + onClose: () => void; + activeIndex: number; + currentQuery: string; + searchContainerRef?: React.RefObject; +} + +export function SearchDropdown({ + visible, + position, + historyItems, + suggestions, + isLoadingSuggestions, + onSelectHistory, + onSelectSuggestion, + onClearHistory, + onClose, + activeIndex, + currentQuery, + searchContainerRef, +}: SearchDropdownProps) { + const dropdownRef = useRef(null); + const [adjustedPosition, setAdjustedPosition] = useState(position); + const { t } = useTranslation("header"); + + useEffect(() => { + if (!visible) { + setAdjustedPosition(position); + return; + } + + const checkPosition = () => { + if (!dropdownRef.current) return; + + const rect = dropdownRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let adjustedX = position.x; + let adjustedY = position.y; + + if (adjustedX + 250 > viewportWidth - 10) { + adjustedX = Math.max(10, viewportWidth - 250 - 10); + } + + if (adjustedY + rect.height > viewportHeight - 10) { + adjustedY = Math.max(10, viewportHeight - rect.height - 10); + } + + setAdjustedPosition({ x: adjustedX, y: adjustedY }); + }; + + requestAnimationFrame(checkPosition); + }, [visible, position]); + + useEffect(() => { + if (!visible) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + + if ( + dropdownRef.current && + !dropdownRef.current.contains(target) && + !searchContainerRef?.current?.contains(target) + ) { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [visible, onClose, searchContainerRef]); + + const handleItemClick = useCallback( + ( + type: "history" | "suggestion", + item: SearchHistoryEntry | SearchSuggestion + ) => { + if (type === "history") { + onSelectHistory((item as SearchHistoryEntry).query); + } else { + onSelectSuggestion(item as SearchSuggestion); + } + }, + [onSelectHistory, onSelectSuggestion] + ); + + if (!visible) return null; + + const totalItems = historyItems.length + suggestions.length; + const hasHistory = historyItems.length > 0; + const hasSuggestions = suggestions.length > 0; + + const getItemIndex = ( + section: "history" | "suggestion", + indexInSection: number + ) => { + if (section === "history") { + return indexInSection; + } + return historyItems.length + indexInSection; + }; + + const dropdownContent = ( +
+ {hasHistory && ( +
+
+ + {t("recent_searches")} + + +
+
    + {historyItems.map((item, index) => ( +
  • + +
  • + ))} +
+
+ )} + + {hasSuggestions && ( +
+
+ + {t("suggestions")} + +
+
    + {suggestions.map((item, index) => ( +
  • + +
  • + ))} +
+
+ )} + + {isLoadingSuggestions && !hasSuggestions && !hasHistory && ( +
{t("loading")}
+ )} + + {!isLoadingSuggestions && + !hasHistory && + !hasSuggestions && + totalItems === 0 && ( +
{t("no_results")}
+ )} +
+ ); + + return createPortal(dropdownContent, document.body); +} diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 4d34f219..4c3c1bd2 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -8,3 +8,5 @@ export * from "./use-format"; export * from "./use-feature"; export * from "./use-download-options-listener"; export * from "./use-game-card"; +export * from "./use-search-history"; +export * from "./use-search-suggestions"; diff --git a/src/renderer/src/hooks/use-search-history.ts b/src/renderer/src/hooks/use-search-history.ts new file mode 100644 index 00000000..dac6d391 --- /dev/null +++ b/src/renderer/src/hooks/use-search-history.ts @@ -0,0 +1,78 @@ +import { useState, useCallback, useEffect } from "react"; + +export interface SearchHistoryEntry { + query: string; + timestamp: number; + context: "library" | "catalogue"; +} + +const STORAGE_KEY = "search-history"; +const MAX_HISTORY_ENTRIES = 15; + +export function useSearchHistory() { + const [history, setHistory] = useState([]); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored) as SearchHistoryEntry[]; + setHistory(parsed); + } catch { + localStorage.removeItem(STORAGE_KEY); + } + } + }, []); + + const addToHistory = useCallback( + (query: string, context: "library" | "catalogue") => { + if (!query.trim()) return; + + const newEntry: SearchHistoryEntry = { + query: query.trim(), + timestamp: Date.now(), + context, + }; + + setHistory((prev) => { + const filtered = prev.filter( + (entry) => entry.query.toLowerCase() !== query.toLowerCase().trim() + ); + const updated = [newEntry, ...filtered].slice(0, MAX_HISTORY_ENTRIES); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }, + [] + ); + + const removeFromHistory = useCallback((query: string) => { + setHistory((prev) => { + const updated = prev.filter((entry) => entry.query !== query); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }, []); + + const clearHistory = useCallback(() => { + setHistory([]); + localStorage.removeItem(STORAGE_KEY); + }, []); + + const getRecentHistory = useCallback( + (context: "library" | "catalogue", limit: number = 3) => { + return history + .filter((entry) => entry.context === context) + .slice(0, limit); + }, + [history] + ); + + return { + history, + addToHistory, + removeFromHistory, + clearHistory, + getRecentHistory, + }; +} diff --git a/src/renderer/src/hooks/use-search-suggestions.ts b/src/renderer/src/hooks/use-search-suggestions.ts new file mode 100644 index 00000000..f2baa8db --- /dev/null +++ b/src/renderer/src/hooks/use-search-suggestions.ts @@ -0,0 +1,149 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAppSelector } from "./redux"; +import { debounce } from "lodash-es"; + +export interface SearchSuggestion { + title: string; + objectId: string; + shop: string; + iconUrl: string | null; + source: "library" | "catalogue"; +} + +export function useSearchSuggestions( + query: string, + isOnLibraryPage: boolean, + enabled: boolean = true +) { + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const library = useAppSelector((state) => state.library.value); + const abortControllerRef = useRef(null); + + const getLibrarySuggestions = useCallback( + (searchQuery: string, limit: number = 3): SearchSuggestion[] => { + if (!searchQuery.trim()) return []; + + const queryLower = searchQuery.toLowerCase(); + const matches: SearchSuggestion[] = []; + + for (const game of library) { + if (matches.length >= limit) break; + + const titleLower = game.title.toLowerCase(); + let queryIndex = 0; + + for ( + let i = 0; + i < titleLower.length && queryIndex < queryLower.length; + i++ + ) { + if (titleLower[i] === queryLower[queryIndex]) { + queryIndex++; + } + } + + if (queryIndex === queryLower.length) { + matches.push({ + title: game.title, + objectId: game.objectId, + shop: game.shop, + iconUrl: game.iconUrl, + source: "library", + }); + } + } + + return matches; + }, + [library] + ); + + const fetchCatalogueSuggestions = useCallback( + async (searchQuery: string, limit: number = 3) => { + if (!searchQuery.trim() || searchQuery.length < 2) { + setSuggestions([]); + setIsLoading(false); + return; + } + + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setIsLoading(true); + + try { + const response = await window.electron.hydraApi.get< + Array<{ + title: string; + objectId: string; + shop: string; + iconUrl: string | null; + }> + >("/catalogue/search/suggestions", { + params: { + query: searchQuery, + limit, + }, + needsAuth: false, + }); + + if (abortController.signal.aborted) return; + + const catalogueSuggestions: SearchSuggestion[] = response.map( + (item) => ({ + ...item, + source: "catalogue" as const, + }) + ); + + setSuggestions(catalogueSuggestions); + } catch (error) { + if (!abortController.signal.aborted) { + setSuggestions([]); + } + } finally { + if (!abortController.signal.aborted) { + setIsLoading(false); + } + } + }, + [] + ); + + const debouncedFetchCatalogue = useRef( + debounce(fetchCatalogueSuggestions, 300) + ).current; + + useEffect(() => { + if (!enabled || !query || query.length < 2) { + setSuggestions([]); + setIsLoading(false); + abortControllerRef.current?.abort(); + debouncedFetchCatalogue.cancel(); + return; + } + + if (isOnLibraryPage) { + const librarySuggestions = getLibrarySuggestions(query, 3); + setSuggestions(librarySuggestions); + setIsLoading(false); + } else { + debouncedFetchCatalogue(query, 3); + } + + return () => { + debouncedFetchCatalogue.cancel(); + abortControllerRef.current?.abort(); + }; + }, [ + query, + isOnLibraryPage, + enabled, + getLibrarySuggestions, + debouncedFetchCatalogue, + ]); + + return { suggestions, isLoading }; +} From 8cd613e3b6634d7bee546e77f7c7658ca2782f55 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 15 Nov 2025 21:04:08 +0200 Subject: [PATCH 03/22] fix: removed unused variables --- src/renderer/src/components/header/header.tsx | 3 +-- src/renderer/src/components/search-dropdown/highlight-text.tsx | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 435c049b..518e4b9f 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -62,8 +62,7 @@ export function Header() { const { t } = useTranslation("header"); - const { addToHistory, removeFromHistory, clearHistory, getRecentHistory } = - useSearchHistory(); + const { addToHistory, clearHistory, getRecentHistory } = useSearchHistory(); const { suggestions, isLoading: isLoadingSuggestions } = useSearchSuggestions( searchValue, diff --git a/src/renderer/src/components/search-dropdown/highlight-text.tsx b/src/renderer/src/components/search-dropdown/highlight-text.tsx index 9f8f0121..9950c8a1 100644 --- a/src/renderer/src/components/search-dropdown/highlight-text.tsx +++ b/src/renderer/src/components/search-dropdown/highlight-text.tsx @@ -19,7 +19,6 @@ export function HighlightText({ text, query }: HighlightTextProps) { return <>{text}; } - const textLower = text.toLowerCase(); const textWords = text.split(/\b/); const matches: Array<{ start: number; end: number; text: string }> = []; From 9979e92d8f20e665742d5bb4d3af27c8df705440 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 15 Nov 2025 21:05:51 +0200 Subject: [PATCH 04/22] fix: reverted detach mode for devtools window --- src/main/services/window-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 2475e485..b11b4a9b 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -198,7 +198,7 @@ export class WindowManager { this.mainWindow.on("ready-to-show", () => { if (!app.isPackaged || isStaging) - WindowManager.mainWindow?.webContents.openDevTools({ mode: "detach" }); + WindowManager.mainWindow?.webContents.openDevTools(); WindowManager.mainWindow?.show(); }); From 093a9f251e94abfe367b7b2ac24d7d426e47af5d Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 15 Nov 2025 21:10:33 +0200 Subject: [PATCH 05/22] feat: selective history removal --- src/locales/en/translation.json | 1 + src/renderer/src/components/header/header.tsx | 8 ++++- .../search-dropdown/search-dropdown.scss | 31 +++++++++++++++++++ .../search-dropdown/search-dropdown.tsx | 26 ++++++++++++++-- 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 53153a93..d2665928 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -97,6 +97,7 @@ "recent_searches": "Recent Searches", "suggestions": "Suggestions", "clear_history": "Clear history", + "remove_from_history": "Remove from history", "loading": "Loading...", "no_results": "No results", "home": "Home", diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 518e4b9f..5f2c1d1d 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -62,7 +62,8 @@ export function Header() { const { t } = useTranslation("header"); - const { addToHistory, clearHistory, getRecentHistory } = useSearchHistory(); + const { addToHistory, removeFromHistory, clearHistory, getRecentHistory } = + useSearchHistory(); const { suggestions, isLoading: isLoadingSuggestions } = useSearchSuggestions( searchValue, @@ -176,6 +177,10 @@ export function Header() { setActiveIndex(-1); }; + const handleRemoveHistoryItem = (query: string) => { + removeFromHistory(query); + }; + const handleClearHistory = () => { clearHistory(); }; @@ -326,6 +331,7 @@ export function Header() { isLoadingSuggestions={isLoadingSuggestions} onSelectHistory={handleSelectHistory} onSelectSuggestion={handleSelectSuggestion} + onRemoveHistoryItem={handleRemoveHistoryItem} onClearHistory={handleClearHistory} onClose={handleCloseDropdown} activeIndex={activeIndex} diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.scss b/src/renderer/src/components/search-dropdown/search-dropdown.scss index 276619c2..78a6fac1 100644 --- a/src/renderer/src/components/search-dropdown/search-dropdown.scss +++ b/src/renderer/src/components/search-dropdown/search-dropdown.scss @@ -57,6 +57,37 @@ margin: 0; } + &__item-container { + position: relative; + display: flex; + align-items: center; + + &:hover .search-dropdown__item-remove { + opacity: 1; + } + } + + &__item-remove { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + color: globals.$muted-color; + padding: 4px; + border-radius: 4px; + opacity: 0; + transition: all ease 0.15s; + display: flex; + align-items: center; + justify-content: center; + background-color: transparent; + + &:hover { + color: #ff5555; + background-color: rgba(255, 85, 85, 0.1); + } + } + &__item { width: 100%; display: flex; diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.tsx b/src/renderer/src/components/search-dropdown/search-dropdown.tsx index a8f08401..d90c3bf5 100644 --- a/src/renderer/src/components/search-dropdown/search-dropdown.tsx +++ b/src/renderer/src/components/search-dropdown/search-dropdown.tsx @@ -1,6 +1,11 @@ import { useEffect, useRef, useCallback, useState } from "react"; import { createPortal } from "react-dom"; -import { ClockIcon, SearchIcon, TrashIcon } from "@primer/octicons-react"; +import { + ClockIcon, + SearchIcon, + TrashIcon, + XIcon, +} from "@primer/octicons-react"; import cn from "classnames"; import { useTranslation } from "react-i18next"; import type { SearchHistoryEntry } from "@renderer/hooks/use-search-history"; @@ -16,6 +21,7 @@ export interface SearchDropdownProps { isLoadingSuggestions: boolean; onSelectHistory: (query: string) => void; onSelectSuggestion: (suggestion: SearchSuggestion) => void; + onRemoveHistoryItem: (query: string) => void; onClearHistory: () => void; onClose: () => void; activeIndex: number; @@ -31,6 +37,7 @@ export function SearchDropdown({ isLoadingSuggestions, onSelectHistory, onSelectSuggestion, + onRemoveHistoryItem, onClearHistory, onClose, activeIndex, @@ -146,7 +153,10 @@ export function SearchDropdown({
    {historyItems.map((item, index) => ( -
  • +
  • +
  • ))}
From 6df34e7f3cc011ab349e1500c47841808f9d7970 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Sun, 16 Nov 2025 13:45:32 -0300 Subject: [PATCH 06/22] chore: update hydra docs link on PR template --- .github/ISSUE_TEMPLATE/bug_report.yml | 65 ---------------------- .github/ISSUE_TEMPLATE/feature_request.yml | 37 ------------ .github/pull-request-template.md | 4 +- 3 files changed, 1 insertion(+), 105 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index e9a91e0c..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Bug Report -description: Create a report to help us improve. Write in English. -title: "[BUG] Write a title for your bug" -labels: ["bug"] -body: - - type: markdown - attributes: - value: | - Thank you for creating a bug report to help us improve! - - type: textarea - id: bug-description - attributes: - label: Describe the bug - description: A clear and concise description of what the bug is. - validations: - required: true - - type: textarea - id: bug-reproduce - attributes: - label: Steps to Reproduce - description: Steps to reproduce the behavior. For example, "1. Go to '...', 2. Click on '...', 3. See error" - validations: - required: true - - type: textarea - id: expected-behavior - attributes: - label: Expected behavior - description: A clear and concise description of what you expected to happen. - validations: - required: false - - type: textarea - id: additional-info - attributes: - label: Additional information and data - description: | - Add screenshots and upload your all logs file here. - Logs location on Windows: "%appdata%/hydralauncher/logs" - Logs location on Linux: "~/.config/hydralauncher/logs" - validations: - required: true - - type: input - id: OS - attributes: - label: Operating System - description: Which operating system are you using (e.g., Windows 11/Linux Distro/Steam Deck)? - validations: - required: true - - type: input - id: hydra-version - attributes: - label: Hydra Version - description: Please provide the version of Hydra you are using. - validations: - required: true - - type: checkboxes - id: terms - attributes: - label: Before opening this Issue - options: - - label: I have searched the issues of this repository and believe that this is not a duplicate. - required: true - - label: I am aware that Hydra team does not offer any support or help regarding the downloaded games. - required: true - - label: I have read the [Frequently Asked Questions (FAQ)](https://github.com/hydralauncher/hydra/wiki/FAQ). - required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 295cee45..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Feature Request -description: Request a new feature. -title: "[REQUEST] " -labels: ["enhancement"] -body: - - type: markdown - attributes: - value: | - Thank you for taking the time to suggest a new feature! - - type: textarea - id: problem-related - attributes: - label: Is your feature request related to a problem? Please describe. - description: A clear and concise description of what the problem is. - validations: - required: true - - type: textarea - id: solution - attributes: - label: Describe the solution you'd like - description: A clear and concise description of what you want to happen. - validations: - required: true - - type: textarea - id: alternatives - attributes: - label: Describe alternatives you've considered - description: A clear and concise description of any alternative solutions or features you've considered. - validations: - required: false - - type: textarea - id: additional-context - attributes: - label: Additional context - description: Add any other context or screenshots about the feature request here. - validations: - required: false diff --git a/.github/pull-request-template.md b/.github/pull-request-template.md index 3653dd16..22223374 100644 --- a/.github/pull-request-template.md +++ b/.github/pull-request-template.md @@ -2,11 +2,9 @@ **When submitting this pull request, I confirm the following (please check the boxes):** -- [ ] I have read and understood the [Contributor Guidelines](https://github.com/hydralauncher/hydra?tab=readme-ov-file#ways-you-can-contribute). +- [ ] I have read the [Hydra documentation](https://docs.hydralauncher.gg/getting-started.html). - [ ] I have checked that there are no duplicate pull requests related to this request. - [ ] I have considered, and confirm that this submission is valuable to others. - [ ] I accept that this submission may not be used and the pull request may be closed at the discretion of the maintainers. **Fill in the PR content:** - -- From a1117c82699193a9f1f6a64b459f6f1cb1ce761e Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 22 Nov 2025 07:26:48 +0200 Subject: [PATCH 07/22] feat: improving suggestion dropdown design --- .../search-dropdown/search-dropdown.scss | 24 ++++++------------- .../search-dropdown/search-dropdown.tsx | 12 +++------- .../src/hooks/use-search-suggestions.ts | 11 +++++++++ 3 files changed, 21 insertions(+), 26 deletions(-) diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.scss b/src/renderer/src/components/search-dropdown/search-dropdown.scss index 78a6fac1..4b1983d1 100644 --- a/src/renderer/src/components/search-dropdown/search-dropdown.scss +++ b/src/renderer/src/components/search-dropdown/search-dropdown.scss @@ -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,15 @@ 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: 2px 6px; + font-size: 11px; + transition: color ease 0.2s; &:hover { color: #dadbe1; - background-color: rgba(255, 255, 255, 0.1); } } @@ -74,18 +71,11 @@ 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; - background-color: transparent; - - &:hover { - color: #ff5555; - background-color: rgba(255, 85, 85, 0.1); - } } &__item { diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.tsx b/src/renderer/src/components/search-dropdown/search-dropdown.tsx index d90c3bf5..9b7af639 100644 --- a/src/renderer/src/components/search-dropdown/search-dropdown.tsx +++ b/src/renderer/src/components/search-dropdown/search-dropdown.tsx @@ -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({