diff --git a/.cursorrules b/.cursorrules index 5015ab7e..23aa8113 100644 --- a/.cursorrules +++ b/.cursorrules @@ -28,6 +28,13 @@ - Use async/await instead of promises when possible - Prefer named exports over default exports for utilities and services +## TypeScript Array Syntax + +- **Always use `T[]` syntax instead of `Array`** for array types +- Prefer: `string[]`, `number[]`, `MyType[]` +- Avoid: `Array`, `Array`, `Array` +- This applies to all type annotations, type assertions, and generic type parameters + ## Comments - Keep comments concise and purposeful; avoid verbose explanations. diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 5084a4a0..d2665928 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -94,6 +94,12 @@ "header": { "search": "Search games", "search_library": "Search library", + "recent_searches": "Recent Searches", + "suggestions": "Suggestions", + "clear_history": "Clear history", + "remove_from_history": "Remove from history", + "loading": "Loading...", + "no_results": "No results", "home": "Home", "catalogue": "Catalogue", "library": "Library", diff --git a/src/main/events/auth/index.ts b/src/main/events/auth/index.ts new file mode 100644 index 00000000..e94e9bc5 --- /dev/null +++ b/src/main/events/auth/index.ts @@ -0,0 +1,3 @@ +import "./get-session-hash"; +import "./open-auth-window"; +import "./sign-out"; diff --git a/src/main/events/autoupdater/index.ts b/src/main/events/autoupdater/index.ts new file mode 100644 index 00000000..f6b70367 --- /dev/null +++ b/src/main/events/autoupdater/index.ts @@ -0,0 +1,2 @@ +import "./check-for-updates"; +import "./restart-and-install-update"; diff --git a/src/main/events/catalogue/index.ts b/src/main/events/catalogue/index.ts new file mode 100644 index 00000000..383ba34c --- /dev/null +++ b/src/main/events/catalogue/index.ts @@ -0,0 +1,4 @@ +import "./get-game-assets"; +import "./get-game-shop-details"; +import "./get-game-stats"; +import "./get-random-game"; diff --git a/src/main/events/cloud-save/index.ts b/src/main/events/cloud-save/index.ts new file mode 100644 index 00000000..92e9f528 --- /dev/null +++ b/src/main/events/cloud-save/index.ts @@ -0,0 +1,4 @@ +import "./download-game-artifact"; +import "./get-game-backup-preview"; +import "./select-game-backup-path"; +import "./upload-save-game"; diff --git a/src/main/events/download-sources/index.ts b/src/main/events/download-sources/index.ts new file mode 100644 index 00000000..325d5570 --- /dev/null +++ b/src/main/events/download-sources/index.ts @@ -0,0 +1,6 @@ +import "./add-download-source"; +import "./get-download-sources-check-baseline"; +import "./get-download-sources-since-value"; +import "./get-download-sources"; +import "./remove-download-source"; +import "./sync-download-sources"; diff --git a/src/main/events/hardware/index.ts b/src/main/events/hardware/index.ts new file mode 100644 index 00000000..76823f51 --- /dev/null +++ b/src/main/events/hardware/index.ts @@ -0,0 +1,2 @@ +import "./check-folder-write-permission"; +import "./get-disk-free-space"; diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 2720d3ce..8efadf64 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -1,107 +1,22 @@ import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants"; import { ipcMain } from "electron"; -import "./catalogue/get-game-shop-details"; -import "./catalogue/get-random-game"; -import "./catalogue/get-game-stats"; -import "./hardware/get-disk-free-space"; -import "./hardware/check-folder-write-permission"; -import "./library/add-game-to-library"; -import "./library/add-custom-game-to-library"; -import "./library/update-custom-game"; -import "./library/update-game-custom-assets"; -import "./library/add-game-to-favorites"; -import "./library/remove-game-from-favorites"; -import "./library/toggle-game-pin"; -import "./library/create-game-shortcut"; -import "./library/close-game"; -import "./library/delete-game-folder"; -import "./library/get-game-by-object-id"; -import "./library/get-library"; -import "./library/refresh-library-assets"; -import "./library/extract-game-download"; -import "./library/clear-new-download-options"; -import "./library/open-game"; -import "./library/open-game-executable-path"; -import "./library/open-game-installer"; -import "./library/open-game-installer-path"; -import "./library/update-executable-path"; -import "./library/update-launch-options"; -import "./library/verify-executable-path"; -import "./library/remove-game"; -import "./library/remove-game-from-library"; -import "./library/select-game-wine-prefix"; -import "./library/reset-game-achievements"; -import "./library/change-game-playtime"; -import "./library/toggle-automatic-cloud-sync"; -import "./library/get-default-wine-prefix-selection-path"; -import "./library/cleanup-unused-assets"; -import "./library/create-steam-shortcut"; -import "./library/copy-custom-game-asset"; -import "./misc/open-checkout"; -import "./misc/open-external"; -import "./misc/show-open-dialog"; -import "./misc/show-item-in-folder"; -import "./misc/install-common-redist"; -import "./misc/can-install-common-redist"; -import "./misc/save-temp-file"; -import "./misc/delete-temp-file"; -import "./misc/install-hydra-decky-plugin"; -import "./misc/get-hydra-decky-plugin-info"; -import "./misc/check-homebrew-folder-exists"; -import "./misc/hydra-api-call"; -import "./torrenting/cancel-game-download"; -import "./torrenting/pause-game-download"; -import "./torrenting/resume-game-download"; -import "./torrenting/start-game-download"; -import "./torrenting/pause-game-seed"; -import "./torrenting/resume-game-seed"; -import "./torrenting/check-debrid-availability"; -import "./user-preferences/get-user-preferences"; -import "./user-preferences/update-user-preferences"; -import "./user-preferences/auto-launch"; -import "./autoupdater/check-for-updates"; -import "./autoupdater/restart-and-install-update"; -import "./user-preferences/authenticate-real-debrid"; -import "./user-preferences/authenticate-torbox"; -import "./download-sources/add-download-source"; -import "./download-sources/sync-download-sources"; -import "./download-sources/get-download-sources-check-baseline"; -import "./download-sources/get-download-sources-since-value"; -import "./auth/sign-out"; -import "./auth/open-auth-window"; -import "./auth/get-session-hash"; -import "./user/get-auth"; -import "./user/get-unlocked-achievements"; -import "./user/get-compared-unlocked-achievements"; -import "./profile/get-me"; -import "./profile/update-profile"; -import "./profile/process-profile-image"; -import "./profile/sync-friend-requests"; -import "./cloud-save/download-game-artifact"; -import "./cloud-save/get-game-backup-preview"; -import "./cloud-save/upload-save-game"; -import "./cloud-save/select-game-backup-path"; -import "./notifications/publish-new-repacks-notification"; -import "./notifications/update-achievement-notification-window"; -import "./notifications/show-achievement-test-notification"; -import "./themes/add-custom-theme"; -import "./themes/delete-custom-theme"; -import "./themes/get-all-custom-themes"; -import "./themes/delete-all-custom-themes"; -import "./themes/update-custom-theme"; -import "./themes/open-editor-window"; -import "./themes/get-custom-theme-by-id"; -import "./themes/get-active-custom-theme"; -import "./themes/close-editor-window"; -import "./themes/toggle-custom-theme"; -import "./themes/copy-theme-achievement-sound"; -import "./themes/remove-theme-achievement-sound"; -import "./themes/get-theme-sound-path"; -import "./themes/get-theme-sound-data-url"; -import "./themes/import-theme-sound-from-store"; -import "./download-sources/remove-download-source"; -import "./download-sources/get-download-sources"; +import "./auth"; +import "./autoupdater"; +import "./catalogue"; +import "./cloud-save"; +import "./download-sources"; +import "./hardware"; +import "./library"; +import "./leveldb"; +import "./misc"; +import "./notifications"; +import "./profile"; +import "./themes"; +import "./torrenting"; +import "./user"; +import "./user-preferences"; + import { isPortableVersion } from "@main/helpers"; ipcMain.handle("ping", () => "pong"); diff --git a/src/main/events/leveldb/helpers.ts b/src/main/events/leveldb/helpers.ts new file mode 100644 index 00000000..e171e65a --- /dev/null +++ b/src/main/events/leveldb/helpers.ts @@ -0,0 +1,27 @@ +import { db } from "@main/level"; + +const sublevelCache = new Map< + string, + ReturnType> +>(); + +/** + * Gets a sublevel by name, creating it if it doesn't exist. + * All sublevels use "json" encoding by default. + * @param sublevelName - The name of the sublevel to get or create + * @returns The sublevel instance + */ +export const getSublevelByName = ( + sublevelName: string +): ReturnType> => { + if (sublevelCache.has(sublevelName)) { + return sublevelCache.get(sublevelName)!; + } + + // All sublevels use "json" encoding - this cannot be changed per sublevel + const sublevel = db.sublevel(sublevelName, { + valueEncoding: "json", + }); + sublevelCache.set(sublevelName, sublevel); + return sublevel; +}; diff --git a/src/main/events/leveldb/index.ts b/src/main/events/leveldb/index.ts new file mode 100644 index 00000000..6007bd33 --- /dev/null +++ b/src/main/events/leveldb/index.ts @@ -0,0 +1,6 @@ +import "./leveldb-get"; +import "./leveldb-put"; +import "./leveldb-del"; +import "./leveldb-clear"; +import "./leveldb-values"; +import "./leveldb-iterator"; diff --git a/src/main/events/leveldb/leveldb-clear.ts b/src/main/events/leveldb/leveldb-clear.ts new file mode 100644 index 00000000..cbed1db0 --- /dev/null +++ b/src/main/events/leveldb/leveldb-clear.ts @@ -0,0 +1,18 @@ +import { registerEvent } from "../register-event"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbClear = async ( + _event: Electron.IpcMainInvokeEvent, + sublevelName: string +) => { + try { + const sublevel = getSublevelByName(sublevelName); + await sublevel.clear(); + } catch (error) { + logger.error("Error in leveldbClear", error); + throw error; + } +}; + +registerEvent("leveldbClear", leveldbClear); diff --git a/src/main/events/leveldb/leveldb-del.ts b/src/main/events/leveldb/leveldb-del.ts new file mode 100644 index 00000000..5bcded1d --- /dev/null +++ b/src/main/events/leveldb/leveldb-del.ts @@ -0,0 +1,28 @@ +import { registerEvent } from "../register-event"; +import { db } from "@main/level"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbDel = async ( + _event: Electron.IpcMainInvokeEvent, + key: string, + sublevelName?: string | null +) => { + try { + if (sublevelName) { + const sublevel = getSublevelByName(sublevelName); + await sublevel.del(key); + } else { + await db.del(key); + } + } catch (error) { + if (error instanceof Error && error.name === "NotFoundError") { + // NotFoundError on delete is not an error, just return + return; + } + logger.error("Error in leveldbDel", error); + throw error; + } +}; + +registerEvent("leveldbDel", leveldbDel); diff --git a/src/main/events/leveldb/leveldb-get.ts b/src/main/events/leveldb/leveldb-get.ts new file mode 100644 index 00000000..059f1b30 --- /dev/null +++ b/src/main/events/leveldb/leveldb-get.ts @@ -0,0 +1,28 @@ +import { registerEvent } from "../register-event"; +import { db } from "@main/level"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbGet = async ( + _event: Electron.IpcMainInvokeEvent, + key: string, + sublevelName?: string | null, + valueEncoding: "json" | "utf8" = "json" +) => { + try { + if (sublevelName) { + // Note: sublevels always use "json" encoding, valueEncoding parameter is ignored + const sublevel = getSublevelByName(sublevelName); + return sublevel.get(key); + } + return db.get(key, { valueEncoding }); + } catch (error) { + if (error instanceof Error && error.name === "NotFoundError") { + return null; + } + logger.error("Error in leveldbGet", error); + throw error; + } +}; + +registerEvent("leveldbGet", leveldbGet); diff --git a/src/main/events/leveldb/leveldb-iterator.ts b/src/main/events/leveldb/leveldb-iterator.ts new file mode 100644 index 00000000..a1960c31 --- /dev/null +++ b/src/main/events/leveldb/leveldb-iterator.ts @@ -0,0 +1,18 @@ +import { registerEvent } from "../register-event"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbIterator = async ( + _event: Electron.IpcMainInvokeEvent, + sublevelName: string +) => { + try { + const sublevel = getSublevelByName(sublevelName); + return sublevel.iterator().all(); + } catch (error) { + logger.error("Error in leveldbIterator", error); + throw error; + } +}; + +registerEvent("leveldbIterator", leveldbIterator); diff --git a/src/main/events/leveldb/leveldb-put.ts b/src/main/events/leveldb/leveldb-put.ts new file mode 100644 index 00000000..9c416722 --- /dev/null +++ b/src/main/events/leveldb/leveldb-put.ts @@ -0,0 +1,27 @@ +import { registerEvent } from "../register-event"; +import { db } from "@main/level"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbPut = async ( + _event: Electron.IpcMainInvokeEvent, + key: string, + value: unknown, + sublevelName?: string | null, + valueEncoding: "json" | "utf8" = "json" +) => { + try { + if (sublevelName) { + // Note: sublevels always use "json" encoding, valueEncoding parameter is ignored + const sublevel = getSublevelByName(sublevelName); + await sublevel.put(key, value); + } else { + await db.put(key, value, { valueEncoding }); + } + } catch (error) { + logger.error("Error in leveldbPut", error); + throw error; + } +}; + +registerEvent("leveldbPut", leveldbPut); diff --git a/src/main/events/leveldb/leveldb-values.ts b/src/main/events/leveldb/leveldb-values.ts new file mode 100644 index 00000000..0e2c3c0f --- /dev/null +++ b/src/main/events/leveldb/leveldb-values.ts @@ -0,0 +1,18 @@ +import { registerEvent } from "../register-event"; +import { getSublevelByName } from "./helpers"; +import { logger } from "@main/services"; + +const leveldbValues = async ( + _event: Electron.IpcMainInvokeEvent, + sublevelName: string +) => { + try { + const sublevel = getSublevelByName(sublevelName); + return sublevel.values().all(); + } catch (error) { + logger.error("Error in leveldbValues", error); + throw error; + } +}; + +registerEvent("leveldbValues", leveldbValues); diff --git a/src/main/events/library/index.ts b/src/main/events/library/index.ts new file mode 100644 index 00000000..d9d628d0 --- /dev/null +++ b/src/main/events/library/index.ts @@ -0,0 +1,32 @@ +import "./add-custom-game-to-library"; +import "./add-game-to-favorites"; +import "./add-game-to-library"; +import "./change-game-playtime"; +import "./cleanup-unused-assets"; +import "./clear-new-download-options"; +import "./close-game"; +import "./copy-custom-game-asset"; +import "./create-game-shortcut"; +import "./create-steam-shortcut"; +import "./delete-game-folder"; +import "./extract-game-download"; +import "./get-default-wine-prefix-selection-path"; +import "./get-game-by-object-id"; +import "./get-library"; +import "./open-game-executable-path"; +import "./open-game-installer-path"; +import "./open-game-installer"; +import "./open-game"; +import "./refresh-library-assets"; +import "./remove-game-from-favorites"; +import "./remove-game-from-library"; +import "./remove-game"; +import "./reset-game-achievements"; +import "./select-game-wine-prefix"; +import "./toggle-automatic-cloud-sync"; +import "./toggle-game-pin"; +import "./update-custom-game"; +import "./update-executable-path"; +import "./update-game-custom-assets"; +import "./update-launch-options"; +import "./verify-executable-path"; diff --git a/src/main/events/misc/index.ts b/src/main/events/misc/index.ts new file mode 100644 index 00000000..354e6687 --- /dev/null +++ b/src/main/events/misc/index.ts @@ -0,0 +1,12 @@ +import "./can-install-common-redist"; +import "./check-homebrew-folder-exists"; +import "./delete-temp-file"; +import "./get-hydra-decky-plugin-info"; +import "./hydra-api-call"; +import "./install-common-redist"; +import "./install-hydra-decky-plugin"; +import "./open-checkout"; +import "./open-external"; +import "./save-temp-file"; +import "./show-item-in-folder"; +import "./show-open-dialog"; diff --git a/src/main/events/notifications/index.ts b/src/main/events/notifications/index.ts new file mode 100644 index 00000000..c6e681e8 --- /dev/null +++ b/src/main/events/notifications/index.ts @@ -0,0 +1,3 @@ +import "./publish-new-repacks-notification"; +import "./show-achievement-test-notification"; +import "./update-achievement-notification-window"; diff --git a/src/main/events/profile/index.ts b/src/main/events/profile/index.ts new file mode 100644 index 00000000..1548249f --- /dev/null +++ b/src/main/events/profile/index.ts @@ -0,0 +1,4 @@ +import "./get-me"; +import "./process-profile-image"; +import "./sync-friend-requests"; +import "./update-profile"; diff --git a/src/main/events/themes/index.ts b/src/main/events/themes/index.ts new file mode 100644 index 00000000..5f4d4a02 --- /dev/null +++ b/src/main/events/themes/index.ts @@ -0,0 +1,15 @@ +import "./add-custom-theme"; +import "./close-editor-window"; +import "./copy-theme-achievement-sound"; +import "./delete-all-custom-themes"; +import "./delete-custom-theme"; +import "./get-active-custom-theme"; +import "./get-all-custom-themes"; +import "./get-custom-theme-by-id"; +import "./get-theme-sound-data-url"; +import "./get-theme-sound-path"; +import "./import-theme-sound-from-store"; +import "./open-editor-window"; +import "./remove-theme-achievement-sound"; +import "./toggle-custom-theme"; +import "./update-custom-theme"; diff --git a/src/main/events/torrenting/index.ts b/src/main/events/torrenting/index.ts new file mode 100644 index 00000000..408ecf17 --- /dev/null +++ b/src/main/events/torrenting/index.ts @@ -0,0 +1,7 @@ +import "./cancel-game-download"; +import "./check-debrid-availability"; +import "./pause-game-download"; +import "./pause-game-seed"; +import "./resume-game-download"; +import "./resume-game-seed"; +import "./start-game-download"; diff --git a/src/main/events/user-preferences/index.ts b/src/main/events/user-preferences/index.ts new file mode 100644 index 00000000..aab898e6 --- /dev/null +++ b/src/main/events/user-preferences/index.ts @@ -0,0 +1,5 @@ +import "./authenticate-real-debrid"; +import "./authenticate-torbox"; +import "./auto-launch"; +import "./get-user-preferences"; +import "./update-user-preferences"; diff --git a/src/main/events/user/index.ts b/src/main/events/user/index.ts new file mode 100644 index 00000000..cf63116f --- /dev/null +++ b/src/main/events/user/index.ts @@ -0,0 +1,3 @@ +import "./get-auth"; +import "./get-compared-unlocked-achievements"; +import "./get-unlocked-achievements"; diff --git a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts index 4b60b962..36449b4d 100644 --- a/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts +++ b/src/main/level/sublevels/downloadSourcesCheckTimestamp.ts @@ -7,7 +7,9 @@ export const getDownloadSourcesCheckBaseline = async (): Promise< string | null > => { try { - const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline); + const timestamp = await db.get(levelKeys.downloadSourcesCheckBaseline, { + valueEncoding: "utf8", + }); return timestamp; } catch (error) { if (error instanceof Error && error.name === "NotFoundError") { @@ -27,7 +29,9 @@ export const updateDownloadSourcesCheckBaseline = async ( timestamp: string ): Promise => { const utcTimestamp = new Date(timestamp).toISOString(); - await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp); + await db.put(levelKeys.downloadSourcesCheckBaseline, utcTimestamp, { + valueEncoding: "utf8", + }); }; // Gets the 'since' value the API used in the last check (for modal comparison) @@ -35,7 +39,9 @@ export const getDownloadSourcesSinceValue = async (): Promise< string | null > => { try { - const timestamp = await db.get(levelKeys.downloadSourcesSinceValue); + const timestamp = await db.get(levelKeys.downloadSourcesSinceValue, { + valueEncoding: "utf8", + }); return timestamp; } catch (error) { if (error instanceof Error && error.name === "NotFoundError") { @@ -55,5 +61,7 @@ export const updateDownloadSourcesSinceValue = async ( timestamp: string ): Promise => { const utcTimestamp = new Date(timestamp).toISOString(); - await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp); + await db.put(levelKeys.downloadSourcesSinceValue, utcTimestamp, { + valueEncoding: "utf8", + }); }; diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index a5a78e4a..7846571e 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -30,7 +30,7 @@ export class HydraApi { private static instance: AxiosInstance; private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes - private static readonly ADD_LOG_INTERCEPTOR = true; + private static readonly ADD_LOG_INTERCEPTOR = false; private static secondsToMilliseconds(seconds: number) { return seconds * 1000; diff --git a/src/main/services/system-path.ts b/src/main/services/system-path.ts index 32b34e11..0b42b0aa 100644 --- a/src/main/services/system-path.ts +++ b/src/main/services/system-path.ts @@ -13,9 +13,9 @@ export class SystemPath { }; static checkIfPathsAreAvailable() { - const paths = Object.keys(SystemPath.paths) as Array< - keyof typeof SystemPath.paths - >; + const paths = Object.keys( + SystemPath.paths + ) as (keyof typeof SystemPath.paths)[]; paths.forEach((pathName) => { try { diff --git a/src/preload/index.ts b/src/preload/index.ts index a2965532..f7c062cb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -619,4 +619,28 @@ contextBridge.exposeInMainWorld("electron", { }, closeEditorWindow: (themeId?: string) => ipcRenderer.invoke("closeEditorWindow", themeId), + + /* LevelDB Generic CRUD */ + leveldb: { + get: ( + key: string, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ) => ipcRenderer.invoke("leveldbGet", key, sublevelName, valueEncoding), + put: ( + key: string, + value: unknown, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ) => + ipcRenderer.invoke("leveldbPut", key, value, sublevelName, valueEncoding), + del: (key: string, sublevelName?: string | null) => + ipcRenderer.invoke("leveldbDel", key, sublevelName), + clear: (sublevelName: string) => + ipcRenderer.invoke("leveldbClear", sublevelName), + values: (sublevelName: string) => + ipcRenderer.invoke("leveldbValues", sublevelName), + iterator: (sublevelName: string) => + ipcRenderer.invoke("leveldbIterator", sublevelName), + }, }); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 391e9c03..9badd12e 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -31,6 +31,8 @@ import { getAchievementSoundUrl, getAchievementSoundVolume, } from "./helpers"; +import { levelDBService } from "./services/leveldb.service"; +import type { UserPreferences } from "@types"; import "./app.scss"; export interface AppProps { @@ -77,11 +79,12 @@ export function App() { const { showSuccessToast } = useToast(); useEffect(() => { - Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then( - ([preferences]) => { - dispatch(setUserPreferences(preferences)); - } - ); + Promise.all([ + levelDBService.get("userPreferences", null, "json"), + updateLibrary(), + ]).then(([preferences]) => { + dispatch(setUserPreferences(preferences as UserPreferences | null)); + }); }, [navigate, location.pathname, dispatch, updateLibrary]); useEffect(() => { @@ -204,7 +207,11 @@ export function App() { }, [dispatch, draggingDisabled]); const loadAndApplyTheme = useCallback(async () => { - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + isActive?: boolean; + code?: string; + }[]; + const activeTheme = allThemes.find((theme) => theme.isActive); if (activeTheme?.code) { injectCustomCss(activeTheme.code); } else { diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index d3164ced..5f2c1d1d 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,79 @@ export function Header() { } else { dispatch(setFilters({ title: "" })); } + setActiveIndex(-1); + }; + + const handleRemoveHistoryItem = (query: string) => { + removeFromHistory(query); + }; + + 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 +317,27 @@ export function Header() {
+ + 0 || + suggestions.length > 0 || + isLoadingSuggestions) + } + position={dropdownPosition} + historyItems={historyItems} + suggestions={suggestions} + isLoadingSuggestions={isLoadingSuggestions} + onSelectHistory={handleSelectHistory} + onSelectSuggestion={handleSelectSuggestion} + onRemoveHistoryItem={handleRemoveHistoryItem} + 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..0d5f0fe4 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/highlight-text.tsx @@ -0,0 +1,108 @@ +import React from "react"; + +interface HighlightTextProps { + readonly text: string; + readonly query: string; +} + +export function HighlightText({ text, query }: Readonly) { + if (!query.trim()) { + return <>{text}; + } + + const queryWords = query + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 0); + + if (queryWords.length === 0) { + return <>{text}; + } + + const textWords = text.split(/\b/); + const matches: { 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: { 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: { text: string; highlight: boolean; key: string }[] = []; + let lastIndex = 0; + + mergedMatches.forEach((match) => { + if (match.start > lastIndex) { + parts.push({ + text: text.slice(lastIndex, match.start), + highlight: false, + key: `${lastIndex}-${match.start}`, + }); + } + + parts.push({ + text: text.slice(match.start, match.end), + highlight: true, + key: `${match.start}-${match.end}`, + }); + + lastIndex = match.end; + }); + + if (lastIndex < text.length) { + parts.push({ + text: text.slice(lastIndex), + highlight: false, + key: `${lastIndex}-${text.length}`, + }); + } + + return ( + <> + {parts.map((part) => + 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..d09b3663 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/search-dropdown.scss @@ -0,0 +1,153 @@ +@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: #ffffff; + background-color: rgba(255, 255, 255, 0.15); + } + } + + &__list { + list-style: none; + padding: 0; + 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: #ff3333; + background-color: rgba(255, 85, 85, 0.2); + } + } + + &__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.4); + color: #ffa000; + 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..d90c3bf5 --- /dev/null +++ b/src/renderer/src/components/search-dropdown/search-dropdown.tsx @@ -0,0 +1,247 @@ +import { useEffect, useRef, useCallback, useState } from "react"; +import { createPortal } from "react-dom"; +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"; +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; + onRemoveHistoryItem: (query: string) => void; + onClearHistory: () => void; + onClose: () => void; + activeIndex: number; + currentQuery: string; + searchContainerRef?: React.RefObject; +} + +export function SearchDropdown({ + visible, + position, + historyItems, + suggestions, + isLoadingSuggestions, + onSelectHistory, + onSelectSuggestion, + onRemoveHistoryItem, + 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/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index bc1a6351..29feabf5 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -1,6 +1,8 @@ import { createContext, useCallback, useEffect, useRef, useState } from "react"; import { setHeaderTitle } from "@renderer/features"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import { orderBy } from "lodash-es"; import { getSteamLanguage } from "@renderer/helpers"; import { useAppDispatch, @@ -10,6 +12,7 @@ import { } from "@renderer/hooks"; import type { + DownloadSource, GameRepack, GameShop, GameStats, @@ -297,7 +300,10 @@ export function GameDetailsContextProvider({ const fetchDownloadSources = async () => { try { - const sources = await window.electron.getDownloadSources(); + const sourcesRaw = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sources = orderBy(sourcesRaw, "createdAt", "desc"); const params = { take: 100, diff --git a/src/renderer/src/context/settings/settings.context.tsx b/src/renderer/src/context/settings/settings.context.tsx index 1160ca3e..338c4e45 100644 --- a/src/renderer/src/context/settings/settings.context.tsx +++ b/src/renderer/src/context/settings/settings.context.tsx @@ -2,6 +2,7 @@ import { createContext, useCallback, useEffect, useState } from "react"; import { setUserPreferences } from "@renderer/features"; import { useAppDispatch } from "@renderer/hooks"; +import { levelDBService } from "@renderer/services/leveldb.service"; import type { UserBlocks, UserPreferences } from "@types"; import { useSearchParams } from "react-router-dom"; @@ -134,9 +135,11 @@ export function SettingsContextProvider({ const updateUserPreferences = async (values: Partial) => { await window.electron.updateUserPreferences(values); - window.electron.getUserPreferences().then((userPreferences) => { - dispatch(setUserPreferences(userPreferences)); - }); + levelDBService + .get("userPreferences", null, "json") + .then((userPreferences) => { + dispatch(setUserPreferences(userPreferences as UserPreferences | null)); + }); }; return ( diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index e35ed57b..56205b2f 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -438,6 +438,25 @@ declare global { onNewDownloadOptions: ( cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void ) => () => Electron.IpcRenderer; + + /* LevelDB Generic CRUD */ + leveldb: { + get: ( + key: string, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ) => Promise; + put: ( + key: string, + value: unknown, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ) => Promise; + del: (key: string, sublevelName?: string | null) => Promise; + clear: (sublevelName: string) => Promise; + values: (sublevelName: string) => Promise; + iterator: (sublevelName: string) => Promise<[string, unknown][]>; + }; } interface Window { diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index e16aa7a4..0b057754 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -3,6 +3,7 @@ import type { GameShop } from "@types"; import Color from "color"; import { v4 as uuidv4 } from "uuid"; import { THEME_WEB_STORE_URL } from "./constants"; +import { levelDBService } from "./services/leveldb.service"; export const formatDownloadProgress = ( progress?: number, @@ -127,7 +128,12 @@ export const getAchievementSoundUrl = async (): Promise => { .default; try { - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + id: string; + isActive?: boolean; + hasCustomSound?: boolean; + }[]; + const activeTheme = allThemes.find((theme) => theme.isActive); if (activeTheme?.hasCustomSound) { const soundDataUrl = await window.electron.getThemeSoundDataUrl( @@ -146,10 +152,18 @@ export const getAchievementSoundUrl = async (): Promise => { export const getAchievementSoundVolume = async (): Promise => { try { - const prefs = await window.electron.getUserPreferences(); + const prefs = (await levelDBService.get( + "userPreferences", + null, + "json" + )) as { achievementSoundVolume?: number } | null; return prefs?.achievementSoundVolume ?? 0.15; } catch (error) { console.error("Failed to get sound volume", error); return 0.15; } }; + +export const getGameKey = (shop: GameShop, objectId: string): string => { + return `${shop}:${objectId}`; +}; 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-catalogue.ts b/src/renderer/src/hooks/use-catalogue.ts index 675f5013..ca2aaa4a 100644 --- a/src/renderer/src/hooks/use-catalogue.ts +++ b/src/renderer/src/hooks/use-catalogue.ts @@ -1,8 +1,9 @@ import axios from "axios"; import { useCallback, useEffect, useState } from "react"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import type { DownloadSource } from "@types"; import { useAppDispatch } from "./redux"; import { setGenres, setTags } from "@renderer/features"; -import type { DownloadSource } from "@types"; export const externalResourcesInstance = axios.create({ baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL, @@ -40,8 +41,9 @@ export function useCatalogue() { }, []); const getDownloadSources = useCallback(() => { - window.electron.getDownloadSources().then((results) => { - setDownloadSources(results.filter((source) => !!source.fingerprint)); + levelDBService.values("downloadSources").then((results) => { + const sources = results as DownloadSource[]; + setDownloadSources(sources.filter((source) => !!source.fingerprint)); }); }, []); 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..c4d5b188 --- /dev/null +++ b/src/renderer/src/hooks/use-search-suggestions.ts @@ -0,0 +1,151 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useAppSelector } from "./redux"; +import { debounce } from "lodash-es"; +import { logger } from "@renderer/logger"; + +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< + { + 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([]); + logger.error("Failed to fetch catalogue suggestions", error); + } + } 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 }; +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 84c7f815..92220a6e 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -21,6 +21,7 @@ import resources from "@locales"; import { logger } from "./logger"; import { addCookieInterceptor } from "./cookies"; +import { levelDBService } from "./services/leveldb.service"; import Catalogue from "./pages/catalogue/catalogue"; import Home from "./pages/home/home"; import Downloads from "./pages/downloads/downloads"; @@ -48,7 +49,11 @@ i18n }, }) .then(async () => { - const userPreferences = await window.electron.getUserPreferences(); + const userPreferences = (await levelDBService.get( + "userPreferences", + null, + "json" + )) as { language?: string } | null; if (userPreferences?.language) { i18n.changeLanguage(userPreferences.language); diff --git a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx index 38b2443b..a362c545 100644 --- a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx +++ b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx @@ -11,6 +11,7 @@ import { getAchievementSoundVolume, } from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; +import { levelDBService } from "@renderer/services/leveldb.service"; import app from "../../../app.scss?inline"; import styles from "../../../components/achievements/notification/achievement-notification.scss?inline"; import root from "react-shadow"; @@ -144,7 +145,11 @@ export function AchievementNotification() { const loadAndApplyTheme = useCallback(async () => { if (!shadowRootRef) return; - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + isActive?: boolean; + code?: string; + }[]; + const activeTheme = allThemes.find((theme) => theme.isActive); if (activeTheme?.code) { injectCustomCss(activeTheme.code, shadowRootRef); } else { diff --git a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx index c9658636..56912fcc 100644 --- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx +++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx @@ -100,20 +100,48 @@ export function GallerySlider() { src?: string; poster?: string; videoSrc?: string; + videoType?: string; alt: string; }> = []; if (shopDetails?.movies) { shopDetails.movies.forEach((video, index) => { - items.push({ - id: String(video.id), - type: "video", - poster: video.thumbnail, - videoSrc: video.mp4.max.startsWith("http://") - ? video.mp4.max.replace("http://", "https://") - : video.mp4.max, - alt: t("video", { number: String(index + 1) }), - }); + // Prefer new formats: HLS (best browser support), then DASH H264, then DASH AV1 + // Fallback to old format: mp4/webm if new formats are not available + let videoSrc: string | undefined; + let videoType: string | undefined; + + if (video.hls_h264) { + videoSrc = video.hls_h264; + videoType = "application/x-mpegURL"; + } else if (video.dash_h264) { + videoSrc = video.dash_h264; + videoType = "application/dash+xml"; + } else if (video.dash_av1) { + videoSrc = video.dash_av1; + videoType = "application/dash+xml"; + } else if (video.mp4?.max) { + // Fallback to old format + videoSrc = video.mp4.max; + videoType = "video/mp4"; + } else if (video.webm?.max) { + // Fallback to webm if mp4 is not available + videoSrc = video.webm.max; + videoType = "video/webm"; + } + + if (videoSrc) { + items.push({ + id: String(video.id), + type: "video", + poster: video.thumbnail, + videoSrc: videoSrc.startsWith("http://") + ? videoSrc.replace("http://", "https://") + : videoSrc, + videoType, + alt: video.name || t("video", { number: String(index + 1) }), + }); + } }); } @@ -172,7 +200,9 @@ export function GallerySlider() { autoPlay={autoplayEnabled} tabIndex={-1} > - + {item.videoSrc && ( + + )} ) : ( { - await window.electron.updateLaunchOptions( - game.shop, - game.objectId, - value - ); + const gameKey = getGameKey(game.shop, game.objectId); + const gameData = (await levelDBService.get( + gameKey, + "games" + )) as Game | null; + if (gameData) { + const trimmedValue = value.trim(); + const updated = { + ...gameData, + launchOptions: trimmedValue ? trimmedValue : null, + }; + await levelDBService.put(gameKey, updated, "games"); + } updateGame(); }, 1000) ).current; @@ -213,9 +223,16 @@ export function GameOptionsModal({ const handleClearLaunchOptions = async () => { setLaunchOptions(""); - window.electron - .updateLaunchOptions(game.shop, game.objectId, null) - .then(updateGame); + const gameKey = getGameKey(game.shop, game.objectId); + const gameData = (await levelDBService.get( + gameKey, + "games" + )) as Game | null; + if (gameData) { + const updated = { ...gameData, launchOptions: null }; + await levelDBService.put(gameKey, updated, "games"); + } + updateGame(); }; const shouldShowWinePrefixConfiguration = @@ -256,11 +273,15 @@ export function GameOptionsModal({ ) => { setAutomaticCloudSync(event.target.checked); - await window.electron.toggleAutomaticCloudSync( - game.shop, - game.objectId, - event.target.checked - ); + const gameKey = getGameKey(game.shop, game.objectId); + const gameData = (await levelDBService.get( + gameKey, + "games" + )) as Game | null; + if (gameData) { + const updated = { ...gameData, automaticCloudSync: event.target.checked }; + await levelDBService.put(gameKey, updated, "games"); + } updateGame(); }; diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 91013da0..51744fc5 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -15,7 +15,7 @@ import { TextField, CheckboxField, } from "@renderer/components"; -import type { DownloadSource, GameRepack } from "@types"; +import type { DownloadSource, Game, GameRepack } from "@types"; import { DownloadSettingsModal } from "./download-settings-modal"; import { gameDetailsContext } from "@renderer/context"; @@ -23,6 +23,8 @@ import { Downloader } from "@shared"; import { orderBy } from "lodash-es"; import { useDate, useFeature, useAppDispatch } from "@renderer/hooks"; import { clearNewDownloadOptions } from "@renderer/features"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import { getGameKey } from "@renderer/helpers"; import "./repacks-modal.scss"; export interface RepacksModalProps { @@ -98,8 +100,11 @@ export function RepacksModal({ useEffect(() => { const fetchDownloadSources = async () => { - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); }; fetchDownloadSources(); @@ -109,10 +114,19 @@ export function RepacksModal({ const fetchLastCheckTimestamp = async () => { setIsLoadingTimestamp(true); - const timestamp = await window.electron.getDownloadSourcesSinceValue(); + try { + const timestamp = (await levelDBService.get( + "downloadSourcesSinceValue", + null, + "utf8" + )) as string | null; - setLastCheckTimestamp(timestamp); - setIsLoadingTimestamp(false); + setLastCheckTimestamp(timestamp); + } catch { + setLastCheckTimestamp(null); + } finally { + setIsLoadingTimestamp(false); + } }; if (visible) { @@ -126,7 +140,20 @@ export function RepacksModal({ game?.newDownloadOptionsCount && game.newDownloadOptionsCount > 0 ) { - globalThis.electron.clearNewDownloadOptions(game.shop, game.objectId); + const gameKey = getGameKey(game.shop, game.objectId); + levelDBService + .get(gameKey, "games") + .then((gameData) => { + if (gameData) { + const updated = { + ...(gameData as Game), + newDownloadOptionsCount: undefined, + }; + return levelDBService.put(gameKey, updated, "games"); + } + return Promise.resolve(); + }) + .catch(() => {}); const gameId = `${game.shop}:${game.objectId}`; dispatch(clearNewDownloadOptions({ gameId })); diff --git a/src/renderer/src/pages/home/home.tsx b/src/renderer/src/pages/home/home.tsx index b8f632a6..91c9b2ff 100644 --- a/src/renderer/src/pages/home/home.tsx +++ b/src/renderer/src/pages/home/home.tsx @@ -1,11 +1,13 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import { orderBy } from "lodash-es"; import { useNavigate } from "react-router-dom"; import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import { Button, GameCard, Hero } from "@renderer/components"; -import type { ShopAssets, Steam250Game } from "@types"; +import type { DownloadSource, ShopAssets, Steam250Game } from "@types"; import flameIconStatic from "@renderer/assets/icons/flame-static.png"; import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif"; @@ -40,7 +42,10 @@ export default function Home() { setCurrentCatalogueCategory(category); setIsLoading(true); - const downloadSources = await window.electron.getDownloadSources(); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const downloadSources = orderBy(sources, "createdAt", "desc"); const params = { take: 12, diff --git a/src/renderer/src/pages/settings/aparence/components/theme-card.tsx b/src/renderer/src/pages/settings/aparence/components/theme-card.tsx index 68eed4c3..eb0ca287 100644 --- a/src/renderer/src/pages/settings/aparence/components/theme-card.tsx +++ b/src/renderer/src/pages/settings/aparence/components/theme-card.tsx @@ -8,6 +8,7 @@ import { useState } from "react"; import { DeleteThemeModal } from "../modals/delete-theme-modal"; import { injectCustomCss, removeCustomCss } from "@renderer/helpers"; import { THEME_WEB_STORE_URL } from "@renderer/constants"; +import { levelDBService } from "@renderer/services/leveldb.service"; interface ThemeCardProps { theme: Theme; @@ -22,11 +23,18 @@ export const ThemeCard = ({ theme, onListUpdated }: ThemeCardProps) => { const handleSetTheme = async () => { try { - const currentTheme = await window.electron.getCustomThemeById(theme.id); + const currentTheme = (await levelDBService.get( + theme.id, + "themes" + )) as Theme | null; if (!currentTheme) return; - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + id: string; + isActive?: boolean; + }[]; + const activeTheme = allThemes.find((t) => t.isActive); if (activeTheme) { removeCustomCss(); diff --git a/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx index 522d8546..c8a2c80d 100644 --- a/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/add-theme-modal.tsx @@ -10,6 +10,7 @@ import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { useCallback } from "react"; import { generateUUID } from "@renderer/helpers"; +import { levelDBService } from "@renderer/services/leveldb.service"; import "./modals.scss"; @@ -90,7 +91,7 @@ export function AddThemeModal({ updatedAt: new Date(), }; - await window.electron.addCustomTheme(theme); + await levelDBService.put(theme.id, theme, "themes"); onThemeAdded(); onClose(); reset(); diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx index 9439d273..fa21bc2c 100644 --- a/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx @@ -3,6 +3,7 @@ import { Modal } from "@renderer/components/modal/modal"; import { useTranslation } from "react-i18next"; import "./modals.scss"; import { removeCustomCss } from "@renderer/helpers"; +import { levelDBService } from "@renderer/services/leveldb.service"; interface DeleteAllThemesModalProps { visible: boolean; @@ -18,13 +19,16 @@ export const DeleteAllThemesModal = ({ const { t } = useTranslation("settings"); const handleDeleteAllThemes = async () => { - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + isActive?: boolean; + }[]; + const activeTheme = allThemes.find((theme) => theme.isActive); if (activeTheme) { removeCustomCss(); } - await window.electron.deleteAllCustomThemes(); + await levelDBService.clear("themes"); await window.electron.closeEditorWindow(); onClose(); onThemesDeleted(); diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx index c1a5a1e0..d2158f6f 100644 --- a/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx @@ -3,6 +3,7 @@ import { Modal } from "@renderer/components/modal/modal"; import { useTranslation } from "react-i18next"; import "./modals.scss"; import { removeCustomCss } from "@renderer/helpers"; +import { levelDBService } from "@renderer/services/leveldb.service"; interface DeleteThemeModalProps { visible: boolean; @@ -28,7 +29,7 @@ export const DeleteThemeModal = ({ removeCustomCss(); } - await window.electron.deleteCustomTheme(themeId); + await levelDBService.del(themeId, "themes"); await window.electron.closeEditorWindow(themeId); onThemeDeleted(); }; diff --git a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx index 93baf1cd..e729ae29 100644 --- a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx @@ -11,6 +11,7 @@ import { import { useToast } from "@renderer/hooks"; import { THEME_WEB_STORE_URL } from "@renderer/constants"; import { logger } from "@renderer/logger"; +import { levelDBService } from "@renderer/services/leveldb.service"; interface ImportThemeModalProps { visible: boolean; @@ -45,9 +46,12 @@ export const ImportThemeModal = ({ }; try { - await window.electron.addCustomTheme(theme); + await levelDBService.put(theme.id, theme, "themes"); - const currentTheme = await window.electron.getCustomThemeById(theme.id); + const currentTheme = (await levelDBService.get( + theme.id, + "themes" + )) as Theme | null; if (!currentTheme) return; @@ -61,7 +65,11 @@ export const ImportThemeModal = ({ logger.error("Failed to import theme sound", soundError); } - const activeTheme = await window.electron.getActiveCustomTheme(); + const allThemes = (await levelDBService.values("themes")) as { + id: string; + isActive?: boolean; + }[]; + const activeTheme = allThemes.find((t) => t.isActive); if (activeTheme) { removeCustomCss(); diff --git a/src/renderer/src/pages/settings/aparence/settings-appearance.tsx b/src/renderer/src/pages/settings/aparence/settings-appearance.tsx index 413e52e9..24247396 100644 --- a/src/renderer/src/pages/settings/aparence/settings-appearance.tsx +++ b/src/renderer/src/pages/settings/aparence/settings-appearance.tsx @@ -5,6 +5,7 @@ import type { Theme } from "@types"; import { ImportThemeModal } from "./modals/import-theme-modal"; import { settingsContext } from "@renderer/context"; import { useNavigate } from "react-router-dom"; +import { levelDBService } from "@renderer/services/leveldb.service"; interface SettingsAppearanceProps { appearance: { @@ -31,7 +32,7 @@ export function SettingsAppearance({ const navigate = useNavigate(); const loadThemes = useCallback(async () => { - const themesList = await window.electron.getAllCustomThemes(); + const themesList = (await levelDBService.values("themes")) as Theme[]; setThemes(themesList); }, []); diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx index 675919e3..f597838e 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.tsx +++ b/src/renderer/src/pages/settings/settings-download-sources.tsx @@ -21,6 +21,8 @@ import { DownloadSourceStatus } from "@shared"; import { settingsContext } from "@renderer/context"; import { useNavigate } from "react-router-dom"; import { setFilters, clearFilters } from "@renderer/features"; +import { levelDBService } from "@renderer/services/leveldb.service"; +import { orderBy } from "lodash-es"; import "./settings-download-sources.scss"; import { logger } from "@renderer/logger"; @@ -52,8 +54,11 @@ export function SettingsDownloadSources() { useEffect(() => { const fetchDownloadSources = async () => { - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); }; fetchDownloadSources(); @@ -73,8 +78,11 @@ export function SettingsDownloadSources() { const intervalId = setInterval(async () => { try { await window.electron.syncDownloadSources(); - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); } catch (error) { logger.error("Failed to fetch download sources:", error); } @@ -88,8 +96,11 @@ export function SettingsDownloadSources() { try { await window.electron.removeDownloadSource(false, downloadSource.id); - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); showSuccessToast(t("removed_download_source")); } catch (error) { logger.error("Failed to remove download source:", error); @@ -103,8 +114,11 @@ export function SettingsDownloadSources() { try { await window.electron.removeDownloadSource(true); - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); showSuccessToast(t("removed_all_download_sources")); } catch (error) { logger.error("Failed to remove all download sources:", error); @@ -116,8 +130,11 @@ export function SettingsDownloadSources() { const handleAddDownloadSource = async () => { try { - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); } catch (error) { logger.error("Failed to refresh download sources:", error); } @@ -127,8 +144,11 @@ export function SettingsDownloadSources() { setIsSyncingDownloadSources(true); try { await window.electron.syncDownloadSources(); - const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources); + const sources = (await levelDBService.values( + "downloadSources" + )) as DownloadSource[]; + const sorted = orderBy(sources, "createdAt", "desc"); + setDownloadSources(sorted); showSuccessToast(t("download_sources_synced_successfully")); } finally { diff --git a/src/renderer/src/pages/theme-editor/theme-editor.tsx b/src/renderer/src/pages/theme-editor/theme-editor.tsx index 3f0be9cf..41dc7a7f 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.tsx +++ b/src/renderer/src/pages/theme-editor/theme-editor.tsx @@ -16,6 +16,7 @@ import { injectCustomCss, getAchievementSoundVolume } from "@renderer/helpers"; import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification"; import { generateAchievementCustomNotificationTest } from "@shared"; import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu"; +import { levelDBService } from "@renderer/services/leveldb.service"; import app from "../../app.scss?inline"; import styles from "../../components/achievements/notification/achievement-notification.scss?inline"; import root from "react-shadow"; @@ -64,15 +65,16 @@ export default function ThemeEditor() { useEffect(() => { if (themeId) { - window.electron.getCustomThemeById(themeId).then((loadedTheme) => { - if (loadedTheme) { - setTheme(loadedTheme); - setCode(loadedTheme.code); - if (loadedTheme.originalSoundPath) { - setSoundPath(loadedTheme.originalSoundPath); + levelDBService.get(themeId, "themes").then((loadedTheme) => { + const theme = loadedTheme as Theme | null; + if (theme) { + setTheme(theme); + setCode(theme.code); + if (theme.originalSoundPath) { + setSoundPath(theme.originalSoundPath); } if (shadowRootRef) { - injectCustomCss(loadedTheme.code, shadowRootRef); + injectCustomCss(theme.code, shadowRootRef); } } }); @@ -132,7 +134,10 @@ export default function ThemeEditor() { if (filePaths && filePaths.length > 0) { const originalPath = filePaths[0]; await window.electron.copyThemeAchievementSound(theme.id, originalPath); - const updatedTheme = await window.electron.getCustomThemeById(theme.id); + const updatedTheme = (await levelDBService.get( + theme.id, + "themes" + )) as Theme | null; if (updatedTheme) { setTheme(updatedTheme); if (updatedTheme.originalSoundPath) { @@ -146,7 +151,10 @@ export default function ThemeEditor() { if (!theme) return; await window.electron.removeThemeAchievementSound(theme.id); - const updatedTheme = await window.electron.getCustomThemeById(theme.id); + const updatedTheme = (await levelDBService.get( + theme.id, + "themes" + )) as Theme | null; if (updatedTheme) { setTheme(updatedTheme); } diff --git a/src/renderer/src/services/leveldb.service.ts b/src/renderer/src/services/leveldb.service.ts new file mode 100644 index 00000000..68e5e9f1 --- /dev/null +++ b/src/renderer/src/services/leveldb.service.ts @@ -0,0 +1,36 @@ +class LevelDBService { + get( + key: string, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ): Promise { + return window.electron.leveldb.get(key, sublevelName, valueEncoding); + } + + put( + key: string, + value: unknown, + sublevelName?: string | null, + valueEncoding?: "json" | "utf8" + ): Promise { + return window.electron.leveldb.put(key, value, sublevelName, valueEncoding); + } + + del(key: string, sublevelName?: string | null): Promise { + return window.electron.leveldb.del(key, sublevelName); + } + + clear(sublevelName: string): Promise { + return window.electron.leveldb.clear(sublevelName); + } + + values(sublevelName: string): Promise { + return window.electron.leveldb.values(sublevelName); + } + + iterator(sublevelName: string): Promise<[string, unknown][]> { + return window.electron.leveldb.iterator(sublevelName); + } +} + +export const levelDBService = new LevelDBService(); diff --git a/src/types/steam.types.ts b/src/types/steam.types.ts index 4dcf460a..12d012ea 100644 --- a/src/types/steam.types.ts +++ b/src/types/steam.types.ts @@ -16,8 +16,11 @@ export interface SteamVideoSource { export interface SteamMovies { id: number; - mp4: SteamVideoSource; - webm: SteamVideoSource; + dash_av1?: string; + dash_h264?: string; + hls_h264?: string; + mp4?: SteamVideoSource; + webm?: SteamVideoSource; thumbnail: string; name: string; highlight: boolean; diff --git a/yarn.lock b/yarn.lock index 478cde97..f4f0de7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6205,9 +6205,9 @@ jiti@^2.6.1: integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== dependencies: argparse "^2.0.1"