From 8203399edada6c26cc70db1514f198665ef9de8d Mon Sep 17 00:00:00 2001 From: "Kiwo.2" Date: Sun, 12 Oct 2025 23:27:20 +0200 Subject: [PATCH 001/118] Matching to Latest Update --- src/locales/hu/translation.json | 95 ++++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 18 deletions(-) diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index 551c1363..b72811d3 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -76,7 +76,19 @@ "edit_game_modal_drop_hero_image_here": "Húzd ide a borítókép képét", "edit_game_modal_drop_to_replace_icon": "Ikon kicserélése ráhúzással", "edit_game_modal_drop_to_replace_logo": "Logó kicserélése ráhúzással", - "edit_game_modal_drop_to_replace_hero": "Borítókép kicserélése ráhúzással" + "edit_game_modal_drop_to_replace_hero": "Borítókép kicserélése ráhúzással", + "install_decky_plugin": "Decky Plugin Telepítése", + "update_decky_plugin": "Decky Plugin Frissítése", + "decky_plugin_installed_version": "Decky Plugin (v{{version}})", + "install_decky_plugin_title": "Telepítsd a Hydra Decky Plugint", + "install_decky_plugin_message": "Ez letölti és telepíteni fogja a Hydra plugint a Decky Loaderhez. Előfordulhat, hogy rendszergazdai jogosultságra lesz szükség. Folytatod?", + "update_decky_plugin_title": "Hydra Decky Plugin Frissítése", + "update_decky_plugin_message": "Egy új verzió elérhető a Hydra Decky Pluginhoz. Szeretnéd frissíteni?", + "decky_plugin_installed": "Decky plugin v{{version}} sikeresen telepítve", + "decky_plugin_installation_failed": "Decky plugin telepítése sikertelen: {{error}}", + "decky_plugin_installation_error": "Decky plugin telepítése hibával járt el: {{error}}", + "confirm": "Megerősít", + "cancel": "Mégse" }, "header": { "search": "Keresés", @@ -126,7 +138,7 @@ "downloading_metadata": "Metaadat letöltése", "filter": "Repackek szűrése", "requirements": "Rendszerkövetelmények", - "minimum": "Minimum", + "minimum": "Minimális", "recommended": "Ajánlott", "paused": "Szüneteltetve", "release_date": "Megjelenés: {{date}}", @@ -150,7 +162,7 @@ "playing_now": "Játékban: ", "change": "Változtatás", "repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni", - "select_folder_hint": "Hogy megváltoztasd a letöltési mappát, menj a <0>Beállítások menüjébe", + "select_folder_hint": "A letöltési mappát a <0>Beállítások menüjében változtathatod meg", "download_now": "Letöltés", "no_shop_details": "A bolt adatai nem érhetőek el.", "download_options": "Letöltési opciók", @@ -193,20 +205,54 @@ "failed_remove_from_library": "Játék eltávolítása a könyvtárból sikertelen", "files_removed_success": "Fájlok eltávolítása sikeres", "failed_remove_files": "Fájlok eltávolítása sikertelen", - "nsfw_content_title": "Ez a játék nem megfelelő tartalmat tartalmaz", + "nsfw_content_title": "Ez a játék tartalmaz nem megfelelő tartalmat", "nsfw_content_description": "A(z) {{title}} tartalma lehetséges hogy nem megfelelő minden korosztály számára. Biztosan folytatni szeretnéd?", "allow_nsfw_content": "Folytatás", "refuse_nsfw_content": "Vissza", "stats": "Statisztikák", "download_count": "Letöltések", "player_count": "Aktív játékosok", - "download_error": "Ez a letöltési opció nem elérhető", + "rating_count": "Értékelés", + "download_error": "Ez a letöltési opció nem elérhető", "download": "Letöltés", "executable_path_in_use": "Ez a futtatható fájl már használatban van a(z) \"{{game}}\" által", "warning": "Figyelmeztetés:", "hydra_needs_to_remain_open": "ehhez a letöltéshez, a Hydrának muszáj nyitva maradnia hogy letöltődjön. Ha a Hydra bezáródik letöltés előtt, a letöltés elveszik.", "achievements": "Achievementek", "achievements_count": "Achievementek {{unlockedCount}}/{{achievementsCount}}", + "show_more": "Mutass többet", + "show_less": "Mutass kevesebbet", + "reviews": "Vélemények", + "leave_a_review": "Hagyd itt a véleményed", + "write_review_placeholder": "Oszd meg a gondolataid a játékról...", + "sort_newest": "Legújabb", + "no_reviews_yet": "Még nem lett vélemény megosztva", + "be_first_to_review": "Légy az első, aki megossza a véleményét a játékról!", + "sort_oldest": "Legrégibb", + "sort_highest_score": "Legmagasabb Pontszám", + "sort_lowest_score": "Legalacsonyabb Pontszám", + "sort_most_voted": "Legszavazottabb", + "rating": "Értékelés", + "rating_stats": "Értékelés", + "rating_very_negative": "Nagyon Negatív", + "rating_negative": "Negatív", + "rating_neutral": "Átlagos", + "rating_positive": "Pozitív", + "rating_very_positive": "Nagyon Pozitív", + "submit_review": "Küldés", + "submitting": "Küldés alatt...", + "review_submitted_successfully": "Vélemény beküldve sikeresen!", + "review_submission_failed": "Vélemény beküldése sikertelen. Kérlek próbáld újra.", + "review_cannot_be_empty": "A vélemény mező nem lehet üres.", + "review_deleted_successfully": "Vélemény sikeresen törölve.", + "review_deletion_failed": "Vélemény törlése sikertelen. Kérlek próbáld újra.", + "loading_reviews": "Vélemények betöltése...", + "loading_more_reviews": "Több vélemény betöltése...", + "load_more_reviews": "Több vélemény betöltése", + "you_seemed_to_enjoy_this_game": "Úgy látszik élvezted ezt a játékot", + "would_you_recommend_this_game": "Szeretnél véleményt írni erről a játékról?", + "yes": "Igen", + "maybe_later": "Talán Később", "cloud_save": "Mentés felhőben", "cloud_save_description": "Mentsd el az előrehaladásod a felhőben, majd folytasd egy másik eszközön", "backups": "Biztonsági másolatok", @@ -219,6 +265,7 @@ "uploading_backup": "Biztonsági mentés feltöltése…", "no_backups": "Még nem hoztál létre biztonsági másolatot ehhez a játékhoz", "backup_uploaded": "Biztonsági mentés feltöltve", + "backup_failed": "Biztonsági mentés sikertelen", "backup_deleted": "Biztonsági mentés törölve", "backup_restored": "Biztonsági mentés helyreállítva", "see_all_achievements": "Achievementlista megtekintése", @@ -256,7 +303,7 @@ "download_error_real_debrid_account_not_authorized": "A Real-Debrid fiókod nem jogosult új letöltésekre. Kérlek, ellenőrízd a fiókbeállításaidat, majd próbáld újra.", "download_error_not_cached_on_real_debrid": "Ez a letöltés nem elérhető a Real-Debriden, és lekérdezni letöltési állapotot még nem lehet.", "update_playtime_title": "Játékidő frissítése", - "update_playtime_description": "A(z) {{game}} játékidejének frissítése", + "update_playtime_description": "A(z) {{game}} játékidejének frissítése manuálisan", "update_playtime": "Játékidő frissítése", "update_playtime_success": "Játékidő sikeresen frissítve", "update_playtime_error": "A Játékidőnek nem sikerült frissülnie", @@ -303,11 +350,17 @@ "caption": "Felirat", "audio": "Hang", "filter_by_source": "Szűrés forrás szerint", - "no_repacks_found": "Nem található forrás ehhez a játékhoz" + "no_repacks_found": "Nem található forrás ehhez a játékhoz" , + "delete_review": "Vélemény törlése", + "remove_review": "Vélemény eltávolítása", + "delete_review_modal_title": "Biztos vagy abban hogy törölni szeretnéd a véleményed?", + "delete_review_modal_description": "Ez a lépés nem vonható vissza.", + "delete_review_modal_delete_button": "Törlés", + "delete_review_modal_cancel_button": "Mégse" }, "activation": { - "title": "Hydra aktiválása", - "installation_id": "Telepítési azonosító:", + "title": "Hydra Aktiválása", + "installation_id": "Telepítési Azonosító:", "enter_activation_code": "Írd be az aktiválási kódod", "message": "Ha nem tudod hol kérdezz efelől, akkor nem kéne ilyened legyen.", "activate": "Aktiválás", @@ -341,7 +394,7 @@ "stop_seeding": "Seedelés leállítása", "resume_seeding": "Seedelés folytatása", "options": "Kezelés", - "alldebrid_size_not_supported": "Letöltési információ az AllDebridhez még nem támogatott", + "alldebrid_size_not_supported": "Letöltési információ az AllDebrid-hez még nem támogatott", "extract": "Fájlok kibontása", "extracting": "Fájlok kibontása…" }, @@ -445,6 +498,8 @@ "delete_theme_description": "Ez törölni fogja a(z) {{theme}} témát", "cancel": "Mégsem", "appearance": "Megjelenés", + "debrid": "Debrid", + "debrid_description": "A Debrid szolgáltatások prémium szolgáltatások amelyek lehetővé teszik, hogy gyorsan letölts különböző fájltároló szolgáltatásokon tárolt fájlokat, csak az internet sebességed szab határt.", "enable_torbox": "TorBox bekapcsolása", "torbox_description": "A TorBox egy olyan premium seedbox szolgáltatás, amely még a piacon elérhető legjobb szerverekkel is felveszi a versenyt.", "torbox_account_linked": "TorBox fiók összekapcsolva", @@ -520,11 +575,12 @@ "available_one": "Elérhető", "available_other": "Elérhető", "no_downloads": "Nincs elérhető letöltés" + "calculating": "Feldolgozás" }, "binary_not_found_modal": { "title": "A programok nincsenek telepítve", "description": "Wine vagy Lutris futtatható fájlok nem találhatók a rendszereden", - "instructions": "Ellenőrízd, hogy melyiket kell helyesen telepíteni a Linux disztribúcióra, hogy a játék normálisan fusson" + "instructions": "Ellenőrízd hogy melyiket kell helyesen telepíteni a Linux disztribúciódra, hogy a játék megfelelően fusson" }, "modal": { "close": "Bezárás gomb" @@ -604,7 +660,7 @@ "report_reason_violence": "Fenyegető", "report_reason_spam": "Spam", "report_reason_other": "Egyéb", - "profile_reported": "Profil jelentve", + "profile_reported": "Profil bejelentve", "your_friend_code": "A barát kódod:", "upload_banner": "Borítókép feltöltés", "uploading_banner": "Borítókép feltöltése…", @@ -622,17 +678,20 @@ "error_adding_friend": "Hiba, barátfelkérés sikertelen. Kérlek ellenőrízd a barát kódot", "friend_code_length_error": "A barát kódnak 8 karakterből kell állnia", "game_removed_from_pinned": "Játék eltávolítva a kitűzöttek közül", - "game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez" + "game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez" + "karma": "Karma", + "karma_count": "karma", + "karma_description": "Pozitív értékelésekre kapott pontok alapján" }, "achievement": { "achievement_unlocked": "Achievement feloldva", - "user_achievements": "{{displayName}} Achievementjei", - "your_achievements": "A te Achievementjeid", - "unlocked_at": "Feloldva ekkor: {{date}}", + "user_achievements": "{{displayName}} achievementjei", + "your_achievements": "A te achievementjeid", + "unlocked_at": "Feloldva: {{date}}", "subscription_needed": "A tartalom megtekintéséhez Hydra Cloud előfizetés szükséges", - "new_achievements_unlocked": "{{achievementCount}} új achievementet oldottál fel {{gameCount}} játékban", + "new_achievements_unlocked": "{{achievementCount}} új achievement feloldva {{gameCount}} játékban", "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementek", - "achievements_unlocked_for_game": "{{achievementCount}} új achievementet oldottál fel a(z) {{gameTitle}} játékban", + "achievements_unlocked_for_game": "{{achievementCount}} új achievement feloldva itt: {{gameTitle}}", "hidden_achievement_tooltip": "Ez egy rejtett achievement", "achievement_earn_points": "Szerezz be {{points}} pontot ezzel az achievement-el", "earned_points": "Megszerzett pontok:", From 3dc71a8d1fb203d1403c07abcd0fa4e1806f80bb Mon Sep 17 00:00:00 2001 From: whintersnow0 Date: Wed, 15 Oct 2025 19:19:08 +0200 Subject: [PATCH 002/118] refactor: remove unnecessary useMemo hooks --- .../src/components/text-field/text-field.tsx | 52 +++++-------------- 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/src/renderer/src/components/text-field/text-field.tsx b/src/renderer/src/components/text-field/text-field.tsx index 1c4d54af..7c0cbb58 100644 --- a/src/renderer/src/components/text-field/text-field.tsx +++ b/src/renderer/src/components/text-field/text-field.tsx @@ -1,16 +1,13 @@ -import React, { useId, useMemo, useState } from "react"; +import React, { useId, useState } from "react"; import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; - import cn from "classnames"; - import "./text-field.scss"; -export interface TextFieldProps - extends React.DetailedHTMLProps< - React.InputHTMLAttributes, - HTMLInputElement - > { +export interface TextFieldProps extends React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement +> { theme?: "primary" | "dark"; label?: string | React.ReactNode; hint?: string | React.ReactNode; @@ -42,44 +39,27 @@ export const TextField = React.forwardRef( ) => { const id = useId(); const [isFocused, setIsFocused] = useState(false); - const [isPasswordVisible, setIsPasswordVisible] = useState(false); - const { t } = useTranslation("forms"); - const showPasswordToggleButton = props.type === "password"; - - const inputType = useMemo(() => { - if (props.type === "password" && isPasswordVisible) return "text"; - return props.type ?? "text"; - }, [props.type, isPasswordVisible]); - - const hintContent = useMemo(() => { - if (error) - return ( - {error} - ); - - if (hint) return {hint}; - return null; - }, [hint, error]); - + const inputType = props.type === "password" && isPasswordVisible ? "text" : props.type ?? "text"; + const hintContent = error ? ( + {error} + ) : hint ? ( + {hint} + ) : null; const handleFocus: React.FocusEventHandler = (event) => { setIsFocused(true); - if (props.onFocus) props.onFocus(event); + props.onFocus?.(event); }; - const handleBlur: React.FocusEventHandler = (event) => { setIsFocused(false); - if (props.onBlur) props.onBlur(event); + props.onBlur?.(event); }; - const hasError = !!error; - return (
{label && } -
( onBlur={handleBlur} type={inputType} /> - {showPasswordToggleButton && ( )}
- {rightContent}
- {hintContent}
); } ); - -TextField.displayName = "TextField"; +TextField.displayName = "TextField"; \ No newline at end of file From c2273dbf712842ef7f6241d8a68b154816bbb64a Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Sat, 18 Oct 2025 14:07:44 +0100 Subject: [PATCH 003/118] feat: moving sources to worker --- src/main/events/download-sources/helpers.ts | 85 ++-------- .../download-sources/sync-download-sources.ts | 20 +-- src/main/main.ts | 4 + .../services/game-matcher-worker-manager.ts | 145 ++++++++++++++++ src/main/services/index.ts | 1 + src/main/workers/game-matcher-worker.ts | 158 ++++++++++++++++++ 6 files changed, 329 insertions(+), 84 deletions(-) create mode 100644 src/main/services/game-matcher-worker-manager.ts create mode 100644 src/main/workers/game-matcher-worker.ts diff --git a/src/main/events/download-sources/helpers.ts b/src/main/events/download-sources/helpers.ts index 2e7489fd..edd3878e 100644 --- a/src/main/events/download-sources/helpers.ts +++ b/src/main/events/download-sources/helpers.ts @@ -192,83 +192,30 @@ export const addNewDownloads = async ( const batch = repacksSublevel.batch(); + // Get title hash mapping and perform matching in worker thread const titleHashMapping = await getTitleHashMapping(); - let hashMatchCount = 0; - let fuzzyMatchCount = 0; - let noMatchCount = 0; - for (const download of downloads) { - let objectIds: string[] = []; - let usedHashMatch = false; + const { GameMatcherWorkerManager } = await import("@main/services"); + const matchResult = await GameMatcherWorkerManager.matchDownloads( + downloads, + steamGames, + titleHashMapping + ); - const titleHash = hashTitle(download.title); - const steamIdsFromHash = titleHashMapping[titleHash]; - - if (steamIdsFromHash && steamIdsFromHash.length > 0) { - hashMatchCount++; - usedHashMatch = true; - - objectIds = steamIdsFromHash.map(String); - } - - if (!usedHashMatch) { - let gamesInSteam: FormattedSteamGame[] = []; - const formattedTitle = formatRepackName(download.title); - - if (formattedTitle && formattedTitle.length > 0) { - const [firstLetter] = formattedTitle; - const games = steamGames[firstLetter] || []; - - gamesInSteam = games.filter((game) => - formattedTitle.startsWith(game.formattedName) - ); - - if (gamesInSteam.length === 0) { - gamesInSteam = games.filter( - (game) => - formattedTitle.includes(game.formattedName) || - game.formattedName.includes(formattedTitle) - ); - } - - if (gamesInSteam.length === 0) { - for (const letter of Object.keys(steamGames)) { - const letterGames = steamGames[letter] || []; - const matches = letterGames.filter( - (game) => - formattedTitle.includes(game.formattedName) || - game.formattedName.includes(formattedTitle) - ); - if (matches.length > 0) { - gamesInSteam = matches; - break; - } - } - } - - if (gamesInSteam.length > 0) { - fuzzyMatchCount++; - objectIds = gamesInSteam.map((game) => String(game.id)); - } else { - noMatchCount++; - } - } else { - noMatchCount++; - } - } - - for (const id of objectIds) { + // Process matched results and write to database + for (const matchedDownload of matchResult.matchedDownloads) { + for (const id of matchedDownload.objectIds) { objectIdsOnSource.add(id); } const repack = { id: nextRepackId++, - objectIds: objectIds, - title: download.title, - uris: download.uris, - fileSize: download.fileSize, + objectIds: matchedDownload.objectIds, + title: matchedDownload.title, + uris: matchedDownload.uris, + fileSize: matchedDownload.fileSize, repacker: downloadSource.name, - uploadDate: download.uploadDate, + uploadDate: matchedDownload.uploadDate, downloadSourceId: downloadSource.id, createdAt: now, updatedAt: now, @@ -280,7 +227,7 @@ export const addNewDownloads = async ( await batch.write(); logger.info( - `Matching stats for ${downloadSource.name}: Hash=${hashMatchCount}, Fuzzy=${fuzzyMatchCount}, None=${noMatchCount}` + `Matching stats for ${downloadSource.name}: Hash=${matchResult.stats.hashMatchCount}, Fuzzy=${matchResult.stats.fuzzyMatchCount}, None=${matchResult.stats.noMatchCount}` ); const existingSource = await downloadSourcesSublevel.get( diff --git a/src/main/events/download-sources/sync-download-sources.ts b/src/main/events/download-sources/sync-download-sources.ts index 88861074..3bb78f22 100644 --- a/src/main/events/download-sources/sync-download-sources.ts +++ b/src/main/events/download-sources/sync-download-sources.ts @@ -31,20 +31,10 @@ const syncDownloadSources = async ( downloadSources.push(source); } - const existingRepacks: Array<{ - id: number; - title: string; - uris: string[]; - repacker: string; - fileSize: string | null; - objectIds: string[]; - uploadDate: Date | string | null; - downloadSourceId: number; - createdAt: Date; - updatedAt: Date; - }> = []; + // Use a Set for O(1) lookups instead of O(n) with array.some() + const existingRepackTitles = new Set(); for await (const [, repack] of repacksSublevel.iterator()) { - existingRepacks.push(repack); + existingRepackTitles.add(repack.title); } // Handle sources with missing fingerprints individually, don't delete all sources @@ -77,9 +67,9 @@ const syncDownloadSources = async ( const source = downloadSourceSchema.parse(response.data); const steamGames = await getSteamGames(); + // O(1) lookup instead of O(n) - massive performance improvement const repacks = source.downloads.filter( - (download) => - !existingRepacks.some((repack) => repack.title === download.title) + (download) => !existingRepackTitles.has(download.title) ); await downloadSourcesSublevel.put(`${downloadSource.id}`, { diff --git a/src/main/main.ts b/src/main/main.ts index 5eecb101..e9b6187c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -17,6 +17,7 @@ import { Lock, DeckyPlugin, ResourceCache, + GameMatcherWorkerManager, } from "@main/services"; export const loadState = async () => { @@ -25,6 +26,9 @@ export const loadState = async () => { ResourceCache.initialize(); await ResourceCache.updateResourcesOnStartup(); + // Initialize game matcher worker thread + GameMatcherWorkerManager.initialize(); + const userPreferences = await db.get( levelKeys.userPreferences, { diff --git a/src/main/services/game-matcher-worker-manager.ts b/src/main/services/game-matcher-worker-manager.ts new file mode 100644 index 00000000..b5d306c7 --- /dev/null +++ b/src/main/services/game-matcher-worker-manager.ts @@ -0,0 +1,145 @@ +import { Worker } from "worker_threads"; +import workerPath from "../workers/game-matcher-worker?modulePath"; + +interface WorkerMessage { + id: string; + data: unknown; +} + +interface WorkerResponse { + id: string; + success: boolean; + result?: unknown; + error?: string; +} + +export type TitleHashMapping = Record; + +export type FormattedSteamGame = { + id: string; + name: string; + formattedName: string; +}; +export type FormattedSteamGamesByLetter = Record; + +interface DownloadToMatch { + title: string; + uris: string[]; + uploadDate: string; + fileSize: string; +} + +interface MatchedDownload { + title: string; + uris: string[]; + uploadDate: string; + fileSize: string; + objectIds: string[]; + usedHashMatch: boolean; +} + +interface MatchResponse { + matchedDownloads: MatchedDownload[]; + stats: { + hashMatchCount: number; + fuzzyMatchCount: number; + noMatchCount: number; + }; +} + +export class GameMatcherWorkerManager { + private static worker: Worker | null = null; + private static messageId = 0; + private static pendingMessages = new Map< + string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { resolve: (value: any) => void; reject: (error: Error) => void } + >(); + + public static initialize() { + if (this.worker) { + return; + } + + try { + console.log( + "[GameMatcherWorker] Initializing worker with path:", + workerPath + ); + + this.worker = new Worker(workerPath); + + this.worker.on("message", (response: WorkerResponse) => { + const pending = this.pendingMessages.get(response.id); + if (pending) { + if (response.success) { + pending.resolve(response.result); + } else { + pending.reject(new Error(response.error || "Unknown error")); + } + this.pendingMessages.delete(response.id); + } + }); + + this.worker.on("error", (error) => { + console.error("[GameMatcherWorker] Worker error:", error); + for (const [id, pending] of this.pendingMessages.entries()) { + pending.reject(error); + this.pendingMessages.delete(id); + } + }); + + this.worker.on("exit", (code) => { + if (code !== 0) { + console.error( + `[GameMatcherWorker] Worker stopped with exit code ${code}` + ); + } + this.worker = null; + for (const [id, pending] of this.pendingMessages.entries()) { + pending.reject(new Error("Worker exited unexpectedly")); + this.pendingMessages.delete(id); + } + }); + + console.log("[GameMatcherWorker] Worker initialized successfully"); + } catch (error) { + console.error("[GameMatcherWorker] Failed to initialize worker:", error); + throw error; + } + } + + private static sendMessage(data: unknown): Promise { + if (!this.worker) { + return Promise.reject(new Error("Worker not initialized")); + } + + const id = `msg_${++this.messageId}`; + const message: WorkerMessage = { id, data }; + + return new Promise((resolve, reject) => { + this.pendingMessages.set(id, { resolve, reject }); + this.worker!.postMessage(message); + }); + } + + public static async matchDownloads( + downloads: DownloadToMatch[], + steamGames: FormattedSteamGamesByLetter, + titleHashMapping: TitleHashMapping + ): Promise { + return this.sendMessage({ + downloads, + steamGames, + titleHashMapping, + }); + } + + public static terminate() { + if (this.worker) { + this.worker.terminate(); + this.worker = null; + this.pendingMessages.clear(); + } + } +} diff --git a/src/main/services/index.ts b/src/main/services/index.ts index c98f09e1..0853859f 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -19,3 +19,4 @@ export * from "./wine"; export * from "./lock"; export * from "./decky-plugin"; export * from "./resource-cache"; +export * from "./game-matcher-worker-manager"; diff --git a/src/main/workers/game-matcher-worker.ts b/src/main/workers/game-matcher-worker.ts new file mode 100644 index 00000000..4930ada0 --- /dev/null +++ b/src/main/workers/game-matcher-worker.ts @@ -0,0 +1,158 @@ +import { parentPort } from "worker_threads"; +import crypto from "node:crypto"; + +export type TitleHashMapping = Record; + +export type FormattedSteamGame = { + id: string; + name: string; + formattedName: string; +}; +export type FormattedSteamGamesByLetter = Record; + +interface DownloadToMatch { + title: string; + uris: string[]; + uploadDate: string; + fileSize: string; +} + +interface MatchedDownload { + title: string; + uris: string[]; + uploadDate: string; + fileSize: string; + objectIds: string[]; + usedHashMatch: boolean; +} + +interface MatchRequest { + downloads: DownloadToMatch[]; + steamGames: FormattedSteamGamesByLetter; + titleHashMapping: TitleHashMapping; +} + +interface MatchResponse { + matchedDownloads: MatchedDownload[]; + stats: { + hashMatchCount: number; + fuzzyMatchCount: number; + noMatchCount: number; + }; +} + +const hashTitle = (title: string): string => { + return crypto.createHash("sha256").update(title).digest("hex"); +}; + +const formatName = (name: string) => { + return name + .normalize("NFD") + .replaceAll(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replaceAll(/[^a-z0-9]/g, ""); +}; + +const formatRepackName = (name: string) => { + return formatName(name.replace("[DL]", "")); +}; + +const matchDownloads = (request: MatchRequest): MatchResponse => { + const { downloads, steamGames, titleHashMapping } = request; + const matchedDownloads: MatchedDownload[] = []; + + let hashMatchCount = 0; + let fuzzyMatchCount = 0; + let noMatchCount = 0; + + for (const download of downloads) { + let objectIds: string[] = []; + let usedHashMatch = false; + + const titleHash = hashTitle(download.title); + const steamIdsFromHash = titleHashMapping[titleHash]; + + if (steamIdsFromHash && steamIdsFromHash.length > 0) { + hashMatchCount++; + usedHashMatch = true; + objectIds = steamIdsFromHash.map(String); + } + + if (!usedHashMatch) { + let gamesInSteam: FormattedSteamGame[] = []; + const formattedTitle = formatRepackName(download.title); + + if (formattedTitle && formattedTitle.length > 0) { + const [firstLetter] = formattedTitle; + const games = steamGames[firstLetter] || []; + + gamesInSteam = games.filter((game) => + formattedTitle.startsWith(game.formattedName) + ); + + if (gamesInSteam.length === 0) { + gamesInSteam = games.filter( + (game) => + formattedTitle.includes(game.formattedName) || + game.formattedName.includes(formattedTitle) + ); + } + + if (gamesInSteam.length === 0) { + for (const letter of Object.keys(steamGames)) { + const letterGames = steamGames[letter] || []; + const matches = letterGames.filter( + (game) => + formattedTitle.includes(game.formattedName) || + game.formattedName.includes(formattedTitle) + ); + if (matches.length > 0) { + gamesInSteam = matches; + break; + } + } + } + + if (gamesInSteam.length > 0) { + fuzzyMatchCount++; + objectIds = gamesInSteam.map((game) => String(game.id)); + } else { + noMatchCount++; + } + } else { + noMatchCount++; + } + } + + matchedDownloads.push({ + ...download, + objectIds, + usedHashMatch, + }); + } + + return { + matchedDownloads, + stats: { + hashMatchCount, + fuzzyMatchCount, + noMatchCount, + }, + }; +}; + +// Message handler +if (parentPort) { + parentPort.on("message", (message: { id: string; data: MatchRequest }) => { + try { + const result = matchDownloads(message.data); + parentPort!.postMessage({ id: message.id, success: true, result }); + } catch (error) { + parentPort!.postMessage({ + id: message.id, + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }); +} From d168e203855e3592885721c30ab355ec99a54177 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Mon, 20 Oct 2025 23:43:47 +0530 Subject: [PATCH 004/118] feat(library): implement large game card and enhance library UI - Added `LibraryGameCardLarge` component for displaying games in a larger format with improved styling and animations. - Introduced SCSS styles for the large game card, including hover effects and gradient overlays. - Updated `LibraryGameCard` component to support mouse enter and leave events for better interaction. - Enhanced the library view options with new styles and functionality for switching between grid, compact, and large views. - Improved overall layout and responsiveness of the library page, ensuring a better user experience across different screen sizes. - Added tooltips for playtime information and context menus for game actions. --- src/locales/en/translation.json | 24 ++ src/main/events/index.ts | 1 + src/main/events/library/get-library.ts | 8 +- .../events/library/refresh-library-assets.ts | 8 + .../library-sync/merge-with-remote-games.ts | 9 +- src/preload/index.ts | 1 + src/renderer/src/app.scss | 2 +- .../src/components/header/header.scss | 4 +- src/renderer/src/components/header/header.tsx | 3 + .../src/components/sidebar/routes.tsx | 6 + src/renderer/src/declaration.d.ts | 1 + src/renderer/src/main.tsx | 2 + .../src/pages/library/filter-options.scss | 58 +++++ .../src/pages/library/filter-options.tsx | 61 +++++ .../library/library-game-card-detailed.scss | 217 +++++++++++++++++ .../library/library-game-card-detailed.tsx | 209 ++++++++++++++++ .../library/library-game-card-large.scss | 227 ++++++++++++++++++ .../pages/library/library-game-card-large.tsx | 205 ++++++++++++++++ .../src/pages/library/library-game-card.scss | 206 ++++++++++++++++ .../src/pages/library/library-game-card.tsx | 212 ++++++++++++++++ src/renderer/src/pages/library/library.scss | 204 ++++++++++++++++ src/renderer/src/pages/library/library.tsx | 164 +++++++++++++ .../src/pages/library/view-options.scss | 50 ++++ .../src/pages/library/view-options.tsx | 45 ++++ 24 files changed, 1920 insertions(+), 7 deletions(-) create mode 100644 src/main/events/library/refresh-library-assets.ts create mode 100644 src/renderer/src/pages/library/filter-options.scss create mode 100644 src/renderer/src/pages/library/filter-options.tsx create mode 100644 src/renderer/src/pages/library/library-game-card-detailed.scss create mode 100644 src/renderer/src/pages/library/library-game-card-detailed.tsx create mode 100644 src/renderer/src/pages/library/library-game-card-large.scss create mode 100644 src/renderer/src/pages/library/library-game-card-large.tsx create mode 100644 src/renderer/src/pages/library/library-game-card.scss create mode 100644 src/renderer/src/pages/library/library-game-card.tsx create mode 100644 src/renderer/src/pages/library/library.scss create mode 100644 src/renderer/src/pages/library/library.tsx create mode 100644 src/renderer/src/pages/library/view-options.scss create mode 100644 src/renderer/src/pages/library/view-options.tsx diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 46bdb28c..97c1e42a 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -13,6 +13,7 @@ }, "sidebar": { "catalogue": "Catalogue", + "library": "Library", "downloads": "Downloads", "settings": "Settings", "my_library": "My library", @@ -94,6 +95,7 @@ "search": "Search games", "home": "Home", "catalogue": "Catalogue", + "library": "Library", "downloads": "Downloads", "search_results": "Search results", "settings": "Settings", @@ -678,6 +680,28 @@ "karma_count": "karma", "karma_description": "Earned from positive likes on reviews" }, + "library": { + "library": "Library", + "play": "Play", + "download": "Download", + "downloading": "Downloading", + "game": "game", + "games": "games", + "grid_view": "Grid view", + "compact_view": "Compact view", + "large_view": "Large view", + "no_games_title": "Your library is empty", + "no_games_description": "Add games from the catalogue or download them to get started", + "amount_hours": "{{amount}} hours", + "amount_minutes": "{{amount}} minutes", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m", + "manual_playtime_tooltip": "This playtime has been manually updated", + "all_games": "All Games", + "favourited_games": "Favourited", + "new_games": "New Games", + "top_10": "Top 10" + }, "achievement": { "achievement_unlocked": "Achievement unlocked", "user_achievements": "{{displayName}}'s Achievements", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 8d21aa11..a533de1a 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -18,6 +18,7 @@ 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/open-game"; import "./library/open-game-executable-path"; diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index 6314f83d..8922802b 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -22,10 +22,12 @@ const getLibrary = async (): Promise => { id: key, ...game, download: download ?? null, + // Spread gameAssets last to ensure all image URLs are properly set ...gameAssets, - // Ensure compatibility with LibraryGame type - libraryHeroImageUrl: - game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, + // Preserve custom image URLs from game if they exist + customIconUrl: game.customIconUrl, + customLogoImageUrl: game.customLogoImageUrl, + customHeroImageUrl: game.customHeroImageUrl, } as LibraryGame; }) ); diff --git a/src/main/events/library/refresh-library-assets.ts b/src/main/events/library/refresh-library-assets.ts new file mode 100644 index 00000000..d8578f1b --- /dev/null +++ b/src/main/events/library/refresh-library-assets.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { mergeWithRemoteGames } from "@main/services"; + +const refreshLibraryAssets = async () => { + await mergeWithRemoteGames(); +}; + +registerEvent("refreshLibraryAssets", refreshLibraryAssets); diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index f7ea2744..33d3e3b5 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -60,13 +60,20 @@ export const mergeWithRemoteGames = async () => { const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey); + // Construct coverImageUrl if not provided by backend (Steam games use predictable pattern) + const coverImageUrl = + game.coverImageUrl || + (game.shop === "steam" + ? `https://shared.steamstatic.com/store_item_assets/steam/apps/${game.objectId}/library_600x900_2x.jpg` + : null); + await gamesShopAssetsSublevel.put(gameKey, { updatedAt: Date.now(), ...localGameShopAsset, shop: game.shop, objectId: game.objectId, title: localGame?.title || game.title, // Preserve local title if it exists - coverImageUrl: game.coverImageUrl, + coverImageUrl, libraryHeroImageUrl: game.libraryHeroImageUrl, libraryImageUrl: game.libraryImageUrl, logoImageUrl: game.logoImageUrl, diff --git a/src/preload/index.ts b/src/preload/index.ts index da914b92..2b8816c9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -208,6 +208,7 @@ contextBridge.exposeInMainWorld("electron", { verifyExecutablePathInUse: (executablePath: string) => ipcRenderer.invoke("verifyExecutablePathInUse", executablePath), getLibrary: () => ipcRenderer.invoke("getLibrary"), + refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"), openGameInstaller: (shop: GameShop, objectId: string) => ipcRenderer.invoke("openGameInstaller", shop, objectId), openGameInstallerPath: (shop: GameShop, objectId: string) => diff --git a/src/renderer/src/app.scss b/src/renderer/src/app.scss index 4c5374e8..ed7b9aa8 100644 --- a/src/renderer/src/app.scss +++ b/src/renderer/src/app.scss @@ -5,7 +5,7 @@ } ::-webkit-scrollbar { - width: 9px; + width: 4px; background-color: globals.$dark-background-color; } diff --git a/src/renderer/src/components/header/header.scss b/src/renderer/src/components/header/header.scss index f0c72ce0..cd25d8e2 100644 --- a/src/renderer/src/components/header/header.scss +++ b/src/renderer/src/components/header/header.scss @@ -24,7 +24,7 @@ background-color: globals.$background-color; display: inline-flex; transition: all ease 0.2s; - width: 200px; + width: 300px; align-items: center; border-radius: 8px; border: solid 1px globals.$border-color; @@ -35,7 +35,7 @@ } &--focused { - width: 250px; + width: 350px; border-color: #dadbe1; } } diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index e61f3954..328d7f64 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -13,6 +13,7 @@ import cn from "classnames"; const pathTitle: Record = { "/": "home", "/catalogue": "catalogue", + "/library": "library", "/downloads": "downloads", "/settings": "settings", }; @@ -41,6 +42,8 @@ export function Header() { if (location.pathname.startsWith("/game")) return headerTitle; if (location.pathname.startsWith("/achievements")) return headerTitle; if (location.pathname.startsWith("/profile")) return headerTitle; + if (location.pathname.startsWith("/library")) + return headerTitle || t("library"); if (location.pathname.startsWith("/search")) return t("search_results"); return t(pathTitle[location.pathname]); diff --git a/src/renderer/src/components/sidebar/routes.tsx b/src/renderer/src/components/sidebar/routes.tsx index 608718b6..f579329e 100644 --- a/src/renderer/src/components/sidebar/routes.tsx +++ b/src/renderer/src/components/sidebar/routes.tsx @@ -3,6 +3,7 @@ import { DownloadIcon, GearIcon, HomeIcon, + BookIcon, } from "@primer/octicons-react"; export const routes = [ @@ -16,6 +17,11 @@ export const routes = [ nameKey: "catalogue", render: () => , }, + { + path: "/library", + nameKey: "library", + render: () => , + }, { path: "/downloads", nameKey: "downloads", diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 9f882aed..fc5a4b97 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -161,6 +161,7 @@ declare global { ) => Promise; verifyExecutablePathInUse: (executablePath: string) => Promise; getLibrary: () => Promise; + refreshLibraryAssets: () => Promise; openGameInstaller: (shop: GameShop, objectId: string) => Promise; openGameInstallerPath: (shop: GameShop, objectId: string) => Promise; openGameExecutablePath: (shop: GameShop, objectId: string) => Promise; diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a1b5f7d0..84c7f815 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -29,6 +29,7 @@ import Settings from "./pages/settings/settings"; import Profile from "./pages/profile/profile"; import Achievements from "./pages/achievements/achievements"; import ThemeEditor from "./pages/theme-editor/theme-editor"; +import Library from "./pages/library/library"; import { AchievementNotification } from "./pages/achievements/notification/achievement-notification"; console.log = logger.log; @@ -64,6 +65,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( }> } /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/pages/library/filter-options.scss b/src/renderer/src/pages/library/filter-options.scss new file mode 100644 index 00000000..e58e285b --- /dev/null +++ b/src/renderer/src/pages/library/filter-options.scss @@ -0,0 +1,58 @@ +@use "../../scss/globals.scss"; + +.library-filter-options { + &__container { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + flex-wrap: wrap; + } + + &__option { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + padding: 8px 16px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + border: none; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all ease 0.2s; + white-space: nowrap; /* prevent label and count from wrapping */ + + &:hover { + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.08); + } + + &.active { + color: #c9aa71; + background: rgba(201, 170, 113, 0.15); + + .library-filter-options__count { + background: rgba(201, 170, 113, 0.25); + color: #c9aa71; + } + } + } + + &__label { + font-weight: 500; + white-space: nowrap; + } + + &__count { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.8); + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + min-width: 24px; + text-align: center; + transition: all ease 0.2s; + } +} diff --git a/src/renderer/src/pages/library/filter-options.tsx b/src/renderer/src/pages/library/filter-options.tsx new file mode 100644 index 00000000..07c935d9 --- /dev/null +++ b/src/renderer/src/pages/library/filter-options.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import "./filter-options.scss"; + +export type FilterOption = "all" | "favourited" | "new" | "top10"; + +interface FilterOptionsProps { + filterBy: FilterOption; + onFilterChange: (filterBy: FilterOption) => void; + allGamesCount: number; + favouritedCount: number; + newGamesCount: number; + top10Count: number; +} + +export function FilterOptions({ + filterBy, + onFilterChange, + allGamesCount, + favouritedCount, + newGamesCount, + top10Count, +}: FilterOptionsProps) { + const { t } = useTranslation("library"); + + return ( +
+ + + + +
+ ); +} diff --git a/src/renderer/src/pages/library/library-game-card-detailed.scss b/src/renderer/src/pages/library/library-game-card-detailed.scss new file mode 100644 index 00000000..0038e918 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-detailed.scss @@ -0,0 +1,217 @@ +@use "../../scss/globals.scss"; + +.library-game-card-detailed { + width: 100%; + height: 350px; + position: relative; + border-radius: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.05); + transition: all ease 0.2s; + cursor: pointer; + display: flex; + align-items: center; + padding: 0; + text-align: left; + + &:before { + content: ""; + top: 0; + left: 0; + width: 100%; + height: 172%; + position: absolute; + background: linear-gradient( + 35deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.07) 51.5%, + rgba(255, 255, 255, 0.15) 74%, + rgba(255, 255, 255, 0.1) 100% + ); + transition: all ease 0.3s; + transform: translateY(-36%); + opacity: 0.5; + z-index: 1; + } + + &:hover::before { + opacity: 1; + transform: translateY(-20%); + } + + &:hover { + transform: scale(1.05); + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.1); + } + + &__background { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-size: cover; + background-position: top; + background-repeat: no-repeat; + z-index: 0; + } + + &__gradient { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.3) 50%, + rgba(0, 0, 0, 0.5) 100% + ); + z-index: 1; + } + + &__overlay { + position: relative; + z-index: 2; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: calc(globals.$spacing-unit * 3); + } + + &__menu-button { + align-self: flex-end; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all ease 0.2s; + color: rgba(255, 255, 255, 0.8); + padding: 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + + &:hover { + background: rgba(0, 0, 0, 0.6); + border-color: rgba(255, 255, 255, 0.25); + transform: scale(1.05); + } + + &:active { + transform: scale(0.95); + } + } + + &__logo-container { + flex: 1; + display: flex; + align-items: center; + min-width: 0; + } + + &__logo { + max-height: 140px; + max-width: 450px; + width: auto; + height: auto; + object-fit: contain; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.6)); + } + + &__title { + font-size: 32px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9); + } + + &__info-bar { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: calc(globals.$spacing-unit * 2); + } + + &__playtime { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.8); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + font-size: 14px; + } + + &__playtime-text { + font-weight: 500; + } + + &__manual-playtime { + color: globals.$warning-color; + } + + &__action-button { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.9); + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all ease 0.2s; + flex-shrink: 0; + + &:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); + transform: scale(1.05); + } + + &:active { + transform: scale(0.98); + } + } + + &__action-icon--downloading { + animation: pulse 1.5s ease-in-out infinite; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} diff --git a/src/renderer/src/pages/library/library-game-card-detailed.tsx b/src/renderer/src/pages/library/library-game-card-detailed.tsx new file mode 100644 index 00000000..d07dad28 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-detailed.tsx @@ -0,0 +1,209 @@ +import { LibraryGame } from "@types"; +import { useDownload, useFormat } from "@renderer/hooks"; +import { useNavigate } from "react-router-dom"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import { + PlayIcon, + DownloadIcon, + ClockIcon, + AlertFillIcon, + ThreeBarsIcon, +} from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import { useCallback, useState } from "react"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import { GameContextMenu } from "@renderer/components"; +import "./library-game-card-detailed.scss"; + +interface LibraryGameCardDetailedProps { + game: LibraryGame; +} + +const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined +) => { + return customUrl || originalUrl || fallbackUrl || ""; +}; + +export function LibraryGameCardDetailed({ + game, +}: LibraryGameCardDetailedProps) { + const { t } = useTranslation("library"); + const { numberFormatter } = useFormat(); + const navigate = useNavigate(); + const { lastPacket } = useDownload(); + const [contextMenu, setContextMenu] = useState<{ + visible: boolean; + position: { x: number; y: number }; + }>({ visible: false, position: { x: 0, y: 0 } }); + + const isGameDownloading = + game?.download?.status === "active" && lastPacket?.gameId === game?.id; + + const formatPlayTime = useCallback( + (playTimeInMilliseconds = 0, isShort = false) => { + const minutes = playTimeInMilliseconds / 60000; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t(isShort ? "amount_minutes_short" : "amount_minutes", { + amount: minutes.toFixed(0), + }); + } + + const hours = minutes / 60; + const hoursKey = isShort ? "amount_hours_short" : "amount_hours"; + const hoursAmount = isShort + ? Math.floor(hours) + : numberFormatter.format(hours); + + return t(hoursKey, { amount: hoursAmount }); + }, + [numberFormatter, t] + ); + + const handleCardClick = () => { + navigate(buildGameDetailsPath(game)); + }; + + const handleActionClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (game.executablePath) { + window.electron.openGame( + game.shop, + game.objectId, + game.executablePath, + game.launchOptions + ); + } else { + navigate(buildGameDetailsPath(game)); + } + }; + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setContextMenu({ + visible: true, + position: { x: e.clientX, y: e.clientY }, + }); + }; + + const handleMenuButtonClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setContextMenu({ + visible: true, + position: { + x: e.currentTarget.getBoundingClientRect().right, + y: e.currentTarget.getBoundingClientRect().bottom, + }, + }); + }; + + const handleCloseContextMenu = () => { + setContextMenu({ visible: false, position: { x: 0, y: 0 } }); + }; + + // Use libraryHeroImageUrl as background, fallback to libraryImageUrl + const backgroundImage = getImageWithCustomPriority( + game.libraryHeroImageUrl, + game.libraryImageUrl, + game.iconUrl + ); + + // For logo, check if logoImageUrl exists (similar to game details page) + const logoImage = game.logoImageUrl; + + return ( + <> + + +
+ {logoImage ? ( + {game.title} + ) : ( +

+ {game.title} +

+ )} +
+ +
+
+ {game.hasManuallyUpdatedPlaytime ? ( + + ) : ( + + )} + + {formatPlayTime(game.playTimeInMilliseconds)} + +
+ + +
+ + + + + ); +} diff --git a/src/renderer/src/pages/library/library-game-card-large.scss b/src/renderer/src/pages/library/library-game-card-large.scss new file mode 100644 index 00000000..86a5a792 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -0,0 +1,227 @@ +@use "../../scss/globals.scss"; + +.library-game-card-large { + width: 100%; + height: 300px; + position: relative; + border-radius: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.05); + transition: all ease 0.2s; + cursor: pointer; + display: flex; + align-items: center; + padding: 0; + text-align: left; + + &:before { + content: ""; + top: 0; + left: 0; + width: 100%; + height: 172%; + position: absolute; + background: linear-gradient( + 35deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.07) 51.5%, + rgba(255, 255, 255, 0.15) 74%, + rgba(255, 255, 255, 0.1) 100% + ); + transition: all ease 0.3s; + transform: translateY(-36%); + opacity: 0.5; + z-index: 1; + } + + &:hover::before { + opacity: 1; + transform: translateY(-20%); + } + + &:hover { + transform: scale(1.05); + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.1); + } + + &__background { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + z-index: 0; + } + + &__gradient { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.3) 50%, + rgba(0, 0, 0, 0.5) 100% + ); + z-index: 1; + } + + &__overlay { + position: relative; + z-index: 2; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: calc(globals.$spacing-unit * 2.5); + } + + &__menu-button { + align-self: flex-end; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all ease 0.2s; + color: rgba(255, 255, 255, 0.8); + padding: 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + opacity: 0; + transform: scale(0.9); + + &:hover { + background: rgba(0, 0, 0, 0.6); + border-color: rgba(255, 255, 255, 0.25); + transform: scale(1.05); + } + + &:active { + transform: scale(0.95); + } + } + + &__logo-container { + flex: 1; + display: flex; + align-items: center; + min-width: 0; + } + + &__logo { + max-height: 120px; + max-width: 400px; + width: auto; + height: auto; + object-fit: contain; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.6)); + } + + &__title { + font-size: 28px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9); + } + + &__info-bar { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: calc(globals.$spacing-unit * 2); + } + + &__playtime { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.8); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + font-size: 14px; + } + + &__playtime-text { + font-weight: 500; + } + + &__manual-playtime { + color: globals.$warning-color; + } + + &__action-button { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all ease 0.2s; + flex-shrink: 0; + opacity: 0; + transform: translateY(10px); + + &:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); + transform: scale(1.05); + } + + &:active { + transform: scale(0.98); + } + } + + &:hover &__menu-button, + &:hover &__action-button { + opacity: 1; + transform: scale(1); + } + + &__action-icon--downloading { + animation: pulse 1.5s ease-in-out infinite; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx new file mode 100644 index 00000000..49d93048 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -0,0 +1,205 @@ +import { LibraryGame } from "@types"; +import { useDownload, useFormat } from "@renderer/hooks"; +import { useNavigate } from "react-router-dom"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import { + PlayIcon, + DownloadIcon, + ClockIcon, + AlertFillIcon, + ThreeBarsIcon, +} from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import { useCallback, useState } from "react"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import { GameContextMenu } from "@renderer/components"; +import "./library-game-card-large.scss"; + +interface LibraryGameCardLargeProps { + game: LibraryGame; +} + +const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined +) => { + return customUrl || originalUrl || fallbackUrl || ""; +}; + +export function LibraryGameCardLarge({ game }: LibraryGameCardLargeProps) { + const { t } = useTranslation("library"); + const { numberFormatter } = useFormat(); + const navigate = useNavigate(); + const { lastPacket } = useDownload(); + const [contextMenu, setContextMenu] = useState<{ + visible: boolean; + position: { x: number; y: number }; + }>({ visible: false, position: { x: 0, y: 0 } }); + + const isGameDownloading = + game?.download?.status === "active" && lastPacket?.gameId === game?.id; + + const formatPlayTime = useCallback( + (playTimeInMilliseconds = 0, isShort = false) => { + const minutes = playTimeInMilliseconds / 60000; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t(isShort ? "amount_minutes_short" : "amount_minutes", { + amount: minutes.toFixed(0), + }); + } + + const hours = minutes / 60; + const hoursKey = isShort ? "amount_hours_short" : "amount_hours"; + const hoursAmount = isShort + ? Math.floor(hours) + : numberFormatter.format(hours); + + return t(hoursKey, { amount: hoursAmount }); + }, + [numberFormatter, t] + ); + + const handleCardClick = () => { + navigate(buildGameDetailsPath(game)); + }; + + const handleActionClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (game.executablePath) { + window.electron.openGame( + game.shop, + game.objectId, + game.executablePath, + game.launchOptions + ); + } else { + navigate(buildGameDetailsPath(game)); + } + }; + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setContextMenu({ + visible: true, + position: { x: e.clientX, y: e.clientY }, + }); + }; + + const handleMenuButtonClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setContextMenu({ + visible: true, + position: { + x: e.currentTarget.getBoundingClientRect().right, + y: e.currentTarget.getBoundingClientRect().bottom, + }, + }); + }; + + const handleCloseContextMenu = () => { + setContextMenu({ visible: false, position: { x: 0, y: 0 } }); + }; + + // Use libraryHeroImageUrl as background, fallback to libraryImageUrl + const backgroundImage = getImageWithCustomPriority( + game.libraryHeroImageUrl, + game.libraryImageUrl, + game.iconUrl + ); + + // For logo, check if logoImageUrl exists (similar to game details page) + const logoImage = game.logoImageUrl; + + return ( + <> + + +
+ {logoImage ? ( + {game.title} + ) : ( +

{game.title}

+ )} +
+ +
+
+ {game.hasManuallyUpdatedPlaytime ? ( + + ) : ( + + )} + + {formatPlayTime(game.playTimeInMilliseconds)} + +
+ + +
+ + + + + ); +} diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss new file mode 100644 index 00000000..30adacec --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -0,0 +1,206 @@ +@use "../../scss/globals.scss"; + +.library-game-card { + &__wrapper { + cursor: pointer; + transition: all ease 0.2s; + box-shadow: 0 8px 10px -2px rgba(0, 0, 0, 0.5); + width: 100%; + aspect-ratio: 3 / 4; + position: relative; + border: none; + background: none; + padding: 0; + border-radius: 4px; + overflow: hidden; + display: block; + container-type: inline-size; + + &:before { + content: ""; + top: 0; + left: 0; + width: 100%; + height: 172%; + position: absolute; + background: linear-gradient( + 35deg, + rgba(0, 0, 0, 0.1) 0%, + rgba(0, 0, 0, 0.07) 51.5%, + rgba(255, 255, 255, 0.15) 64%, + rgba(255, 255, 255, 0.1) 100% + ); + transition: all ease 0.3s; + transform: translateY(-36%); + opacity: 0.5; + z-index: 1; + } + + &:hover { + transform: scale(1.05); + } + + &:hover::before { + opacity: 1; + transform: translateY(-20%); + } + } + + &__overlay { + position: absolute; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + height: 100%; + width: 100%; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.2) 20%, transparent 100%); + padding: 8px; + z-index: 2; + } + + &__top-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + width: 100%; + } + + &__playtime { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: rgba(255, 255, 255, 0.8); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + display: flex; + align-items: center; + gap: 4px; + padding: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all ease 0.2s; + + &-long { + display: inline; + font-size: 12px; + } + + &-short { + display: none; + font-size: 12px; + } + + // When the card is narrow (less than 140px), show short format + @container (max-width: 140px) { + &-long { + display: none; + } + + &-short { + display: inline; + } + } + } + + &__manual-playtime { + color: globals.$warning-color; + } + + &__action-button { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: solid 1px rgba(255, 255, 255, 0.2); + border-radius: 50%; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all ease 0.2s; + color: rgba(255, 255, 255, 0.9); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + opacity: 0; + transform: scale(0.9); + + &:hover { + background: rgba(0, 0, 0, 0.8); + border-color: rgba(255, 255, 255, 0.4); + transform: scale(1.1); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4); + } + + &:active { + transform: scale(0.95); + } + } + + &__menu-button { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: solid 1px rgba(255, 255, 255, 0.15); + border-radius: 4px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all ease 0.2s; + color: rgba(255, 255, 255, 0.8); + padding: 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + opacity: 0; + transform: scale(0.9); + + &:hover { + background: rgba(0, 0, 0, 0.6); + border-color: rgba(255, 255, 255, 0.25); + transform: scale(1.05); + } + + &:active { + transform: scale(0.95); + } + } + + &__wrapper:hover &__action-button, + &__wrapper:hover &__menu-button { + opacity: 1; + transform: scale(1); + } + + &__action-icon { + &--downloading { + animation: pulse 1.5s ease-in-out infinite; + } + } + + &__game-image { + object-fit: cover; + border-radius: 4px; + width: 100%; + height: 100%; + min-width: 100%; + min-height: 100%; + display: block; + top: 0; + left: 0; + z-index: 0; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx new file mode 100644 index 00000000..e18df1b0 --- /dev/null +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -0,0 +1,212 @@ +import { LibraryGame } from "@types"; +import { useFormat, useDownload } from "@renderer/hooks"; +import { useNavigate } from "react-router-dom"; +import { useCallback, useState } from "react"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import { + ClockIcon, + PlayIcon, + DownloadIcon, + AlertFillIcon, + ThreeBarsIcon, +} from "@primer/octicons-react"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import { Tooltip } from "react-tooltip"; +import { useTranslation } from "react-i18next"; +import { GameContextMenu } from "@renderer/components"; +import "./library-game-card.scss"; + +interface LibraryGameCardProps { + game: LibraryGame; + onMouseEnter: () => void; + onMouseLeave: () => void; +} + +export function LibraryGameCard({ + game, + onMouseEnter, + onMouseLeave, +}: LibraryGameCardProps) { + const { t } = useTranslation("library"); + const { numberFormatter } = useFormat(); + const navigate = useNavigate(); + const [isTooltipHovered, setIsTooltipHovered] = useState(false); + const { lastPacket } = useDownload(); + const [contextMenu, setContextMenu] = useState<{ + visible: boolean; + position: { x: number; y: number }; + }>({ visible: false, position: { x: 0, y: 0 } }); + + const isGameDownloading = + game?.download?.status === "active" && lastPacket?.gameId === game?.id; + + const formatPlayTime = useCallback( + (playTimeInMilliseconds = 0, isShort = false) => { + const minutes = playTimeInMilliseconds / 60000; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t(isShort ? "amount_minutes_short" : "amount_minutes", { + amount: minutes.toFixed(0), + }); + } + + const hours = minutes / 60; + const hoursKey = isShort ? "amount_hours_short" : "amount_hours"; + const hoursAmount = isShort + ? Math.floor(hours) + : numberFormatter.format(hours); + + return t(hoursKey, { amount: hoursAmount }); + }, + [numberFormatter, t] + ); + + const handleCardClick = () => { + navigate(buildGameDetailsPath(game)); + }; + + const handleActionClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (game.executablePath) { + // Game is installed, launch it + window.electron.openGame( + game.shop, + game.objectId, + game.executablePath, + game.launchOptions + ); + } else { + // Game is not installed, navigate to download options + navigate(buildGameDetailsPath(game)); + } + }; + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setContextMenu({ + visible: true, + position: { x: e.clientX, y: e.clientY }, + }); + }; + + const handleMenuButtonClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setContextMenu({ + visible: true, + position: { + x: e.currentTarget.getBoundingClientRect().right, + y: e.currentTarget.getBoundingClientRect().bottom, + }, + }); + }; + + const handleCloseContextMenu = () => { + setContextMenu({ visible: false, position: { x: 0, y: 0 } }); + }; + + const coverImage = + game.coverImageUrl ?? + game.libraryImageUrl ?? + game.libraryHeroImageUrl ?? + game.iconUrl ?? + undefined; + + return ( + <> + + + + {/* Action button - Play or Download */} + + + + {game.title} + + setIsTooltipHovered(true)} + afterHide={() => setIsTooltipHovered(false)} + /> + + + ); +} diff --git a/src/renderer/src/pages/library/library.scss b/src/renderer/src/pages/library/library.scss new file mode 100644 index 00000000..1e8038d9 --- /dev/null +++ b/src/renderer/src/pages/library/library.scss @@ -0,0 +1,204 @@ +@use "../../scss/globals.scss"; + +.library { + &__content { + padding: calc(globals.$spacing-unit * 3); + height: 100%; + width: 100%; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 3); + align-items: flex-start; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + } + + &__page-header { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1.5); + width: 100%; + } + + &__page-title { + margin: 0; + font-size: 20px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + } + + &__controls-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: calc(globals.$spacing-unit * 2); + } + + &__controls-left { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__controls-right { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__header-controls { + display: flex; + flex-direction: column; + align-items: end; + gap: calc(globals.$spacing-unit * 1); + &__left { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 1); + } + } + &__header-title { + font-size: 20px; + font-weight: 700; + } + &__filter-label { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + white-space: nowrap; + } + + &__separator { + width: 100%; + height: 1px; + background: rgba(255, 255, 255, 0.1); + border: none; + margin: 0; + } + + &__count { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 8px 16px; + } + + &__count-label { + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + font-weight: 500; + } + + &__count-number { + color: rgba(255, 255, 255, 0.9); + font-size: 13px; + font-weight: 600; + } + + &__no-games { + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + flex-direction: column; + gap: globals.$spacing-unit; + padding: calc(globals.$spacing-unit * 4); + } + + &__telescope-icon { + width: 60px; + height: 60px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: calc(globals.$spacing-unit * 2); + } + + &__games-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: calc(globals.$spacing-unit * 2); + width: 100%; + + // Grid view - larger cards + &--grid { + grid-template-columns: repeat(2, 1fr); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(4, 1fr); + } + + @container #{globals.$app-container} (min-width: 1300px) { + grid-template-columns: repeat(5, 1fr); + } + + @container #{globals.$app-container} (min-width: 2000px) { + grid-template-columns: repeat(6, 1fr); + } + + @container #{globals.$app-container} (min-width: 2600px) { + grid-template-columns: repeat(8, 1fr); + } + + @container #{globals.$app-container} (min-width: 3000px) { + grid-template-columns: repeat(12, 1fr); + } + } + + // Compact view - smaller cards + &--compact { + grid-template-columns: repeat(3, 1fr); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(5, 1fr); + } + + @container #{globals.$app-container} (min-width: 1300px) { + grid-template-columns: repeat(7, 1fr); + } + + @container #{globals.$app-container} (min-width: 2000px) { + grid-template-columns: repeat(9, 1fr); + } + + @container #{globals.$app-container} (min-width: 2600px) { + grid-template-columns: repeat(12, 1fr); + } + + @container #{globals.$app-container} (min-width: 3000px) { + grid-template-columns: repeat(16, 1fr); + } + } + } + + &__games-list { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + width: 100%; + + // Large view - 2 columns grid + &--large { + display: grid; + grid-template-columns: repeat(1, 1fr); + + @container #{globals.$app-container} (min-width: 900px) { + grid-template-columns: repeat(2, 1fr); + } + } + } +} diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx new file mode 100644 index 00000000..0014f5a4 --- /dev/null +++ b/src/renderer/src/pages/library/library.tsx @@ -0,0 +1,164 @@ +import { useEffect, useMemo, useState } from "react"; +import { useLibrary, useAppDispatch, useUserDetails } from "@renderer/hooks"; +import { setHeaderTitle } from "@renderer/features"; +import { TelescopeIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import { LibraryGameCard } from "./library-game-card"; +// detailed view removed — keep file if needed later +import { LibraryGameCardLarge } from "./library-game-card-large"; +import { ViewOptions, ViewMode } from "./view-options"; +import { FilterOptions, FilterOption } from "./filter-options"; +import "./library.scss"; + +export default function Library() { + const { library, updateLibrary } = useLibrary(); + + const [viewMode, setViewMode] = useState("grid"); + const [filterBy, setFilterBy] = useState("all"); + const dispatch = useAppDispatch(); + const { t } = useTranslation("library"); + const { userDetails, fetchUserDetails } = useUserDetails(); + + useEffect(() => { + dispatch(setHeaderTitle(t("library"))); + + // Refresh library assets from cloud, then update library display + window.electron + .refreshLibraryAssets() + .then(() => updateLibrary()) + .catch(() => updateLibrary()); // Fallback to local cache on error + + // Listen for library sync completion to refresh cover images + const unsubscribe = window.electron.onLibraryBatchComplete(() => { + updateLibrary(); + }); + + return () => { + unsubscribe(); + }; + }, [dispatch, t, updateLibrary]); + + // Ensure we have the current user details available + useEffect(() => { + fetchUserDetails().catch(() => { + /* ignore errors - fallback to local state */ + }); + }, [fetchUserDetails]); + + const handleOnMouseEnterGameCard = () => { + // Optional: pause animations if needed + }; + + const handleOnMouseLeaveGameCard = () => { + // Optional: resume animations if needed + }; + + const filteredLibrary = useMemo(() => { + switch (filterBy) { + case "favourited": + return library.filter((game) => game.favorite); + case "new": + return library.filter( + (game) => (game.playTimeInMilliseconds || 0) === 0 + ); + case "top10": + return library + .slice() + .sort( + (a, b) => + (b.playTimeInMilliseconds || 0) - (a.playTimeInMilliseconds || 0) + ) + .slice(0, 10); + case "all": + default: + return library; + } + }, [library, filterBy]); + + // No sorting for now — rely on filteredLibrary + const sortedLibrary = filteredLibrary; + + // Calculate counts for filters + const allGamesCount = library.length; + const favouritedCount = library.filter((game) => game.favorite).length; + const newGamesCount = library.filter( + (game) => (game.playTimeInMilliseconds || 0) === 0 + ).length; + const top10Count = Math.min(10, library.length); + + const hasGames = library.length > 0; + + return ( +
+ {hasGames && ( + <> +
+

+ {`${t("Welcome", { defaultValue: "Welcome" })} ${ + userDetails?.displayName || "John Doe" + }`} +

+ +
+
+ +
+ +
+ +
+
+
+ + )} + + {!hasGames && ( +
+
+ +
+

{t("no_games_title")}

+

{t("no_games_description")}

+
+ )} + + {hasGames && viewMode === "large" && ( +
+ {sortedLibrary.map((game) => ( + + ))} +
+ )} + + {hasGames && viewMode !== "large" && ( +
    + {sortedLibrary.map((game) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/renderer/src/pages/library/view-options.scss b/src/renderer/src/pages/library/view-options.scss new file mode 100644 index 00000000..a9b4e197 --- /dev/null +++ b/src/renderer/src/pages/library/view-options.scss @@ -0,0 +1,50 @@ +@use "../../scss/globals.scss"; + +.library-view-options { + &__container { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + } + + &__label { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.8); + white-space: nowrap; + } + + &__options { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + flex-wrap: wrap; + white-space: nowrap; + } + + &__option { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit); + padding: 8px 16px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + border: none; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all ease 0.2s; + white-space: nowrap; + + &:hover { + color: rgba(255, 255, 255, 0.95); + background: rgba(255, 255, 255, 0.06); + } + + &.active { + color: #c9aa71; + background: rgba(201, 170, 113, 0.15); + } + } +} diff --git a/src/renderer/src/pages/library/view-options.tsx b/src/renderer/src/pages/library/view-options.tsx new file mode 100644 index 00000000..3ea19cee --- /dev/null +++ b/src/renderer/src/pages/library/view-options.tsx @@ -0,0 +1,45 @@ +import { AppsIcon, RowsIcon, SquareIcon } from "@primer/octicons-react"; +import { useTranslation } from "react-i18next"; +import "./view-options.scss"; + +export type ViewMode = "grid" | "compact" | "large"; + +interface ViewOptionsProps { + viewMode: ViewMode; + onViewModeChange: (viewMode: ViewMode) => void; +} + +export function ViewOptions({ viewMode, onViewModeChange }: ViewOptionsProps) { + const { t } = useTranslation("library"); + + return ( +
+
+ + + +
+
+ ); +} From 48ce9a247640347f6667546ad8f60dcc75feaa08 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 21 Oct 2025 04:18:11 +0100 Subject: [PATCH 005/118] feat: using api download sources --- .../download-sources/add-download-source.ts | 99 ++---- .../check-download-source-exists.ts | 17 - .../delete-all-download-sources.ts | 13 - .../delete-download-source.ts | 28 -- .../get-download-sources-list.ts | 19 -- .../download-sources/get-download-sources.ts | 4 +- src/main/events/download-sources/helpers.ts | 314 ------------------ .../remove-download-source.ts | 17 +- .../sync-download-sources-from-api.ts | 19 -- .../download-sources/sync-download-sources.ts | 113 +------ .../update-missing-fingerprints.ts | 67 ---- .../validate-download-source.ts | 32 -- src/main/events/index.ts | 7 - src/main/events/repacks/get-all-repacks.ts | 16 - src/main/level/sublevels/download-sources.ts | 14 +- src/main/level/sublevels/index.ts | 1 - src/main/level/sublevels/keys.ts | 1 - src/main/level/sublevels/repacks.ts | 22 -- src/main/main.ts | 8 - .../services/game-matcher-worker-manager.ts | 145 -------- src/main/services/hydra-api.ts | 5 - src/main/services/index.ts | 2 - src/main/services/resource-cache.ts | 157 --------- src/main/workers/game-matcher-worker.ts | 158 --------- src/preload/index.ts | 12 - src/renderer/src/app.tsx | 34 -- .../src/components/game-card/game-card.tsx | 40 +-- .../game-details/game-details.context.tsx | 61 ++-- src/renderer/src/declaration.d.ts | 22 +- .../src/features/download-sources-slice.ts | 21 -- src/renderer/src/features/index.ts | 2 - src/renderer/src/hooks/index.ts | 1 - src/renderer/src/hooks/use-catalogue.ts | 12 +- src/renderer/src/hooks/use-repacks.ts | 26 -- .../src/pages/catalogue/catalogue.tsx | 73 ++-- .../src/pages/catalogue/game-item.tsx | 14 +- .../src/pages/game-details/game-reviews.tsx | 4 - .../game-details/modals/repacks-modal.tsx | 27 +- src/renderer/src/pages/home/home.tsx | 18 +- .../settings/add-download-source-modal.scss | 7 + .../settings/add-download-source-modal.tsx | 128 ++----- .../settings/settings-download-sources.tsx | 102 +++--- src/renderer/src/store.ts | 4 - src/shared/constants.ts | 6 +- src/types/index.ts | 28 +- 45 files changed, 295 insertions(+), 1625 deletions(-) delete mode 100644 src/main/events/download-sources/check-download-source-exists.ts delete mode 100644 src/main/events/download-sources/delete-all-download-sources.ts delete mode 100644 src/main/events/download-sources/delete-download-source.ts delete mode 100644 src/main/events/download-sources/get-download-sources-list.ts delete mode 100644 src/main/events/download-sources/helpers.ts delete mode 100644 src/main/events/download-sources/sync-download-sources-from-api.ts delete mode 100644 src/main/events/download-sources/update-missing-fingerprints.ts delete mode 100644 src/main/events/download-sources/validate-download-source.ts delete mode 100644 src/main/events/repacks/get-all-repacks.ts delete mode 100644 src/main/level/sublevels/repacks.ts delete mode 100644 src/main/services/game-matcher-worker-manager.ts delete mode 100644 src/main/services/resource-cache.ts delete mode 100644 src/main/workers/game-matcher-worker.ts delete mode 100644 src/renderer/src/features/download-sources-slice.ts delete mode 100644 src/renderer/src/hooks/use-repacks.ts diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts index e51cae3e..45bcd27c 100644 --- a/src/main/events/download-sources/add-download-source.ts +++ b/src/main/events/download-sources/add-download-source.ts @@ -1,76 +1,45 @@ import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { HydraApi, logger } from "@main/services"; -import { importDownloadSourceToLocal } from "./helpers"; +import { HydraApi } from "@main/services/hydra-api"; +import { downloadSourcesSublevel } from "@main/level"; +import type { DownloadSource } from "@types"; const addDownloadSource = async ( _event: Electron.IpcMainInvokeEvent, url: string ) => { - const result = await importDownloadSourceToLocal(url, true); - if (!result) { - throw new Error("Failed to import download source"); - } - - // Verify that repacks were actually written to the database (read-after-write) - // This ensures all async operations are complete before proceeding - let repackCount = 0; - for await (const [, repack] of repacksSublevel.iterator()) { - if (repack.downloadSourceId === result.id) { - repackCount++; - } - } - - await HydraApi.post("/profile/download-sources", { - urls: [url], - }); - - const { fingerprint } = await HydraApi.put<{ fingerprint: string }>( - "/download-sources", - { - objectIds: result.objectIds, - }, - { needsAuth: false } - ); - - // Update the source with fingerprint - const updatedSource = await downloadSourcesSublevel.get(`${result.id}`); - if (updatedSource) { - await downloadSourcesSublevel.put(`${result.id}`, { - ...updatedSource, - fingerprint, - updatedAt: new Date(), - }); - } - - // Final verification: ensure the source with fingerprint is persisted - const finalSource = await downloadSourcesSublevel.get(`${result.id}`); - if (!finalSource || !finalSource.fingerprint) { - throw new Error("Failed to persist download source with fingerprint"); - } - - // Verify repacks still exist after fingerprint update - let finalRepackCount = 0; - for await (const [, repack] of repacksSublevel.iterator()) { - if (repack.downloadSourceId === result.id) { - finalRepackCount++; - } - } - - if (finalRepackCount !== repackCount) { - logger.warn( - `Repack count mismatch! Before: ${repackCount}, After: ${finalRepackCount}` + try { + const downloadSource = await HydraApi.post( + "/download-sources", + { + url, + }, + { needsAuth: false } ); - } else { - logger.info( - `Final verification passed: ${finalRepackCount} repacks confirmed` - ); - } - return { - ...result, - fingerprint, - }; + if (HydraApi.isLoggedIn()) { + try { + await HydraApi.post("/profile/download-sources", { + urls: [url], + }); + } catch (error) { + console.error("Failed to add download source to profile:", error); + } + } + + const downloadSourceForStorage = { + ...downloadSource, + fingerprint: downloadSource.fingerprint || "", + }; + await downloadSourcesSublevel.put( + downloadSource.id, + downloadSourceForStorage + ); + + return downloadSource; + } catch (error) { + console.error("Failed to add download source:", error); + throw error; + } }; registerEvent("addDownloadSource", addDownloadSource); diff --git a/src/main/events/download-sources/check-download-source-exists.ts b/src/main/events/download-sources/check-download-source-exists.ts deleted file mode 100644 index 36dd88ce..00000000 --- a/src/main/events/download-sources/check-download-source-exists.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel } from "@main/level"; - -const checkDownloadSourceExists = async ( - _event: Electron.IpcMainInvokeEvent, - url: string -): Promise => { - for await (const [, source] of downloadSourcesSublevel.iterator()) { - if (source.url === url) { - return true; - } - } - - return false; -}; - -registerEvent("checkDownloadSourceExists", checkDownloadSourceExists); diff --git a/src/main/events/download-sources/delete-all-download-sources.ts b/src/main/events/download-sources/delete-all-download-sources.ts deleted file mode 100644 index cbf3958f..00000000 --- a/src/main/events/download-sources/delete-all-download-sources.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { invalidateIdCaches } from "./helpers"; - -const deleteAllDownloadSources = async ( - _event: Electron.IpcMainInvokeEvent -) => { - await Promise.all([repacksSublevel.clear(), downloadSourcesSublevel.clear()]); - - invalidateIdCaches(); -}; - -registerEvent("deleteAllDownloadSources", deleteAllDownloadSources); diff --git a/src/main/events/download-sources/delete-download-source.ts b/src/main/events/download-sources/delete-download-source.ts deleted file mode 100644 index 5322b96c..00000000 --- a/src/main/events/download-sources/delete-download-source.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { invalidateIdCaches } from "./helpers"; - -const deleteDownloadSource = async ( - _event: Electron.IpcMainInvokeEvent, - id: number -) => { - const repacksToDelete: string[] = []; - - for await (const [key, repack] of repacksSublevel.iterator()) { - if (repack.downloadSourceId === id) { - repacksToDelete.push(key); - } - } - - const batch = repacksSublevel.batch(); - for (const key of repacksToDelete) { - batch.del(key); - } - await batch.write(); - - await downloadSourcesSublevel.del(`${id}`); - - invalidateIdCaches(); -}; - -registerEvent("deleteDownloadSource", deleteDownloadSource); diff --git a/src/main/events/download-sources/get-download-sources-list.ts b/src/main/events/download-sources/get-download-sources-list.ts deleted file mode 100644 index db26ad01..00000000 --- a/src/main/events/download-sources/get-download-sources-list.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel, DownloadSource } from "@main/level"; - -const getDownloadSourcesList = async (_event: Electron.IpcMainInvokeEvent) => { - const sources: DownloadSource[] = []; - - for await (const [, source] of downloadSourcesSublevel.iterator()) { - sources.push(source); - } - - // Sort by createdAt descending - sources.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); - - return sources; -}; - -registerEvent("getDownloadSourcesList", getDownloadSourcesList); diff --git a/src/main/events/download-sources/get-download-sources.ts b/src/main/events/download-sources/get-download-sources.ts index bbebd06c..cf7cd4d7 100644 --- a/src/main/events/download-sources/get-download-sources.ts +++ b/src/main/events/download-sources/get-download-sources.ts @@ -1,8 +1,8 @@ -import { HydraApi } from "@main/services"; +import { downloadSourcesSublevel } from "@main/level"; import { registerEvent } from "../register-event"; const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => { - return HydraApi.get("/profile/download-sources"); + return downloadSourcesSublevel.values().all(); }; registerEvent("getDownloadSources", getDownloadSources); diff --git a/src/main/events/download-sources/helpers.ts b/src/main/events/download-sources/helpers.ts deleted file mode 100644 index edd3878e..00000000 --- a/src/main/events/download-sources/helpers.ts +++ /dev/null @@ -1,314 +0,0 @@ -import axios from "axios"; -import { z } from "zod"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { DownloadSourceStatus } from "@shared"; -import crypto from "node:crypto"; -import { logger, ResourceCache } from "@main/services"; - -export const downloadSourceSchema = z.object({ - name: z.string().max(255), - downloads: z.array( - z.object({ - title: z.string().max(255), - uris: z.array(z.string()), - uploadDate: z.string().max(255), - fileSize: z.string().max(255), - }) - ), -}); - -export type TitleHashMapping = Record; - -let titleHashMappingCache: TitleHashMapping | null = null; - -export const getTitleHashMapping = async (): Promise => { - if (titleHashMappingCache) { - return titleHashMappingCache; - } - - try { - const cached = - ResourceCache.getCachedData("sources-manifest"); - if (cached) { - titleHashMappingCache = cached; - return cached; - } - - const fetched = await ResourceCache.fetchAndCache( - "sources-manifest", - "https://cdn.losbroxas.org/sources-manifest.json", - 10000 - ); - titleHashMappingCache = fetched; - return fetched; - } catch (error) { - logger.error("Failed to fetch title hash mapping:", error); - return {} as TitleHashMapping; - } -}; - -export const hashTitle = (title: string): string => { - return crypto.createHash("sha256").update(title).digest("hex"); -}; - -export type SteamGamesByLetter = Record; -export type FormattedSteamGame = { - id: string; - name: string; - formattedName: string; -}; -export type FormattedSteamGamesByLetter = Record; - -export const formatName = (name: string) => { - return name - .normalize("NFD") - .replaceAll(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replaceAll(/[^a-z0-9]/g, ""); -}; - -export const formatRepackName = (name: string) => { - return formatName(name.replace("[DL]", "")); -}; - -interface DownloadSource { - id: number; - url: string; - name: string; - etag: string | null; - status: number; - downloadCount: number; - objectIds: string[]; - fingerprint?: string; - createdAt: Date; - updatedAt: Date; -} - -const getDownloadSourcesMap = async (): Promise< - Map -> => { - const map = new Map(); - for await (const [key, source] of downloadSourcesSublevel.iterator()) { - map.set(key, source); - } - - return map; -}; - -export const checkUrlExists = async (url: string): Promise => { - const sources = await getDownloadSourcesMap(); - for (const source of sources.values()) { - if (source.url === url) { - return true; - } - } - return false; -}; - -let steamGamesFormattedCache: FormattedSteamGamesByLetter | null = null; - -export const getSteamGames = async (): Promise => { - if (steamGamesFormattedCache) { - return steamGamesFormattedCache; - } - - let steamGames: SteamGamesByLetter; - - const cached = ResourceCache.getCachedData( - "steam-games-by-letter" - ); - if (cached) { - steamGames = cached; - } else { - steamGames = await ResourceCache.fetchAndCache( - "steam-games-by-letter", - `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json` - ); - } - - const formattedData: FormattedSteamGamesByLetter = {}; - for (const [letter, games] of Object.entries(steamGames)) { - formattedData[letter] = games.map((game) => ({ - ...game, - formattedName: formatName(game.name), - })); - } - - steamGamesFormattedCache = formattedData; - return formattedData; -}; - -export type SublevelIterator = AsyncIterable<[string, { id: number }]>; - -export interface SublevelWithId { - iterator: () => SublevelIterator; -} - -let maxRepackId: number | null = null; -let maxDownloadSourceId: number | null = null; - -export const getNextId = async (sublevel: SublevelWithId): Promise => { - const isRepackSublevel = sublevel === repacksSublevel; - const isDownloadSourceSublevel = sublevel === downloadSourcesSublevel; - - if (isRepackSublevel && maxRepackId !== null) { - return ++maxRepackId; - } - - if (isDownloadSourceSublevel && maxDownloadSourceId !== null) { - return ++maxDownloadSourceId; - } - - let maxId = 0; - for await (const [, value] of sublevel.iterator()) { - if (value.id > maxId) { - maxId = value.id; - } - } - - if (isRepackSublevel) { - maxRepackId = maxId; - } else if (isDownloadSourceSublevel) { - maxDownloadSourceId = maxId; - } - - return maxId + 1; -}; - -export const invalidateIdCaches = () => { - maxRepackId = null; - maxDownloadSourceId = null; -}; - -export const addNewDownloads = async ( - downloadSource: { id: number; name: string }, - downloads: z.infer["downloads"], - steamGames: FormattedSteamGamesByLetter -) => { - const now = new Date(); - const objectIdsOnSource = new Set(); - - let nextRepackId = await getNextId(repacksSublevel); - - const batch = repacksSublevel.batch(); - - // Get title hash mapping and perform matching in worker thread - const titleHashMapping = await getTitleHashMapping(); - - const { GameMatcherWorkerManager } = await import("@main/services"); - const matchResult = await GameMatcherWorkerManager.matchDownloads( - downloads, - steamGames, - titleHashMapping - ); - - // Process matched results and write to database - for (const matchedDownload of matchResult.matchedDownloads) { - for (const id of matchedDownload.objectIds) { - objectIdsOnSource.add(id); - } - - const repack = { - id: nextRepackId++, - objectIds: matchedDownload.objectIds, - title: matchedDownload.title, - uris: matchedDownload.uris, - fileSize: matchedDownload.fileSize, - repacker: downloadSource.name, - uploadDate: matchedDownload.uploadDate, - downloadSourceId: downloadSource.id, - createdAt: now, - updatedAt: now, - }; - - batch.put(`${repack.id}`, repack); - } - - await batch.write(); - - logger.info( - `Matching stats for ${downloadSource.name}: Hash=${matchResult.stats.hashMatchCount}, Fuzzy=${matchResult.stats.fuzzyMatchCount}, None=${matchResult.stats.noMatchCount}` - ); - - const existingSource = await downloadSourcesSublevel.get( - `${downloadSource.id}` - ); - if (existingSource) { - await downloadSourcesSublevel.put(`${downloadSource.id}`, { - ...existingSource, - objectIds: Array.from(objectIdsOnSource), - }); - } - - return Array.from(objectIdsOnSource); -}; - -export const importDownloadSourceToLocal = async ( - url: string, - throwOnDuplicate = false -) => { - const urlExists = await checkUrlExists(url); - if (urlExists) { - if (throwOnDuplicate) { - throw new Error("Download source with this URL already exists"); - } - return null; - } - - const response = await axios.get>(url); - - const steamGames = await getSteamGames(); - - const now = new Date(); - - const nextId = await getNextId(downloadSourcesSublevel); - - const downloadSource = { - id: nextId, - url, - name: response.data.name, - etag: response.headers["etag"] || null, - status: DownloadSourceStatus.UpToDate, - downloadCount: response.data.downloads.length, - objectIds: [], - createdAt: now, - updatedAt: now, - }; - - await downloadSourcesSublevel.put(`${downloadSource.id}`, downloadSource); - - const objectIds = await addNewDownloads( - downloadSource, - response.data.downloads, - steamGames - ); - - // Invalidate ID caches after creating new repacks to prevent ID collisions - invalidateIdCaches(); - - return { - ...downloadSource, - objectIds, - }; -}; - -export const updateDownloadSourcePreservingTimestamp = async ( - existingSource: DownloadSource, - url: string -) => { - const response = await axios.get>(url); - - const updatedSource = { - ...existingSource, - name: response.data.name, - etag: response.headers["etag"] || null, - status: DownloadSourceStatus.UpToDate, - downloadCount: response.data.downloads.length, - updatedAt: new Date(), - // Preserve the original createdAt timestamp - }; - - await downloadSourcesSublevel.put(`${existingSource.id}`, updatedSource); - - return updatedSource; -}; diff --git a/src/main/events/download-sources/remove-download-source.ts b/src/main/events/download-sources/remove-download-source.ts index bcc66998..8efe0072 100644 --- a/src/main/events/download-sources/remove-download-source.ts +++ b/src/main/events/download-sources/remove-download-source.ts @@ -1,18 +1,27 @@ import { HydraApi } from "@main/services"; +import { downloadSourcesSublevel } from "@main/level"; import { registerEvent } from "../register-event"; const removeDownloadSource = async ( _event: Electron.IpcMainInvokeEvent, - url?: string, - removeAll = false + removeAll = false, + downloadSourceId?: string ) => { const params = new URLSearchParams({ all: removeAll.toString(), }); - if (url) params.set("url", url); + if (downloadSourceId) params.set("downloadSourceId", downloadSourceId); - return HydraApi.delete(`/profile/download-sources?${params.toString()}`); + if (HydraApi.isLoggedIn()) { + void HydraApi.delete(`/profile/download-sources?${params.toString()}`); + } + + if (removeAll) { + await downloadSourcesSublevel.clear(); + } else if (downloadSourceId) { + await downloadSourcesSublevel.del(downloadSourceId); + } }; registerEvent("removeDownloadSource", removeDownloadSource); diff --git a/src/main/events/download-sources/sync-download-sources-from-api.ts b/src/main/events/download-sources/sync-download-sources-from-api.ts deleted file mode 100644 index 3cac8819..00000000 --- a/src/main/events/download-sources/sync-download-sources-from-api.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HydraApi, logger } from "@main/services"; -import { importDownloadSourceToLocal, checkUrlExists } from "./helpers"; - -export const syncDownloadSourcesFromApi = async () => { - try { - const apiSources = await HydraApi.get< - { url: string; createdAt: string; updatedAt: string }[] - >("/profile/download-sources"); - - for (const apiSource of apiSources) { - const exists = await checkUrlExists(apiSource.url); - if (!exists) { - await importDownloadSourceToLocal(apiSource.url, false); - } - } - } catch (error) { - logger.error("Failed to sync download sources from API:", error); - } -}; diff --git a/src/main/events/download-sources/sync-download-sources.ts b/src/main/events/download-sources/sync-download-sources.ts index 3bb78f22..987ad1c1 100644 --- a/src/main/events/download-sources/sync-download-sources.ts +++ b/src/main/events/download-sources/sync-download-sources.ts @@ -1,105 +1,24 @@ +import { HydraApi } from "@main/services"; import { registerEvent } from "../register-event"; -import axios, { AxiosError } from "axios"; -import { downloadSourcesSublevel, repacksSublevel } from "@main/level"; -import { DownloadSourceStatus } from "@shared"; -import { - invalidateIdCaches, - downloadSourceSchema, - getSteamGames, - addNewDownloads, -} from "./helpers"; +import { downloadSourcesSublevel } from "@main/level"; +import type { DownloadSource } from "@types"; -const syncDownloadSources = async ( - _event: Electron.IpcMainInvokeEvent -): Promise => { - let newRepacksCount = 0; +const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => { + const downloadSources = await downloadSourcesSublevel.values().all(); - try { - const downloadSources: Array<{ - id: number; - url: string; - name: string; - etag: string | null; - status: number; - downloadCount: number; - objectIds: string[]; - fingerprint?: string; - createdAt: Date; - updatedAt: Date; - }> = []; - for await (const [, source] of downloadSourcesSublevel.iterator()) { - downloadSources.push(source); - } + const response = await HydraApi.post( + "/download-sources/sync", + { + ids: downloadSources.map((downloadSource) => downloadSource.id), + }, + { needsAuth: false } + ); - // Use a Set for O(1) lookups instead of O(n) with array.some() - const existingRepackTitles = new Set(); - for await (const [, repack] of repacksSublevel.iterator()) { - existingRepackTitles.add(repack.title); - } - - // Handle sources with missing fingerprints individually, don't delete all sources - const sourcesWithFingerprints = downloadSources.filter( - (source) => source.fingerprint - ); - const sourcesWithoutFingerprints = downloadSources.filter( - (source) => !source.fingerprint - ); - - // For sources without fingerprints, just continue with normal sync - // They will get fingerprints updated later by updateMissingFingerprints - const allSourcesToSync = [ - ...sourcesWithFingerprints, - ...sourcesWithoutFingerprints, - ]; - - for (const downloadSource of allSourcesToSync) { - const headers: Record = {}; - - if (downloadSource.etag) { - headers["If-None-Match"] = downloadSource.etag; - } - - try { - const response = await axios.get(downloadSource.url, { - headers, - }); - - const source = downloadSourceSchema.parse(response.data); - const steamGames = await getSteamGames(); - - // O(1) lookup instead of O(n) - massive performance improvement - const repacks = source.downloads.filter( - (download) => !existingRepackTitles.has(download.title) - ); - - await downloadSourcesSublevel.put(`${downloadSource.id}`, { - ...downloadSource, - etag: response.headers["etag"] || null, - downloadCount: source.downloads.length, - status: DownloadSourceStatus.UpToDate, - }); - - await addNewDownloads(downloadSource, repacks, steamGames); - - newRepacksCount += repacks.length; - } catch (err: unknown) { - const isNotModified = (err as AxiosError).response?.status === 304; - - await downloadSourcesSublevel.put(`${downloadSource.id}`, { - ...downloadSource, - status: isNotModified - ? DownloadSourceStatus.UpToDate - : DownloadSourceStatus.Errored, - }); - } - } - - invalidateIdCaches(); - - return newRepacksCount; - } catch (err) { - return -1; + for (const downloadSource of response) { + await downloadSourcesSublevel.put(downloadSource.id, downloadSource); } + + return response; }; registerEvent("syncDownloadSources", syncDownloadSources); diff --git a/src/main/events/download-sources/update-missing-fingerprints.ts b/src/main/events/download-sources/update-missing-fingerprints.ts deleted file mode 100644 index 7fd43c63..00000000 --- a/src/main/events/download-sources/update-missing-fingerprints.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesSublevel } from "@main/level"; -import { HydraApi, logger } from "@main/services"; - -const updateMissingFingerprints = async ( - _event: Electron.IpcMainInvokeEvent -): Promise => { - const sourcesNeedingFingerprints: Array<{ - id: number; - objectIds: string[]; - }> = []; - - for await (const [, source] of downloadSourcesSublevel.iterator()) { - if ( - !source.fingerprint && - source.objectIds && - source.objectIds.length > 0 - ) { - sourcesNeedingFingerprints.push({ - id: source.id, - objectIds: source.objectIds, - }); - } - } - - if (sourcesNeedingFingerprints.length === 0) { - return 0; - } - - logger.info( - `Updating fingerprints for ${sourcesNeedingFingerprints.length} sources` - ); - - await Promise.all( - sourcesNeedingFingerprints.map(async (source) => { - try { - const { fingerprint } = await HydraApi.put<{ fingerprint: string }>( - "/download-sources", - { - objectIds: source.objectIds, - }, - { needsAuth: false } - ); - - const existingSource = await downloadSourcesSublevel.get( - `${source.id}` - ); - if (existingSource) { - await downloadSourcesSublevel.put(`${source.id}`, { - ...existingSource, - fingerprint, - updatedAt: new Date(), - }); - } - } catch (error) { - logger.error( - `Failed to update fingerprint for source ${source.id}:`, - error - ); - } - }) - ); - - return sourcesNeedingFingerprints.length; -}; - -registerEvent("updateMissingFingerprints", updateMissingFingerprints); diff --git a/src/main/events/download-sources/validate-download-source.ts b/src/main/events/download-sources/validate-download-source.ts deleted file mode 100644 index 2bc86df7..00000000 --- a/src/main/events/download-sources/validate-download-source.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { registerEvent } from "../register-event"; -import axios from "axios"; -import { z } from "zod"; - -const downloadSourceSchema = z.object({ - name: z.string().max(255), - downloads: z.array( - z.object({ - title: z.string().max(255), - uris: z.array(z.string()), - uploadDate: z.string().max(255), - fileSize: z.string().max(255), - }) - ), -}); - -const validateDownloadSource = async ( - _event: Electron.IpcMainInvokeEvent, - url: string -) => { - const response = await axios.get>(url); - - const { name } = downloadSourceSchema.parse(response.data); - - return { - name, - etag: response.headers["etag"] || null, - downloadCount: response.data.downloads.length, - }; -}; - -registerEvent("validateDownloadSource", validateDownloadSource); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 8d21aa11..0ab5499a 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -63,14 +63,7 @@ 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/update-missing-fingerprints"; -import "./download-sources/delete-download-source"; -import "./download-sources/delete-all-download-sources"; -import "./download-sources/validate-download-source"; import "./download-sources/sync-download-sources"; -import "./download-sources/get-download-sources-list"; -import "./download-sources/check-download-source-exists"; -import "./repacks/get-all-repacks"; import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; diff --git a/src/main/events/repacks/get-all-repacks.ts b/src/main/events/repacks/get-all-repacks.ts deleted file mode 100644 index 6eb83a39..00000000 --- a/src/main/events/repacks/get-all-repacks.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { registerEvent } from "../register-event"; -import { repacksSublevel, GameRepack } from "@main/level"; - -const getAllRepacks = async (_event: Electron.IpcMainInvokeEvent) => { - const repacks: GameRepack[] = []; - - for await (const [, repack] of repacksSublevel.iterator()) { - if (Array.isArray(repack.objectIds)) { - repacks.push(repack); - } - } - - return repacks; -}; - -registerEvent("getAllRepacks", getAllRepacks); diff --git a/src/main/level/sublevels/download-sources.ts b/src/main/level/sublevels/download-sources.ts index 59104e3c..b6cdad0b 100644 --- a/src/main/level/sublevels/download-sources.ts +++ b/src/main/level/sublevels/download-sources.ts @@ -1,18 +1,6 @@ import { db } from "../level"; import { levelKeys } from "./keys"; - -export interface DownloadSource { - id: number; - name: string; - url: string; - status: number; - objectIds: string[]; - downloadCount: number; - fingerprint?: string; - etag: string | null; - createdAt: Date; - updatedAt: Date; -} +import type { DownloadSource } from "@types"; export const downloadSourcesSublevel = db.sublevel( levelKeys.downloadSources, diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index 7224fc64..3619ae26 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -7,4 +7,3 @@ export * from "./game-achievements"; export * from "./keys"; export * from "./themes"; export * from "./download-sources"; -export * from "./repacks"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index 6faacd52..a28690b2 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -18,5 +18,4 @@ export const levelKeys = { screenState: "screenState", rpcPassword: "rpcPassword", downloadSources: "downloadSources", - repacks: "repacks", }; diff --git a/src/main/level/sublevels/repacks.ts b/src/main/level/sublevels/repacks.ts deleted file mode 100644 index 6257665b..00000000 --- a/src/main/level/sublevels/repacks.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { db } from "../level"; -import { levelKeys } from "./keys"; - -export interface GameRepack { - id: number; - title: string; - uris: string[]; - repacker: string; - fileSize: string | null; - objectIds: string[]; - uploadDate: Date | string | null; - downloadSourceId: number; - createdAt: Date; - updatedAt: Date; -} - -export const repacksSublevel = db.sublevel( - levelKeys.repacks, - { - valueEncoding: "json", - } -); diff --git a/src/main/main.ts b/src/main/main.ts index e9b6187c..617dd135 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -16,19 +16,11 @@ import { Ludusavi, Lock, DeckyPlugin, - ResourceCache, - GameMatcherWorkerManager, } from "@main/services"; export const loadState = async () => { await Lock.acquireLock(); - ResourceCache.initialize(); - await ResourceCache.updateResourcesOnStartup(); - - // Initialize game matcher worker thread - GameMatcherWorkerManager.initialize(); - const userPreferences = await db.get( levelKeys.userPreferences, { diff --git a/src/main/services/game-matcher-worker-manager.ts b/src/main/services/game-matcher-worker-manager.ts deleted file mode 100644 index b5d306c7..00000000 --- a/src/main/services/game-matcher-worker-manager.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Worker } from "worker_threads"; -import workerPath from "../workers/game-matcher-worker?modulePath"; - -interface WorkerMessage { - id: string; - data: unknown; -} - -interface WorkerResponse { - id: string; - success: boolean; - result?: unknown; - error?: string; -} - -export type TitleHashMapping = Record; - -export type FormattedSteamGame = { - id: string; - name: string; - formattedName: string; -}; -export type FormattedSteamGamesByLetter = Record; - -interface DownloadToMatch { - title: string; - uris: string[]; - uploadDate: string; - fileSize: string; -} - -interface MatchedDownload { - title: string; - uris: string[]; - uploadDate: string; - fileSize: string; - objectIds: string[]; - usedHashMatch: boolean; -} - -interface MatchResponse { - matchedDownloads: MatchedDownload[]; - stats: { - hashMatchCount: number; - fuzzyMatchCount: number; - noMatchCount: number; - }; -} - -export class GameMatcherWorkerManager { - private static worker: Worker | null = null; - private static messageId = 0; - private static pendingMessages = new Map< - string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { resolve: (value: any) => void; reject: (error: Error) => void } - >(); - - public static initialize() { - if (this.worker) { - return; - } - - try { - console.log( - "[GameMatcherWorker] Initializing worker with path:", - workerPath - ); - - this.worker = new Worker(workerPath); - - this.worker.on("message", (response: WorkerResponse) => { - const pending = this.pendingMessages.get(response.id); - if (pending) { - if (response.success) { - pending.resolve(response.result); - } else { - pending.reject(new Error(response.error || "Unknown error")); - } - this.pendingMessages.delete(response.id); - } - }); - - this.worker.on("error", (error) => { - console.error("[GameMatcherWorker] Worker error:", error); - for (const [id, pending] of this.pendingMessages.entries()) { - pending.reject(error); - this.pendingMessages.delete(id); - } - }); - - this.worker.on("exit", (code) => { - if (code !== 0) { - console.error( - `[GameMatcherWorker] Worker stopped with exit code ${code}` - ); - } - this.worker = null; - for (const [id, pending] of this.pendingMessages.entries()) { - pending.reject(new Error("Worker exited unexpectedly")); - this.pendingMessages.delete(id); - } - }); - - console.log("[GameMatcherWorker] Worker initialized successfully"); - } catch (error) { - console.error("[GameMatcherWorker] Failed to initialize worker:", error); - throw error; - } - } - - private static sendMessage(data: unknown): Promise { - if (!this.worker) { - return Promise.reject(new Error("Worker not initialized")); - } - - const id = `msg_${++this.messageId}`; - const message: WorkerMessage = { id, data }; - - return new Promise((resolve, reject) => { - this.pendingMessages.set(id, { resolve, reject }); - this.worker!.postMessage(message); - }); - } - - public static async matchDownloads( - downloads: DownloadToMatch[], - steamGames: FormattedSteamGamesByLetter, - titleHashMapping: TitleHashMapping - ): Promise { - return this.sendMessage({ - downloads, - steamGames, - titleHashMapping, - }); - } - - public static terminate() { - if (this.worker) { - this.worker.terminate(); - this.worker = null; - this.pendingMessages.clear(); - } - } -} diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index dd26e6f0..ffc5756c 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -105,11 +105,6 @@ export class HydraApi { // WSClient.close(); // WSClient.connect(); - - const { syncDownloadSourcesFromApi } = await import( - "../events/download-sources/sync-download-sources-from-api" - ); - syncDownloadSourcesFromApi(); } } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 0853859f..88b39d1b 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -18,5 +18,3 @@ export * from "./library-sync"; export * from "./wine"; export * from "./lock"; export * from "./decky-plugin"; -export * from "./resource-cache"; -export * from "./game-matcher-worker-manager"; diff --git a/src/main/services/resource-cache.ts b/src/main/services/resource-cache.ts deleted file mode 100644 index c59f873d..00000000 --- a/src/main/services/resource-cache.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { app } from "electron"; -import axios from "axios"; -import fs from "node:fs"; -import path from "node:path"; -import { logger } from "./logger"; - -interface CachedResource { - data: T; - etag: string | null; -} - -export class ResourceCache { - private static cacheDir: string; - - static initialize() { - this.cacheDir = path.join(app.getPath("userData"), "resource-cache"); - - if (!fs.existsSync(this.cacheDir)) { - fs.mkdirSync(this.cacheDir, { recursive: true }); - } - } - - private static getCacheFilePath(resourceName: string): string { - return path.join(this.cacheDir, `${resourceName}.json`); - } - - private static getEtagFilePath(resourceName: string): string { - return path.join(this.cacheDir, `${resourceName}.etag`); - } - - private static readCachedResource( - resourceName: string - ): CachedResource | null { - const dataPath = this.getCacheFilePath(resourceName); - const etagPath = this.getEtagFilePath(resourceName); - - if (!fs.existsSync(dataPath)) { - return null; - } - - try { - const data = JSON.parse(fs.readFileSync(dataPath, "utf-8")) as T; - const etag = fs.existsSync(etagPath) - ? fs.readFileSync(etagPath, "utf-8") - : null; - - return { data, etag }; - } catch (error) { - logger.error(`Failed to read cached resource ${resourceName}:`, error); - return null; - } - } - - private static writeCachedResource( - resourceName: string, - data: T, - etag: string | null - ): void { - const dataPath = this.getCacheFilePath(resourceName); - const etagPath = this.getEtagFilePath(resourceName); - - try { - fs.writeFileSync(dataPath, JSON.stringify(data), "utf-8"); - - if (etag) { - fs.writeFileSync(etagPath, etag, "utf-8"); - } - - logger.info( - `Cached resource ${resourceName} with etag: ${etag || "none"}` - ); - } catch (error) { - logger.error(`Failed to write cached resource ${resourceName}:`, error); - } - } - - static async fetchAndCache( - resourceName: string, - url: string, - timeout: number = 10000 - ): Promise { - const cached = this.readCachedResource(resourceName); - const headers: Record = {}; - - if (cached?.etag) { - headers["If-None-Match"] = cached.etag; - } - - try { - const response = await axios.get(url, { - headers, - timeout, - }); - - const newEtag = response.headers["etag"] || null; - this.writeCachedResource(resourceName, response.data, newEtag); - - return response.data; - } catch (error: unknown) { - const axiosError = error as { - response?: { status?: number }; - message?: string; - }; - - if (axiosError.response?.status === 304 && cached) { - logger.info(`Resource ${resourceName} not modified, using cache`); - return cached.data; - } - - if (cached) { - logger.warn( - `Failed to fetch ${resourceName}, using cached version:`, - axiosError.message || "Unknown error" - ); - return cached.data; - } - - logger.error( - `Failed to fetch ${resourceName} and no cache available:`, - error - ); - throw error; - } - } - - static getCachedData(resourceName: string): T | null { - const cached = this.readCachedResource(resourceName); - return cached?.data || null; - } - - static async updateResourcesOnStartup(): Promise { - logger.info("Starting background resource cache update..."); - - const resources = [ - { - name: "steam-games-by-letter", - url: `${import.meta.env.MAIN_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`, - }, - { - name: "sources-manifest", - url: "https://cdn.losbroxas.org/sources-manifest.json", - }, - ]; - - await Promise.allSettled( - resources.map(async (resource) => { - try { - await this.fetchAndCache(resource.name, resource.url); - } catch (error) { - logger.error(`Failed to update ${resource.name} on startup:`, error); - } - }) - ); - - logger.info("Resource cache update complete"); - } -} diff --git a/src/main/workers/game-matcher-worker.ts b/src/main/workers/game-matcher-worker.ts deleted file mode 100644 index 4930ada0..00000000 --- a/src/main/workers/game-matcher-worker.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { parentPort } from "worker_threads"; -import crypto from "node:crypto"; - -export type TitleHashMapping = Record; - -export type FormattedSteamGame = { - id: string; - name: string; - formattedName: string; -}; -export type FormattedSteamGamesByLetter = Record; - -interface DownloadToMatch { - title: string; - uris: string[]; - uploadDate: string; - fileSize: string; -} - -interface MatchedDownload { - title: string; - uris: string[]; - uploadDate: string; - fileSize: string; - objectIds: string[]; - usedHashMatch: boolean; -} - -interface MatchRequest { - downloads: DownloadToMatch[]; - steamGames: FormattedSteamGamesByLetter; - titleHashMapping: TitleHashMapping; -} - -interface MatchResponse { - matchedDownloads: MatchedDownload[]; - stats: { - hashMatchCount: number; - fuzzyMatchCount: number; - noMatchCount: number; - }; -} - -const hashTitle = (title: string): string => { - return crypto.createHash("sha256").update(title).digest("hex"); -}; - -const formatName = (name: string) => { - return name - .normalize("NFD") - .replaceAll(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replaceAll(/[^a-z0-9]/g, ""); -}; - -const formatRepackName = (name: string) => { - return formatName(name.replace("[DL]", "")); -}; - -const matchDownloads = (request: MatchRequest): MatchResponse => { - const { downloads, steamGames, titleHashMapping } = request; - const matchedDownloads: MatchedDownload[] = []; - - let hashMatchCount = 0; - let fuzzyMatchCount = 0; - let noMatchCount = 0; - - for (const download of downloads) { - let objectIds: string[] = []; - let usedHashMatch = false; - - const titleHash = hashTitle(download.title); - const steamIdsFromHash = titleHashMapping[titleHash]; - - if (steamIdsFromHash && steamIdsFromHash.length > 0) { - hashMatchCount++; - usedHashMatch = true; - objectIds = steamIdsFromHash.map(String); - } - - if (!usedHashMatch) { - let gamesInSteam: FormattedSteamGame[] = []; - const formattedTitle = formatRepackName(download.title); - - if (formattedTitle && formattedTitle.length > 0) { - const [firstLetter] = formattedTitle; - const games = steamGames[firstLetter] || []; - - gamesInSteam = games.filter((game) => - formattedTitle.startsWith(game.formattedName) - ); - - if (gamesInSteam.length === 0) { - gamesInSteam = games.filter( - (game) => - formattedTitle.includes(game.formattedName) || - game.formattedName.includes(formattedTitle) - ); - } - - if (gamesInSteam.length === 0) { - for (const letter of Object.keys(steamGames)) { - const letterGames = steamGames[letter] || []; - const matches = letterGames.filter( - (game) => - formattedTitle.includes(game.formattedName) || - game.formattedName.includes(formattedTitle) - ); - if (matches.length > 0) { - gamesInSteam = matches; - break; - } - } - } - - if (gamesInSteam.length > 0) { - fuzzyMatchCount++; - objectIds = gamesInSteam.map((game) => String(game.id)); - } else { - noMatchCount++; - } - } else { - noMatchCount++; - } - } - - matchedDownloads.push({ - ...download, - objectIds, - usedHashMatch, - }); - } - - return { - matchedDownloads, - stats: { - hashMatchCount, - fuzzyMatchCount, - noMatchCount, - }, - }; -}; - -// Message handler -if (parentPort) { - parentPort.on("message", (message: { id: string; data: MatchRequest }) => { - try { - const result = matchDownloads(message.data); - parentPort!.postMessage({ id: message.id, success: true, result }); - } catch (error) { - parentPort!.postMessage({ - id: message.id, - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } - }); -} diff --git a/src/preload/index.ts b/src/preload/index.ts index da914b92..f89ec4db 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -99,22 +99,10 @@ contextBridge.exposeInMainWorld("electron", { /* Download sources */ addDownloadSource: (url: string) => ipcRenderer.invoke("addDownloadSource", url), - updateMissingFingerprints: () => - ipcRenderer.invoke("updateMissingFingerprints"), removeDownloadSource: (url: string, removeAll?: boolean) => ipcRenderer.invoke("removeDownloadSource", url, removeAll), getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), - deleteDownloadSource: (id: number) => - ipcRenderer.invoke("deleteDownloadSource", id), - deleteAllDownloadSources: () => - ipcRenderer.invoke("deleteAllDownloadSources"), - validateDownloadSource: (url: string) => - ipcRenderer.invoke("validateDownloadSource", url), syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"), - getDownloadSourcesList: () => ipcRenderer.invoke("getDownloadSourcesList"), - checkDownloadSourceExists: (url: string) => - ipcRenderer.invoke("checkDownloadSourceExists", url), - getAllRepacks: () => ipcRenderer.invoke("getAllRepacks"), /* Library */ toggleAutomaticCloudSync: ( diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 74a2a97e..168a4435 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -7,7 +7,6 @@ import { useAppSelector, useDownload, useLibrary, - useRepacks, useToast, useUserDetails, } from "@renderer/hooks"; @@ -20,7 +19,6 @@ import { setUserDetails, setProfileBackground, setGameRunning, - setIsImportingSources, } from "@renderer/features"; import { useTranslation } from "react-i18next"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; @@ -40,8 +38,6 @@ export function App() { const { t } = useTranslation("app"); - const { updateRepacks } = useRepacks(); - const { clearDownload, setLastPacket } = useDownload(); const { @@ -199,36 +195,6 @@ export function App() { }); }, [dispatch, draggingDisabled]); - useEffect(() => { - (async () => { - dispatch(setIsImportingSources(true)); - - try { - // Initial repacks load - await updateRepacks(); - - // Sync all local sources (check for updates) - const newRepacksCount = await window.electron.syncDownloadSources(); - - if (newRepacksCount > 0) { - window.electron.publishNewRepacksNotification(newRepacksCount); - } - - // Update fingerprints for sources that don't have them - await window.electron.updateMissingFingerprints(); - - // Update repacks AFTER all syncing and fingerprint updates are complete - await updateRepacks(); - } catch (error) { - console.error("Error syncing download sources:", error); - // Still update repacks even if sync fails - await updateRepacks(); - } finally { - dispatch(setIsImportingSources(false)); - } - })(); - }, [updateRepacks, dispatch]); - const loadAndApplyTheme = useCallback(async () => { const activeTheme = await window.electron.getActiveCustomTheme(); if (activeTheme?.code) { diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx index 5752ba19..598874b5 100644 --- a/src/renderer/src/components/game-card/game-card.tsx +++ b/src/renderer/src/components/game-card/game-card.tsx @@ -1,5 +1,5 @@ import { DownloadIcon, PeopleIcon } from "@primer/octicons-react"; -import type { GameStats } from "@types"; +import type { GameStats, ShopAssets } from "@types"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; @@ -8,15 +8,15 @@ import "./game-card.scss"; import { useTranslation } from "react-i18next"; import { Badge } from "../badge/badge"; import { StarRating } from "../star-rating/star-rating"; -import { useCallback, useState, useMemo } from "react"; -import { useFormat, useRepacks } from "@renderer/hooks"; +import { useCallback, useState } from "react"; +import { useFormat } from "@renderer/hooks"; export interface GameCardProps extends React.DetailedHTMLProps< React.ButtonHTMLAttributes, HTMLButtonElement > { - game: any; + game: ShopAssets; } const shopIcon = { @@ -28,13 +28,6 @@ export function GameCard({ game, ...props }: GameCardProps) { const [stats, setStats] = useState(null); - const { getRepacksForObjectId } = useRepacks(); - const repacks = getRepacksForObjectId(game.objectId); - - const uniqueRepackers = Array.from( - new Set(repacks.map((repack) => repack.repacker)) - ); - const handleHover = useCallback(() => { if (!stats) { window.electron.getGameStats(game.objectId, game.shop).then((stats) => { @@ -45,14 +38,7 @@ export function GameCard({ game, ...props }: GameCardProps) { const { numberFormatter } = useFormat(); - const firstThreeRepackers = useMemo( - () => uniqueRepackers.slice(0, 3), - [uniqueRepackers] - ); - const remainingCount = useMemo( - () => uniqueRepackers.length - 3, - [uniqueRepackers] - ); + console.log("game", game); return ( - } - /> - - {validationResult && ( -
-
-

{validationResult?.name}

- - {t("found_download_option", { - count: validationResult?.downloadCount, - countFormatted: - validationResult?.downloadCount.toLocaleString(), - })} - -
- - + +
- )} + ); diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx index f873b321..85c0569a 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.tsx +++ b/src/renderer/src/pages/settings/settings-download-sources.tsx @@ -16,7 +16,7 @@ import { TrashIcon, } from "@primer/octicons-react"; import { AddDownloadSourceModal } from "./add-download-source-modal"; -import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks"; +import { useAppDispatch, useToast } from "@renderer/hooks"; import { DownloadSourceStatus } from "@shared"; import { settingsContext } from "@renderer/context"; import { useNavigate } from "react-router-dom"; @@ -35,7 +35,6 @@ export function SettingsDownloadSources() { useState(false); const [isRemovingDownloadSource, setIsRemovingDownloadSource] = useState(false); - const [isFetchingSources, setIsFetchingSources] = useState(true); const { sourceUrl, clearSourceUrl } = useContext(settingsContext); @@ -46,37 +45,29 @@ export function SettingsDownloadSources() { const navigate = useNavigate(); - const { updateRepacks } = useRepacks(); - - const getDownloadSources = async () => { - await window.electron - .getDownloadSourcesList() - .then((sources) => { - setDownloadSources(sources); - }) - .finally(() => { - setIsFetchingSources(false); - }); - }; - - useEffect(() => { - getDownloadSources(); - }, []); - useEffect(() => { if (sourceUrl) setShowAddDownloadSourceModal(true); }, [sourceUrl]); + useEffect(() => { + const fetchDownloadSources = async () => { + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources); + }; + + fetchDownloadSources(); + }, []); + const handleRemoveSource = async (downloadSource: DownloadSource) => { setIsRemovingDownloadSource(true); try { - await window.electron.deleteDownloadSource(downloadSource.id); - await window.electron.removeDownloadSource(downloadSource.url); - + await window.electron.removeDownloadSource(false, downloadSource.id); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources as DownloadSource[]); showSuccessToast(t("removed_download_source")); - await getDownloadSources(); - updateRepacks(); + } catch (error) { + console.error("Failed to remove download source:", error); } finally { setIsRemovingDownloadSource(false); } @@ -86,53 +77,44 @@ export function SettingsDownloadSources() { setIsRemovingDownloadSource(true); try { - await window.electron.deleteAllDownloadSources(); - await window.electron.removeDownloadSource("", true); - - showSuccessToast(t("removed_download_sources")); - await getDownloadSources(); - setShowConfirmationDeleteAllSourcesModal(false); - updateRepacks(); + await window.electron.removeDownloadSource(true); + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources as DownloadSource[]); + showSuccessToast(t("removed_all_download_sources")); + } catch (error) { + console.error("Failed to remove all download sources:", error); } finally { setIsRemovingDownloadSource(false); + setShowConfirmationDeleteAllSourcesModal(false); } }; const handleAddDownloadSource = async () => { - // Refresh sources list and repacks after import completes - await getDownloadSources(); - - // Force repacks update to ensure UI reflects new data - await updateRepacks(); - - showSuccessToast(t("added_download_source")); + try { + const sources = await window.electron.getDownloadSources(); + setDownloadSources(sources as DownloadSource[]); + } catch (error) { + console.error("Failed to refresh download sources:", error); + } }; const syncDownloadSources = async () => { setIsSyncingDownloadSources(true); - try { - // Sync local sources (check for updates) - await window.electron.syncDownloadSources(); - - // Refresh sources and repacks AFTER sync completes - await getDownloadSources(); - await updateRepacks(); - - showSuccessToast(t("download_sources_synced")); - } catch (error) { - console.error("Error syncing download sources:", error); - // Still refresh the UI even if sync fails - await getDownloadSources(); - await updateRepacks(); + const sources = await window.electron.syncDownloadSources(); + setDownloadSources(sources); } finally { setIsSyncingDownloadSources(false); } }; const statusTitle = { - [DownloadSourceStatus.UpToDate]: t("download_source_up_to_date"), - [DownloadSourceStatus.Errored]: t("download_source_errored"), + [DownloadSourceStatus.PendingMatching]: t( + "download_source_pending_matching" + ), + [DownloadSourceStatus.Matched]: t("download_source_matched"), + [DownloadSourceStatus.Matching]: t("download_source_matching"), + [DownloadSourceStatus.Failed]: t("download_source_failed"), }; const handleModalClose = () => { @@ -180,8 +162,7 @@ export function SettingsDownloadSources() { disabled={ !downloadSources.length || isSyncingDownloadSources || - isRemovingDownloadSource || - isFetchingSources + isRemovingDownloadSource } onClick={syncDownloadSources} > @@ -197,8 +178,7 @@ export function SettingsDownloadSources() { disabled={ isRemovingDownloadSource || isSyncingDownloadSources || - !downloadSources.length || - isFetchingSources + !downloadSources.length } > @@ -209,11 +189,7 @@ export function SettingsDownloadSources() { type="button" theme="outline" onClick={() => setShowAddDownloadSourceModal(true)} - disabled={ - isSyncingDownloadSources || - isFetchingSources || - isRemovingDownloadSource - } + disabled={isSyncingDownloadSources || isRemovingDownloadSource} > {t("add_download_source")} diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index 264b1296..9903271c 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -8,8 +8,6 @@ import { userDetailsSlice, gameRunningSlice, subscriptionSlice, - repacksSlice, - downloadSourcesSlice, catalogueSearchSlice, } from "@renderer/features"; @@ -23,8 +21,6 @@ export const store = configureStore({ userDetails: userDetailsSlice.reducer, gameRunning: gameRunningSlice.reducer, subscription: subscriptionSlice.reducer, - repacks: repacksSlice.reducer, - downloadSources: downloadSourcesSlice.reducer, catalogueSearch: catalogueSearchSlice.reducer, }, }); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 851aec49..619dca65 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -11,8 +11,10 @@ export enum Downloader { } export enum DownloadSourceStatus { - UpToDate, - Errored, + PendingMatching = "PENDING_MATCHING", + Matched = "MATCHED", + Matching = "MATCHING", + Failed = "FAILED", } export enum CatalogueCategory { diff --git a/src/types/index.ts b/src/types/index.ts index 63b18645..092adaf8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,29 +16,22 @@ export interface DiskUsage { } export interface GameRepack { - id: number; + id: string; title: string; - uris: string[]; - repacker: string; fileSize: string | null; - objectIds: string[]; - uploadDate: Date | string | null; - createdAt: Date; - updatedAt: Date; + uris: string[]; + uploadDate: string | null; + downloadSourceId: string; + downloadSourceName: string; } export interface DownloadSource { - id: number; + id: string; name: string; url: string; - repackCount: number; status: DownloadSourceStatus; - objectIds: string[]; downloadCount: number; fingerprint?: string; - etag: string | null; - createdAt: Date; - updatedAt: Date; } export interface ShopAssets { @@ -51,6 +44,7 @@ export interface ShopAssets { logoImageUrl: string; logoPosition: string | null; coverImageUrl: string | null; + downloadSources: string[]; } export type ShopDetails = SteamAppDetails & { @@ -231,12 +225,6 @@ export interface DownloadSourceDownload { fileSize: string; } -export interface DownloadSourceValidationResult { - name: string; - etag: string; - downloadCount: number; -} - export interface GameStats { downloadCount: number; playerCount: number; @@ -366,7 +354,7 @@ export type CatalogueSearchResult = { title: string; shop: GameShop; genres: string[]; -} & Pick; +} & Pick; export type LibraryGame = Game & Partial & { From e1ce5bc6cb8e6d2cbc14a7e7615c0b95619d9cf2 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 21 Oct 2025 04:20:11 +0100 Subject: [PATCH 006/118] feat: using api download sources --- src/main/events/library/add-custom-game-to-library.ts | 1 + src/main/services/library-sync/merge-with-remote-games.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/events/library/add-custom-game-to-library.ts b/src/main/events/library/add-custom-game-to-library.ts index f2f2dd40..6a90087e 100644 --- a/src/main/events/library/add-custom-game-to-library.ts +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -37,6 +37,7 @@ const addCustomGameToLibrary = async ( logoImageUrl: logoImageUrl || "", logoPosition: null, coverImageUrl: iconUrl || "", + downloadSources: [], }; await gamesShopAssetsSublevel.put(gameKey, assets); diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index f7ea2744..c00e4961 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -72,6 +72,7 @@ export const mergeWithRemoteGames = async () => { logoImageUrl: game.logoImageUrl, iconUrl: game.iconUrl, logoPosition: game.logoPosition, + downloadSources: game.downloadSources, }); } }) From 8a40c678f7c2db1d4558cf1cca018359a592e3d7 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 21 Oct 2025 04:21:56 +0100 Subject: [PATCH 007/118] feat: using api download sources --- src/renderer/src/pages/game-details/game-details.tsx | 1 - src/types/index.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index f0778494..04b78aa4 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -102,7 +102,6 @@ export default function GameDetails() { automaticallyExtract: boolean ) => { const response = await startDownload({ - repackId: repack.id, objectId: objectId!, title: gameTitle, downloader, diff --git a/src/types/index.ts b/src/types/index.ts index 092adaf8..7d11171b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -107,7 +107,6 @@ export type AppUpdaterEvent = /* Events */ export interface StartGameDownloadPayload { - repackId: number; objectId: string; title: string; shop: GameShop; From 33e0d509668c4eb24dd09593c332324c6380b032 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Wed, 22 Oct 2025 14:24:04 +0530 Subject: [PATCH 008/118] feat: add achievements tracking to game library - Updated `get-library.ts` to include unlocked and total achievement counts for each game. - Removed `library-game-card-detailed.tsx` and its associated styles as part of the refactor. - Enhanced `library-game-card-large.tsx` to display achievements with progress bars. - Modified `library-game-card.scss` and `library-game-card-large.scss` to style the achievements section. - Introduced a new `search-bar` component for filtering the game library. - Implemented fuzzy search functionality in the library view. - Updated `view-options` to improve UI consistency. - Added achievement-related properties to the `LibraryGame` type in `index.ts`. - Created a new `copilot-instructions.md` for project guidelines. --- src/main/events/library/get-library.ts | 17 ++ .../library/library-game-card-detailed.scss | 217 ------------------ .../library/library-game-card-detailed.tsx | 209 ----------------- .../library/library-game-card-large.scss | 75 +++++- .../pages/library/library-game-card-large.tsx | 78 +++++-- .../src/pages/library/library-game-card.scss | 67 +++++- .../src/pages/library/library-game-card.tsx | 59 +++-- src/renderer/src/pages/library/library.tsx | 46 +++- .../src/pages/library/search-bar.scss | 75 ++++++ src/renderer/src/pages/library/search-bar.tsx | 44 ++++ .../src/pages/library/view-options.scss | 4 +- .../src/pages/library/view-options.tsx | 3 - src/types/index.ts | 2 + 13 files changed, 405 insertions(+), 491 deletions(-) delete mode 100644 src/renderer/src/pages/library/library-game-card-detailed.scss delete mode 100644 src/renderer/src/pages/library/library-game-card-detailed.tsx create mode 100644 src/renderer/src/pages/library/search-bar.scss create mode 100644 src/renderer/src/pages/library/search-bar.tsx diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index 8922802b..f62c60e7 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -4,6 +4,7 @@ import { downloadsSublevel, gamesShopAssetsSublevel, gamesSublevel, + gameAchievementsSublevel, } from "@main/level"; const getLibrary = async (): Promise => { @@ -18,10 +19,26 @@ const getLibrary = async (): Promise => { const download = await downloadsSublevel.get(key); const gameAssets = await gamesShopAssetsSublevel.get(key); + let unlockedAchievementCount = 0; + let achievementCount = 0; + + try { + const achievements = await gameAchievementsSublevel.get(key); + if (achievements) { + achievementCount = achievements.achievements.length; + unlockedAchievementCount = + achievements.unlockedAchievements.length; + } + } catch { + // No achievements data for this game + } + return { id: key, ...game, download: download ?? null, + unlockedAchievementCount, + achievementCount, // Spread gameAssets last to ensure all image URLs are properly set ...gameAssets, // Preserve custom image URLs from game if they exist diff --git a/src/renderer/src/pages/library/library-game-card-detailed.scss b/src/renderer/src/pages/library/library-game-card-detailed.scss deleted file mode 100644 index 0038e918..00000000 --- a/src/renderer/src/pages/library/library-game-card-detailed.scss +++ /dev/null @@ -1,217 +0,0 @@ -@use "../../scss/globals.scss"; - -.library-game-card-detailed { - width: 100%; - height: 350px; - position: relative; - border-radius: 8px; - overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.05); - transition: all ease 0.2s; - cursor: pointer; - display: flex; - align-items: center; - padding: 0; - text-align: left; - - &:before { - content: ""; - top: 0; - left: 0; - width: 100%; - height: 172%; - position: absolute; - background: linear-gradient( - 35deg, - rgba(0, 0, 0, 0.1) 0%, - rgba(0, 0, 0, 0.07) 51.5%, - rgba(255, 255, 255, 0.15) 74%, - rgba(255, 255, 255, 0.1) 100% - ); - transition: all ease 0.3s; - transform: translateY(-36%); - opacity: 0.5; - z-index: 1; - } - - &:hover::before { - opacity: 1; - transform: translateY(-20%); - } - - &:hover { - transform: scale(1.05); - transform: translateY(-2px); - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); - border-color: rgba(255, 255, 255, 0.1); - } - - &__background { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-size: cover; - background-position: top; - background-repeat: no-repeat; - z-index: 0; - } - - &__gradient { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient( - 90deg, - rgba(0, 0, 0, 0.1) 0%, - rgba(0, 0, 0, 0.3) 50%, - rgba(0, 0, 0, 0.5) 100% - ); - z-index: 1; - } - - &__overlay { - position: relative; - z-index: 2; - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - justify-content: space-between; - padding: calc(globals.$spacing-unit * 3); - } - - &__menu-button { - align-self: flex-end; - background: rgba(0, 0, 0, 0.3); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: solid 1px rgba(255, 255, 255, 0.15); - border-radius: 4px; - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all ease 0.2s; - color: rgba(255, 255, 255, 0.8); - padding: 0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - - &:hover { - background: rgba(0, 0, 0, 0.6); - border-color: rgba(255, 255, 255, 0.25); - transform: scale(1.05); - } - - &:active { - transform: scale(0.95); - } - } - - &__logo-container { - flex: 1; - display: flex; - align-items: center; - min-width: 0; - } - - &__logo { - max-height: 140px; - max-width: 450px; - width: auto; - height: auto; - object-fit: contain; - filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.6)); - } - - &__title { - font-size: 32px; - font-weight: 700; - color: rgba(255, 255, 255, 0.95); - margin: 0; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9); - } - - &__info-bar { - display: flex; - justify-content: space-between; - align-items: flex-end; - gap: calc(globals.$spacing-unit * 2); - } - - &__playtime { - background: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - color: rgba(255, 255, 255, 0.8); - border: solid 1px rgba(255, 255, 255, 0.15); - border-radius: 4px; - display: flex; - align-items: center; - gap: 6px; - padding: 8px 12px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - font-size: 14px; - } - - &__playtime-text { - font-weight: 500; - } - - &__manual-playtime { - color: globals.$warning-color; - } - - &__action-button { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 24px; - border-radius: 6px; - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - border: 1px solid rgba(255, 255, 255, 0.2); - color: rgba(255, 255, 255, 0.9); - cursor: pointer; - font-size: 14px; - font-weight: 600; - transition: all ease 0.2s; - flex-shrink: 0; - - &:hover { - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.3); - transform: scale(1.05); - } - - &:active { - transform: scale(0.98); - } - } - - &__action-icon--downloading { - animation: pulse 1.5s ease-in-out infinite; - } -} - -@keyframes pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} diff --git a/src/renderer/src/pages/library/library-game-card-detailed.tsx b/src/renderer/src/pages/library/library-game-card-detailed.tsx deleted file mode 100644 index d07dad28..00000000 --- a/src/renderer/src/pages/library/library-game-card-detailed.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { LibraryGame } from "@types"; -import { useDownload, useFormat } from "@renderer/hooks"; -import { useNavigate } from "react-router-dom"; -import { buildGameDetailsPath } from "@renderer/helpers"; -import { - PlayIcon, - DownloadIcon, - ClockIcon, - AlertFillIcon, - ThreeBarsIcon, -} from "@primer/octicons-react"; -import { useTranslation } from "react-i18next"; -import { useCallback, useState } from "react"; -import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; -import { GameContextMenu } from "@renderer/components"; -import "./library-game-card-detailed.scss"; - -interface LibraryGameCardDetailedProps { - game: LibraryGame; -} - -const getImageWithCustomPriority = ( - customUrl: string | null | undefined, - originalUrl: string | null | undefined, - fallbackUrl?: string | null | undefined -) => { - return customUrl || originalUrl || fallbackUrl || ""; -}; - -export function LibraryGameCardDetailed({ - game, -}: LibraryGameCardDetailedProps) { - const { t } = useTranslation("library"); - const { numberFormatter } = useFormat(); - const navigate = useNavigate(); - const { lastPacket } = useDownload(); - const [contextMenu, setContextMenu] = useState<{ - visible: boolean; - position: { x: number; y: number }; - }>({ visible: false, position: { x: 0, y: 0 } }); - - const isGameDownloading = - game?.download?.status === "active" && lastPacket?.gameId === game?.id; - - const formatPlayTime = useCallback( - (playTimeInMilliseconds = 0, isShort = false) => { - const minutes = playTimeInMilliseconds / 60000; - - if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { - return t(isShort ? "amount_minutes_short" : "amount_minutes", { - amount: minutes.toFixed(0), - }); - } - - const hours = minutes / 60; - const hoursKey = isShort ? "amount_hours_short" : "amount_hours"; - const hoursAmount = isShort - ? Math.floor(hours) - : numberFormatter.format(hours); - - return t(hoursKey, { amount: hoursAmount }); - }, - [numberFormatter, t] - ); - - const handleCardClick = () => { - navigate(buildGameDetailsPath(game)); - }; - - const handleActionClick = async (e: React.MouseEvent) => { - e.stopPropagation(); - - if (game.executablePath) { - window.electron.openGame( - game.shop, - game.objectId, - game.executablePath, - game.launchOptions - ); - } else { - navigate(buildGameDetailsPath(game)); - } - }; - - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - setContextMenu({ - visible: true, - position: { x: e.clientX, y: e.clientY }, - }); - }; - - const handleMenuButtonClick = (e: React.MouseEvent) => { - e.stopPropagation(); - setContextMenu({ - visible: true, - position: { - x: e.currentTarget.getBoundingClientRect().right, - y: e.currentTarget.getBoundingClientRect().bottom, - }, - }); - }; - - const handleCloseContextMenu = () => { - setContextMenu({ visible: false, position: { x: 0, y: 0 } }); - }; - - // Use libraryHeroImageUrl as background, fallback to libraryImageUrl - const backgroundImage = getImageWithCustomPriority( - game.libraryHeroImageUrl, - game.libraryImageUrl, - game.iconUrl - ); - - // For logo, check if logoImageUrl exists (similar to game details page) - const logoImage = game.logoImageUrl; - - return ( - <> - - -
- {logoImage ? ( - {game.title} - ) : ( -

- {game.title} -

- )} -
- -
-
- {game.hasManuallyUpdatedPlaytime ? ( - - ) : ( - - )} - - {formatPlayTime(game.playTimeInMilliseconds)} - -
- - -
- - - - - ); -} diff --git a/src/renderer/src/pages/library/library-game-card-large.scss b/src/renderer/src/pages/library/library-game-card-large.scss index 86a5a792..10e8beb5 100644 --- a/src/renderer/src/pages/library/library-game-card-large.scss +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -65,9 +65,9 @@ right: 0; bottom: 0; background: linear-gradient( - 90deg, - rgba(0, 0, 0, 0.1) 0%, - rgba(0, 0, 0, 0.3) 50%, + 0deg, + rgba(0, 0, 0, 1) 0%, + rgba(0, 0, 0, 0.2) 50%, rgba(0, 0, 0, 0.5) 100% ); z-index: 1; @@ -81,11 +81,18 @@ display: flex; flex-direction: column; justify-content: space-between; - padding: calc(globals.$spacing-unit * 2.5); + padding: calc(globals.$spacing-unit * 2); + } + + &__top-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: calc(globals.$spacing-unit); } &__menu-button { - align-self: flex-end; + align-self: flex-start; background: rgba(0, 0, 0, 0.3); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); @@ -175,6 +182,59 @@ color: globals.$warning-color; } + &__achievements { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 12px; + width: 100%; + } + + &__achievement-header { + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; + } + &__achievements-gap { + display: flex; + align-items: center; + gap: 6px; + } + + &__achievement-trophy { + color: #ffd700; + flex-shrink: 0; + } + + &__achievement-progress { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + overflow: hidden; + } + + &__achievement-bar { + height: 100%; + background: linear-gradient(90deg, #ffd700, #ffed4e); + border-radius: 4px; + transition: width 0.3s ease; + } + + &__achievement-count { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + } + + &__achievement-percentage { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + white-space: nowrap; + } + &__action-button { display: flex; align-items: center; @@ -191,8 +251,6 @@ font-weight: 600; transition: all ease 0.2s; flex-shrink: 0; - opacity: 0; - transform: translateY(10px); &:hover { background: rgba(255, 255, 255, 0.15); @@ -205,8 +263,7 @@ } } - &:hover &__menu-button, - &:hover &__action-button { + &:hover &__menu-button { opacity: 1; transform: scale(1); } diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx index 49d93048..cc1c659d 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -8,6 +8,7 @@ import { ClockIcon, AlertFillIcon, ThreeBarsIcon, + TrophyIcon, } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; import { useCallback, useState } from "react"; @@ -130,14 +131,29 @@ export function LibraryGameCardLarge({ game }: LibraryGameCardLargeProps) {
- +
+
+ {game.hasManuallyUpdatedPlaytime ? ( + + ) : ( + + )} + + {formatPlayTime(game.playTimeInMilliseconds)} + +
+ +
{logoImage ? ( @@ -152,19 +168,39 @@ export function LibraryGameCardLarge({ game }: LibraryGameCardLargeProps) {
-
- {game.hasManuallyUpdatedPlaytime ? ( - - ) : ( - - )} - - {formatPlayTime(game.playTimeInMilliseconds)} - -
+ {/* Achievements section */} + {(game.achievementCount ?? 0) > 0 && ( +
+
+
+ + + {game.unlockedAchievementCount ?? 0} /{" "} + {game.achievementCount ?? 0} + +
+ + {Math.round( + ((game.unlockedAchievementCount ?? 0) / + (game.achievementCount ?? 1)) * + 100 + )} + % + +
+
+
+
+
+ )}
- {/* Action button - Play or Download */} - + {/* Achievements section - shown on hover */} + {(game.achievementCount ?? 0) > 0 && ( +
+
+
+ + + {game.unlockedAchievementCount ?? 0} /{" "} + {game.achievementCount ?? 0} + +
+ + {Math.round( + ((game.unlockedAchievementCount ?? 0) / + (game.achievementCount ?? 1)) * + 100 + )} + % + +
+
+
+
+
+ )}
("grid"); const [filterBy, setFilterBy] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); const dispatch = useAppDispatch(); const { t } = useTranslation("library"); const { userDetails, fetchUserDetails } = useUserDetails(); @@ -53,27 +55,60 @@ export default function Library() { // Optional: resume animations if needed }; + // Simple fuzzy search function + const fuzzySearch = (query: string, items: typeof library) => { + if (!query.trim()) return items; + + const queryLower = query.toLowerCase(); + return items.filter((game) => { + const titleLower = game.title.toLowerCase(); + let matches = 0; + let queryIndex = 0; + + for ( + let i = 0; + i < titleLower.length && queryIndex < queryLower.length; + i++ + ) { + if (titleLower[i] === queryLower[queryIndex]) { + matches++; + queryIndex++; + } + } + + return queryIndex === queryLower.length; + }); + }; + const filteredLibrary = useMemo(() => { + let filtered; + switch (filterBy) { case "favourited": - return library.filter((game) => game.favorite); + filtered = library.filter((game) => game.favorite); + break; case "new": - return library.filter( + filtered = library.filter( (game) => (game.playTimeInMilliseconds || 0) === 0 ); + break; case "top10": - return library + filtered = library .slice() .sort( (a, b) => (b.playTimeInMilliseconds || 0) - (a.playTimeInMilliseconds || 0) ) .slice(0, 10); + break; case "all": default: - return library; + filtered = library; } - }, [library, filterBy]); + + // Apply search filter + return fuzzySearch(searchQuery, filtered); + }, [library, filterBy, searchQuery]); // No sorting for now — rely on filteredLibrary const sortedLibrary = filteredLibrary; @@ -112,6 +147,7 @@ export default function Library() {
+ void; +} + +export const SearchBar: FC = ({ value, onChange }) => { + const { t } = useTranslation(); + const inputRef = useRef(null); + + const handleClear = () => { + onChange(""); + inputRef.current?.focus(); + }; + + return ( +
+
+ + onChange(e.target.value)} + /> + {value && ( + + )} +
+
+ ); +}; diff --git a/src/renderer/src/pages/library/view-options.scss b/src/renderer/src/pages/library/view-options.scss index a9b4e197..3f49851c 100644 --- a/src/renderer/src/pages/library/view-options.scss +++ b/src/renderer/src/pages/library/view-options.scss @@ -26,13 +26,13 @@ display: flex; align-items: center; gap: calc(globals.$spacing-unit); - padding: 8px 16px; + padding: 8px 10px; border-radius: 6px; background: rgba(255, 255, 255, 0.04); border: none; color: rgba(255, 255, 255, 0.6); cursor: pointer; - font-size: 13px; + font-size: 14px; font-weight: 500; transition: all ease 0.2s; white-space: nowrap; diff --git a/src/renderer/src/pages/library/view-options.tsx b/src/renderer/src/pages/library/view-options.tsx index 3ea19cee..788eefa9 100644 --- a/src/renderer/src/pages/library/view-options.tsx +++ b/src/renderer/src/pages/library/view-options.tsx @@ -21,7 +21,6 @@ export function ViewOptions({ viewMode, onViewModeChange }: ViewOptionsProps) { title={t("grid_view")} > - {t("Grid View")}
diff --git a/src/types/index.ts b/src/types/index.ts index 63b18645..f714a938 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -372,6 +372,8 @@ export type LibraryGame = Game & Partial & { id: string; download: Download | null; + unlockedAchievementCount?: number; + achievementCount?: number; }; export type UserGameDetails = ShopAssets & { From 811a6ad9557ba74505d58f0948aaf71269b0058a Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Wed, 22 Oct 2025 14:42:47 +0530 Subject: [PATCH 009/118] refactor: remove unused imports and download logic from LibraryGameCard --- .../src/pages/library/library-game-card.tsx | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/src/renderer/src/pages/library/library-game-card.tsx b/src/renderer/src/pages/library/library-game-card.tsx index 0eecc4a6..be0a5a73 100644 --- a/src/renderer/src/pages/library/library-game-card.tsx +++ b/src/renderer/src/pages/library/library-game-card.tsx @@ -1,5 +1,5 @@ import { LibraryGame } from "@types"; -import { useFormat, useDownload } from "@renderer/hooks"; +import { useFormat } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; import { useCallback, useState } from "react"; import { buildGameDetailsPath } from "@renderer/helpers"; @@ -8,8 +8,6 @@ import { AlertFillIcon, ThreeBarsIcon, TrophyIcon, - PlayIcon, - DownloadIcon, } from "@primer/octicons-react"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { Tooltip } from "react-tooltip"; @@ -32,15 +30,11 @@ export function LibraryGameCard({ const { numberFormatter } = useFormat(); const navigate = useNavigate(); const [isTooltipHovered, setIsTooltipHovered] = useState(false); - const { lastPacket } = useDownload(); const [contextMenu, setContextMenu] = useState<{ visible: boolean; position: { x: number; y: number }; }>({ visible: false, position: { x: 0, y: 0 } }); - const isGameDownloading = - game?.download?.status === "active" && lastPacket?.gameId === game?.id; - const formatPlayTime = useCallback( (playTimeInMilliseconds = 0, isShort = false) => { const minutes = playTimeInMilliseconds / 60000; @@ -66,23 +60,6 @@ export function LibraryGameCard({ navigate(buildGameDetailsPath(game)); }; - const handleActionClick = async (e: React.MouseEvent) => { - e.stopPropagation(); - - if (game.executablePath) { - // Game is installed, launch it - window.electron.openGame( - game.shop, - game.objectId, - game.executablePath, - game.launchOptions - ); - } else { - // Game is not installed, navigate to download options - navigate(buildGameDetailsPath(game)); - } - }; - const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); From 107b61f663fd4a8a7b8e8a2f6f7188bfada34ac1 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Wed, 22 Oct 2025 14:46:25 +0530 Subject: [PATCH 010/118] style: update active state colors for filter and view options --- src/renderer/src/pages/library/filter-options.scss | 10 ++++++---- src/renderer/src/pages/library/view-options.scss | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/pages/library/filter-options.scss b/src/renderer/src/pages/library/filter-options.scss index e58e285b..377527a1 100644 --- a/src/renderer/src/pages/library/filter-options.scss +++ b/src/renderer/src/pages/library/filter-options.scss @@ -29,12 +29,14 @@ } &.active { - color: #c9aa71; - background: rgba(201, 170, 113, 0.15); + &.active { + color: rgba(255, 255, 255, 0.95); + background: rgba(255, 255, 255, 0.15); + } .library-filter-options__count { - background: rgba(201, 170, 113, 0.25); - color: #c9aa71; + background: rgba(255, 255, 255, 0.25); + color: rgba(255, 255, 255, 0.95); } } } diff --git a/src/renderer/src/pages/library/view-options.scss b/src/renderer/src/pages/library/view-options.scss index 3f49851c..6815f625 100644 --- a/src/renderer/src/pages/library/view-options.scss +++ b/src/renderer/src/pages/library/view-options.scss @@ -43,8 +43,8 @@ } &.active { - color: #c9aa71; - background: rgba(201, 170, 113, 0.15); + color: rgba(255, 255, 255, 0.95); + background: rgba(255, 255, 255, 0.15); } } } From e19102ea66d8adad2ab94f73cfacc7faf3c4fbc2 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Wed, 22 Oct 2025 16:12:12 +0530 Subject: [PATCH 011/118] style: update active state styles for filter and view options; adjust achievement progress bar styles --- .../src/pages/library/filter-options.scss | 14 ++++++---- .../library/library-game-card-large.scss | 23 +++++++++++----- .../src/pages/library/library-game-card.scss | 27 ++++++++++++++----- src/renderer/src/pages/library/library.tsx | 16 +---------- .../src/pages/library/view-options.scss | 9 +++++-- 5 files changed, 54 insertions(+), 35 deletions(-) diff --git a/src/renderer/src/pages/library/filter-options.scss b/src/renderer/src/pages/library/filter-options.scss index 377527a1..cc899d56 100644 --- a/src/renderer/src/pages/library/filter-options.scss +++ b/src/renderer/src/pages/library/filter-options.scss @@ -22,6 +22,7 @@ font-weight: 500; transition: all ease 0.2s; white-space: nowrap; /* prevent label and count from wrapping */ + border: 1px solid rgba(0, 0, 0, 0.06); &:hover { color: rgba(255, 255, 255, 0.9); @@ -29,14 +30,17 @@ } &.active { - &.active { - color: rgba(255, 255, 255, 0.95); - background: rgba(255, 255, 255, 0.15); + color: #000; + background: #fff; + svg, + svg * { + fill: currentColor; + color: currentColor; } .library-filter-options__count { - background: rgba(255, 255, 255, 0.25); - color: rgba(255, 255, 255, 0.95); + background: #ebebeb; + color: rgba(0, 0, 0, 0.9); } } } diff --git a/src/renderer/src/pages/library/library-game-card-large.scss b/src/renderer/src/pages/library/library-game-card-large.scss index 10e8beb5..0be1e907 100644 --- a/src/renderer/src/pages/library/library-game-card-large.scss +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -203,28 +203,39 @@ } &__achievement-trophy { - color: #ffd700; + color: #fff; flex-shrink: 0; } &__achievement-progress { width: 100%; - height: 6px; - background: rgba(255, 255, 255, 0.1); + height: 4px; + transition: all ease 0.2s; + background-color: rgba(255, 255, 255, 0.08); border-radius: 4px; overflow: hidden; + + &::-webkit-progress-bar { + background-color: transparent; + border-radius: 4px; + } + + &::-webkit-progress-value { + background-color: globals.$muted-color; + border-radius: 4px; + } } &__achievement-bar { height: 100%; - background: linear-gradient(90deg, #ffd700, #ffed4e); + background-color: globals.$muted-color; border-radius: 4px; transition: width 0.3s ease; } &__achievement-count { font-size: 14px; - font-weight: 600; + font-weight: 500; color: rgba(255, 255, 255, 0.9); white-space: nowrap; } @@ -239,7 +250,7 @@ display: flex; align-items: center; gap: 8px; - padding: 12px 24px; + padding: 10px 20px; border-radius: 6px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss index ae4db0c8..b763ae2c 100644 --- a/src/renderer/src/pages/library/library-game-card.scss +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -131,28 +131,41 @@ } &__achievement-trophy { - color: #ffd700; + color: #fff; flex-shrink: 0; } &__achievement-progress { + margin-top: 8px; width: 100%; - height: 6px; - background: rgba(255, 255, 255, 0.1); - border-radius: 3px; + height: 4px; + transition: all ease 0.2s; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 4px; overflow: hidden; + + &::-webkit-progress-bar { + background-color: transparent; + border-radius: 4px; + } + + &::-webkit-progress-value { + background-color: globals.$muted-color; + border-radius: 4px; + } } &__achievement-bar { height: 100%; - background: linear-gradient(90deg, #ffd700, #ffed4e); - border-radius: 3px; + background-color: globals.$muted-color; + border-radius: 4px; transition: width 0.3s ease; + position: relative; } &__achievement-count { font-size: 12px; - font-weight: 600; + font-weight: 500; color: rgba(255, 255, 255, 0.9); white-space: nowrap; } diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 1c6baf17..748aba4f 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { useLibrary, useAppDispatch, useUserDetails } from "@renderer/hooks"; +import { useLibrary, useAppDispatch } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; import { TelescopeIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; @@ -19,7 +19,6 @@ export default function Library() { const [searchQuery, setSearchQuery] = useState(""); const dispatch = useAppDispatch(); const { t } = useTranslation("library"); - const { userDetails, fetchUserDetails } = useUserDetails(); useEffect(() => { dispatch(setHeaderTitle(t("library"))); @@ -40,13 +39,6 @@ export default function Library() { }; }, [dispatch, t, updateLibrary]); - // Ensure we have the current user details available - useEffect(() => { - fetchUserDetails().catch(() => { - /* ignore errors - fallback to local state */ - }); - }, [fetchUserDetails]); - const handleOnMouseEnterGameCard = () => { // Optional: pause animations if needed }; @@ -128,12 +120,6 @@ export default function Library() { {hasGames && ( <>
-

- {`${t("Welcome", { defaultValue: "Welcome" })} ${ - userDetails?.displayName || "John Doe" - }`} -

-
Date: Wed, 22 Oct 2025 18:28:24 +0530 Subject: [PATCH 012/118] style: update compact view styles for game cards; adjust grid layout and add button order --- .../src/pages/library/library-game-card.scss | 7 +++++++ src/renderer/src/pages/library/library.scss | 15 +++++++++------ src/renderer/src/pages/library/library.tsx | 2 +- src/renderer/src/pages/library/view-options.tsx | 14 +++++++------- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss index b763ae2c..d6bf8c6d 100644 --- a/src/renderer/src/pages/library/library-game-card.scss +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -280,3 +280,10 @@ opacity: 0.5; } } + +/* Force fixed size for compact grid cells so cards render at 220x320 */ +.library__games-grid--compact .library-game-card__wrapper { + width: 215px; + height: 320px; + aspect-ratio: unset; +} diff --git a/src/renderer/src/pages/library/library.scss b/src/renderer/src/pages/library/library.scss index 1e8038d9..9b660a45 100644 --- a/src/renderer/src/pages/library/library.scss +++ b/src/renderer/src/pages/library/library.scss @@ -161,26 +161,29 @@ // Compact view - smaller cards &--compact { - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(auto-fill, 215px); + grid-auto-rows: 320px; + justify-content: start; @container #{globals.$app-container} (min-width: 900px) { - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(auto-fill, 215px); } @container #{globals.$app-container} (min-width: 1300px) { - grid-template-columns: repeat(7, 1fr); + grid-template-columns: repeat(auto-fill, 215px); } + /* keep same pattern for very large screens */ @container #{globals.$app-container} (min-width: 2000px) { - grid-template-columns: repeat(9, 1fr); + grid-template-columns: repeat(auto-fill, 215px); } @container #{globals.$app-container} (min-width: 2600px) { - grid-template-columns: repeat(12, 1fr); + grid-template-columns: repeat(auto-fill, 215px); } @container #{globals.$app-container} (min-width: 3000px) { - grid-template-columns: repeat(16, 1fr); + grid-template-columns: repeat(auto-fill, 210px); } } } diff --git a/src/renderer/src/pages/library/library.tsx b/src/renderer/src/pages/library/library.tsx index 748aba4f..2d58bb08 100644 --- a/src/renderer/src/pages/library/library.tsx +++ b/src/renderer/src/pages/library/library.tsx @@ -14,7 +14,7 @@ import "./library.scss"; export default function Library() { const { library, updateLibrary } = useLibrary(); - const [viewMode, setViewMode] = useState("grid"); + const [viewMode, setViewMode] = useState("compact"); const [filterBy, setFilterBy] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); const dispatch = useAppDispatch(); diff --git a/src/renderer/src/pages/library/view-options.tsx b/src/renderer/src/pages/library/view-options.tsx index 788eefa9..662bab65 100644 --- a/src/renderer/src/pages/library/view-options.tsx +++ b/src/renderer/src/pages/library/view-options.tsx @@ -15,13 +15,6 @@ export function ViewOptions({ viewMode, onViewModeChange }: ViewOptionsProps) { return (
- + + +
- {!hasAnyGames && ( -
-
- -
-

{t("no_recent_activity_title")}

- {isMe &&

{t("no_recent_activity_description")}

} -
- )} +
+ +
+ {t("loading_reviews")} +
) : reviews.length === 0 ? (

{t("no_reviews", "No reviews yet")}

@@ -461,22 +470,35 @@ export function ProfileContent() {
- {formatDistance(new Date(review.createdAt), new Date(), { addSuffix: true })} + {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )}
{Array.from({ length: 5 }, (_, index) => ( -
+
handleVoteReview(review.id, true)} + onClick={() => + handleVoteReview(review.id, true) + } disabled={votingReviews.has(review.id)} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} @@ -518,7 +544,9 @@ export function ProfileContent() { handleVoteReview(review.id, false)} + onClick={() => + handleVoteReview(review.id, false) + } disabled={votingReviews.has(review.id)} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} From f5470b29c065bf110c5fcefd408ae3ca5ed86330 Mon Sep 17 00:00:00 2001 From: ctrlcat0x Date: Thu, 23 Oct 2025 10:58:31 +0530 Subject: [PATCH 015/118] style: adjust hover effects and dimensions for game cards; refine context menu actions --- .../game-context-menu/game-context-menu.tsx | 4 +- .../library/library-game-card-large.scss | 16 ++++---- .../pages/library/library-game-card-large.tsx | 39 ++++++++++++++----- .../src/pages/library/library-game-card.scss | 6 +-- 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/game-context-menu/game-context-menu.tsx b/src/renderer/src/components/game-context-menu/game-context-menu.tsx index 694012b7..782857a9 100644 --- a/src/renderer/src/components/game-context-menu/game-context-menu.tsx +++ b/src/renderer/src/components/game-context-menu/game-context-menu.tsx @@ -70,8 +70,10 @@ export function GameContextMenu({ onClick: () => { if (isGameRunning) { void handleCloseGame(); - } else { + } else if (canPlay) { void handlePlayGame(); + } else { + handleOpenDownloadOptions(); } }, disabled: isDeleting, diff --git a/src/renderer/src/pages/library/library-game-card-large.scss b/src/renderer/src/pages/library/library-game-card-large.scss index 0be1e907..700c7d0b 100644 --- a/src/renderer/src/pages/library/library-game-card-large.scss +++ b/src/renderer/src/pages/library/library-game-card-large.scss @@ -40,8 +40,7 @@ } &:hover { - transform: scale(1.05); - transform: translateY(-2px); + transform: scale(1.01); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); border-color: rgba(255, 255, 255, 0.1); } @@ -66,9 +65,9 @@ bottom: 0; background: linear-gradient( 0deg, - rgba(0, 0, 0, 1) 0%, + rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.2) 50%, - rgba(0, 0, 0, 0.5) 100% + rgba(0, 0, 0, 0.3) 100% ); z-index: 1; } @@ -154,9 +153,9 @@ &__info-bar { display: flex; - justify-content: space-between; - align-items: flex-end; + align-items: center; gap: calc(globals.$spacing-unit * 2); + justify-content: flex-end; } &__playtime { @@ -187,7 +186,8 @@ flex-direction: column; gap: 6px; padding: 6px 12px; - width: 100%; + flex: 1 1 auto; + min-width: 0; } &__achievement-header { @@ -261,7 +261,7 @@ font-size: 14px; font-weight: 600; transition: all ease 0.2s; - flex-shrink: 0; + flex: 0 0 auto; &:hover { background: rgba(255, 255, 255, 0.15); diff --git a/src/renderer/src/pages/library/library-game-card-large.tsx b/src/renderer/src/pages/library/library-game-card-large.tsx index cc1c659d..3c9285e9 100644 --- a/src/renderer/src/pages/library/library-game-card-large.tsx +++ b/src/renderer/src/pages/library/library-game-card-large.tsx @@ -9,9 +9,11 @@ import { AlertFillIcon, ThreeBarsIcon, TrophyIcon, + XIcon, } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; import { useCallback, useState } from "react"; +import { useGameActions } from "@renderer/components/game-context-menu/use-game-actions"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { GameContextMenu } from "@renderer/components"; import "./library-game-card-large.scss"; @@ -66,18 +68,32 @@ export function LibraryGameCardLarge({ game }: LibraryGameCardLargeProps) { navigate(buildGameDetailsPath(game)); }; + const { + handlePlayGame, + handleOpenDownloadOptions, + handleCloseGame, + isGameRunning, + } = useGameActions(game); + const handleActionClick = async (e: React.MouseEvent) => { e.stopPropagation(); - if (game.executablePath) { - window.electron.openGame( - game.shop, - game.objectId, - game.executablePath, - game.launchOptions - ); - } else { - navigate(buildGameDetailsPath(game)); + if (isGameRunning) { + try { + await handleCloseGame(); + } catch (e) { + void e; + } + return; + } + try { + await handlePlayGame(); + } catch (err) { + try { + handleOpenDownloadOptions(); + } catch (e) { + void e; + } } }; @@ -215,6 +231,11 @@ export function LibraryGameCardLarge({ game }: LibraryGameCardLargeProps) { /> {t("downloading")} + ) : isGameRunning ? ( + <> + + {t("close")} + ) : game.executablePath ? ( <> diff --git a/src/renderer/src/pages/library/library-game-card.scss b/src/renderer/src/pages/library/library-game-card.scss index d6bf8c6d..aa957d12 100644 --- a/src/renderer/src/pages/library/library-game-card.scss +++ b/src/renderer/src/pages/library/library-game-card.scss @@ -37,7 +37,7 @@ } &:hover { - transform: scale(1.05); + transform: scale(1.02); } &:hover::before { @@ -215,8 +215,8 @@ -webkit-backdrop-filter: blur(8px); border: solid 1px rgba(255, 255, 255, 0.15); border-radius: 4px; - width: 32px; - height: 32px; + width: 28px; + height: 28px; display: flex; align-items: center; justify-content: center; From acf8f340dd171c64b819c95c37045acf1fd314ae Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 10:33:29 +0300 Subject: [PATCH 016/118] ci: review message ui change and fix loading reviews positioning --- .../profile-content/profile-content.scss | 22 +++++++- .../profile-content/profile-content.tsx | 51 ++++++++++--------- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss index 4cdea61b..21acfa47 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -182,6 +182,10 @@ .user-reviews__loading { padding: calc(globals.$spacing-unit * 2); color: rgba(255, 255, 255, 0.8); + text-align: center; + display: flex; + justify-content: center; + align-items: center; } .user-reviews__empty { @@ -208,6 +212,14 @@ margin-bottom: calc(globals.$spacing-unit * 1.5); } +.user-reviews__review-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: calc(globals.$spacing-unit * 1.5); + margin-bottom: calc(globals.$spacing-unit * 1.5); +} + .user-reviews__review-game { display: flex; align-items: center; @@ -215,8 +227,8 @@ } .user-reviews__game-icon { - width: 40px; - height: 40px; + width: 24px; + height: 24px; border-radius: 8px; object-fit: cover; } @@ -227,6 +239,12 @@ gap: calc(globals.$spacing-unit * 0.25); } +.user-reviews__game-details { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 0.5); +} + .user-reviews__game-title { background: none; border: none; diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index d245aa5e..97d9b1a9 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -461,29 +461,12 @@ export function ProfileContent() { transition={{ duration: 0.3 }} >
-
- {review.game.title} -
- -
- {formatDistance( - new Date(review.createdAt), - new Date(), - { addSuffix: true } - )} -
-
+
+ {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )}
@@ -517,6 +500,28 @@ export function ProfileContent() { }} /> +
+
+
+
+ {review.game.title} + +
+
+
+
+
Date: Thu, 23 Oct 2025 10:34:15 +0300 Subject: [PATCH 017/118] ci: formatting --- .../src/pages/profile/profile-content/profile-content.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 97d9b1a9..b37f7517 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -512,7 +512,9 @@ export function ProfileContent() {
{/* render reviews content unconditionally */} - {isLoadingReviews ? ( -
- {t("loading_reviews")} -
- ) : reviews.length === 0 ? ( -
-

{t("no_reviews", "No reviews yet")}

-
- ) : ( -
- {reviews.map((review) => { + {(() => { + if (isLoadingReviews) { + return ( +
+ {t("loading_reviews")} +
+ ); + } + + if (reviews.length === 0) { + return ( +
+

{t("no_reviews", "No reviews yet")}

+
+ ); + } + + return ( +
+ {reviews.map((review) => { const isOwnReview = userDetails?.id === review.user.id; return ( @@ -586,7 +595,8 @@ export function ProfileContent() { ); })}
- )} + ); + })()}
From f5399774316da2250c7049c3049f2900406f4e58 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 11:53:35 +0300 Subject: [PATCH 021/118] fix: refactoring functions to prevent nesting more than 4 lvls --- .../profile-content/profile-content.tsx | 294 +++++++++--------- 1 file changed, 143 insertions(+), 151 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 679556db..f5511a1e 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -437,166 +437,158 @@ export function ProfileContent() {
{/* render reviews content unconditionally */} - {(() => { - if (isLoadingReviews) { - return ( -
- {t("loading_reviews")} -
- ); - } - - if (reviews.length === 0) { - return ( -
-

{t("no_reviews", "No reviews yet")}

-
- ); - } - - return ( -
- {reviews.map((review) => { - const isOwnReview = userDetails?.id === review.user.id; + {isLoadingReviews && ( +
+ {t("loading_reviews")} +
+ )} + {!isLoadingReviews && reviews.length === 0 && ( +
+

{t("no_reviews", "No reviews yet")}

+
+ )} + {!isLoadingReviews && reviews.length > 0 && ( +
+ {reviews.map((review) => { + const isOwnReview = userDetails?.id === review.user.id; - return ( - -
-
- {formatDistance( - new Date(review.createdAt), - new Date(), - { addSuffix: true } - )} -
+ return ( + +
+
+ {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )} +
-
- {Array.from({ length: 5 }, (_, index) => ( -
- -
- ))} -
-
- -
- -
-
-
-
- {review.game.title} - + +
+ ))} +
+
+ +
+ +
+
+
+
+ {review.game.title} + +
-
-
-
- - handleVoteReview(review.id, true) - } - disabled={votingReviews.has(review.id)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.upvotes} - - - +
+
+ + handleVoteReview(review.id, true) + } + disabled={votingReviews.has(review.id)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.upvotes} + + + - - handleVoteReview(review.id, false) - } - disabled={votingReviews.has(review.id)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.downvotes} - - - + + handleVoteReview(review.id, false) + } + disabled={votingReviews.has(review.id)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.downvotes} + + + +
+ + {isOwnReview && ( + + )}
- - {isOwnReview && ( - - )} -
- - ); - })} -
- ); - })()} + + ); + })} +
+ )}
From d21ec52814e17d657360bfba75443aa1cc3bae45 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 12:06:23 +0300 Subject: [PATCH 022/118] ci: deleted comments --- .../profile-content/profile-content.tsx | 264 +++++++++--------- 1 file changed, 131 insertions(+), 133 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index f5511a1e..dd09ed0b 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -26,8 +26,7 @@ import { Star, ThumbsUp, ThumbsDown, TrashIcon } from "lucide-react"; import type { GameShop } from "@types"; import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; import { - // removed: sectionVariants, - // removed: chevronVariants, + GAME_STATS_ANIMATION_DURATION_IN_MS, } from "./profile-animations"; import "./profile-content.scss"; @@ -450,145 +449,145 @@ export function ProfileContent() { {!isLoadingReviews && reviews.length > 0 && (
{reviews.map((review) => { - const isOwnReview = userDetails?.id === review.user.id; + const isOwnReview = userDetails?.id === review.user.id; - return ( - -
-
- {formatDistance( - new Date(review.createdAt), - new Date(), - { addSuffix: true } - )} -
- -
- {Array.from({ length: 5 }, (_, index) => ( -
- -
- ))} -
+ return ( + +
+
+ {formatDistance( + new Date(review.createdAt), + new Date(), + { addSuffix: true } + )}
-
+
+ {Array.from({ length: 5 }, (_, index) => ( +
+ +
+ ))} +
+
-
-
-
-
- {review.game.title} - -
+
+ +
+
+
+
+ {review.game.title} +
+
-
-
- - handleVoteReview(review.id, true) - } - disabled={votingReviews.has(review.id)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.upvotes} - - - +
+
+ + handleVoteReview(review.id, true) + } + disabled={votingReviews.has(review.id)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.upvotes} + + + - - handleVoteReview(review.id, false) - } - disabled={votingReviews.has(review.id)} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - - - - {review.downvotes} - - - -
- - {isOwnReview && ( - - )} + + handleVoteReview(review.id, false) + } + disabled={votingReviews.has(review.id)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + + {review.downvotes} + + +
- - ); - })} -
- )} + + {isOwnReview && ( + + )} +
+ + ); + })} +
+ )}
@@ -621,8 +620,7 @@ export function ProfileContent() { statsIndex, libraryGames, pinnedGames, - // removed isPinnedCollapsed, - // removed toggleSection, + sortBy, activeTab, ]); From daf9751cf6ebf1532c600f86be3b9dde2ab05931 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 14:27:03 +0300 Subject: [PATCH 023/118] ci: import formatting --- .../src/pages/profile/profile-content/profile-content.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index dd09ed0b..cd833c6c 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -25,10 +25,7 @@ import { buildGameDetailsPath } from "@renderer/helpers"; import { Star, ThumbsUp, ThumbsDown, TrashIcon } from "lucide-react"; import type { GameShop } from "@types"; import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; -import { - - GAME_STATS_ANIMATION_DURATION_IN_MS, -} from "./profile-animations"; +import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations"; import "./profile-content.scss"; type SortOption = "playtime" | "achievementCount" | "playedRecently"; From cc95deb709c78d0d9621ca8ad37ea0280873381b Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 14:40:02 +0300 Subject: [PATCH 024/118] fix: proreply reseting user reviews on profile changing --- .../src/pages/profile/profile-content/profile-content.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index cd833c6c..9955640b 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -108,6 +108,14 @@ export function ProfileContent() { } }, [sortBy, getUserLibraryGames, userProfile]); + // Clear reviews state and reset tab when switching users + useEffect(() => { + setReviews([]); + setReviewsTotalCount(0); + setIsLoadingReviews(false); + setActiveTab("library"); + }, [userProfile?.id]); + useEffect(() => { if (userProfile?.id) { fetchUserReviews(); From 81a77411ccd325692395d79f188fee5f6d2911df Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 16:54:18 +0300 Subject: [PATCH 025/118] ci: fix gap between game image and game name in reviews --- .../src/pages/profile/profile-content/profile-content.scss | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss index 45a7f119..15b9de6d 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -230,27 +230,24 @@ .user-reviews__review-game { display: flex; - align-items: center; gap: calc(globals.$spacing-unit); } .user-reviews__game-icon { width: 24px; height: 24px; - border-radius: 8px; object-fit: cover; } .user-reviews__game-info { display: flex; flex-direction: column; - gap: calc(globals.$spacing-unit * 0.25); } .user-reviews__game-details { display: flex; align-items: center; - gap: calc(globals.$spacing-unit * 0.5); + gap: calc(globals.$spacing-unit * 0.75); } .user-reviews__game-title { From 29e1713824856a4f455dd06361214b5fc69786ab Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 23 Oct 2025 20:06:37 +0300 Subject: [PATCH 026/118] fix: upvote/downvote button arent being disabled after click --- .../profile-content/profile-content.tsx | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 9955640b..44af9c1d 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -181,7 +181,14 @@ export function ProfileContent() { setVotingReviews((prev) => new Set(prev).add(reviewId)); const review = reviews.find((r) => r.id === reviewId); - if (!review) return; + if (!review) { + setVotingReviews((prev) => { + const next = new Set(prev); + next.delete(reviewId); + return next; + }); + return; + } const wasUpvoted = review.hasUpvoted; const wasDownvoted = review.hasDownvoted; @@ -258,11 +265,13 @@ export function ProfileContent() { }) ); } finally { - setVotingReviews((prev) => { - const newSet = new Set(prev); - newSet.delete(reviewId); - return newSet; - }); + setTimeout(() => { + setVotingReviews((prev) => { + const newSet = new Set(prev); + newSet.delete(reviewId); + return newSet; + }); + }, 500); } }; @@ -536,6 +545,10 @@ export function ProfileContent() { handleVoteReview(review.id, true) } disabled={votingReviews.has(review.id)} + style={{ + opacity: votingReviews.has(review.id) ? 0.5 : 1, + cursor: votingReviews.has(review.id) ? "not-allowed" : "pointer", + }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} > @@ -559,6 +572,10 @@ export function ProfileContent() { handleVoteReview(review.id, false) } disabled={votingReviews.has(review.id)} + style={{ + opacity: votingReviews.has(review.id) ? 0.5 : 1, + cursor: votingReviews.has(review.id) ? "not-allowed" : "pointer", + }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} > @@ -628,6 +645,12 @@ export function ProfileContent() { sortBy, activeTab, + // ensure reviews UI updates correctly + reviews, + reviewsTotalCount, + isLoadingReviews, + votingReviews, + deleteModalVisible, ]); return ( From 40f7e6e2ad210d56bddd15b2b59828358f3919fb Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:47:54 -0300 Subject: [PATCH 027/118] chore: bump electron version to 35 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 342b078a..59497aad 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "@types/winreg": "^1.2.36", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.2.1", - "electron": "^33.4.11", + "electron": "^35.7.5", "electron-builder": "^26.0.12", "electron-vite": "^3.0.0", "eslint": "^8.56.0", From a388acf9481ac1dcdff964b7c24749d084ba6247 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:51:15 -0300 Subject: [PATCH 028/118] chore: update node version on gh actions --- .github/workflows/build-renderer.yml | 4 ++-- .github/workflows/build.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- yarn.lock | 19 +++++++++++++------ 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-renderer.yml b/.github/workflows/build-renderer.yml index 6aefac43..ed7a99ab 100644 --- a/.github/workflows/build-renderer.yml +++ b/.github/workflows/build-renderer.yml @@ -6,7 +6,7 @@ concurrency: on: push: - branches: main + branches: [main] jobs: build: @@ -19,7 +19,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.0 + node-version: 22.19.5 - name: Install dependencies run: yarn --frozen-lockfile --ignore-scripts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5062c7ad..32688379 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.3 + node-version: 22.19.5 - name: Install dependencies run: yarn --frozen-lockfile diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ac359364..6d08525c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.3 + node-version: 22.19.5 - name: Install dependencies run: yarn --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ceb42c7..a06eeb21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20.18.3 + node-version: 22.19.5 - name: Install dependencies run: yarn --frozen-lockfile diff --git a/yarn.lock b/yarn.lock index 0337a77b..5ffc3f03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3206,13 +3206,20 @@ dependencies: undici-types "~7.14.0" -"@types/node@^20.12.7", "@types/node@^20.9.0": +"@types/node@^20.12.7": version "20.19.21" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.21.tgz#6e5378e04993c40395473b13baf94a09875157b8" integrity sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA== dependencies: undici-types "~6.21.0" +"@types/node@^22.7.7": + version "22.18.12" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.18.12.tgz#e165d87bc25d7bf6d3657035c914db7485de84fb" + integrity sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog== + dependencies: + undici-types "~6.21.0" + "@types/parse-torrent-file@*": version "4.0.6" resolved "https://registry.yarnpkg.com/@types/parse-torrent-file/-/parse-torrent-file-4.0.6.tgz#11801dfd5b0a017302a164b72c8869f2bcba15b1" @@ -4651,13 +4658,13 @@ electron-vite@^3.0.0: magic-string "^0.30.17" picocolors "^1.1.1" -electron@^33.4.11: - version "33.4.11" - resolved "https://registry.yarnpkg.com/electron/-/electron-33.4.11.tgz#225d7f106ed3edf788ced318c63858d8b8a446dc" - integrity sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg== +electron@^35.7.5: + version "35.7.5" + resolved "https://registry.yarnpkg.com/electron/-/electron-35.7.5.tgz#294a4aebb2ad2a884de730c410f2358d061e8d53" + integrity sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw== dependencies: "@electron/get" "^2.0.0" - "@types/node" "^20.9.0" + "@types/node" "^22.7.7" extract-zip "^2.0.1" embla-carousel-autoplay@^8.6.0: From 29e822f2f110a3ae83d0f18862a37d5614112fbe Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Thu, 23 Oct 2025 17:56:45 -0300 Subject: [PATCH 029/118] fix: node version on gh actions files --- .github/workflows/build-renderer.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-renderer.yml b/.github/workflows/build-renderer.yml index ed7a99ab..f7361883 100644 --- a/.github/workflows/build-renderer.yml +++ b/.github/workflows/build-renderer.yml @@ -19,7 +19,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 22.19.5 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile --ignore-scripts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 32688379..86fce350 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 22.19.5 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6d08525c..89e8b59f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 22.19.5 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a06eeb21..11df9b9f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 22.19.5 + node-version: 22.21.0 - name: Install dependencies run: yarn --frozen-lockfile From 8de6c92d28ef63d26217d85a82880292d5d90c90 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 24 Oct 2025 08:19:55 +0300 Subject: [PATCH 030/118] ci: formatting --- .../profile/profile-content/profile-content.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 44af9c1d..e284cb88 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -546,8 +546,12 @@ export function ProfileContent() { } disabled={votingReviews.has(review.id)} style={{ - opacity: votingReviews.has(review.id) ? 0.5 : 1, - cursor: votingReviews.has(review.id) ? "not-allowed" : "pointer", + opacity: votingReviews.has(review.id) + ? 0.5 + : 1, + cursor: votingReviews.has(review.id) + ? "not-allowed" + : "pointer", }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} @@ -573,8 +577,12 @@ export function ProfileContent() { } disabled={votingReviews.has(review.id)} style={{ - opacity: votingReviews.has(review.id) ? 0.5 : 1, - cursor: votingReviews.has(review.id) ? "not-allowed" : "pointer", + opacity: votingReviews.has(review.id) + ? 0.5 + : 1, + cursor: votingReviews.has(review.id) + ? "not-allowed" + : "pointer", }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} From 11c19f5fe5bd49e7fb5560f65e383e98fb3000fa Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Fri, 24 Oct 2025 20:20:51 -0300 Subject: [PATCH 031/118] chore: downgrade to latest of 34 --- package.json | 2 +- src/renderer/src/pages/game-details/game-reviews.tsx | 4 ---- yarn.lock | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 59497aad..f74825a1 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "@types/winreg": "^1.2.36", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.2.1", - "electron": "^35.7.5", + "electron": "^34.5.8", "electron-builder": "^26.0.12", "electron-vite": "^3.0.0", "eslint": "^8.56.0", diff --git a/src/renderer/src/pages/game-details/game-reviews.tsx b/src/renderer/src/pages/game-details/game-reviews.tsx index f8117f43..1ce44550 100644 --- a/src/renderer/src/pages/game-details/game-reviews.tsx +++ b/src/renderer/src/pages/game-details/game-reviews.tsx @@ -144,8 +144,6 @@ export function GameReviews({ } }, [objectId, userDetailsId, shop, game, onUserReviewedChange]); - console.log("reviews", reviews); - const loadReviews = useCallback( async (reset = false) => { if (!objectId) return; @@ -440,8 +438,6 @@ export function GameReviews({ }); }, [reviews]); - console.log("reviews", reviews); - return (
{showReviewPrompt && diff --git a/yarn.lock b/yarn.lock index 5ffc3f03..d936ff61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4658,7 +4658,7 @@ electron-vite@^3.0.0: magic-string "^0.30.17" picocolors "^1.1.1" -electron@^35.7.5: +electron@^35.2.1: version "35.7.5" resolved "https://registry.yarnpkg.com/electron/-/electron-35.7.5.tgz#294a4aebb2ad2a884de730c410f2358d061e8d53" integrity sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw== From 4471bf0f8bc9ee7e27a48dbfc2c9a3be3299f6a7 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Fri, 24 Oct 2025 21:05:40 -0300 Subject: [PATCH 032/118] chore: bump to electron 37 --- package.json | 4 ++-- yarn.lock | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index f74825a1..b34425a0 100644 --- a/package.json +++ b/package.json @@ -116,9 +116,9 @@ "@types/winreg": "^1.2.36", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.2.1", - "electron": "^34.5.8", + "electron": "^37.7.1", "electron-builder": "^26.0.12", - "electron-vite": "^3.0.0", + "electron-vite": "^3.1.0", "eslint": "^8.56.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.4", diff --git a/yarn.lock b/yarn.lock index d936ff61..c362ada8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4646,7 +4646,7 @@ electron-updater@^6.6.2: semver "^7.6.3" tiny-typed-emitter "^2.1.0" -electron-vite@^3.0.0: +electron-vite@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/electron-vite/-/electron-vite-3.1.0.tgz#1784907a83d23c6c8093ec68b8e414a74d814385" integrity sha512-M7aAzaRvSl5VO+6KN4neJCYLHLpF/iWo5ztchI/+wMxIieDZQqpbCYfaEHHHPH6eupEzfvZdLYdPdmvGqoVe0Q== @@ -4658,10 +4658,10 @@ electron-vite@^3.0.0: magic-string "^0.30.17" picocolors "^1.1.1" -electron@^35.2.1: - version "35.7.5" - resolved "https://registry.yarnpkg.com/electron/-/electron-35.7.5.tgz#294a4aebb2ad2a884de730c410f2358d061e8d53" - integrity sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw== +electron@^37.7.1: + version "37.7.1" + resolved "https://registry.yarnpkg.com/electron/-/electron-37.7.1.tgz#7d771b3d3365b5458f8bc758385defee14387034" + integrity sha512-2EmIqWv4T8BtgFQosB3/0Fezs09X3l0wXhIzes/cNt/GI+UDljbQr3NiF2J9WnqP0aFSbUEfztGUQMiX+qDvsw== dependencies: "@electron/get" "^2.0.0" "@types/node" "^22.7.7" From ee35bc24b27c05458f888cbf2e477bdb80f7c1df Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Fri, 24 Oct 2025 21:11:56 -0300 Subject: [PATCH 033/118] chore: undo remove hydra api logs --- src/main/services/hydra-api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 07f81d68..dd26e6f0 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -29,7 +29,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 = false; + private static readonly ADD_LOG_INTERCEPTOR = true; private static secondsToMilliseconds(seconds: number) { return seconds * 1000; From 7f2343413efc80cb9ba894b77162b96ee1aa34dc Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 26 Oct 2025 17:26:25 +0200 Subject: [PATCH 034/118] feat: added manual page selection and changed functionality of pagination --- .../src/pages/catalogue/pagination.scss | 29 +++++ .../src/pages/catalogue/pagination.tsx | 112 ++++++++++++++---- 2 files changed, 121 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/pages/catalogue/pagination.scss b/src/renderer/src/pages/catalogue/pagination.scss index 141dfe54..cac10211 100644 --- a/src/renderer/src/pages/catalogue/pagination.scss +++ b/src/renderer/src/pages/catalogue/pagination.scss @@ -1,3 +1,5 @@ +@use "../../scss/globals.scss"; + .pagination { display: flex; gap: 4px; @@ -18,4 +20,31 @@ font-size: 16px; } } + + &__page-input { + box-sizing: border-box; + width: 40px; + min-width: 40px; + max-width: 40px; + min-height: 40px; + border-radius: 8px; + border: solid 1px globals.$border-color; + background-color: transparent; + color: globals.$muted-color; + text-align: center; + font-size: 12px; + padding: 0 6px; + outline: none; + } + + &__double-chevron { + display: flex; + align-items: center; + justify-content: center; + font-size: 0; // remove whitespace node width between SVGs + } + + &__double-chevron > svg + svg { + margin-left: -8px; // pull the second chevron closer + } } diff --git a/src/renderer/src/pages/catalogue/pagination.tsx b/src/renderer/src/pages/catalogue/pagination.tsx index dfae6164..4040c4b5 100644 --- a/src/renderer/src/pages/catalogue/pagination.tsx +++ b/src/renderer/src/pages/catalogue/pagination.tsx @@ -1,6 +1,7 @@ import { Button } from "@renderer/components/button/button"; import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react"; import { useFormat } from "@renderer/hooks/use-format"; +import { useEffect, useRef, useState } from "react"; import "./pagination.scss"; interface PaginationProps { @@ -16,6 +17,17 @@ export function Pagination({ }: PaginationProps) { const { formatNumber } = useFormat(); + const [isJumpOpen, setIsJumpOpen] = useState(false); + const [jumpValue, setJumpValue] = useState(""); + const jumpInputRef = useRef(null); + + useEffect(() => { + if (isJumpOpen) { + setJumpValue(""); + setTimeout(() => jumpInputRef.current?.focus(), 0); + } + }, [isJumpOpen, page]); + if (totalPages <= 1) return null; const visiblePages = 3; @@ -30,6 +42,19 @@ export function Pagination({ return (
+ {startPage > 1 && ( + + )} + - {page > 2 && ( - <> - - -
- ... -
- - )} - {Array.from( { length: endPage - startPage + 1 }, (_, i) => startPage + i @@ -72,9 +80,60 @@ export function Pagination({ {page < totalPages - 1 && ( <> -
- ... -
+ {isJumpOpen ? ( + { + const val = e.target.value; + if (val === "") { + setJumpValue(""); + return; + } + const num = Number(val); + if (Number.isNaN(num)) { + return; + } + if (num < 1) { + setJumpValue("1"); + return; + } + if (num > totalPages) { + setJumpValue(String(totalPages)); + return; + } + setJumpValue(val); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + if (jumpValue.trim() === "") return; + const parsed = Number(jumpValue); + if (Number.isNaN(parsed)) return; + const target = Math.max(1, Math.min(totalPages, parsed)); + onPageChange(target); + setIsJumpOpen(false); + } else if (e.key === "Escape") { + setIsJumpOpen(false); + } + }} + onBlur={() => { + setIsJumpOpen(false); + }} + aria-label="Go to page" + /> + ) : ( + + )} + + {endPage < totalPages && ( + + )}
); } From cb3e52de34be44bf731aa2f5a04b477d7454d5c8 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 26 Oct 2025 19:37:57 +0200 Subject: [PATCH 035/118] fix: go to page button did not appear correctly for the last pages --- .../src/pages/catalogue/pagination.tsx | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/catalogue/pagination.tsx b/src/renderer/src/pages/catalogue/pagination.tsx index 4040c4b5..eaaa97a8 100644 --- a/src/renderer/src/pages/catalogue/pagination.tsx +++ b/src/renderer/src/pages/catalogue/pagination.tsx @@ -31,11 +31,15 @@ export function Pagination({ if (totalPages <= 1) return null; const visiblePages = 3; + const isLastThree = totalPages > 3 && page >= totalPages - 2; let startPage = Math.max(1, page - 1); let endPage = startPage + visiblePages - 1; - if (endPage > totalPages) { + if (isLastThree) { + startPage = Math.max(1, totalPages - 2); + endPage = totalPages; + } else if (endPage > totalPages) { endPage = totalPages; startPage = Math.max(1, endPage - visiblePages + 1); } @@ -64,6 +68,72 @@ export function Pagination({ + {isLastThree && startPage > 1 && ( + <> + + {isJumpOpen ? ( + { + const val = e.target.value; + if (val === "") { + setJumpValue(""); + return; + } + const num = Number(val); + if (Number.isNaN(num)) { + return; + } + if (num < 1) { + setJumpValue("1"); + return; + } + if (num > totalPages) { + setJumpValue(String(totalPages)); + return; + } + setJumpValue(val); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + if (jumpValue.trim() === "") return; + const parsed = Number(jumpValue); + if (Number.isNaN(parsed)) return; + const target = Math.max(1, Math.min(totalPages, parsed)); + onPageChange(target); + setIsJumpOpen(false); + } else if (e.key === "Escape") { + setIsJumpOpen(false); + } + }} + onBlur={() => { + setIsJumpOpen(false); + }} + aria-label="Go to page" + /> + ) : ( + + )} + + )} + {Array.from( { length: endPage - startPage + 1 }, (_, i) => startPage + i @@ -78,7 +148,7 @@ export function Pagination({ ))} - {page < totalPages - 1 && ( + {!isLastThree && page < totalPages - 1 && ( <> {isJumpOpen ? ( Date: Sun, 26 Oct 2025 19:49:15 +0200 Subject: [PATCH 036/118] fix: duplications --- .../src/pages/catalogue/pagination.tsx | 171 +++++++----------- 1 file changed, 63 insertions(+), 108 deletions(-) diff --git a/src/renderer/src/pages/catalogue/pagination.tsx b/src/renderer/src/pages/catalogue/pagination.tsx index eaaa97a8..1ba02d06 100644 --- a/src/renderer/src/pages/catalogue/pagination.tsx +++ b/src/renderer/src/pages/catalogue/pagination.tsx @@ -2,6 +2,7 @@ import { Button } from "@renderer/components/button/button"; import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react"; import { useFormat } from "@renderer/hooks/use-format"; import { useEffect, useRef, useState } from "react"; +import type { ChangeEvent, KeyboardEvent } from "react"; import "./pagination.scss"; interface PaginationProps { @@ -44,6 +45,66 @@ export function Pagination({ startPage = Math.max(1, endPage - visiblePages + 1); } + const onJumpChange = (e: ChangeEvent) => { + const val = e.target.value; + if (val === "") { + setJumpValue(""); + return; + } + const num = Number(val); + if (Number.isNaN(num)) { + return; + } + if (num < 1) { + setJumpValue("1"); + return; + } + if (num > totalPages) { + setJumpValue(String(totalPages)); + return; + } + setJumpValue(val); + }; + + const onJumpKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + if (jumpValue.trim() === "") return; + const parsed = Number(jumpValue); + if (Number.isNaN(parsed)) return; + const target = Math.max(1, Math.min(totalPages, parsed)); + onPageChange(target); + setIsJumpOpen(false); + } else if (e.key === "Escape") { + setIsJumpOpen(false); + } + }; + + const JumpControl = () => + isJumpOpen ? ( + { + setIsJumpOpen(false); + }} + aria-label="Go to page" + /> + ) : ( + + ); + return (
{startPage > 1 && ( @@ -77,60 +138,7 @@ export function Pagination({ > {formatNumber(1)} - {isJumpOpen ? ( - { - const val = e.target.value; - if (val === "") { - setJumpValue(""); - return; - } - const num = Number(val); - if (Number.isNaN(num)) { - return; - } - if (num < 1) { - setJumpValue("1"); - return; - } - if (num > totalPages) { - setJumpValue(String(totalPages)); - return; - } - setJumpValue(val); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - if (jumpValue.trim() === "") return; - const parsed = Number(jumpValue); - if (Number.isNaN(parsed)) return; - const target = Math.max(1, Math.min(totalPages, parsed)); - onPageChange(target); - setIsJumpOpen(false); - } else if (e.key === "Escape") { - setIsJumpOpen(false); - } - }} - onBlur={() => { - setIsJumpOpen(false); - }} - aria-label="Go to page" - /> - ) : ( - - )} + )} @@ -150,60 +158,7 @@ export function Pagination({ {!isLastThree && page < totalPages - 1 && ( <> - {isJumpOpen ? ( - { - const val = e.target.value; - if (val === "") { - setJumpValue(""); - return; - } - const num = Number(val); - if (Number.isNaN(num)) { - return; - } - if (num < 1) { - setJumpValue("1"); - return; - } - if (num > totalPages) { - setJumpValue(String(totalPages)); - return; - } - setJumpValue(val); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - if (jumpValue.trim() === "") return; - const parsed = Number(jumpValue); - if (Number.isNaN(parsed)) return; - const target = Math.max(1, Math.min(totalPages, parsed)); - onPageChange(target); - setIsJumpOpen(false); - } else if (e.key === "Escape") { - setIsJumpOpen(false); - } - }} - onBlur={() => { - setIsJumpOpen(false); - }} - aria-label="Go to page" - /> - ) : ( - - )} + + ); +} + interface PaginationProps { page: number; totalPages: number; @@ -79,32 +120,6 @@ export function Pagination({ } }; - const JumpControl = () => - isJumpOpen ? ( - { - setIsJumpOpen(false); - }} - aria-label="Go to page" - /> - ) : ( - - ); - return (
{startPage > 1 && ( @@ -138,7 +153,16 @@ export function Pagination({ > {formatNumber(1)} - + setIsJumpOpen(true)} + onClose={() => setIsJumpOpen(false)} + onChange={onJumpChange} + onKeyDown={onJumpKeyDown} + /> )} @@ -158,7 +182,16 @@ export function Pagination({ {!isLastThree && page < totalPages - 1 && ( <> - + setIsJumpOpen(true)} + onClose={() => setIsJumpOpen(false)} + onChange={onJumpChange} + onKeyDown={onJumpKeyDown} + />
diff --git a/src/renderer/src/pages/settings/settings-real-debrid.tsx b/src/renderer/src/pages/settings/settings-real-debrid.tsx index 42ba6ad9..db3a29a3 100644 --- a/src/renderer/src/pages/settings/settings-real-debrid.tsx +++ b/src/renderer/src/pages/settings/settings-real-debrid.tsx @@ -133,7 +133,7 @@ export function SettingsRealDebrid() { {t("save_changes")} } - placeholder="API Token" + placeholder={t("api_token")} hint={ diff --git a/src/renderer/src/pages/settings/settings-torbox.tsx b/src/renderer/src/pages/settings/settings-torbox.tsx index 610dc942..46c8e2f9 100644 --- a/src/renderer/src/pages/settings/settings-torbox.tsx +++ b/src/renderer/src/pages/settings/settings-torbox.tsx @@ -116,7 +116,7 @@ export function SettingsTorBox() { onChange={(event) => setForm({ ...form, torBoxApiToken: event.target.value }) } - placeholder="API Token" + placeholder={t("api_token")} rightContent={ -
- - {formatDistance(new Date(review.createdAt), new Date(), { - addSuffix: true, - })} +
+
+ + + {review.score}/5 + +
+ {review.playTimeInSeconds && review.playTimeInSeconds > 0 && ( +
+ + + {t("played_for")} {formatPlayTime(review.playTimeInSeconds)} + +
+ )}
-
- {[1, 2, 3, 4, 5].map((starValue) => ( - - ))} +
+
+ + {formatDistance(new Date(review.createdAt), new Date(), { + addSuffix: true, + })} +
diff --git a/src/types/index.ts b/src/types/index.ts index 63b18645..38abdbb8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -255,6 +255,7 @@ export interface GameReview { isBlocked: boolean; hasUpvoted: boolean; hasDownvoted: boolean; + playTimeInSeconds?: number; user: { id: string; displayName: string; From 9e09a5decb7ed2556b9b881fc4d8f6c9a72c1a92 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Mon, 27 Oct 2025 19:28:29 +0200 Subject: [PATCH 050/118] fix: translation key fix and formatting --- src/locales/en/translation.json | 2 +- .../src/pages/game-details/review-item.tsx | 27 +++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index e578b251..cef82a4b 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -223,7 +223,7 @@ "show_more": "Show more", "show_less": "Show less", "reviews": "Reviews", - "played_for": "Played for", + "review_played_for": "Played for", "leave_a_review": "Leave a Review", "write_review_placeholder": "Share your thoughts about this game...", "sort_newest": "Newest", diff --git a/src/renderer/src/pages/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx index d6b9fd9c..8ffe42c0 100644 --- a/src/renderer/src/pages/game-details/review-item.tsx +++ b/src/renderer/src/pages/game-details/review-item.tsx @@ -30,8 +30,6 @@ interface ReviewItemProps { ) => void; } - - const getRatingText = (score: number, t: (key: string) => string): string => { switch (score) { case 1: @@ -157,22 +155,23 @@ export function ReviewItem({
- - - {review.score}/5 - -
+ className="game-details__review-score-stars" + title={getRatingText(review.score, t)} + > + + + {review.score}/5 + +
{review.playTimeInSeconds && review.playTimeInSeconds > 0 && (
- {t("played_for")} {formatPlayTime(review.playTimeInSeconds)} + {t("review_played_for")}{" "} + {formatPlayTime(review.playTimeInSeconds)}
)} From b431ed479c84aa9406ae2fb581fe0cbfe880733e Mon Sep 17 00:00:00 2001 From: Moyasee Date: Mon, 27 Oct 2025 20:07:08 +0200 Subject: [PATCH 051/118] fix: converted conditional to boolean --- src/renderer/src/pages/game-details/review-item.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx index 8ffe42c0..15b12fbe 100644 --- a/src/renderer/src/pages/game-details/review-item.tsx +++ b/src/renderer/src/pages/game-details/review-item.tsx @@ -166,12 +166,14 @@ export function ReviewItem({ {review.score}/5
- {review.playTimeInSeconds && review.playTimeInSeconds > 0 && ( + {Boolean( + review.playTimeInSeconds && review.playTimeInSeconds > 0 + ) && (
{t("review_played_for")}{" "} - {formatPlayTime(review.playTimeInSeconds)} + {formatPlayTime(review.playTimeInSeconds!)}
)} From 5c770bc7e7eed580485b8e68d423f21e9df7d3bb Mon Sep 17 00:00:00 2001 From: Moyasee Date: Mon, 27 Oct 2025 20:12:24 +0200 Subject: [PATCH 052/118] fix: unnecessary assertion --- src/renderer/src/pages/game-details/review-item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/pages/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx index 15b12fbe..82d7128c 100644 --- a/src/renderer/src/pages/game-details/review-item.tsx +++ b/src/renderer/src/pages/game-details/review-item.tsx @@ -173,7 +173,7 @@ export function ReviewItem({ {t("review_played_for")}{" "} - {formatPlayTime(review.playTimeInSeconds!)} + {formatPlayTime(review.playTimeInSeconds || 0)}
)} From ddd6af0d4c945a3b0907ce94645616a324e2e6f8 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:22:47 -0300 Subject: [PATCH 053/118] fix: add theme editor dev tools back --- src/main/services/window-manager.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 7055fc09..178eb8de 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -462,6 +462,7 @@ export class WindowManager { editorWindow.once("ready-to-show", () => { editorWindow.show(); + this.mainWindow?.webContents.openDevTools(); if (!app.isPackaged || isStaging) { editorWindow.webContents.openDevTools(); } @@ -474,6 +475,7 @@ export class WindowManager { }); editorWindow.on("close", () => { + this.mainWindow?.webContents.closeDevTools(); this.editorWindows.delete(themeId); }); } From 61072aa02a1a36702e03687a34e3864ae53cee72 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:25:18 -0300 Subject: [PATCH 054/118] fix: add theme editor dev tools back --- 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 178eb8de..118ff98b 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -470,7 +470,7 @@ export class WindowManager { editorWindow.webContents.on("before-input-event", (_event, input) => { if (input.key === "F12") { - editorWindow.webContents.toggleDevTools(); + this.mainWindow?.webContents.toggleDevTools(); } }); From 1123aaa65ea49bea512a3eef23210ce8389eddb2 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Tue, 28 Oct 2025 06:48:42 -0300 Subject: [PATCH 055/118] chore: remove zod dep --- package.json | 3 +-- yarn.lock | 5 ----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/package.json b/package.json index 08c1d80e..9ed25fa9 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,7 @@ "winreg": "^1.2.5", "ws": "^8.18.1", "yaml": "^2.6.1", - "yup": "^1.5.0", - "zod": "^3.24.1" + "yup": "^1.5.0" }, "devDependencies": { "@aws-sdk/client-s3": "^3.705.0", diff --git a/yarn.lock b/yarn.lock index 6340a43f..6fb80492 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9190,8 +9190,3 @@ yup@^1.5.0: tiny-case "^1.0.3" toposort "^2.0.2" type-fest "^2.19.0" - -zod@^3.24.1: - version "3.25.76" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" - integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== From 120aad6c1cacc2406aad9def4baa63b89d69eef8 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 28 Oct 2025 17:34:20 +0200 Subject: [PATCH 056/118] feat: Hide to tray on game startup and ability to disable trailers auto-play --- src/locales/en/translation.json | 4 +++- src/main/services/process-watcher.ts | 15 +++++++++++- .../gallery-slider/gallery-slider.tsx | 7 +++++- .../src/pages/settings/settings-behavior.tsx | 24 +++++++++++++++++++ src/types/level.types.ts | 2 ++ 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 46bdb28c..b17dda80 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -541,7 +541,9 @@ "hidden": "Hidden", "test_notification": "Test notification", "notification_preview": "Achievement Notification Preview", - "enable_friend_start_game_notifications": "When a friend starts playing a game" + "enable_friend_start_game_notifications": "When a friend starts playing a game", + "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", + "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup" }, "notifications": { "download_complete": "Download complete", diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 06f5f7d8..9ca40b24 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -4,7 +4,8 @@ import type { Game, GameRunning } from "@types"; import { PythonRPC } from "./python-rpc"; import axios from "axios"; import { ProcessPayload } from "./download/types"; -import { gamesSublevel, levelKeys } from "@main/level"; +import { db, gamesSublevel, levelKeys } from "@main/level"; +import type { UserPreferences } from "@types"; import { CloudSync } from "./cloud-sync"; import { logger } from "./logger"; import path from "path"; @@ -209,6 +210,18 @@ function onOpenGame(game: Game) { lastSyncTick: now, }); + // Hide Hydra to tray on game startup if enabled + db + .get(levelKeys.userPreferences, { + valueEncoding: "json", + }) + .then((userPreferences) => { + if (userPreferences?.hideToTrayOnGameStart) { + WindowManager.mainWindow?.hide(); + } + }) + .catch(() => {}); + if (game.remoteId) { updateGamePlaytime( game, 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 4bf8dc48..c9658636 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 @@ -7,11 +7,16 @@ import { } from "@primer/octicons-react"; import useEmblaCarousel from "embla-carousel-react"; import { gameDetailsContext } from "@renderer/context"; +import { useAppSelector } from "@renderer/hooks"; import "./gallery-slider.scss"; export function GallerySlider() { const { shopDetails } = useContext(gameDetailsContext); const { t } = useTranslation("game_details"); + const userPreferences = useAppSelector( + (state) => state.userPreferences.value + ); + const autoplayEnabled = userPreferences?.autoplayGameTrailers !== false; const hasScreenshots = shopDetails && shopDetails.screenshots?.length; @@ -164,7 +169,7 @@ export function GallerySlider() { poster={item.poster} loop muted - autoPlay + autoPlay={autoplayEnabled} tabIndex={-1} > diff --git a/src/renderer/src/pages/settings/settings-behavior.tsx b/src/renderer/src/pages/settings/settings-behavior.tsx index 64df52d7..bc91fc9d 100644 --- a/src/renderer/src/pages/settings/settings-behavior.tsx +++ b/src/renderer/src/pages/settings/settings-behavior.tsx @@ -27,6 +27,8 @@ export function SettingsBehavior() { showDownloadSpeedInMegabytes: false, extractFilesByDefault: true, enableSteamAchievements: false, + autoplayGameTrailers: true, + hideToTrayOnGameStart: false, }); const { t } = useTranslation("settings"); @@ -49,6 +51,10 @@ export function SettingsBehavior() { extractFilesByDefault: userPreferences.extractFilesByDefault ?? true, enableSteamAchievements: userPreferences.enableSteamAchievements ?? false, + autoplayGameTrailers: + userPreferences.autoplayGameTrailers ?? true, + hideToTrayOnGameStart: + userPreferences.hideToTrayOnGameStart ?? false, }); } }, [userPreferences]); @@ -76,6 +82,16 @@ export function SettingsBehavior() { } /> + + handleChange({ + hideToTrayOnGameStart: !form.hideToTrayOnGameStart, + }) + } + /> + {showRunAtStartup && ( )} + + handleChange({ autoplayGameTrailers: !form.autoplayGameTrailers }) + } + /> + Date: Tue, 28 Oct 2025 17:36:11 +0200 Subject: [PATCH 057/118] ci: formatting --- src/main/services/process-watcher.ts | 7 +++---- src/renderer/src/pages/settings/settings-behavior.tsx | 6 ++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 9ca40b24..4130daba 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -211,10 +211,9 @@ function onOpenGame(game: Game) { }); // Hide Hydra to tray on game startup if enabled - db - .get(levelKeys.userPreferences, { - valueEncoding: "json", - }) + db.get(levelKeys.userPreferences, { + valueEncoding: "json", + }) .then((userPreferences) => { if (userPreferences?.hideToTrayOnGameStart) { WindowManager.mainWindow?.hide(); diff --git a/src/renderer/src/pages/settings/settings-behavior.tsx b/src/renderer/src/pages/settings/settings-behavior.tsx index bc91fc9d..c5698ef7 100644 --- a/src/renderer/src/pages/settings/settings-behavior.tsx +++ b/src/renderer/src/pages/settings/settings-behavior.tsx @@ -51,10 +51,8 @@ export function SettingsBehavior() { extractFilesByDefault: userPreferences.extractFilesByDefault ?? true, enableSteamAchievements: userPreferences.enableSteamAchievements ?? false, - autoplayGameTrailers: - userPreferences.autoplayGameTrailers ?? true, - hideToTrayOnGameStart: - userPreferences.hideToTrayOnGameStart ?? false, + autoplayGameTrailers: userPreferences.autoplayGameTrailers ?? true, + hideToTrayOnGameStart: userPreferences.hideToTrayOnGameStart ?? false, }); } }, [userPreferences]); From dbf5d7afc76bdfb7620c2597c744453a89cf40ad Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 28 Oct 2025 17:43:19 +0200 Subject: [PATCH 058/118] fix: multiple imports --- src/main/services/process-watcher.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 4130daba..6408c30d 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -1,11 +1,10 @@ import { WindowManager } from "./window-manager"; import { createGame, updateGamePlaytime } from "./library-sync"; -import type { Game, GameRunning } from "@types"; +import type { Game, GameRunning, UserPreferences } from "@types"; import { PythonRPC } from "./python-rpc"; import axios from "axios"; import { ProcessPayload } from "./download/types"; import { db, gamesSublevel, levelKeys } from "@main/level"; -import type { UserPreferences } from "@types"; import { CloudSync } from "./cloud-sync"; import { logger } from "./logger"; import path from "path"; From 6b96c99bb177f18dc8a54834acea21cfc4c0c495 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 28 Oct 2025 21:37:28 +0000 Subject: [PATCH 059/118] ci: fixing release pipeline --- .github/workflows/release.yml | 6 +++ src/locales/en/translation.json | 1 + src/locales/pt-BR/translation.json | 3 ++ .../download-sources/add-download-source.ts | 9 +++- .../remove-download-source.ts | 2 +- src/main/main.ts | 7 +++- src/main/services/hydra-api.ts | 5 ++- src/main/services/index.ts | 1 + src/main/services/user/index.ts | 3 ++ .../services/user/sync-download-sources.ts | 42 +++++++++++++++++++ .../settings/add-download-source-modal.tsx | 6 ++- 11 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 src/main/services/user/index.ts create mode 100644 src/main/services/user/sync-download-sources.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ceb42c7..9524c4b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,12 @@ jobs: - name: Build with cx_Freeze run: python python_rpc/setup.py build + - name: Copy OpenSSL DLLs + if: matrix.os == 'windows-2022' + run: | + cp hydra-python-rpc/lib/libcrypto-1_1.dll hydra-python-rpc/lib/libcrypto-1_1-x64.dll + cp hydra-python-rpc/lib/libssl-1_1.dll hydra-python-rpc/lib/libssl-1_1-x64.dll + - name: Build Linux if: matrix.os == 'ubuntu-latest' run: | diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index bfc4a379..3977c27d 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -430,6 +430,7 @@ "add_download_source": "Add source", "adding": "Adding…", "failed_add_download_source": "Failed to add download source. Please try again.", + "download_source_already_exists": "This download source URL already exists.", "download_count_zero": "No download options", "download_count_one": "{{countFormatted}} download option", "download_count_other": "{{countFormatted}} download options", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 968483a6..c9e908ac 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -416,6 +416,9 @@ "validate_download_source": "Validar", "remove_download_source": "Remover", "add_download_source": "Adicionar fonte", + "adding": "Adicionando…", + "failed_add_download_source": "Falha ao adicionar fonte de download. Tente novamente.", + "download_source_already_exists": "Esta URL de fonte de download já existe.", "download_count_zero": "Sem downloads na lista", "download_count_one": "{{countFormatted}} download na lista", "download_count_other": "{{countFormatted}} downloads na lista", diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts index d4e65ef3..ee426a82 100644 --- a/src/main/events/download-sources/add-download-source.ts +++ b/src/main/events/download-sources/add-download-source.ts @@ -9,6 +9,13 @@ const addDownloadSource = async ( url: string ) => { try { + const existingSources = await downloadSourcesSublevel.values().all(); + const urlExists = existingSources.some((source) => source.url === url); + + if (urlExists) { + throw new Error("Download source with this URL already exists"); + } + const downloadSource = await HydraApi.post( "/download-sources", { @@ -17,7 +24,7 @@ const addDownloadSource = async ( { needsAuth: false } ); - if (HydraApi.isLoggedIn()) { + if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) { try { await HydraApi.post("/profile/download-sources", { urls: [url], diff --git a/src/main/events/download-sources/remove-download-source.ts b/src/main/events/download-sources/remove-download-source.ts index 8efe0072..9caeaba5 100644 --- a/src/main/events/download-sources/remove-download-source.ts +++ b/src/main/events/download-sources/remove-download-source.ts @@ -13,7 +13,7 @@ const removeDownloadSource = async ( if (downloadSourceId) params.set("downloadSourceId", downloadSourceId); - if (HydraApi.isLoggedIn()) { + if (HydraApi.isLoggedIn() && HydraApi.hasActiveSubscription()) { void HydraApi.delete(`/profile/download-sources?${params.toString()}`); } diff --git a/src/main/main.ts b/src/main/main.ts index 6e477a18..f2440b9f 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -50,9 +50,14 @@ export const loadState = async () => { DeckyPlugin.checkAndUpdateIfOutdated(); } - await HydraApi.setupApi().then(() => { + await HydraApi.setupApi().then(async () => { uploadGamesBatch(); void migrateDownloadSources(); + + const { syncDownloadSourcesFromApi } = await import( + "./services/user" + ); + void syncDownloadSourcesFromApi(); // WSClient.connect(); }); diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index ffc5756c..12090df3 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -46,7 +46,7 @@ export class HydraApi { return this.userAuth.authToken !== ""; } - private static hasActiveSubscription() { + public static hasActiveSubscription() { const expiresAt = new Date(this.userAuth.subscription?.expiresAt ?? 0); return expiresAt > new Date(); } @@ -105,6 +105,9 @@ export class HydraApi { // WSClient.close(); // WSClient.connect(); + + const { syncDownloadSourcesFromApi } = await import("./user"); + syncDownloadSourcesFromApi(); } } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 88b39d1b..da4e6848 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -18,3 +18,4 @@ export * from "./library-sync"; export * from "./wine"; export * from "./lock"; export * from "./decky-plugin"; +export * from "./user"; diff --git a/src/main/services/user/index.ts b/src/main/services/user/index.ts new file mode 100644 index 00000000..b5001f7a --- /dev/null +++ b/src/main/services/user/index.ts @@ -0,0 +1,3 @@ +export * from "./get-user-data"; +export * from "./sync-download-sources"; + diff --git a/src/main/services/user/sync-download-sources.ts b/src/main/services/user/sync-download-sources.ts new file mode 100644 index 00000000..c5695d68 --- /dev/null +++ b/src/main/services/user/sync-download-sources.ts @@ -0,0 +1,42 @@ +import { HydraApi, logger } from "../"; +import { downloadSourcesSublevel } from "@main/level"; +import type { DownloadSource } from "@types"; + +export const syncDownloadSourcesFromApi = async () => { + if (!HydraApi.isLoggedIn() || !HydraApi.hasActiveSubscription()) { + return; + } + + try { + const profileSources = await HydraApi.get( + "/profile/download-sources" + ); + + const existingSources = await downloadSourcesSublevel.values().all(); + const existingUrls = new Set(existingSources.map((source) => source.url)); + + for (const downloadSource of profileSources) { + if (!existingUrls.has(downloadSource.url)) { + try { + await downloadSourcesSublevel.put(downloadSource.id, { + ...downloadSource, + isRemote: true, + createdAt: new Date().toISOString(), + }); + + logger.log( + `Synced download source from profile: ${downloadSource.url}` + ); + } catch (error) { + logger.error( + `Failed to sync download source ${downloadSource.url}:`, + error + ); + } + } + } + } catch (error) { + logger.error("Failed to sync download sources from API:", error); + } +}; + diff --git a/src/renderer/src/pages/settings/add-download-source-modal.tsx b/src/renderer/src/pages/settings/add-download-source-modal.tsx index d7071391..d96c67a5 100644 --- a/src/renderer/src/pages/settings/add-download-source-modal.tsx +++ b/src/renderer/src/pages/settings/add-download-source-modal.tsx @@ -57,9 +57,13 @@ export function AddDownloadSourceModal({ onAddDownloadSource(); } catch (error) { logger.error("Failed to add download source:", error); + const errorMessage = error instanceof Error && error.message.includes("already exists") + ? t("download_source_already_exists") + : t("failed_add_download_source"); + setError("url", { type: "server", - message: t("failed_add_download_source"), + message: errorMessage, }); } finally { setIsLoading(false); From a11b3e887796966b64e2ec73786f27d9c5a2e741 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 28 Oct 2025 21:38:07 +0000 Subject: [PATCH 060/118] ci: fixing release pipeline --- src/main/events/download-sources/add-download-source.ts | 2 +- src/main/main.ts | 6 ++---- src/main/services/user/index.ts | 1 - src/main/services/user/sync-download-sources.ts | 1 - .../src/pages/settings/add-download-source-modal.tsx | 9 +++++---- 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/main/events/download-sources/add-download-source.ts b/src/main/events/download-sources/add-download-source.ts index ee426a82..bea009cb 100644 --- a/src/main/events/download-sources/add-download-source.ts +++ b/src/main/events/download-sources/add-download-source.ts @@ -11,7 +11,7 @@ const addDownloadSource = async ( try { const existingSources = await downloadSourcesSublevel.values().all(); const urlExists = existingSources.some((source) => source.url === url); - + if (urlExists) { throw new Error("Download source with this URL already exists"); } diff --git a/src/main/main.ts b/src/main/main.ts index f2440b9f..ffb8f8a9 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -53,10 +53,8 @@ export const loadState = async () => { await HydraApi.setupApi().then(async () => { uploadGamesBatch(); void migrateDownloadSources(); - - const { syncDownloadSourcesFromApi } = await import( - "./services/user" - ); + + const { syncDownloadSourcesFromApi } = await import("./services/user"); void syncDownloadSourcesFromApi(); // WSClient.connect(); }); diff --git a/src/main/services/user/index.ts b/src/main/services/user/index.ts index b5001f7a..b1d8c9b7 100644 --- a/src/main/services/user/index.ts +++ b/src/main/services/user/index.ts @@ -1,3 +1,2 @@ export * from "./get-user-data"; export * from "./sync-download-sources"; - diff --git a/src/main/services/user/sync-download-sources.ts b/src/main/services/user/sync-download-sources.ts index c5695d68..ff9819ce 100644 --- a/src/main/services/user/sync-download-sources.ts +++ b/src/main/services/user/sync-download-sources.ts @@ -39,4 +39,3 @@ export const syncDownloadSourcesFromApi = async () => { logger.error("Failed to sync download sources from API:", error); } }; - diff --git a/src/renderer/src/pages/settings/add-download-source-modal.tsx b/src/renderer/src/pages/settings/add-download-source-modal.tsx index d96c67a5..af6f8b4d 100644 --- a/src/renderer/src/pages/settings/add-download-source-modal.tsx +++ b/src/renderer/src/pages/settings/add-download-source-modal.tsx @@ -57,10 +57,11 @@ export function AddDownloadSourceModal({ onAddDownloadSource(); } catch (error) { logger.error("Failed to add download source:", error); - const errorMessage = error instanceof Error && error.message.includes("already exists") - ? t("download_source_already_exists") - : t("failed_add_download_source"); - + const errorMessage = + error instanceof Error && error.message.includes("already exists") + ? t("download_source_already_exists") + : t("failed_add_download_source"); + setError("url", { type: "server", message: errorMessage, From ce0619bbe398b6e03e61641d3ecfae6d41d5bbca Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 28 Oct 2025 22:40:06 +0000 Subject: [PATCH 061/118] ci: adding releases --- .github/workflows/build-renderer.yml | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-renderer.yml b/.github/workflows/build-renderer.yml index f7361883..0d219c1e 100644 --- a/.github/workflows/build-renderer.yml +++ b/.github/workflows/build-renderer.yml @@ -6,23 +6,38 @@ concurrency: on: push: - branches: [main] + branches: + - main + - release/** jobs: build: runs-on: ubuntu-latest + permissions: + contents: read + + env: + NODE_OPTIONS: --max-old-space-size=4096 + BRANCH_NAME: ${{ github.ref_name }} + steps: - name: Check out Git repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Install Node.js + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22.21.0 + cache: 'yarn' + + - name: Enable Corepack (Yarn) + run: corepack enable - name: Install dependencies - run: yarn --frozen-lockfile --ignore-scripts + run: yarn install --frozen-lockfile --ignore-scripts - name: Build Renderer run: yarn build @@ -36,5 +51,5 @@ jobs: run: | npx --yes wrangler@3 pages deploy out/renderer \ --project-name="hydra" \ - --commit-dirty=true \ - --branch="main" + --branch "$BRANCH_NAME" \ + --commit-dirty \ No newline at end of file From dc8a19e8451e8e8466fe1453cb64d1fb9452f7a1 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 28 Oct 2025 23:02:40 +0000 Subject: [PATCH 062/118] ci: adding ci vars --- .env.example | 1 + .github/workflows/build-renderer.yml | 4 ++-- .github/workflows/release.yml | 8 +++++--- src/main/services/window-manager.ts | 13 +++++++++---- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 3f914eb3..051d8aa3 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,4 @@ MAIN_VITE_AUTH_URL= MAIN_VITE_WS_URL= RENDERER_VITE_REAL_DEBRID_REFERRAL_ID= RENDERER_VITE_TORBOX_REFERRAL_CODE= +MAIN_VITE_LAUNCHER_SUBDOMAIN= diff --git a/.github/workflows/build-renderer.yml b/.github/workflows/build-renderer.yml index 0d219c1e..2904bcb8 100644 --- a/.github/workflows/build-renderer.yml +++ b/.github/workflows/build-renderer.yml @@ -31,7 +31,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 22.21.0 - cache: 'yarn' + cache: "yarn" - name: Enable Corepack (Yarn) run: corepack enable @@ -52,4 +52,4 @@ jobs: npx --yes wrangler@3 pages deploy out/renderer \ --project-name="hydra" \ --branch "$BRANCH_NAME" \ - --commit-dirty \ No newline at end of file + --commit-dirty diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75ff209a..7408665f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,9 @@ concurrency: on: push: - branches: [main] + branches: + - main + - release/** jobs: build: @@ -61,7 +63,7 @@ jobs: RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }} RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }} - MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }} + MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }} - name: Build Windows if: matrix.os == 'windows-2022' @@ -78,7 +80,7 @@ jobs: RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }} RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }} - MAIN_VITE_RENDERER_URL: ${{ vars.MAIN_VITE_RENDERER_URL }} + MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }} - name: Create artifact uses: actions/upload-artifact@v4 diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 7055fc09..4c52b581 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -24,7 +24,8 @@ import type { UserPreferences, } from "@types"; import { AuthPage, generateAchievementCustomNotificationTest } from "@shared"; -import { isStaging } from "@main/constants"; +import { appVersion, isStaging } from "@main/constants"; +import { logger } from "./logger"; export class WindowManager { public static mainWindow: Electron.BrowserWindow | null = null; @@ -54,6 +55,10 @@ export class WindowManager { show: false, }; + private static formatVersionNumber(version: string) { + return version.replaceAll(".", "-"); + } + private static async loadWindowURL(window: BrowserWindow, hash: string = "") { // HMR for renderer base on electron-vite cli. // Load the remote URL for development or the local html file for production. @@ -63,12 +68,12 @@ export class WindowManager { // Try to load from remote URL in production try { await window.loadURL( - `${import.meta.env.MAIN_VITE_RENDERER_URL}#/${hash}` + `https://release-${this.formatVersionNumber(appVersion)}.${import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN}#/${hash}` ); } catch (error) { // Fall back to local file if remote URL fails - console.error( - "Failed to load from MAIN_VITE_RENDERER_URL, falling back to local file:", + logger.error( + "Failed to load from MAIN_VITE_LAUNCHER_SUBDOMAIN, falling back to local file:", error ); window.loadFile(path.join(__dirname, "../renderer/index.html"), { From 8a12c6e088195a6fb22e30c9ab2af0e1f1a7c3b7 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 28 Oct 2025 23:26:28 +0000 Subject: [PATCH 063/118] chore: sync with main --- src/main/services/window-manager.ts | 2 +- src/main/vite-env.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 33b6785c..c7335f8a 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -68,7 +68,7 @@ export class WindowManager { // Try to load from remote URL in production try { await window.loadURL( - `https://release-${this.formatVersionNumber(appVersion)}.${import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN}#/${hash}` + `https://release-v${this.formatVersionNumber(appVersion)}.${import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN}#/${hash}` ); } catch (error) { // Fall back to local file if remote URL fails diff --git a/src/main/vite-env.d.ts b/src/main/vite-env.d.ts index c9b006d5..7b0ed536 100644 --- a/src/main/vite-env.d.ts +++ b/src/main/vite-env.d.ts @@ -7,7 +7,7 @@ interface ImportMetaEnv { readonly MAIN_VITE_CHECKOUT_URL: string; readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string; readonly MAIN_VITE_WS_URL: string; - readonly MAIN_VITE_RENDERER_URL: string; + readonly MAIN_VITE_LAUNCHER_SUBDOMAIN: string; readonly ELECTRON_RENDERER_URL: string; } From dc6d578462a392ba359b61a034df7598d70f4a70 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Tue, 28 Oct 2025 23:49:09 +0000 Subject: [PATCH 064/118] chore: sync with main --- 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 c7335f8a..aeff3808 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -64,7 +64,7 @@ export class WindowManager { // Load the remote URL for development or the local html file for production. if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { window.loadURL(`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`); - } else if (import.meta.env.MAIN_VITE_RENDERER_URL) { + } else if (import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN) { // Try to load from remote URL in production try { await window.loadURL( From b1069426e4b035703ba2f79aeb758030bc0344c0 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Wed, 29 Oct 2025 01:47:35 +0000 Subject: [PATCH 065/118] chore: sync with main --- package.json | 2 +- src/main/services/window-manager.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 9ed25fa9..5d84e763 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.7.1", + "version": "3.7.2", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index aeff3808..673bf1a0 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -24,7 +24,7 @@ import type { UserPreferences, } from "@types"; import { AuthPage, generateAchievementCustomNotificationTest } from "@shared"; -import { appVersion, isStaging } from "@main/constants"; +import { isStaging } from "@main/constants"; import { logger } from "./logger"; export class WindowManager { @@ -68,7 +68,7 @@ export class WindowManager { // Try to load from remote URL in production try { await window.loadURL( - `https://release-v${this.formatVersionNumber(appVersion)}.${import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN}#/${hash}` + `https://release-v${this.formatVersionNumber(app.getVersion())}.${import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN}#/${hash}` ); } catch (error) { // Fall back to local file if remote URL fails From 274080069fd7997600c9ad296c52a148f821c934 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Wed, 29 Oct 2025 02:12:17 +0000 Subject: [PATCH 066/118] feat: forcing dev tools --- src/main/services/window-manager.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 673bf1a0..3e241467 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -197,6 +197,8 @@ export class WindowManager { this.mainWindow.removeMenu(); this.mainWindow.on("ready-to-show", () => { + WindowManager.mainWindow?.webContents.openDevTools(); + if (!app.isPackaged || isStaging) WindowManager.mainWindow?.webContents.openDevTools(); WindowManager.mainWindow?.show(); From f99f8d95548638678b21610b8511e44207e00730 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Wed, 29 Oct 2025 02:32:45 +0000 Subject: [PATCH 067/118] feat: forcing dev tools --- src/main/services/window-manager.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 3e241467..a9b742ba 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -24,7 +24,7 @@ import type { UserPreferences, } from "@types"; import { AuthPage, generateAchievementCustomNotificationTest } from "@shared"; -import { isStaging } from "@main/constants"; +import { appVersion, isStaging } from "@main/constants"; import { logger } from "./logger"; export class WindowManager { @@ -197,8 +197,6 @@ export class WindowManager { this.mainWindow.removeMenu(); this.mainWindow.on("ready-to-show", () => { - WindowManager.mainWindow?.webContents.openDevTools(); - if (!app.isPackaged || isStaging) WindowManager.mainWindow?.webContents.openDevTools(); WindowManager.mainWindow?.show(); From e143fadf381a5992e26b699ca6c7b4a38e0bfc06 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Wed, 29 Oct 2025 02:55:37 +0000 Subject: [PATCH 068/118] fix: fixing import --- 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 a9b742ba..673bf1a0 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -24,7 +24,7 @@ import type { UserPreferences, } from "@types"; import { AuthPage, generateAchievementCustomNotificationTest } from "@shared"; -import { appVersion, isStaging } from "@main/constants"; +import { isStaging } from "@main/constants"; import { logger } from "./logger"; export class WindowManager { From 58bdbdab71297a7cc559c0c5cfcd38b2131ceeb0 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Wed, 29 Oct 2025 16:16:11 +0200 Subject: [PATCH 069/118] fix: disabling unnecessary api calls if game is custom --- src/main/events/catalogue/get-game-stats.ts | 4 ++++ .../achievements/get-game-achievement-data.ts | 4 ++++ .../src/context/cloud-sync/cloud-sync.context.tsx | 6 ++++++ .../context/game-details/game-details.context.tsx | 12 +++++++----- src/renderer/src/pages/game-details/game-reviews.tsx | 4 ++-- 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/main/events/catalogue/get-game-stats.ts b/src/main/events/catalogue/get-game-stats.ts index b836531d..b7b7125c 100644 --- a/src/main/events/catalogue/get-game-stats.ts +++ b/src/main/events/catalogue/get-game-stats.ts @@ -10,6 +10,10 @@ const getGameStats = async ( objectId: string, shop: GameShop ) => { + if (shop === "custom") { + return null; + } + const cachedStats = await gamesStatsCacheSublevel.get( levelKeys.game(shop, objectId) ); diff --git a/src/main/services/achievements/get-game-achievement-data.ts b/src/main/services/achievements/get-game-achievement-data.ts index ffbfac1a..69437801 100644 --- a/src/main/services/achievements/get-game-achievement-data.ts +++ b/src/main/services/achievements/get-game-achievement-data.ts @@ -27,6 +27,10 @@ export const getGameAchievementData = async ( shop: GameShop, useCachedData: boolean ) => { + if (shop === "custom") { + return []; + } + const gameKey = levelKeys.game(shop, objectId); const cachedAchievements = await gameAchievementsSublevel.get(gameKey); diff --git a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx index b94c94d7..ce5f0a6e 100644 --- a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx +++ b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx @@ -98,6 +98,12 @@ export function CloudSyncContextProvider({ ); const getGameArtifacts = useCallback(async () => { + // Don't make API requests for custom games + if (shop === "custom") { + setArtifacts([]); + return; + } + const params = new URLSearchParams({ objectId, shop, 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 14e5d587..3706b02e 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -142,10 +142,12 @@ export function GameDetailsContextProvider({ } }); - window.electron.getGameStats(objectId, shop).then((result) => { - if (abortController.signal.aborted) return; - setStats(result); - }); + if (shop !== "custom") { + window.electron.getGameStats(objectId, shop).then((result) => { + if (abortController.signal.aborted) return; + setStats(result); + }); + } const assetsPromise = window.electron.getGameAssets(objectId, shop); @@ -167,7 +169,7 @@ export function GameDetailsContextProvider({ setIsLoading(false); }); - if (userDetails) { + if (userDetails && shop !== "custom") { window.electron .getUnlockedAchievements(objectId, shop) .then((achievements) => { diff --git a/src/renderer/src/pages/game-details/game-reviews.tsx b/src/renderer/src/pages/game-details/game-reviews.tsx index f70c84b2..1a6fc675 100644 --- a/src/renderer/src/pages/game-details/game-reviews.tsx +++ b/src/renderer/src/pages/game-details/game-reviews.tsx @@ -117,7 +117,7 @@ export function GameReviews({ }); const checkUserReview = useCallback(async () => { - if (!objectId || !userDetailsId) return; + if (!objectId || !userDetailsId || shop === "custom") return; try { const response = await window.electron.hydraApi.get<{ @@ -147,7 +147,7 @@ export function GameReviews({ const loadReviews = useCallback( async (reset = false) => { - if (!objectId) return; + if (!objectId || shop === "custom") return; if (abortControllerRef.current) { abortControllerRef.current.abort(); From dff68a3e260e5105ca1b51e6d1c5200cc8b470bb Mon Sep 17 00:00:00 2001 From: Moyasee Date: Wed, 29 Oct 2025 16:22:12 +0200 Subject: [PATCH 070/118] fix: removed comments --- src/renderer/src/context/cloud-sync/cloud-sync.context.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx index ce5f0a6e..abc359e9 100644 --- a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx +++ b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx @@ -98,7 +98,6 @@ export function CloudSyncContextProvider({ ); const getGameArtifacts = useCallback(async () => { - // Don't make API requests for custom games if (shop === "custom") { setArtifacts([]); return; From 4b8d64c72b51ac7eb8f74fa73eb0e5c94d6d659f Mon Sep 17 00:00:00 2001 From: Moyasee Date: Wed, 29 Oct 2025 16:44:48 +0200 Subject: [PATCH 071/118] feat: disabled favorite/unfavorite get request for custom games --- src/main/events/library/add-game-to-favorites.ts | 4 +++- src/main/events/library/remove-game-from-favorites.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/events/library/add-game-to-favorites.ts b/src/main/events/library/add-game-to-favorites.ts index 68c81abb..53985a09 100644 --- a/src/main/events/library/add-game-to-favorites.ts +++ b/src/main/events/library/add-game-to-favorites.ts @@ -13,7 +13,9 @@ const addGameToFavorites = async ( const game = await gamesSublevel.get(gameKey); if (!game) return; - HydraApi.put(`/profile/games/${shop}/${objectId}/favorite`).catch(() => {}); + if (shop !== "custom") { + HydraApi.put(`/profile/games/${shop}/${objectId}/favorite`).catch(() => {}); + } try { await gamesSublevel.put(gameKey, { diff --git a/src/main/events/library/remove-game-from-favorites.ts b/src/main/events/library/remove-game-from-favorites.ts index f06f55ce..7c79cbf4 100644 --- a/src/main/events/library/remove-game-from-favorites.ts +++ b/src/main/events/library/remove-game-from-favorites.ts @@ -13,7 +13,11 @@ const removeGameFromFavorites = async ( const game = await gamesSublevel.get(gameKey); if (!game) return; - HydraApi.put(`/profile/games/${shop}/${objectId}/unfavorite`).catch(() => {}); + if (shop !== "custom") { + HydraApi.put(`/profile/games/${shop}/${objectId}/unfavorite`).catch( + () => {} + ); + } try { await gamesSublevel.put(gameKey, { From feedcb1dc7511fc336a3e7f15ed3ef6085d9f32c Mon Sep 17 00:00:00 2001 From: Moyasee Date: Wed, 29 Oct 2025 16:49:51 +0200 Subject: [PATCH 072/118] feat: disabled assets request for custom games --- src/main/events/catalogue/get-game-assets.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/events/catalogue/get-game-assets.ts b/src/main/events/catalogue/get-game-assets.ts index de1d2b1f..0e45f886 100644 --- a/src/main/events/catalogue/get-game-assets.ts +++ b/src/main/events/catalogue/get-game-assets.ts @@ -6,6 +6,10 @@ import { gamesShopAssetsSublevel, levelKeys } from "@main/level"; const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60 * 8; // 8 hours export const getGameAssets = async (objectId: string, shop: GameShop) => { + if (shop === "custom") { + return null; + } + const cachedAssets = await gamesShopAssetsSublevel.get( levelKeys.game(shop, objectId) ); From c24ad34bc785acc9c0ccdb15be4268792807921c Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Wed, 29 Oct 2025 12:42:39 -0300 Subject: [PATCH 073/118] fix: hltb and achievements being called for custom games --- src/main/events/catalogue/get-game-shop-details.ts | 2 ++ src/main/events/library/remove-game-from-library.ts | 2 +- src/renderer/src/pages/game-details/game-details-content.tsx | 4 ++-- src/types/game.types.ts | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/events/catalogue/get-game-shop-details.ts b/src/main/events/catalogue/get-game-shop-details.ts index d6d27b9c..1a7fc455 100644 --- a/src/main/events/catalogue/get-game-shop-details.ts +++ b/src/main/events/catalogue/get-game-shop-details.ts @@ -26,6 +26,8 @@ const getGameShopDetails = async ( shop: GameShop, language: string ): Promise => { + if (shop === "custom") return null; + if (shop === "steam") { const [cachedData, cachedAssets] = await Promise.all([ gamesShopCacheSublevel.get( diff --git a/src/main/events/library/remove-game-from-library.ts b/src/main/events/library/remove-game-from-library.ts index fbb60ab2..95133c70 100644 --- a/src/main/events/library/remove-game-from-library.ts +++ b/src/main/events/library/remove-game-from-library.ts @@ -84,7 +84,7 @@ const removeGameFromLibrary = async ( await resetShopAssets(gameKey); } - if (game?.remoteId) { + if (game.remoteId) { HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {}); } diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index ab51a212..63c4c974 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -228,7 +228,7 @@ export function GameDetailsContent() { )} - {game?.shop !== "custom" && shop && objectId && ( + {shop !== "custom" && shop && objectId && ( - {game?.shop !== "custom" && } + {shop !== "custom" && }
diff --git a/src/types/game.types.ts b/src/types/game.types.ts index ed8fb852..35d537a8 100644 --- a/src/types/game.types.ts +++ b/src/types/game.types.ts @@ -1,4 +1,4 @@ -export type GameShop = "steam" | "epic" | "custom"; +export type GameShop = "steam" | "custom"; export type ShortcutLocation = "desktop" | "start_menu"; From ad588b5600a1172c4f7e67d1c2a8f78f6b0404ce Mon Sep 17 00:00:00 2001 From: Moyasee Date: Wed, 29 Oct 2025 19:51:09 +0200 Subject: [PATCH 074/118] fix: images with big height breaking layout --- src/renderer/src/pages/game-details/hero.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/src/pages/game-details/hero.scss b/src/renderer/src/pages/game-details/hero.scss index 6bd63320..41264fe4 100644 --- a/src/renderer/src/pages/game-details/hero.scss +++ b/src/renderer/src/pages/game-details/hero.scss @@ -146,6 +146,8 @@ $hero-height: 350px; &__game-logo { width: 200px; align-self: flex-end; + object-fit: contain; + object-position: left bottom; @media (min-width: 768px) { width: 250px; @@ -153,6 +155,7 @@ $hero-height: 350px; @media (min-width: 1024px) { width: 300px; + max-height: 150px; } } From 499a830e3ec1bd50124f97c515be01363ecf770e Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Wed, 29 Oct 2025 18:23:06 +0000 Subject: [PATCH 075/118] chore: sync with main --- .github/workflows/release.yml | 1 - .../src/components/text-field/text-field.tsx | 16 ++++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7408665f..df01b358 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,6 @@ concurrency: on: push: branches: - - main - release/** jobs: diff --git a/src/renderer/src/components/text-field/text-field.tsx b/src/renderer/src/components/text-field/text-field.tsx index 7c0cbb58..76759126 100644 --- a/src/renderer/src/components/text-field/text-field.tsx +++ b/src/renderer/src/components/text-field/text-field.tsx @@ -4,10 +4,11 @@ import { useTranslation } from "react-i18next"; import cn from "classnames"; import "./text-field.scss"; -export interface TextFieldProps extends React.DetailedHTMLProps< - React.InputHTMLAttributes, - HTMLInputElement -> { +export interface TextFieldProps + extends React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement + > { theme?: "primary" | "dark"; label?: string | React.ReactNode; hint?: string | React.ReactNode; @@ -42,7 +43,10 @@ export const TextField = React.forwardRef( const [isPasswordVisible, setIsPasswordVisible] = useState(false); const { t } = useTranslation("forms"); const showPasswordToggleButton = props.type === "password"; - const inputType = props.type === "password" && isPasswordVisible ? "text" : props.type ?? "text"; + const inputType = + props.type === "password" && isPasswordVisible + ? "text" + : (props.type ?? "text"); const hintContent = error ? ( {error} ) : hint ? ( @@ -106,4 +110,4 @@ export const TextField = React.forwardRef( ); } ); -TextField.displayName = "TextField"; \ No newline at end of file +TextField.displayName = "TextField"; From 49df40650c8e8779b7cfff917441ead54df971f2 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:27:36 -0300 Subject: [PATCH 076/118] chore: prettier --- .../src/components/text-field/text-field.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/text-field/text-field.tsx b/src/renderer/src/components/text-field/text-field.tsx index 7c0cbb58..76759126 100644 --- a/src/renderer/src/components/text-field/text-field.tsx +++ b/src/renderer/src/components/text-field/text-field.tsx @@ -4,10 +4,11 @@ import { useTranslation } from "react-i18next"; import cn from "classnames"; import "./text-field.scss"; -export interface TextFieldProps extends React.DetailedHTMLProps< - React.InputHTMLAttributes, - HTMLInputElement -> { +export interface TextFieldProps + extends React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement + > { theme?: "primary" | "dark"; label?: string | React.ReactNode; hint?: string | React.ReactNode; @@ -42,7 +43,10 @@ export const TextField = React.forwardRef( const [isPasswordVisible, setIsPasswordVisible] = useState(false); const { t } = useTranslation("forms"); const showPasswordToggleButton = props.type === "password"; - const inputType = props.type === "password" && isPasswordVisible ? "text" : props.type ?? "text"; + const inputType = + props.type === "password" && isPasswordVisible + ? "text" + : (props.type ?? "text"); const hintContent = error ? ( {error} ) : hint ? ( @@ -106,4 +110,4 @@ export const TextField = React.forwardRef( ); } ); -TextField.displayName = "TextField"; \ No newline at end of file +TextField.displayName = "TextField"; From 2fb44a6c0e4fd899f85500ccad1c114d5481e5f2 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Wed, 29 Oct 2025 15:49:43 -0300 Subject: [PATCH 077/118] chore: remove build renderer trigger on main --- .github/workflows/build-renderer.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build-renderer.yml b/.github/workflows/build-renderer.yml index 2904bcb8..34f7d303 100644 --- a/.github/workflows/build-renderer.yml +++ b/.github/workflows/build-renderer.yml @@ -7,7 +7,6 @@ concurrency: on: push: branches: - - main - release/** jobs: From 53c162f0e49f91250e61ef910fb76f9ae3f0524b Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:55:55 -0300 Subject: [PATCH 078/118] feat: add i18n --- src/locales/es/translation.json | 4 +++- src/locales/pt-BR/translation.json | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index dfa7f7a1..863b8332 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -541,7 +541,9 @@ "notification_preview": "Probar notificación de logro", "debrid": "Debrid", "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.", - "enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego" + "enable_friend_start_game_notifications": "Cuando un amigo está jugando un juego", + "autoplay_trailers_on_game_page": "Reproducir trailers automáticamente en la página del juego", + "hide_to_tray_on_game_start": "Ocultar Hydra en la bandeja al iniciar un juego" }, "notifications": { "download_complete": "Descarga completada", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index c9e908ac..5bfc2af3 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -538,7 +538,9 @@ "hidden": "Oculta", "test_notification": "Testar notificação", "notification_preview": "Prévia da Notificação de Conquistas", - "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo" + "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo", + "autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo", + "hide_to_tray_on_game_start": "Ocultar o Hydra na bandeja ao iniciar um jogo" }, "notifications": { "download_complete": "Download concluído", From 0990951183325aac65245911898f750932ed1ca6 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Wed, 29 Oct 2025 18:06:46 -0300 Subject: [PATCH 079/118] chore: fix aur package --- .github/workflows/update-aur.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/update-aur.yml b/.github/workflows/update-aur.yml index 2a3583bc..4ce84f3c 100644 --- a/.github/workflows/update-aur.yml +++ b/.github/workflows/update-aur.yml @@ -98,6 +98,7 @@ jobs: # Update pkgver in PKGBUILD cd hydra-launcher-bin NEW_VERSION="${{ steps.get-version.outputs.version }}" + NEW_VERSION="${NEW_VERSION#v}" echo "Updating PKGBUILD pkgver to $NEW_VERSION" From 65e49550ad8f0f85c32f70f8c4ac2af79d62f22f Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Wed, 29 Oct 2025 18:10:27 -0300 Subject: [PATCH 080/118] chore: fix aur package --- .github/workflows/update-aur.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/update-aur.yml b/.github/workflows/update-aur.yml index 4ce84f3c..52fe907e 100644 --- a/.github/workflows/update-aur.yml +++ b/.github/workflows/update-aur.yml @@ -138,6 +138,9 @@ jobs: COMMIT_MSG="v${{ steps.get-version.outputs.version }}" git commit -m "$COMMIT_MSG" + + export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa -F ~/.ssh/config -o UserKnownHostsFile=$SSH_PATH/known_hosts" + git push origin master echo "Successfully updated AUR package to version ${{ steps.get-version.outputs.version }}" fi From 0e7e53478a5e02ca9b09769e15b2a8f408d459d5 Mon Sep 17 00:00:00 2001 From: Wkeynhk <86107421+Wkeynhk@users.noreply.github.com> Date: Thu, 30 Oct 2025 00:47:30 +0300 Subject: [PATCH 081/118] Update translation.json --- src/locales/ru/translation.json | 41 ++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 886c7d07..15a9c9cb 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -212,6 +212,7 @@ "stats": "Статистика", "download_count": "Загрузки", "player_count": "Активные игроки", + "rating_count": "Оценка", "download_error": "Этот вариант загрузки недоступен", "download": "Скачать", "executable_path_in_use": "Исполняемый файл уже используется \"{{game}}\"", @@ -252,17 +253,6 @@ "would_you_recommend_this_game": "Хотите оставить отзыв об этой игре?", "yes": "Да", "maybe_later": "Возможно позже", - "rating_count": "Оценка", - "delete_review": "Удалить отзыв", - "remove_review": "Удалить отзыв", - "delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?", - "delete_review_modal_description": "Это действие нельзя отменить.", - "delete_review_modal_delete_button": "Удалить", - "delete_review_modal_cancel_button": "Отмена", - "show_original": "Показать оригинал", - "show_translation": "Показать перевод", - "show_original_translated_from": "Показать оригинал (переведено с {{language}})", - "hide_original": "Скрыть оригинал", "cloud_save": "Облачное сохранение", "cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве", "backups": "Резервные копии", @@ -360,7 +350,18 @@ "caption": "Субтитры", "audio": "Аудио", "filter_by_source": "Фильтр по источнику", - "no_repacks_found": "Источники для этой игры не найдены" + "no_repacks_found": "Источники для этой игры не найдены", + "delete_review": "Удалить отзыв", + "remove_review": "Удалить отзыв", + "delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?", + "delete_review_modal_description": "Это действие нельзя отменить.", + "delete_review_modal_delete_button": "Удалить", + "delete_review_modal_cancel_button": "Отмена", + "vote_failed": "Не удалось зарегистрировать ваш голос. Пожалуйста, попробуйте снова.", + "show_original": "Показать оригинал", + "show_translation": "Показать перевод", + "show_original_translated_from": "Показать оригинал (переведено с {{language}})", + "hide_original": "Скрыть оригинал" }, "activation": { "title": "Активировать Hydra", @@ -427,6 +428,9 @@ "validate_download_source": "Проверить", "remove_download_source": "Удалить", "add_download_source": "Добавить источник", + "adding": "Добавление…", + "failed_add_download_source": "Не удалось добавить источник. Пожалуйста, попробуйте снова.", + "download_source_already_exists": "Этот URL источника уже существует.", "download_count_zero": "В списке нет загрузок", "download_count_one": "{{countFormatted}} загрузка в списке", "download_count_other": "{{countFormatted}} загрузок в списке", @@ -434,9 +438,16 @@ "add_download_source_description": "Вставьте ссылку на .json-файл", "download_source_up_to_date": "Обновлён", "download_source_errored": "Ошибка", + "download_source_pending_matching": "Скоро обновится", + "download_source_matched": "Обновлен", + "download_source_matching": "Обновление", + "download_source_failed": "Ошибка", + "download_source_no_information": "Информация отсутствует", "sync_download_sources": "Обновить источники", "removed_download_source": "Источник удален", "removed_download_sources": "Источники удалены", + "removed_all_download_sources": "Все источники удалены", + "download_sources_synced_successfully": "Все источники синхронизированы", "cancel_button_confirmation_delete_all_sources": "Нет", "confirm_button_confirmation_delete_all_sources": "Да, удалить все", "title_confirmation_delete_all_sources": "Удалить все источники", @@ -467,6 +478,7 @@ "seed_after_download_complete": "Раздавать после завершения загрузки", "show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением", "account": "Аккаунт", + "hydra_cloud": "Hydra Cloud", "no_users_blocked": "У вас нет заблокированных пользователей", "subscription_active_until": "Ваша подписка на Hydra Cloud активна до {{date}}", "manage_subscription": "Управлять подпиской", @@ -540,7 +552,9 @@ "hidden": "Скрытый", "test_notification": "Тестовое уведомление", "notification_preview": "Предварительный просмотр уведомления о достижении", - "enable_friend_start_game_notifications": "Когда друг начинает играть в игру" + "enable_friend_start_game_notifications": "Когда друг начинает играть в игру", + "autoplay_trailers_on_game_page": "Автоматически начинать воспроизведение трейлеров на странице игры", + "hide_to_tray_on_game_start": "Скрывать Hydra в трей при запуске игры" }, "notifications": { "download_complete": "Загрузка завершена", @@ -590,6 +604,7 @@ "activity": "Недавняя активность", "library": "Библиотека", "pinned": "Закрепленные", + "sort_by": "Сортировать по:", "achievements_earned": "Заработанные достижения", "played_recently": "Недавно сыгранные", "playtime": "Время игры", From 4dd28bbbf149f138d9de384dc9eaba220d306b7f Mon Sep 17 00:00:00 2001 From: "Kiwo.2" Date: Thu, 30 Oct 2025 01:12:29 +0100 Subject: [PATCH 082/118] Hungarian Translation 3.7.2 --- src/locales/hu/translation.json | 96 ++++++++++++++++++--------------- 1 file changed, 52 insertions(+), 44 deletions(-) diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index b72811d3..a91902dc 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -8,7 +8,7 @@ "no_results": "Nincs találat", "start_typing": "Kereséshez gépelj...", "hot": "Most felkapott", - "weekly": "📅 A hét felkapott játékai", + "weekly": "📅 A hét felkapottjai", "achievements": "🏆 Achievement támogatott" }, "sidebar": { @@ -26,7 +26,7 @@ "sign_in": "Bejelentkezés", "friends": "Barátok", "need_help": "Elakadtál?", - "favorites": "Kedvenc játékok", + "favorites": "Kedvenc Játékaim", "playable_button_title": "Csak az azonnal játszható játékokat mutasd", "add_custom_game_tooltip": "Saját játék hozzáadása", "show_playable_only_tooltip": "Csak játszható játék mutatása", @@ -76,8 +76,8 @@ "edit_game_modal_drop_hero_image_here": "Húzd ide a borítókép képét", "edit_game_modal_drop_to_replace_icon": "Ikon kicserélése ráhúzással", "edit_game_modal_drop_to_replace_logo": "Logó kicserélése ráhúzással", - "edit_game_modal_drop_to_replace_hero": "Borítókép kicserélése ráhúzással", - "install_decky_plugin": "Decky Plugin Telepítése", + "edit_game_modal_drop_to_replace_hero": "Borítókép kicserélése ráhúzással", + "install_decky_plugin": "Decky Plugin Telepítése", "update_decky_plugin": "Decky Plugin Frissítése", "decky_plugin_installed_version": "Decky Plugin (v{{version}})", "install_decky_plugin_title": "Telepítsd a Hydra Decky Plugint", @@ -88,7 +88,7 @@ "decky_plugin_installation_failed": "Decky plugin telepítése sikertelen: {{error}}", "decky_plugin_installation_error": "Decky plugin telepítése hibával járt el: {{error}}", "confirm": "Megerősít", - "cancel": "Mégse" + "cancel": "Mégse" }, "header": { "search": "Keresés", @@ -120,7 +120,7 @@ "result_count": "{{resultCount}} találatok", "filter_count": "{{filterCount}} elérhető", "clear_filters": "{{filterCount}} kiválaszott szűrő törlése" - }, + }, "game_details": { "open_download_options": "Letöltési opciók megnyitása", "download_options_zero": "Nincs letöltési opció", @@ -178,13 +178,13 @@ "open_folder": "Mappa megnyitása", "open_download_location": "Letöltött fájlok megtekintése", "create_shortcut": "Asztali parancsikon létrehozása", - "create_shortcut_simple": "Parancsikon létrehozása", + "create_shortcut_simple": "Parancsikon létrehozása", "clear": "Visszavon", "remove_files": "Fájlok eltávolítása", "remove_from_library_title": "Biztos vagy ebben?", "remove_from_library_description": "Ezzel eltávolítod a játékot {{game}} a könyvtáradból", - "options": "Beállítások", - "properties": "További beállítások", + "options": "Beállítások",ä + "properties": "További beállítások", "executable_section_title": "Futtatható fájl", "executable_section_description": "A fájl amely futtatásra fog kerülni amikor a \"Játék\" lenyomásra kerül", "downloads_section_title": "Letöltések", @@ -204,27 +204,27 @@ "game_removed_from_library": "Játék eltávolítva a könyvtárból", "failed_remove_from_library": "Játék eltávolítása a könyvtárból sikertelen", "files_removed_success": "Fájlok eltávolítása sikeres", - "failed_remove_files": "Fájlok eltávolítása sikertelen", + "failed_remove_files": "Fájlok eltávolítása sikertelen", "nsfw_content_title": "Ez a játék tartalmaz nem megfelelő tartalmat", - "nsfw_content_description": "A(z) {{title}} tartalma lehetséges hogy nem megfelelő minden korosztály számára. Biztosan folytatni szeretnéd?", + "nsfw_content_description": "A(z) {{title}} tartalma lehetséges hogy nem megfelelő minden korosztály számára. Biztosan folytatni szeretnéd?", "allow_nsfw_content": "Folytatás", "refuse_nsfw_content": "Vissza", "stats": "Statisztikák", "download_count": "Letöltések", "player_count": "Aktív játékosok", - "rating_count": "Értékelés", - "download_error": "Ez a letöltési opció nem elérhető", + "rating_count": "Értékelés", + "download_error": "Ez a letöltési opció nem elérhető", "download": "Letöltés", "executable_path_in_use": "Ez a futtatható fájl már használatban van a(z) \"{{game}}\" által", "warning": "Figyelmeztetés:", "hydra_needs_to_remain_open": "ehhez a letöltéshez, a Hydrának muszáj nyitva maradnia hogy letöltődjön. Ha a Hydra bezáródik letöltés előtt, a letöltés elveszik.", "achievements": "Achievementek", "achievements_count": "Achievementek {{unlockedCount}}/{{achievementsCount}}", - "show_more": "Mutass többet", + "show_more": "Mutass többet", "show_less": "Mutass kevesebbet", "reviews": "Vélemények", "leave_a_review": "Hagyd itt a véleményed", - "write_review_placeholder": "Oszd meg a gondolataid a játékról...", + "write_review_placeholder": "Oszd meg gondolatod a játékról...", "sort_newest": "Legújabb", "no_reviews_yet": "Még nem lett vélemény megosztva", "be_first_to_review": "Légy az első, aki megossza a véleményét a játékról!", @@ -252,7 +252,7 @@ "you_seemed_to_enjoy_this_game": "Úgy látszik élvezted ezt a játékot", "would_you_recommend_this_game": "Szeretnél véleményt írni erről a játékról?", "yes": "Igen", - "maybe_later": "Talán Később", + "maybe_later": "Talán később", "cloud_save": "Mentés felhőben", "cloud_save_description": "Mentsd el az előrehaladásod a felhőben, majd folytasd egy másik eszközön", "backups": "Biztonsági másolatok", @@ -350,19 +350,24 @@ "caption": "Felirat", "audio": "Hang", "filter_by_source": "Szűrés forrás szerint", - "no_repacks_found": "Nem található forrás ehhez a játékhoz" , - "delete_review": "Vélemény törlése", + "no_repacks_found": "Nem található forrás ehhez a játékhoz", + "delete_review": "Vélemény törlése", "remove_review": "Vélemény eltávolítása", "delete_review_modal_title": "Biztos vagy abban hogy törölni szeretnéd a véleményed?", "delete_review_modal_description": "Ez a lépés nem vonható vissza.", "delete_review_modal_delete_button": "Törlés", - "delete_review_modal_cancel_button": "Mégse" + "delete_review_modal_cancel_button": "Mégse", + "vote_failed": "A szavazatod nem regisztrálódott. Kérlek próbáld újra.", + "show_original": "Eredeti megjelenítése", + "show_translation": "Fordítás megjelenítése", + "show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})", + "hide_original": "Eredeti elrejtése" }, "activation": { "title": "Hydra Aktiválása", "installation_id": "Telepítési Azonosító:", "enter_activation_code": "Írd be az aktiválási kódod", - "message": "Ha nem tudod hol kérdezz efelől, akkor nem kéne ilyened legyen.", + "message": "Ha nem tudod merre kérdezz efelől, akkor nem kéne ilyened legyen.", "activate": "Aktiválás", "loading": "Töltés…" }, @@ -394,7 +399,6 @@ "stop_seeding": "Seedelés leállítása", "resume_seeding": "Seedelés folytatása", "options": "Kezelés", - "alldebrid_size_not_supported": "Letöltési információ az AllDebrid-hez még nem támogatott", "extract": "Fájlok kibontása", "extracting": "Fájlok kibontása…" }, @@ -420,20 +424,30 @@ "debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ", "save_changes": "Változtatások mentése", "changes_saved": "Változtatások sikeresen mentve", - "download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.", + "download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL Forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.", "validate_download_source": "Érvényesítés", "remove_download_source": "Eltávolítás", "add_download_source": "Forrás hozáadása", + "adding": "Hozzáadás…", + "failed_add_download_source": "Letöltési forrás hozzáadása sikertelen. Kérlek próbáld újra.", + "download_source_already_exists": "Ez a letöltési forrás URL már létezik.", "download_count_zero": "Nincs letöltési opció", "download_count_one": "{{countFormatted}} letöltési opció", "download_count_other": "{{countFormatted}} letöltési opció", - "download_source_url": "URL forrás:", + "download_source_url": "URL Forrás:", "add_download_source_description": "Helyezd be a .json fájl URL-jét", "download_source_up_to_date": "Naprakész", "download_source_errored": "Hiba történt", + "download_source_pending_matching": "Frissítés hamarosan", + "download_source_matched": "Naprakész", + "download_source_matching": "Frissítés..", + "download_source_failed": "Hiba", + "download_source_no_information": "Nincs elérhető információ", "sync_download_sources": "Források szinkronizálása", "removed_download_source": "Letöltési forrás eltávolítva", "removed_download_sources": "Letöltési források eltávolítva", + "removed_all_download_sources": "Összes letöltési forrás eltávolítva", + "download_sources_synced_successfully": "Az összes letöltési forrás szinkronizálva", "cancel_button_confirmation_delete_all_sources": "Nem", "confirm_button_confirmation_delete_all_sources": "Igen, törölj mindent", "title_confirmation_delete_all_sources": "Az összes letöltési forrás törlése", @@ -446,6 +460,7 @@ "found_download_option_one": "{{countFormatted}} Letöltési opció találva", "found_download_option_other": "{{countFormatted}} Letöltési opciók találva", "import": "Importálás", + "importing": "Importálás...", "public": "Publikus", "private": "Privát", "friends_only": "Csak barátok", @@ -463,6 +478,7 @@ "seed_after_download_complete": "Letöltés utáni seedelés", "show_hidden_achievement_description": "Rejtett achievementek leírásának megjelenítése feloldás előtt", "account": "Fiók", + "hydra_cloud": "Hydra Cloud", "no_users_blocked": "Nincsenek letiltott felhasználóid", "subscription_active_until": "Hydra Cloud előfizetésed aktív, eddig: {{date}}", "manage_subscription": "Előfizetés kezelése", @@ -498,26 +514,15 @@ "delete_theme_description": "Ez törölni fogja a(z) {{theme}} témát", "cancel": "Mégsem", "appearance": "Megjelenés", - "debrid": "Debrid", - "debrid_description": "A Debrid szolgáltatások prémium szolgáltatások amelyek lehetővé teszik, hogy gyorsan letölts különböző fájltároló szolgáltatásokon tárolt fájlokat, csak az internet sebességed szab határt.", + "debrid": "Debrid", + "debrid_description": "A Debrid szolgáltatások prémium szolgáltatások amelyek lehetővé teszik, hogy gyorsan letölts különböző fájltároló szolgáltatásokon tárolt fájlokat, és csak az internet sebességed szab határt.", "enable_torbox": "TorBox bekapcsolása", "torbox_description": "A TorBox egy olyan premium seedbox szolgáltatás, amely még a piacon elérhető legjobb szerverekkel is felveszi a versenyt.", "torbox_account_linked": "TorBox fiók összekapcsolva", "create_real_debrid_account": "Kattints ide ha még nincs Real-Debrid fiókod", "create_torbox_account": "Kattints ide ha még nincs TorBox fiókod", "real_debrid_account_linked": "Real-Debrid fiók összekapcsolva", - "enable_all_debrid": "All-Debrid bekapcsolása", - "all_debrid_description": "Az All-Debrid egy korlátozásmentes letöltőprogram, ami lehetővé teszi a fájlok gyors letöltését különböző forrásokból.", - "all_debrid_free_account_error": "Ez a fiók: \"{{username}}\" egy ingyenes fiók. Kérlek iratkozz fel az All-Debridre", - "all_debrid_account_linked": "All-Debrid fiók összekapcsolva", - "alldebrid_missing_key": "Kérlek adj meg egy API key-t", - "alldebrid_invalid_key": "Érvénytelen API key", - "alldebrid_blocked": "Az API key-ed Földrajzilag vagy IP-alapján van blokkolva", - "alldebrid_banned": "Ez a fiók kitiltásra került", - "alldebrid_unknown_error": "Egy ismeretlen hiba történt", - "alldebrid_invalid_response": "Érvénytelen válasz az All-Debrid felől", - "alldebrid_network_error": "Hálózati hiba. Ellenőrízd az internetkapcsolatod", - "name_min_length": "A téma neve legalább 3 karakter hosszú legyen", + "name_min_length": "A téma neve legalább 3 karakter hosszú kell legyen", "import_theme": "Téma importálása", "import_theme_description": "Ezt a témát fogod importálni a Témaáruház-ból: {{theme}}", "error_importing_theme": "Hiba lépett fel a téma importálása közben", @@ -548,6 +553,8 @@ "test_notification": "Értesítés tesztelése", "notification_preview": "Achievement Értesítés Előnézete", "enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot" + "autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán", + "hide_to_tray_on_game_start": "Hydra elrejtése játék elindításakor a tálcára" }, "notifications": { "download_complete": "Letöltés befejezve", @@ -574,11 +581,11 @@ "game_card": { "available_one": "Elérhető", "available_other": "Elérhető", - "no_downloads": "Nincs elérhető letöltés" - "calculating": "Feldolgozás" + "no_downloads": "Nincs elérhető letöltés", + "calculating": "Számítás alatt.." }, "binary_not_found_modal": { - "title": "A programok nincsenek telepítve", + "title": "Hiányzó programok", "description": "Wine vagy Lutris futtatható fájlok nem találhatók a rendszereden", "instructions": "Ellenőrízd hogy melyiket kell helyesen telepíteni a Linux disztribúciódra, hogy a játék megfelelően fusson" }, @@ -597,6 +604,7 @@ "activity": "Legutóbbi tevékenység", "library": "Könyvtár", "pinned": "Kitűzve", + "sort_by": "Rendezés:", "achievements_earned": "Elért achievementek", "played_recently": "Nemrég játszva", "playtime": "Játszottidő", @@ -666,7 +674,7 @@ "uploading_banner": "Borítókép feltöltése…", "background_image_updated": "Borítókép frissítve", "stats": "Statisztikák", - "achievements": "achievementek", + "achievements": "achievement", "games": "Játékok", "top_percentile": "Top {{percentile}}%", "ranking_updated_weekly": "A rangsor hetente frissül.", @@ -678,10 +686,10 @@ "error_adding_friend": "Hiba, barátfelkérés sikertelen. Kérlek ellenőrízd a barát kódot", "friend_code_length_error": "A barát kódnak 8 karakterből kell állnia", "game_removed_from_pinned": "Játék eltávolítva a kitűzöttek közül", - "game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez" + "game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez", "karma": "Karma", "karma_count": "karma", - "karma_description": "Pozitív értékelésekre kapott pontok alapján" + "karma_description": "Pozitív értékelésekkel szerzett pontok" }, "achievement": { "achievement_unlocked": "Achievement feloldva", @@ -690,7 +698,7 @@ "unlocked_at": "Feloldva: {{date}}", "subscription_needed": "A tartalom megtekintéséhez Hydra Cloud előfizetés szükséges", "new_achievements_unlocked": "{{achievementCount}} új achievement feloldva {{gameCount}} játékban", - "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementek", + "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievement", "achievements_unlocked_for_game": "{{achievementCount}} új achievement feloldva itt: {{gameTitle}}", "hidden_achievement_tooltip": "Ez egy rejtett achievement", "achievement_earn_points": "Szerezz be {{points}} pontot ezzel az achievement-el", From 41092c2dd442c4efbf2ea15604df1cfc34a0c8be Mon Sep 17 00:00:00 2001 From: "Kiwo.2" Date: Thu, 30 Oct 2025 01:35:54 +0100 Subject: [PATCH 083/118] Update to 3.7.2 --- src/locales/hu/translation.json | 36 --------------------------------- 1 file changed, 36 deletions(-) diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index 88039aee..a91902dc 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -183,11 +183,7 @@ "remove_files": "Fájlok eltávolítása", "remove_from_library_title": "Biztos vagy ebben?", "remove_from_library_description": "Ezzel eltávolítod a játékot {{game}} a könyvtáradból", -<<<<<<< HEAD "options": "Beállítások",ä -======= - "options": "Beállítások", ->>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de "properties": "További beállítások", "executable_section_title": "Futtatható fájl", "executable_section_description": "A fájl amely futtatásra fog kerülni amikor a \"Játék\" lenyomásra kerül", @@ -228,11 +224,7 @@ "show_less": "Mutass kevesebbet", "reviews": "Vélemények", "leave_a_review": "Hagyd itt a véleményed", -<<<<<<< HEAD "write_review_placeholder": "Oszd meg gondolatod a játékról...", -======= - "write_review_placeholder": "Oszd meg a gondolataid a játékról...", ->>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de "sort_newest": "Legújabb", "no_reviews_yet": "Még nem lett vélemény megosztva", "be_first_to_review": "Légy az első, aki megossza a véleményét a játékról!", @@ -260,11 +252,7 @@ "you_seemed_to_enjoy_this_game": "Úgy látszik élvezted ezt a játékot", "would_you_recommend_this_game": "Szeretnél véleményt írni erről a játékról?", "yes": "Igen", -<<<<<<< HEAD "maybe_later": "Talán később", -======= - "maybe_later": "Talán Később", ->>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de "cloud_save": "Mentés felhőben", "cloud_save_description": "Mentsd el az előrehaladásod a felhőben, majd folytasd egy másik eszközön", "backups": "Biztonsági másolatok", @@ -368,16 +356,12 @@ "delete_review_modal_title": "Biztos vagy abban hogy törölni szeretnéd a véleményed?", "delete_review_modal_description": "Ez a lépés nem vonható vissza.", "delete_review_modal_delete_button": "Törlés", -<<<<<<< HEAD "delete_review_modal_cancel_button": "Mégse", "vote_failed": "A szavazatod nem regisztrálódott. Kérlek próbáld újra.", "show_original": "Eredeti megjelenítése", "show_translation": "Fordítás megjelenítése", "show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})", "hide_original": "Eredeti elrejtése" -======= - "delete_review_modal_cancel_button": "Mégse" ->>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de }, "activation": { "title": "Hydra Aktiválása", @@ -531,22 +515,14 @@ "cancel": "Mégsem", "appearance": "Megjelenés", "debrid": "Debrid", -<<<<<<< HEAD "debrid_description": "A Debrid szolgáltatások prémium szolgáltatások amelyek lehetővé teszik, hogy gyorsan letölts különböző fájltároló szolgáltatásokon tárolt fájlokat, és csak az internet sebességed szab határt.", -======= - "debrid_description": "A Debrid szolgáltatások prémium szolgáltatások amelyek lehetővé teszik, hogy gyorsan letölts különböző fájltároló szolgáltatásokon tárolt fájlokat, csak az internet sebességed szab határt.", ->>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de "enable_torbox": "TorBox bekapcsolása", "torbox_description": "A TorBox egy olyan premium seedbox szolgáltatás, amely még a piacon elérhető legjobb szerverekkel is felveszi a versenyt.", "torbox_account_linked": "TorBox fiók összekapcsolva", "create_real_debrid_account": "Kattints ide ha még nincs Real-Debrid fiókod", "create_torbox_account": "Kattints ide ha még nincs TorBox fiókod", "real_debrid_account_linked": "Real-Debrid fiók összekapcsolva", -<<<<<<< HEAD "name_min_length": "A téma neve legalább 3 karakter hosszú kell legyen", -======= - "name_min_length": "A téma neve legalább 3 karakter hosszú legyen", ->>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de "import_theme": "Téma importálása", "import_theme_description": "Ezt a témát fogod importálni a Témaáruház-ból: {{theme}}", "error_importing_theme": "Hiba lépett fel a téma importálása közben", @@ -606,11 +582,7 @@ "available_one": "Elérhető", "available_other": "Elérhető", "no_downloads": "Nincs elérhető letöltés", -<<<<<<< HEAD "calculating": "Számítás alatt.." -======= - "calculating": "Feldolgozás" ->>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de }, "binary_not_found_modal": { "title": "Hiányzó programok", @@ -717,11 +689,7 @@ "game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez", "karma": "Karma", "karma_count": "karma", -<<<<<<< HEAD "karma_description": "Pozitív értékelésekkel szerzett pontok" -======= - "karma_description": "Pozitív értékelésekre kapott pontok alapján" ->>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de }, "achievement": { "achievement_unlocked": "Achievement feloldva", @@ -730,11 +698,7 @@ "unlocked_at": "Feloldva: {{date}}", "subscription_needed": "A tartalom megtekintéséhez Hydra Cloud előfizetés szükséges", "new_achievements_unlocked": "{{achievementCount}} új achievement feloldva {{gameCount}} játékban", -<<<<<<< HEAD "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievement", -======= - "achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementek", ->>>>>>> 21074322fa5ef3a1d6168a2b841ec2505db8f0de "achievements_unlocked_for_game": "{{achievementCount}} új achievement feloldva itt: {{gameTitle}}", "hidden_achievement_tooltip": "Ez egy rejtett achievement", "achievement_earn_points": "Szerezz be {{points}} pontot ezzel az achievement-el", From 90c5ccb7969e16554218007c35f1fccf606f9a5a Mon Sep 17 00:00:00 2001 From: "Kiwo.2" Date: Thu, 30 Oct 2025 01:40:36 +0100 Subject: [PATCH 084/118] Update to 3.7.2 --- src/locales/hu/translation.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index a91902dc..3e0c408a 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -183,7 +183,7 @@ "remove_files": "Fájlok eltávolítása", "remove_from_library_title": "Biztos vagy ebben?", "remove_from_library_description": "Ezzel eltávolítod a játékot {{game}} a könyvtáradból", - "options": "Beállítások",ä + "options": "Beállítások", "properties": "További beállítások", "executable_section_title": "Futtatható fájl", "executable_section_description": "A fájl amely futtatásra fog kerülni amikor a \"Játék\" lenyomásra kerül", @@ -428,7 +428,7 @@ "validate_download_source": "Érvényesítés", "remove_download_source": "Eltávolítás", "add_download_source": "Forrás hozáadása", - "adding": "Hozzáadás…", + "adding": "Hozzáadás…", "failed_add_download_source": "Letöltési forrás hozzáadása sikertelen. Kérlek próbáld újra.", "download_source_already_exists": "Ez a letöltési forrás URL már létezik.", "download_count_zero": "Nincs letöltési opció", @@ -438,7 +438,7 @@ "add_download_source_description": "Helyezd be a .json fájl URL-jét", "download_source_up_to_date": "Naprakész", "download_source_errored": "Hiba történt", - "download_source_pending_matching": "Frissítés hamarosan", + "download_source_pending_matching": "Hamarosan frissítve", "download_source_matched": "Naprakész", "download_source_matching": "Frissítés..", "download_source_failed": "Hiba", @@ -460,7 +460,7 @@ "found_download_option_one": "{{countFormatted}} Letöltési opció találva", "found_download_option_other": "{{countFormatted}} Letöltési opciók találva", "import": "Importálás", - "importing": "Importálás...", + "importing": "Importálás...", "public": "Publikus", "private": "Privát", "friends_only": "Csak barátok", @@ -478,7 +478,7 @@ "seed_after_download_complete": "Letöltés utáni seedelés", "show_hidden_achievement_description": "Rejtett achievementek leírásának megjelenítése feloldás előtt", "account": "Fiók", - "hydra_cloud": "Hydra Cloud", + "hydra_cloud": "Hydra Cloud", "no_users_blocked": "Nincsenek letiltott felhasználóid", "subscription_active_until": "Hydra Cloud előfizetésed aktív, eddig: {{date}}", "manage_subscription": "Előfizetés kezelése", @@ -553,7 +553,7 @@ "test_notification": "Értesítés tesztelése", "notification_preview": "Achievement Értesítés Előnézete", "enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot" - "autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán", + "autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán", "hide_to_tray_on_game_start": "Hydra elrejtése játék elindításakor a tálcára" }, "notifications": { From 3fce26f1f7a0384d14cbef10781e5a1472ecda3b Mon Sep 17 00:00:00 2001 From: "Kiwo.2" Date: Thu, 30 Oct 2025 01:55:15 +0100 Subject: [PATCH 085/118] Update to 3.7.2 --- src/locales/hu/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index 3e0c408a..e4bc7eb0 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -8,7 +8,7 @@ "no_results": "Nincs találat", "start_typing": "Kereséshez gépelj...", "hot": "Most felkapott", - "weekly": "📅 A hét felkapottjai", + "weekly": "📅 Heti kiemeltek", "achievements": "🏆 Achievement támogatott" }, "sidebar": { From 6e76111e2346ca2c3b1c53f4d33b5152884ed072 Mon Sep 17 00:00:00 2001 From: "Kiwo.2" Date: Thu, 30 Oct 2025 02:10:02 +0100 Subject: [PATCH 086/118] Missing Comma Fix --- src/locales/hu/translation.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index e4bc7eb0..d771a8d4 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -8,7 +8,7 @@ "no_results": "Nincs találat", "start_typing": "Kereséshez gépelj...", "hot": "Most felkapott", - "weekly": "📅 Heti kiemeltek", + "weekly": "📅 A hét felkapottjai", "achievements": "🏆 Achievement támogatott" }, "sidebar": { @@ -183,7 +183,7 @@ "remove_files": "Fájlok eltávolítása", "remove_from_library_title": "Biztos vagy ebben?", "remove_from_library_description": "Ezzel eltávolítod a játékot {{game}} a könyvtáradból", - "options": "Beállítások", + "options": "Beállítások",ä "properties": "További beállítások", "executable_section_title": "Futtatható fájl", "executable_section_description": "A fájl amely futtatásra fog kerülni amikor a \"Játék\" lenyomásra kerül", @@ -391,7 +391,7 @@ "download_in_progress": "Folyamatban lévő", "queued_downloads": "Várakozósoron lévő letöltések", "downloads_completed": "Befejezett", - "queued": "Várakozási sorban", + "queued": "Várakozásban", "no_downloads_title": "Oly üres..", "no_downloads_description": "Még nem töltöttél le semmit a Hydra segítségével, de soha nem késő elkezdeni.", "checking_files": "Fájlok ellenőrzése…", @@ -428,7 +428,7 @@ "validate_download_source": "Érvényesítés", "remove_download_source": "Eltávolítás", "add_download_source": "Forrás hozáadása", - "adding": "Hozzáadás…", + "adding": "Hozzáadás…", "failed_add_download_source": "Letöltési forrás hozzáadása sikertelen. Kérlek próbáld újra.", "download_source_already_exists": "Ez a letöltési forrás URL már létezik.", "download_count_zero": "Nincs letöltési opció", @@ -438,7 +438,7 @@ "add_download_source_description": "Helyezd be a .json fájl URL-jét", "download_source_up_to_date": "Naprakész", "download_source_errored": "Hiba történt", - "download_source_pending_matching": "Hamarosan frissítve", + "download_source_pending_matching": "Frissítés hamarosan", "download_source_matched": "Naprakész", "download_source_matching": "Frissítés..", "download_source_failed": "Hiba", @@ -460,7 +460,7 @@ "found_download_option_one": "{{countFormatted}} Letöltési opció találva", "found_download_option_other": "{{countFormatted}} Letöltési opciók találva", "import": "Importálás", - "importing": "Importálás...", + "importing": "Importálás...", "public": "Publikus", "private": "Privát", "friends_only": "Csak barátok", @@ -478,7 +478,7 @@ "seed_after_download_complete": "Letöltés utáni seedelés", "show_hidden_achievement_description": "Rejtett achievementek leírásának megjelenítése feloldás előtt", "account": "Fiók", - "hydra_cloud": "Hydra Cloud", + "hydra_cloud": "Hydra Cloud", "no_users_blocked": "Nincsenek letiltott felhasználóid", "subscription_active_until": "Hydra Cloud előfizetésed aktív, eddig: {{date}}", "manage_subscription": "Előfizetés kezelése", @@ -552,8 +552,8 @@ "hidden": "Rejtett", "test_notification": "Értesítés tesztelése", "notification_preview": "Achievement Értesítés Előnézete", - "enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot" - "autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán", + "enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot", + "autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán", "hide_to_tray_on_game_start": "Hydra elrejtése játék elindításakor a tálcára" }, "notifications": { From dcc671f9993c57e8311aa7a0683613a7fb9caab3 Mon Sep 17 00:00:00 2001 From: "Kiwo.2" Date: Thu, 30 Oct 2025 02:15:35 +0100 Subject: [PATCH 087/118] Mistake Correction --- src/locales/hu/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index d771a8d4..909f242b 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -183,7 +183,7 @@ "remove_files": "Fájlok eltávolítása", "remove_from_library_title": "Biztos vagy ebben?", "remove_from_library_description": "Ezzel eltávolítod a játékot {{game}} a könyvtáradból", - "options": "Beállítások",ä + "options": "Beállítások", "properties": "További beállítások", "executable_section_title": "Futtatható fájl", "executable_section_description": "A fájl amely futtatásra fog kerülni amikor a \"Játék\" lenyomásra kerül", From 4ff8dc4fa7a563f282a63ba226cb1205da9df6c5 Mon Sep 17 00:00:00 2001 From: "Kiwo.2" Date: Thu, 30 Oct 2025 02:32:18 +0100 Subject: [PATCH 088/118] Fix with Prettier --- src/locales/hu/translation.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/locales/hu/translation.json b/src/locales/hu/translation.json index 909f242b..8aea356b 100644 --- a/src/locales/hu/translation.json +++ b/src/locales/hu/translation.json @@ -357,7 +357,7 @@ "delete_review_modal_description": "Ez a lépés nem vonható vissza.", "delete_review_modal_delete_button": "Törlés", "delete_review_modal_cancel_button": "Mégse", - "vote_failed": "A szavazatod nem regisztrálódott. Kérlek próbáld újra.", + "vote_failed": "A szavazatod nem regisztrálódott. Kérlek próbáld újra.", "show_original": "Eredeti megjelenítése", "show_translation": "Fordítás megjelenítése", "show_original_translated_from": "Eredeti megjelenítése (fordítva: {{language}})", @@ -428,9 +428,9 @@ "validate_download_source": "Érvényesítés", "remove_download_source": "Eltávolítás", "add_download_source": "Forrás hozáadása", - "adding": "Hozzáadás…", + "adding": "Hozzáadás…", "failed_add_download_source": "Letöltési forrás hozzáadása sikertelen. Kérlek próbáld újra.", - "download_source_already_exists": "Ez a letöltési forrás URL már létezik.", + "download_source_already_exists": "Ez a letöltési forrás URL már létezik.", "download_count_zero": "Nincs letöltési opció", "download_count_one": "{{countFormatted}} letöltési opció", "download_count_other": "{{countFormatted}} letöltési opció", @@ -438,16 +438,16 @@ "add_download_source_description": "Helyezd be a .json fájl URL-jét", "download_source_up_to_date": "Naprakész", "download_source_errored": "Hiba történt", - "download_source_pending_matching": "Frissítés hamarosan", + "download_source_pending_matching": "Frissítés hamarosan", "download_source_matched": "Naprakész", "download_source_matching": "Frissítés..", "download_source_failed": "Hiba", - "download_source_no_information": "Nincs elérhető információ", + "download_source_no_information": "Nincs elérhető információ", "sync_download_sources": "Források szinkronizálása", "removed_download_source": "Letöltési forrás eltávolítva", "removed_download_sources": "Letöltési források eltávolítva", "removed_all_download_sources": "Összes letöltési forrás eltávolítva", - "download_sources_synced_successfully": "Az összes letöltési forrás szinkronizálva", + "download_sources_synced_successfully": "Az összes letöltési forrás szinkronizálva", "cancel_button_confirmation_delete_all_sources": "Nem", "confirm_button_confirmation_delete_all_sources": "Igen, törölj mindent", "title_confirmation_delete_all_sources": "Az összes letöltési forrás törlése", @@ -460,7 +460,7 @@ "found_download_option_one": "{{countFormatted}} Letöltési opció találva", "found_download_option_other": "{{countFormatted}} Letöltési opciók találva", "import": "Importálás", - "importing": "Importálás...", + "importing": "Importálás...", "public": "Publikus", "private": "Privát", "friends_only": "Csak barátok", @@ -478,7 +478,7 @@ "seed_after_download_complete": "Letöltés utáni seedelés", "show_hidden_achievement_description": "Rejtett achievementek leírásának megjelenítése feloldás előtt", "account": "Fiók", - "hydra_cloud": "Hydra Cloud", + "hydra_cloud": "Hydra Cloud", "no_users_blocked": "Nincsenek letiltott felhasználóid", "subscription_active_until": "Hydra Cloud előfizetésed aktív, eddig: {{date}}", "manage_subscription": "Előfizetés kezelése", @@ -553,7 +553,7 @@ "test_notification": "Értesítés tesztelése", "notification_preview": "Achievement Értesítés Előnézete", "enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot", - "autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán", + "autoplay_trailers_on_game_page": "Játékelőzetes automatikus lejátszása a játék oldalán", "hide_to_tray_on_game_start": "Hydra elrejtése játék elindításakor a tálcára" }, "notifications": { From a2ef0f304daa827d29aeaca4a13e92a4fe87b5fb Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Thu, 30 Oct 2025 07:35:49 -0300 Subject: [PATCH 089/118] fix: playtime count and custom games request on process watcher --- .../achievement-watcher-manager.ts | 2 + src/main/services/library-sync/create-game.ts | 4 ++ .../library-sync/update-game-playtime.ts | 4 ++ src/main/services/process-watcher.ts | 44 +++++++++++++------ 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/main/services/achievements/achievement-watcher-manager.ts b/src/main/services/achievements/achievement-watcher-manager.ts index b862abbe..dd65165a 100644 --- a/src/main/services/achievements/achievement-watcher-manager.ts +++ b/src/main/services/achievements/achievement-watcher-manager.ts @@ -167,6 +167,8 @@ export class AchievementWatcherManager { shop: GameShop, objectId: string ) { + if (shop === "custom") return; + const gameKey = levelKeys.game(shop, objectId); if (this.alreadySyncedGames.get(gameKey)) return; diff --git a/src/main/services/library-sync/create-game.ts b/src/main/services/library-sync/create-game.ts index a346d3b4..e9ec9612 100644 --- a/src/main/services/library-sync/create-game.ts +++ b/src/main/services/library-sync/create-game.ts @@ -3,6 +3,10 @@ import { HydraApi } from "../hydra-api"; import { gamesSublevel, levelKeys } from "@main/level"; export const createGame = async (game: Game) => { + if (game.shop === "custom") { + return; + } + return HydraApi.post(`/profile/games`, { objectId: game.objectId, playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds ?? 0), diff --git a/src/main/services/library-sync/update-game-playtime.ts b/src/main/services/library-sync/update-game-playtime.ts index 3689b302..b53ebebc 100644 --- a/src/main/services/library-sync/update-game-playtime.ts +++ b/src/main/services/library-sync/update-game-playtime.ts @@ -6,6 +6,10 @@ export const updateGamePlaytime = async ( deltaInMillis: number, lastTimePlayed: Date ) => { + if (game.shop === "custom") { + return; + } + return HydraApi.put(`/profile/games/${game.remoteId}`, { playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000), lastTimePlayed, diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 6408c30d..a1449255 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -198,11 +198,6 @@ export const watchProcesses = async () => { function onOpenGame(game: Game) { const now = performance.now(); - AchievementWatcherManager.firstSyncWithRemoteIfNeeded( - game.shop, - game.objectId - ); - gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { lastTick: now, firstTick: now, @@ -220,6 +215,13 @@ function onOpenGame(game: Game) { }) .catch(() => {}); + if (game.shop === "custom") return; + + AchievementWatcherManager.firstSyncWithRemoteIfNeeded( + game.shop, + game.objectId + ); + if (game.remoteId) { updateGamePlaytime( game, @@ -255,18 +257,20 @@ function onTickGame(game: Game) { const delta = now - gamePlaytime.lastTick; - gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { + const updatedGame: Game = { ...game, playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta, lastTimePlayed: new Date(), - }); + }; + + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), updatedGame); gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { ...gamePlaytime, lastTick: now, }); - if (currentTick % TICKS_TO_UPDATE_API === 0) { + if (currentTick % TICKS_TO_UPDATE_API === 0 && game.shop !== "custom") { const deltaToSync = now - gamePlaytime.lastSyncTick + @@ -279,19 +283,20 @@ function onTickGame(game: Game) { gamePromise .then(() => { gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { - ...game, + ...updatedGame, unsyncedDeltaPlayTimeInMilliseconds: 0, }); }) .catch(() => { gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { - ...game, + ...updatedGame, unsyncedDeltaPlayTimeInMilliseconds: deltaToSync, }); }) .finally(() => { gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), { ...gamePlaytime, + lastTick: now, lastSyncTick: now, }); }); @@ -299,11 +304,24 @@ function onTickGame(game: Game) { } const onCloseGame = (game: Game) => { + const now = performance.now(); const gamePlaytime = gamesPlaytime.get( levelKeys.game(game.shop, game.objectId) )!; gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId)); + const delta = now - gamePlaytime.lastTick; + + const updatedGame: Game = { + ...game, + playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta, + lastTimePlayed: new Date(), + }; + + gamesSublevel.put(levelKeys.game(game.shop, game.objectId), updatedGame); + + if (game.shop === "custom") return; + if (game.remoteId) { if (game.automaticCloudSync) { CloudSync.uploadSaveGame( @@ -315,20 +333,20 @@ const onCloseGame = (game: Game) => { } const deltaToSync = - performance.now() - + now - gamePlaytime.lastSyncTick + (game.unsyncedDeltaPlayTimeInMilliseconds ?? 0); return updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!) .then(() => { return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { - ...game, + ...updatedGame, unsyncedDeltaPlayTimeInMilliseconds: 0, }); }) .catch(() => { return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { - ...game, + ...updatedGame, unsyncedDeltaPlayTimeInMilliseconds: deltaToSync, }); }); From 459bf731217ace778a9ff9e7c6dc18288d2a17bd Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Thu, 30 Oct 2025 07:36:23 -0300 Subject: [PATCH 090/118] fix: request download-sources on custom game --- src/renderer/src/context/game-details/game-details.context.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 c5b88607..bc1a6351 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -293,6 +293,8 @@ export function GameDetailsContextProvider({ }, [objectId, shop, userDetails]); useEffect(() => { + if (shop === "custom") return; + const fetchDownloadSources = async () => { try { const sources = await window.electron.getDownloadSources(); From aadf648a2bfe7a52db7036d90ec7b418663f68c6 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Thu, 30 Oct 2025 07:58:43 -0300 Subject: [PATCH 091/118] chore: unnecessary casting --- .../src/pages/settings/settings-download-sources.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx index 75f0cc73..675919e3 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.tsx +++ b/src/renderer/src/pages/settings/settings-download-sources.tsx @@ -89,7 +89,7 @@ export function SettingsDownloadSources() { try { await window.electron.removeDownloadSource(false, downloadSource.id); const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources as DownloadSource[]); + setDownloadSources(sources); showSuccessToast(t("removed_download_source")); } catch (error) { logger.error("Failed to remove download source:", error); @@ -104,7 +104,7 @@ export function SettingsDownloadSources() { try { await window.electron.removeDownloadSource(true); const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources as DownloadSource[]); + setDownloadSources(sources); showSuccessToast(t("removed_all_download_sources")); } catch (error) { logger.error("Failed to remove all download sources:", error); @@ -117,7 +117,7 @@ export function SettingsDownloadSources() { const handleAddDownloadSource = async () => { try { const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources as DownloadSource[]); + setDownloadSources(sources); } catch (error) { logger.error("Failed to refresh download sources:", error); } @@ -128,7 +128,7 @@ export function SettingsDownloadSources() { try { await window.electron.syncDownloadSources(); const sources = await window.electron.getDownloadSources(); - setDownloadSources(sources as DownloadSource[]); + setDownloadSources(sources); showSuccessToast(t("download_sources_synced_successfully")); } finally { From 4bfe6d7f86f864c8989339d7ee6a6bc6ea483f64 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:32:08 -0300 Subject: [PATCH 092/118] feat: limit game text search to 255 chars --- src/renderer/src/components/header/header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index e61f3954..8166658d 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -60,7 +60,7 @@ export function Header() { }; const handleSearch = (value: string) => { - dispatch(setFilters({ title: value })); + dispatch(setFilters({ title: value.trim().slice(0, 255) })); if (!location.pathname.startsWith("/catalogue")) { navigate("/catalogue"); From 2aa31c0db0556b1a674d6cd49d08e2ce1fecbf67 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:34:49 -0300 Subject: [PATCH 093/118] feat: limit game text search to 255 chars --- src/renderer/src/pages/catalogue/catalogue.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/renderer/src/pages/catalogue/catalogue.tsx b/src/renderer/src/pages/catalogue/catalogue.tsx index bbeda906..b9eb3c24 100644 --- a/src/renderer/src/pages/catalogue/catalogue.tsx +++ b/src/renderer/src/pages/catalogue/catalogue.tsx @@ -35,7 +35,7 @@ export default function Catalogue() { const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue(); - const { steamGenres, steamUserTags } = useAppSelector( + const { steamGenres, steamUserTags, filters, page } = useAppSelector( (state) => state.catalogueSearch ); @@ -47,8 +47,6 @@ export default function Catalogue() { const { formatNumber } = useFormat(); - const { filters, page } = useAppSelector((state) => state.catalogueSearch); - const dispatch = useAppDispatch(); const { t, i18n } = useTranslation("catalogue"); From 80e0adcd4969ffda3eb25c8006009a22f1e879f7 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 30 Oct 2025 23:33:07 +0200 Subject: [PATCH 094/118] fix: removed ability to enter non-number symbols to pagination --- .../src/pages/catalogue/pagination.tsx | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/pages/catalogue/pagination.tsx b/src/renderer/src/pages/catalogue/pagination.tsx index 9febc8f8..8dd85cab 100644 --- a/src/renderer/src/pages/catalogue/pagination.tsx +++ b/src/renderer/src/pages/catalogue/pagination.tsx @@ -29,9 +29,11 @@ function JumpControl({ return isOpen ? ( ) => { - const val = e.target.value; - if (val === "") { + const raw = e.target.value; + const digitsOnly = raw.replace(/\D+/g, ""); + if (digitsOnly === "") { setJumpValue(""); return; } - const num = Number(val); + const num = parseInt(digitsOnly, 10); if (Number.isNaN(num)) { + setJumpValue(""); return; } if (num < 1) { @@ -104,19 +108,38 @@ export function Pagination({ setJumpValue(String(totalPages)); return; } - setJumpValue(val); + setJumpValue(String(num)); }; const onJumpKeyDown = (e: KeyboardEvent) => { + // Allow common control keys + const controlKeys = [ + "Backspace", + "Delete", + "Tab", + "ArrowLeft", + "ArrowRight", + "Home", + "End", + ]; + + if (controlKeys.includes(e.key) || e.ctrlKey || e.metaKey) { + return; + } + if (e.key === "Enter") { - if (jumpValue.trim() === "") return; - const parsed = Number(jumpValue); + const sanitized = jumpValue.replace(/\D+/g, ""); + if (sanitized.trim() === "") return; + const parsed = parseInt(sanitized, 10); if (Number.isNaN(parsed)) return; const target = Math.max(1, Math.min(totalPages, parsed)); onPageChange(target); setIsJumpOpen(false); } else if (e.key === "Escape") { setIsJumpOpen(false); + } else if (!/^\d$/.test(e.key)) { + // Block any non-digit input (e.g., '.', ',') + e.preventDefault(); } }; From bbbf861594575fa8bfa685fecc3dd6e3ca967134 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 30 Oct 2025 23:36:41 +0200 Subject: [PATCH 095/118] fix: deleted comments --- src/renderer/src/pages/catalogue/pagination.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/renderer/src/pages/catalogue/pagination.tsx b/src/renderer/src/pages/catalogue/pagination.tsx index 8dd85cab..c41635d0 100644 --- a/src/renderer/src/pages/catalogue/pagination.tsx +++ b/src/renderer/src/pages/catalogue/pagination.tsx @@ -112,7 +112,6 @@ export function Pagination({ }; const onJumpKeyDown = (e: KeyboardEvent) => { - // Allow common control keys const controlKeys = [ "Backspace", "Delete", @@ -138,7 +137,6 @@ export function Pagination({ } else if (e.key === "Escape") { setIsJumpOpen(false); } else if (!/^\d$/.test(e.key)) { - // Block any non-digit input (e.g., '.', ',') e.preventDefault(); } }; From bd059cc7fa3800276f053f9650bdd355a85677a9 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Thu, 30 Oct 2025 23:45:29 +0200 Subject: [PATCH 096/118] feat: update cursorrules --- .cursorrules | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.cursorrules b/.cursorrules index 0b0c009c..5015ab7e 100644 --- a/.cursorrules +++ b/.cursorrules @@ -27,3 +27,11 @@ - Follow TypeScript strict mode conventions - Use async/await instead of promises when possible - Prefer named exports over default exports for utilities and services + +## Comments + +- Keep comments concise and purposeful; avoid verbose explanations. +- Focus on the "why" or non-obvious context, not restating the code. +- Prefer self-explanatory naming and structure over excessive comments. +- Do not comment every line or obvious behavior; remove stale comments. +- Use docblocks only where they add value (public APIs, complex logic). From aadbda770b5a6479688c40ae6c6f36cc904e06ac Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 31 Oct 2025 00:19:49 +0200 Subject: [PATCH 097/118] fix: linting issue, marked props as read-only --- src/renderer/src/pages/catalogue/pagination.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/pages/catalogue/pagination.tsx b/src/renderer/src/pages/catalogue/pagination.tsx index c41635d0..ecc2afe3 100644 --- a/src/renderer/src/pages/catalogue/pagination.tsx +++ b/src/renderer/src/pages/catalogue/pagination.tsx @@ -58,7 +58,7 @@ export function Pagination({ page, totalPages, onPageChange, -}: PaginationProps) { +}: Readonly) { const { formatNumber } = useFormat(); const [isJumpOpen, setIsJumpOpen] = useState(false); @@ -90,12 +90,12 @@ export function Pagination({ const onJumpChange = (e: ChangeEvent) => { const raw = e.target.value; - const digitsOnly = raw.replace(/\D+/g, ""); + const digitsOnly = raw.replaceAll(/\D+/g, ""); if (digitsOnly === "") { setJumpValue(""); return; } - const num = parseInt(digitsOnly, 10); + const num = Number.parseInt(digitsOnly, 10); if (Number.isNaN(num)) { setJumpValue(""); return; @@ -127,9 +127,9 @@ export function Pagination({ } if (e.key === "Enter") { - const sanitized = jumpValue.replace(/\D+/g, ""); + const sanitized = jumpValue.replaceAll(/\D+/g, ""); if (sanitized.trim() === "") return; - const parsed = parseInt(sanitized, 10); + const parsed = Number.parseInt(sanitized, 10); if (Number.isNaN(parsed)) return; const target = Math.max(1, Math.min(totalPages, parsed)); onPageChange(target); From aa148c0b70234013c2137b64afc7530faf1ea48f Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Thu, 30 Oct 2025 20:01:47 -0300 Subject: [PATCH 098/118] fix: trim --- src/renderer/src/components/header/header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 8166658d..6f97729f 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -60,7 +60,7 @@ export function Header() { }; const handleSearch = (value: string) => { - dispatch(setFilters({ title: value.trim().slice(0, 255) })); + dispatch(setFilters({ title: value.slice(0, 255) })); if (!location.pathname.startsWith("/catalogue")) { navigate("/catalogue"); From b8af69b0fbc52533b9c144b23ad22c107de53cd7 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Fri, 31 Oct 2025 12:01:42 +0000 Subject: [PATCH 099/118] fix: fixing review partial --- src/locales/en/translation.json | 4 +++- src/locales/es/translation.json | 4 +++- src/locales/pt-BR/translation.json | 4 +++- src/locales/pt-PT/translation.json | 4 +++- src/locales/ru/translation.json | 4 +++- .../src/pages/game-details/game-reviews.tsx | 1 - .../src/pages/game-details/review-item.tsx | 21 ++++++------------- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 668f1547..b48c6ece 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -361,7 +361,9 @@ "show_original": "Show original", "show_translation": "Show translation", "show_original_translated_from": "Show original (translated from {{language}})", - "hide_original": "Hide original" + "hide_original": "Hide original", + "show": "Show", + "hide": "Hide" }, "activation": { "title": "Activate Hydra", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 863b8332..75a9adce 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -361,7 +361,9 @@ "you_seemed_to_enjoy_this_game": "Parece que has disfrutado de este juego", "language": "Idioma", "caption": "Subtítulo", - "audio": "Audio" + "audio": "Audio", + "show": "Mostrar", + "hide": "Ocultar" }, "activation": { "title": "Activar Hydra", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 5bfc2af3..98946d51 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -349,7 +349,9 @@ "show_translation": "Mostrar tradução", "show_original_translated_from": "Mostrar original (traduzido do {{language}})", "hide_original": "Ocultar original", - "rating_count": "Avaliação" + "rating_count": "Avaliação", + "show": "Mostrar", + "hide": "Ocultar" }, "activation": { "title": "Ativação", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 2894cf65..a99bb93d 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -180,7 +180,9 @@ "download_error_not_cached_on_torbox": "Este download não está disponível no TorBox e a verificação do status do download não está disponível.", "game_removed_from_favorites": "Jogo removido dos favoritos", "game_added_to_favorites": "Jogo adicionado aos favoritos", - "create_start_menu_shortcut": "Criar atalho no Menu Iniciar" + "create_start_menu_shortcut": "Criar atalho no Menu Iniciar", + "show": "Mostrar", + "hide": "Ocultar" }, "activation": { "title": "Ativação", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 886c7d07..388763bd 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -360,7 +360,9 @@ "caption": "Субтитры", "audio": "Аудио", "filter_by_source": "Фильтр по источнику", - "no_repacks_found": "Источники для этой игры не найдены" + "no_repacks_found": "Источники для этой игры не найдены", + "show": "Показать", + "hide": "Скрыть" }, "activation": { "title": "Активировать Hydra", diff --git a/src/renderer/src/pages/game-details/game-reviews.tsx b/src/renderer/src/pages/game-details/game-reviews.tsx index 1a6fc675..2dfd8864 100644 --- a/src/renderer/src/pages/game-details/game-reviews.tsx +++ b/src/renderer/src/pages/game-details/game-reviews.tsx @@ -163,7 +163,6 @@ export function GameReviews({ take: "20", skip: skip.toString(), sortBy: reviewsSortBy, - language: i18n.language, }); const response = await window.electron.hydraApi.get( diff --git a/src/renderer/src/pages/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx index f5e3528a..7e407e20 100644 --- a/src/renderer/src/pages/game-details/review-item.tsx +++ b/src/renderer/src/pages/game-details/review-item.tsx @@ -71,24 +71,16 @@ export function ReviewItem({ const [showOriginal, setShowOriginal] = useState(false); - // Check if this is the user's own review const isOwnReview = userDetailsId === review.user.id; - // Helper to get base language code (e.g., "pt" from "pt-BR") - const getBaseLanguage = (lang: string) => lang.split("-")[0]; + const getBaseLanguage = (lang: string | null) => lang?.split("-")[0] || ""; - // Check if the review is in a different language (comparing base language codes) const isDifferentLanguage = getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language); - // Check if translation is available and needed (but not for own reviews) const needsTranslation = - !isOwnReview && - isDifferentLanguage && - review.translations && - review.translations[i18n.language]; + !isOwnReview && isDifferentLanguage && review.translations[i18n.language]; - // Get the full language name using Intl.DisplayNames const getLanguageName = (languageCode: string) => { try { const displayNames = new Intl.DisplayNames([i18n.language], { @@ -100,7 +92,6 @@ export function ReviewItem({ } }; - // Determine which content to show - always show original for own reviews const displayContent = needsTranslation ? review.translations[i18n.language] : review.reviewHtml; @@ -109,12 +100,12 @@ export function ReviewItem({ return (
- Review from blocked user —{" "} + {t("review_from_blocked_user")}
@@ -191,7 +182,7 @@ export function ReviewItem({ {showOriginal ? t("hide_original") : t("show_original_translated_from", { - language: getLanguageName(review.detectedLanguage), + language: getLanguageName(review.detectedLanguage!), })} {showOriginal && ( @@ -323,7 +314,7 @@ export function ReviewItem({ className="game-details__blocked-review-hide-link" onClick={() => onToggleVisibility(review.id)} > - Hide + {t("hide")} )}
From ff8a61ff7a83394e08609a48770cfe1323c8d8ce Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Fri, 31 Oct 2025 12:05:24 +0000 Subject: [PATCH 100/118] fix: fixing review partial --- src/locales/en/translation.json | 1 + src/locales/es/translation.json | 1 + src/locales/pt-BR/translation.json | 1 + src/locales/pt-PT/translation.json | 1 + src/locales/ru/translation.json | 3 ++- 5 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index b48c6ece..8b9ff73e 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -362,6 +362,7 @@ "show_translation": "Show translation", "show_original_translated_from": "Show original (translated from {{language}})", "hide_original": "Hide original", + "review_from_blocked_user": "Review from blocked user", "show": "Show", "hide": "Hide" }, diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 75a9adce..adf25e33 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -362,6 +362,7 @@ "language": "Idioma", "caption": "Subtítulo", "audio": "Audio", + "review_from_blocked_user": "Reseña de usuario bloqueado", "show": "Mostrar", "hide": "Ocultar" }, diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 98946d51..42743a64 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -350,6 +350,7 @@ "show_original_translated_from": "Mostrar original (traduzido do {{language}})", "hide_original": "Ocultar original", "rating_count": "Avaliação", + "review_from_blocked_user": "Avaliação de usuário bloqueado", "show": "Mostrar", "hide": "Ocultar" }, diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index a99bb93d..6c1963cc 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -181,6 +181,7 @@ "game_removed_from_favorites": "Jogo removido dos favoritos", "game_added_to_favorites": "Jogo adicionado aos favoritos", "create_start_menu_shortcut": "Criar atalho no Menu Iniciar", + "review_from_blocked_user": "Avaliação de utilizador bloqueado", "show": "Mostrar", "hide": "Ocultar" }, diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 682b1322..6f4d4b92 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -363,7 +363,8 @@ "show_original": "Показать оригинал", "show_translation": "Показать перевод", "show_original_translated_from": "Показать оригинал (переведено с {{language}})", - "hide_original": "Скрыть оригинал" + "hide_original": "Скрыть оригинал", + "review_from_blocked_user": "Отзыв от заблокированного пользователя" }, "activation": { "title": "Активировать Hydra", From c71f5947ba8dfb7856625477289bb9b2f44346f2 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Fri, 31 Oct 2025 10:20:11 -0300 Subject: [PATCH 101/118] feat: use new ep to track game playtime --- src/main/services/library-sync/update-game-playtime.ts | 4 ++-- src/main/services/process-watcher.ts | 8 ++++---- src/main/services/window-manager.ts | 8 +------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/main/services/library-sync/update-game-playtime.ts b/src/main/services/library-sync/update-game-playtime.ts index b53ebebc..a669a363 100644 --- a/src/main/services/library-sync/update-game-playtime.ts +++ b/src/main/services/library-sync/update-game-playtime.ts @@ -1,7 +1,7 @@ import type { Game } from "@types"; import { HydraApi } from "../hydra-api"; -export const updateGamePlaytime = async ( +export const trackGamePlaytime = async ( game: Game, deltaInMillis: number, lastTimePlayed: Date @@ -10,7 +10,7 @@ export const updateGamePlaytime = async ( return; } - return HydraApi.put(`/profile/games/${game.remoteId}`, { + return HydraApi.put(`/profile/games/${game.shop}/${game.objectId}`, { playTimeDeltaInSeconds: Math.trunc(deltaInMillis / 1000), lastTimePlayed, }); diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index a1449255..db5bbee1 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -1,5 +1,5 @@ import { WindowManager } from "./window-manager"; -import { createGame, updateGamePlaytime } from "./library-sync"; +import { createGame, trackGamePlaytime } from "./library-sync"; import type { Game, GameRunning, UserPreferences } from "@types"; import { PythonRPC } from "./python-rpc"; import axios from "axios"; @@ -223,7 +223,7 @@ function onOpenGame(game: Game) { ); if (game.remoteId) { - updateGamePlaytime( + trackGamePlaytime( game, game.unsyncedDeltaPlayTimeInMilliseconds ?? 0, new Date() @@ -277,7 +277,7 @@ function onTickGame(game: Game) { (game.unsyncedDeltaPlayTimeInMilliseconds ?? 0); const gamePromise = game.remoteId - ? updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!) + ? trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!) : createGame(game); gamePromise @@ -337,7 +337,7 @@ const onCloseGame = (game: Game) => { gamePlaytime.lastSyncTick + (game.unsyncedDeltaPlayTimeInMilliseconds ?? 0); - return updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!) + return trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!) .then(() => { return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { ...updatedGame, diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 673bf1a0..2484e8e7 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -289,12 +289,6 @@ export class WindowManager { } } - private static loadNotificationWindowURL() { - if (this.notificationWindow) { - this.loadWindowURL(this.notificationWindow, "achievement-notification"); - } - } - private static readonly NOTIFICATION_WINDOW_WIDTH = 360; private static readonly NOTIFICATION_WINDOW_HEIGHT = 140; @@ -387,7 +381,7 @@ export class WindowManager { this.notificationWindow.setIgnoreMouseEvents(true); this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1); - this.loadNotificationWindowURL(); + this.loadWindowURL(this.notificationWindow, "achievement-notification"); if (!app.isPackaged || isStaging) { this.notificationWindow.webContents.openDevTools(); From 138120460cf0957eaf584162172f8791c2c318e1 Mon Sep 17 00:00:00 2001 From: jarexe Date: Fri, 31 Oct 2025 10:57:44 -0300 Subject: [PATCH 102/118] fix: correct achievement notification positioning on multi-monitor setups --- src/main/services/window-manager.ts | 38 +++++++++++++++++++---------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 673bf1a0..834bf7ab 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -302,46 +302,58 @@ export class WindowManager { position: AchievementCustomNotificationPosition | undefined ) { const display = screen.getPrimaryDisplay(); - const { width, height } = display.workAreaSize; + const { + x: displayX, + y: displayY, + width: displayWidth, + height: displayHeight, + } = display.bounds; if (position === "bottom-left") { return { - x: 0, - y: height - this.NOTIFICATION_WINDOW_HEIGHT, + x: displayX, + y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT, }; } if (position === "bottom-center") { return { - x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2, - y: height - this.NOTIFICATION_WINDOW_HEIGHT, + x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2, + y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT, }; } if (position === "bottom-right") { return { - x: width - this.NOTIFICATION_WINDOW_WIDTH, - y: height - this.NOTIFICATION_WINDOW_HEIGHT, + x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH, + y: displayY + displayHeight - this.NOTIFICATION_WINDOW_HEIGHT, + }; + } + + if (position === "top-left") { + return { + x: displayX, + y: displayY, }; } if (position === "top-center") { return { - x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2, - y: 0, + x: displayX + (displayWidth - this.NOTIFICATION_WINDOW_WIDTH) / 2, + y: displayY, }; } if (position === "top-right") { return { - x: width - this.NOTIFICATION_WINDOW_WIDTH, - y: 0, + x: displayX + displayWidth - this.NOTIFICATION_WINDOW_WIDTH, + y: displayY, }; } return { - x: 0, - y: 0, + x: displayX, + y: displayY, }; } From 51c4e4f5b38b411f0ac92d13a1b15d8b676ceecc Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:07:06 -0300 Subject: [PATCH 103/118] chore: bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5d84e763..ee039574 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.7.2", + "version": "3.7.3", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", From d167628ed44e22c6090f352ee57cdb5710b32811 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:57:15 -0300 Subject: [PATCH 104/118] fix: prevent crash when detectedLanguage is null --- src/renderer/src/pages/game-details/review-item.tsx | 5 +++-- src/types/index.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/game-details/review-item.tsx b/src/renderer/src/pages/game-details/review-item.tsx index 7e407e20..c411e0cb 100644 --- a/src/renderer/src/pages/game-details/review-item.tsx +++ b/src/renderer/src/pages/game-details/review-item.tsx @@ -81,7 +81,8 @@ export function ReviewItem({ const needsTranslation = !isOwnReview && isDifferentLanguage && review.translations[i18n.language]; - const getLanguageName = (languageCode: string) => { + const getLanguageName = (languageCode: string | null) => { + if (!languageCode) return ""; try { const displayNames = new Intl.DisplayNames([i18n.language], { type: "language", @@ -182,7 +183,7 @@ export function ReviewItem({ {showOriginal ? t("hide_original") : t("show_original_translated_from", { - language: getLanguageName(review.detectedLanguage!), + language: getLanguageName(review.detectedLanguage), })} {showOriginal && ( diff --git a/src/types/index.ts b/src/types/index.ts index 4b13c496..c04b6232 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -252,7 +252,7 @@ export interface GameReview { translations: { [key: string]: string; }; - detectedLanguage: string; + detectedLanguage: string | null; } export interface TrendingGame extends ShopAssets { From 19bf99ff119f65394bcdaed84d0a1423472b1d88 Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:16:03 -0300 Subject: [PATCH 105/118] chore: add sleep to aur script --- .github/workflows/update-aur.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/update-aur.yml b/.github/workflows/update-aur.yml index 52fe907e..fa12b500 100644 --- a/.github/workflows/update-aur.yml +++ b/.github/workflows/update-aur.yml @@ -95,6 +95,8 @@ jobs: - name: Update PKGBUILD and .SRCINFO if: steps.check-update.outputs.update_needed == 'true' run: | + # sleeps for 1 minute to be sure GH updated the release info + sleep 60 # Update pkgver in PKGBUILD cd hydra-launcher-bin NEW_VERSION="${{ steps.get-version.outputs.version }}" From bf387aef3f240febd3e4b6d66c3d78be698c983f Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Sun, 2 Nov 2025 17:30:45 +0000 Subject: [PATCH 106/118] feat: improving animations --- .../user-profile/user-profile.context.tsx | 76 +- .../profile-content/profile-content.scss | 27 +- .../profile-content/profile-content.tsx | 681 +++++++++++------- .../user-library-game-card.scss | 25 +- .../user-library-game-card.tsx | 25 +- 5 files changed, 542 insertions(+), 292 deletions(-) diff --git a/src/renderer/src/context/user-profile/user-profile.context.tsx b/src/renderer/src/context/user-profile/user-profile.context.tsx index 87e2a669..9f3a861d 100644 --- a/src/renderer/src/context/user-profile/user-profile.context.tsx +++ b/src/renderer/src/context/user-profile/user-profile.context.tsx @@ -14,12 +14,15 @@ export interface UserProfileContext { isMe: boolean; userStats: UserStats | null; getUserProfile: () => Promise; - getUserLibraryGames: (sortBy?: string) => Promise; + getUserLibraryGames: (sortBy?: string, reset?: boolean) => Promise; + loadMoreLibraryGames: (sortBy?: string) => Promise; setSelectedBackgroundImage: React.Dispatch>; backgroundImage: string; badges: Badge[]; libraryGames: UserGame[]; pinnedGames: UserGame[]; + hasMoreLibraryGames: boolean; + isLoadingLibraryGames: boolean; } export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3"; @@ -30,12 +33,15 @@ export const userProfileContext = createContext({ isMe: false, userStats: null, getUserProfile: async () => {}, - getUserLibraryGames: async (_sortBy?: string) => {}, + getUserLibraryGames: async (_sortBy?: string, _reset?: boolean) => {}, + loadMoreLibraryGames: async (_sortBy?: string) => false, setSelectedBackgroundImage: () => {}, backgroundImage: "", badges: [], libraryGames: [], pinnedGames: [], + hasMoreLibraryGames: false, + isLoadingLibraryGames: false, }); const { Provider } = userProfileContext; @@ -62,6 +68,9 @@ export function UserProfileContextProvider({ DEFAULT_USER_PROFILE_BACKGROUND ); const [selectedBackgroundImage, setSelectedBackgroundImage] = useState(""); + const [libraryPage, setLibraryPage] = useState(0); + const [hasMoreLibraryGames, setHasMoreLibraryGames] = useState(true); + const [isLoadingLibraryGames, setIsLoadingLibraryGames] = useState(false); const isMe = userDetails?.id === userProfile?.id; @@ -93,7 +102,13 @@ export function UserProfileContextProvider({ }, [userId]); const getUserLibraryGames = useCallback( - async (sortBy?: string) => { + async (sortBy?: string, reset = true) => { + if (reset) { + setLibraryPage(0); + setHasMoreLibraryGames(true); + setIsLoadingLibraryGames(true); + } + try { const params = new URLSearchParams(); params.append("take", "12"); @@ -115,18 +130,68 @@ export function UserProfileContextProvider({ if (response) { setLibraryGames(response.library); setPinnedGames(response.pinnedGames); + setHasMoreLibraryGames(response.library.length === 12); } else { setLibraryGames([]); setPinnedGames([]); + setHasMoreLibraryGames(false); } } catch (error) { setLibraryGames([]); setPinnedGames([]); + setHasMoreLibraryGames(false); + } finally { + setIsLoadingLibraryGames(false); } }, [userId] ); + const loadMoreLibraryGames = useCallback( + async (sortBy?: string): Promise => { + if (isLoadingLibraryGames || !hasMoreLibraryGames) { + return false; + } + + setIsLoadingLibraryGames(true); + try { + const nextPage = libraryPage + 1; + const params = new URLSearchParams(); + params.append("take", "12"); + params.append("skip", String(nextPage * 12)); + if (sortBy) { + params.append("sortBy", sortBy); + } + + const queryString = params.toString(); + const url = queryString + ? `/users/${userId}/library?${queryString}` + : `/users/${userId}/library`; + + const response = await window.electron.hydraApi.get<{ + library: UserGame[]; + pinnedGames: UserGame[]; + }>(url); + + if (response && response.library.length > 0) { + setLibraryGames((prev) => [...prev, ...response.library]); + setLibraryPage(nextPage); + setHasMoreLibraryGames(response.library.length === 12); + return true; + } else { + setHasMoreLibraryGames(false); + return false; + } + } catch (error) { + setHasMoreLibraryGames(false); + return false; + } finally { + setIsLoadingLibraryGames(false); + } + }, + [userId, libraryPage, hasMoreLibraryGames, isLoadingLibraryGames] + ); + const getUserProfile = useCallback(async () => { getUserStats(); getUserLibraryGames(); @@ -164,6 +229,8 @@ export function UserProfileContextProvider({ setLibraryGames([]); setPinnedGames([]); setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND); + setLibraryPage(0); + setHasMoreLibraryGames(true); getUserProfile(); getBadges(); @@ -177,12 +244,15 @@ export function UserProfileContextProvider({ isMe, getUserProfile, getUserLibraryGames, + loadMoreLibraryGames, setSelectedBackgroundImage, backgroundImage: getBackgroundImageUrl(), userStats, badges, libraryGames, pinnedGames, + hasMoreLibraryGames, + isLoadingLibraryGames, }} > {children} diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.scss b/src/renderer/src/pages/profile/profile-content/profile-content.scss index 15b9de6d..ffdf6a45 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -101,6 +101,11 @@ gap: calc(globals.$spacing-unit); margin-bottom: calc(globals.$spacing-unit * 2); border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + } + + &__tab-wrapper { + position: relative; } &__tab { @@ -111,19 +116,22 @@ cursor: pointer; font-size: 14px; font-weight: 500; - border-bottom: 2px solid transparent; - transition: all ease 0.2s; - - &:hover { - color: rgba(255, 255, 255, 0.8); - } + transition: color ease 0.2s; &--active { color: white; - border-bottom-color: white; } } + &__tab-underline { + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: white; + } + &__games-grid { list-style: none; margin: 0; @@ -179,10 +187,6 @@ &__tab-panels { display: block; } - - &__tab-panel[hidden] { - display: none; - } } } @@ -210,7 +214,6 @@ .user-reviews__review-item { border-radius: 8px; - padding: calc(globals.$spacing-unit * 2); } .user-reviews__review-header { diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index e284cb88..749c7588 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -26,6 +26,8 @@ import { Star, ThumbsUp, ThumbsDown, TrashIcon } from "lucide-react"; import type { GameShop } from "@types"; import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; import "./profile-content.scss"; type SortOption = "playtime" | "achievementCount" | "playedRecently"; @@ -71,6 +73,9 @@ export function ProfileContent() { libraryGames, pinnedGames, getUserLibraryGames, + loadMoreLibraryGames, + hasMoreLibraryGames, + isLoadingLibraryGames, } = useContext(userProfileContext); const { userDetails } = useUserDetails(); const { formatDistance } = useDate(); @@ -104,10 +109,69 @@ export function ProfileContent() { useEffect(() => { if (userProfile) { - getUserLibraryGames(sortBy); + getUserLibraryGames(sortBy, true); } }, [sortBy, getUserLibraryGames, userProfile]); + const loadMoreRef = useRef(null); + const observerRef = useRef(null); + + useEffect(() => { + if (activeTab !== "library" || !hasMoreLibraryGames) { + return; + } + + // Clean up previous observer + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + // Use setTimeout to ensure the DOM element is available after render + const timeoutId = setTimeout(() => { + const currentRef = loadMoreRef.current; + if (!currentRef) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if ( + entry?.isIntersecting && + hasMoreLibraryGames && + !isLoadingLibraryGames + ) { + loadMoreLibraryGames(sortBy); + } + }, + { + root: null, + rootMargin: "200px", + threshold: 0.1, + } + ); + + observerRef.current = observer; + observer.observe(currentRef); + }, 100); + + return () => { + clearTimeout(timeoutId); + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + }; + }, [ + activeTab, + hasMoreLibraryGames, + isLoadingLibraryGames, + loadMoreLibraryGames, + sortBy, + libraryGames.length, + ]); + // Clear reviews state and reset tab when switching users useEffect(() => { setReviews([]); @@ -332,294 +396,373 @@ export function ProfileContent() {
- - +
+ + {activeTab === "library" && ( + + )} +
+
+ + {activeTab === "reviews" && ( + + )} +
-