From 7e59e02d033bccc7f83fbe0577e40848760749c8 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 16:18:49 +0300 Subject: [PATCH 01/24] Feat: Custom Games --- src/locales/en/translation.json | 55 +++- src/locales/ru/translation.json | 53 ++- src/main/events/index.ts | 2 + .../library/add-custom-game-to-library.ts | 68 ++++ .../events/library/change-game-playtime.ts | 12 +- src/main/events/library/update-custom-game.ts | 53 +++ src/main/index.ts | 48 +++ .../library-sync/merge-with-remote-games.ts | 2 + .../library-sync/upload-games-batch.ts | 2 +- src/preload/index.ts | 19 ++ src/renderer/src/assets/play-logo.svg | 4 + .../sidebar-adding-custom-game-modal.scss | 52 +++ .../sidebar-adding-custom-game-modal.tsx | 183 +++++++++++ .../components/sidebar/sidebar-game-item.tsx | 12 +- .../src/components/sidebar/sidebar.scss | 20 ++ .../src/components/sidebar/sidebar.tsx | 54 ++- .../game-details/game-details.context.tsx | 6 + src/renderer/src/declaration.d.ts | 15 + src/renderer/src/helpers.ts | 20 ++ .../game-details/game-details-content.tsx | 98 ++++-- .../src/pages/game-details/game-details.scss | 37 +++ .../hero/hero-panel-playtime.scss | 1 + .../game-details/hero/hero-panel-playtime.tsx | 18 +- .../modals/edit-custom-game-modal.scss | 46 +++ .../modals/edit-custom-game-modal.tsx | 311 ++++++++++++++++++ .../src/pages/game-details/modals/index.ts | 1 + src/types/game.types.ts | 2 +- src/types/level.types.ts | 2 + 28 files changed, 1145 insertions(+), 51 deletions(-) create mode 100644 src/main/events/library/add-custom-game-to-library.ts create mode 100644 src/main/events/library/update-custom-game.ts create mode 100644 src/renderer/src/assets/play-logo.svg create mode 100644 src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss create mode 100644 src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx create mode 100644 src/renderer/src/pages/game-details/modals/edit-custom-game-modal.scss create mode 100644 src/renderer/src/pages/game-details/modals/edit-custom-game-modal.tsx diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9f8de8f8..86ef567b 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -28,7 +28,60 @@ "friends": "Friends", "need_help": "Need help?", "favorites": "Favorites", - "playable_button_title": "Show only games you can play now" + "playable_button_title": "Show only games you can play now", + "add_custom_game_tooltip": "Add Custom Game", + "show_playable_only_tooltip": "Show Playable Only", + "custom_game_modal": "Add Custom Game", + "custom_game_modal_description": "Add a custom game to your library by selecting an executable file", + "custom_game_modal_executable_path": "Executable Path", + "custom_game_modal_select_executable": "Select executable file", + "custom_game_modal_game_name": "Game Name", + "custom_game_modal_enter_name": "Enter game name", + "custom_game_modal_image": "Game Image", + "custom_game_modal_select_image": "Select game image", + "custom_game_modal_image_preview": "Game image preview", + "custom_game_modal_browse": "Browse", + "custom_game_modal_cancel": "Cancel", + "custom_game_modal_add": "Add Game", + "custom_game_modal_adding": "Adding Game...", + "custom_game_modal_fill_required": "Please fill in all required fields", + "custom_game_modal_success": "Custom game added successfully", + "custom_game_modal_failed": "Failed to add custom game", + "custom_game_modal_executable": "Executable", + "custom_game_modal_image_filter": "Image", + "custom_game_modal_icon": "Game Icon", + "custom_game_modal_select_icon": "Select game icon", + "custom_game_modal_icon_preview": "Game icon preview", + "custom_game_modal_logo": "Game Logo", + "custom_game_modal_select_logo": "Select game logo", + "custom_game_modal_logo_preview": "Game logo preview", + "custom_game_modal_hero": "Library Hero Image", + "custom_game_modal_select_hero": "Select library hero image", + "custom_game_modal_hero_preview": "Library hero image preview", + "edit_custom_game_modal": "Edit Custom Game", + "edit_custom_game_modal_description": "Edit your custom game details", + "edit_custom_game_modal_game_name": "Game Name", + "edit_custom_game_modal_enter_name": "Enter game name", + "edit_custom_game_modal_image": "Game Image", + "edit_custom_game_modal_select_image": "Select game image", + "edit_custom_game_modal_browse": "Browse", + "edit_custom_game_modal_image_preview": "Game image preview", + "edit_custom_game_modal_icon": "Game Icon", + "edit_custom_game_modal_select_icon": "Select game icon", + "edit_custom_game_modal_icon_preview": "Game icon preview", + "edit_custom_game_modal_logo": "Game Logo", + "edit_custom_game_modal_select_logo": "Select game logo", + "edit_custom_game_modal_logo_preview": "Game logo preview", + "edit_custom_game_modal_hero": "Library Hero Image", + "edit_custom_game_modal_select_hero": "Select library hero image", + "edit_custom_game_modal_hero_preview": "Library hero image preview", + "edit_custom_game_modal_cancel": "Cancel", + "edit_custom_game_modal_update": "Update Game", + "edit_custom_game_modal_updating": "Updating Game...", + "edit_custom_game_modal_fill_required": "Please fill in all required fields", + "edit_custom_game_modal_success": "Custom game updated successfully", + "edit_custom_game_modal_failed": "Failed to update custom game", + "edit_custom_game_modal_image_filter": "Image" }, "header": { "search": "Search games", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index e576e5d9..ae8f2b9d 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -28,7 +28,58 @@ "friends": "Друзья", "need_help": "Нужна помощь?", "favorites": "Избранное", - "playable_button_title": "Показать только игры, в которые можно играть сейчас" + "playable_button_title": "Показать только игры, в которые можно играть сейчас", + "custom_game_modal": "Добавить пользовательскую игру", + "custom_game_modal_description": "Добавьте пользовательскую игру в библиотеку, выбрав исполняемый файл", + "custom_game_modal_executable_path": "Путь к исполняемому файлу", + "custom_game_modal_select_executable": "Выберите исполняемый файл", + "custom_game_modal_game_name": "Название игры", + "custom_game_modal_enter_name": "Введите название игры", + "custom_game_modal_image": "Изображение игры", + "custom_game_modal_select_image": "Выберите изображение игры", + "custom_game_modal_image_preview": "Предварительный просмотр изображения игры", + "custom_game_modal_browse": "Обзор", + "custom_game_modal_cancel": "Отмена", + "custom_game_modal_add": "Добавить игру", + "custom_game_modal_adding": "Добавление игры...", + "custom_game_modal_fill_required": "Пожалуйста, заполните все обязательные поля", + "custom_game_modal_success": "Пользовательская игра успешно добавлена", + "custom_game_modal_failed": "Не удалось добавить пользовательскую игру", + "custom_game_modal_executable": "Исполняемый файл", + "custom_game_modal_image_filter": "Изображение", + "custom_game_modal_icon": "Иконка игры", + "custom_game_modal_select_icon": "Выберите иконку игры", + "custom_game_modal_icon_preview": "Предпросмотр иконки игры", + "custom_game_modal_logo": "Логотип игры", + "custom_game_modal_select_logo": "Выберите логотип игры", + "custom_game_modal_logo_preview": "Предпросмотр логотипа игры", + "custom_game_modal_hero": "Изображение героя библиотеки", + "custom_game_modal_select_hero": "Выберите изображение героя библиотеки", + "custom_game_modal_hero_preview": "Предпросмотр изображения героя библиотеки", + "edit_custom_game_modal": "Редактировать пользовательскую игру", + "edit_custom_game_modal_description": "Редактируйте детали вашей пользовательской игры", + "edit_custom_game_modal_game_name": "Название игры", + "edit_custom_game_modal_enter_name": "Введите название игры", + "edit_custom_game_modal_image": "Изображение игры", + "edit_custom_game_modal_select_image": "Выберите изображение игры", + "edit_custom_game_modal_browse": "Обзор", + "edit_custom_game_modal_image_preview": "Предпросмотр изображения игры", + "edit_custom_game_modal_icon": "Иконка игры", + "edit_custom_game_modal_select_icon": "Выберите иконку игры", + "edit_custom_game_modal_icon_preview": "Предпросмотр иконки игры", + "edit_custom_game_modal_logo": "Логотип игры", + "edit_custom_game_modal_select_logo": "Выберите логотип игры", + "edit_custom_game_modal_logo_preview": "Предпросмотр логотипа игры", + "edit_custom_game_modal_hero": "Изображение героя библиотеки", + "edit_custom_game_modal_select_hero": "Выберите изображение героя библиотеки", + "edit_custom_game_modal_hero_preview": "Предпросмотр изображения героя библиотеки", + "edit_custom_game_modal_cancel": "Отмена", + "edit_custom_game_modal_update": "Обновить игру", + "edit_custom_game_modal_updating": "Обновление игры...", + "edit_custom_game_modal_fill_required": "Пожалуйста, заполните все обязательные поля", + "edit_custom_game_modal_success": "Пользовательская игра успешно обновлена", + "edit_custom_game_modal_failed": "Не удалось обновить пользовательскую игру", + "edit_custom_game_modal_image_filter": "Изображение" }, "header": { "search": "Поиск", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 9765b517..ea7597ad 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -14,6 +14,8 @@ import "./catalogue/get-developers"; import "./hardware/get-disk-free-space"; import "./hardware/check-folder-write-permission"; import "./library/add-game-to-library"; +import "./library/add-custom-game-to-library"; +import "./library/update-custom-game"; import "./library/add-game-to-favorites"; import "./library/remove-game-from-favorites"; import "./library/create-game-shortcut"; diff --git a/src/main/events/library/add-custom-game-to-library.ts b/src/main/events/library/add-custom-game-to-library.ts new file mode 100644 index 00000000..382328a5 --- /dev/null +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -0,0 +1,68 @@ +import { registerEvent } from "../register-event"; +import { + gamesSublevel, + gamesShopAssetsSublevel, + levelKeys, +} from "@main/level"; +import { randomUUID } from "crypto"; +import type { GameShop } from "@types"; + + +const addCustomGameToLibrary = async ( + _event: Electron.IpcMainInvokeEvent, + title: string, + executablePath: string, + iconUrl?: string, + logoImageUrl?: string, + libraryHeroImageUrl?: string +) => { + const objectId = randomUUID(); + const shop: GameShop = "custom"; + const gameKey = levelKeys.game(shop, objectId); + + const existingGames = await gamesSublevel.iterator().all(); + const existingGame = existingGames.find(([_key, game]) => + game.executablePath === executablePath && !game.isDeleted + ); + + if (existingGame) { + throw new Error("A game with this executable path already exists in your library"); + } + + const assets = { + objectId, + shop, + title, + iconUrl: iconUrl || null, + libraryHeroImageUrl: libraryHeroImageUrl || "", + libraryImageUrl: iconUrl || "", + logoImageUrl: logoImageUrl || "", + logoPosition: null, + coverImageUrl: iconUrl || "", + }; + await gamesShopAssetsSublevel.put(gameKey, assets); + + const game = { + title, + iconUrl: iconUrl || null, + logoImageUrl: logoImageUrl || null, + libraryHeroImageUrl: libraryHeroImageUrl || null, + objectId, + shop, + remoteId: null, + isDeleted: false, + playTimeInMilliseconds: 0, + lastTimePlayed: null, + executablePath, + launchOptions: null, + favorite: false, + automaticCloudSync: false, + hasManuallyUpdatedPlaytime: false, + }; + + await gamesSublevel.put(gameKey, game); + + return game; +}; + +registerEvent("addCustomGameToLibrary", addCustomGameToLibrary); \ No newline at end of file diff --git a/src/main/events/library/change-game-playtime.ts b/src/main/events/library/change-game-playtime.ts index ff37c33e..20da70ec 100644 --- a/src/main/events/library/change-game-playtime.ts +++ b/src/main/events/library/change-game-playtime.ts @@ -13,16 +13,20 @@ const changeGamePlaytime = async ( const gameKey = levelKeys.game(shop, objectId); const game = await gamesSublevel.get(gameKey); if (!game) return; - await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, { - playTimeInSeconds, - }); + + if (game.remoteId) { + await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, { + playTimeInSeconds, + }); + } + await gamesSublevel.put(gameKey, { ...game, playTimeInMilliseconds: playTimeInSeconds * 1000, hasManuallyUpdatedPlaytime: true, }); } catch (error) { - throw new Error(`Failed to update game favorite status: ${error}`); + throw new Error(`Failed to update game playtime: ${error}`); } }; diff --git a/src/main/events/library/update-custom-game.ts b/src/main/events/library/update-custom-game.ts new file mode 100644 index 00000000..4ff858a2 --- /dev/null +++ b/src/main/events/library/update-custom-game.ts @@ -0,0 +1,53 @@ +import { registerEvent } from "../register-event"; +import { + gamesSublevel, + gamesShopAssetsSublevel, + levelKeys, +} from "@main/level"; +import type { GameShop } from "@types"; + +const updateCustomGame = async ( + _event: Electron.IpcMainInvokeEvent, + shop: GameShop, + objectId: string, + title: string, + iconUrl?: string, + logoImageUrl?: string, + libraryHeroImageUrl?: string +) => { + const gameKey = levelKeys.game(shop, objectId); + + const existingGame = await gamesSublevel.get(gameKey); + if (!existingGame) { + throw new Error("Game not found"); + } + + const updatedGame = { + ...existingGame, + title, + iconUrl: iconUrl || null, + logoImageUrl: logoImageUrl || null, + libraryHeroImageUrl: libraryHeroImageUrl || null, + }; + + await gamesSublevel.put(gameKey, updatedGame); + + const existingAssets = await gamesShopAssetsSublevel.get(gameKey); + if (existingAssets) { + const updatedAssets = { + ...existingAssets, + title, + iconUrl: iconUrl || null, + libraryHeroImageUrl: libraryHeroImageUrl || "", + libraryImageUrl: iconUrl || "", + logoImageUrl: logoImageUrl || "", + coverImageUrl: iconUrl || "", + }; + + await gamesShopAssetsSublevel.put(gameKey, updatedAssets); + } + + return updatedGame; +}; + +registerEvent("updateCustomGame", updateCustomGame); \ No newline at end of file diff --git a/src/main/index.ts b/src/main/index.ts index af197a6b..af9aa676 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -64,6 +64,54 @@ app.whenReady().then(async () => { return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString()); }); + protocol.handle("gradient", (request) => { + const gradientCss = decodeURIComponent(request.url.slice("gradient:".length)); + + const match = gradientCss.match(/linear-gradient\(([^,]+),\s*([^,]+),\s*([^)]+)\)/); + + let direction = "45deg"; + let color1 = '#4a90e2'; + let color2 = '#7b68ee'; + + if (match) { + direction = match[1].trim(); + color1 = match[2].trim(); + color2 = match[3].trim(); + } + + let x1 = "0%", y1 = "0%", x2 = "100%", y2 = "100%"; + + if (direction === "to right") { + x1 = "0%"; y1 = "0%"; x2 = "100%"; y2 = "0%"; + } else if (direction === "to bottom") { + x1 = "0%"; y1 = "0%"; x2 = "0%"; y2 = "100%"; + } else if (direction === "45deg") { + x1 = "0%"; y1 = "100%"; x2 = "100%"; y2 = "0%"; + } else if (direction === "135deg") { + x1 = "0%"; y1 = "0%"; x2 = "100%"; y2 = "100%"; + } else if (direction === "225deg") { + x1 = "100%"; y1 = "0%"; x2 = "0%"; y2 = "100%"; + } else if (direction === "315deg") { + x1 = "100%"; y1 = "100%"; x2 = "0%"; y2 = "0%"; + } + + const svgContent = ` + + + + + + + + + + `; + + return new Response(svgContent, { + headers: { 'Content-Type': 'image/svg+xml' } + }); + }); + await loadState(); const language = await db 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 a35414ac..c5bbe144 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -44,6 +44,8 @@ export const mergeWithRemoteGames = async () => { remoteId: game.id, shop: game.shop, iconUrl: game.iconUrl, + libraryHeroImageUrl: game.libraryHeroImageUrl, + logoImageUrl: game.logoImageUrl, lastTimePlayed: game.lastTimePlayed, playTimeInMilliseconds: game.playTimeInMilliseconds, hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime, diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index 837fb48a..10d2c8dd 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -11,7 +11,7 @@ export const uploadGamesBatch = async () => { .all() .then((results) => { return results.filter( - (game) => !game.isDeleted && game.remoteId === null + (game) => !game.isDeleted && game.remoteId === null && game.shop !== "custom" ); }); diff --git a/src/preload/index.ts b/src/preload/index.ts index d29417b0..b9ad8d98 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -128,6 +128,23 @@ contextBridge.exposeInMainWorld("electron", { ), addGameToLibrary: (shop: GameShop, objectId: string, title: string) => ipcRenderer.invoke("addGameToLibrary", shop, objectId, title), + addCustomGameToLibrary: ( + title: string, + executablePath: string, + iconUrl?: string, + logoImageUrl?: string, + libraryHeroImageUrl?: string + ) => + ipcRenderer.invoke("addCustomGameToLibrary", title, executablePath, iconUrl, logoImageUrl, libraryHeroImageUrl), + updateCustomGame: ( + shop: GameShop, + objectId: string, + title: string, + iconUrl?: string, + logoImageUrl?: string, + libraryHeroImageUrl?: string + ) => + ipcRenderer.invoke("updateCustomGame", shop, objectId, title, iconUrl, logoImageUrl, libraryHeroImageUrl), createGameShortcut: ( shop: GameShop, objectId: string, @@ -476,4 +493,6 @@ contextBridge.exposeInMainWorld("electron", { }, closeEditorWindow: (themeId?: string) => ipcRenderer.invoke("closeEditorWindow", themeId), + + }); diff --git a/src/renderer/src/assets/play-logo.svg b/src/renderer/src/assets/play-logo.svg new file mode 100644 index 00000000..51ecaa28 --- /dev/null +++ b/src/renderer/src/assets/play-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss new file mode 100644 index 00000000..48b84da3 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss @@ -0,0 +1,52 @@ +@use "../../scss/globals.scss"; + +.sidebar-adding-custom-game-modal { + &__container { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 3); + width: 100%; + max-width: 500px; + margin: 0 auto; + text-align: center; + } + + &__form { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + text-align: left; + } + + &__image-section { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + text-align: left; + } + + &__image-preview { + display: flex; + justify-content: center; + align-items: center; + padding: globals.$spacing-unit; + border: 1px solid globals.$border-color; + border-radius: 4px; + background-color: rgba(255, 255, 255, 0.05); + } + + &__preview-image { + max-width: 120px; + max-height: 80px; + width: auto; + height: auto; + border-radius: 4px; + object-fit: contain; + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: calc(globals.$spacing-unit * 2); + } +} \ No newline at end of file diff --git a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx new file mode 100644 index 00000000..771c5464 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { FileDirectoryIcon } from "@primer/octicons-react"; + +import { Modal, TextField, Button } from "@renderer/components"; +import { useLibrary, useToast } from "@renderer/hooks"; +import { buildGameDetailsPath, generateRandomGradient } from "@renderer/helpers"; + +import "./sidebar-adding-custom-game-modal.scss"; + +export interface SidebarAddingCustomGameModalProps { + visible: boolean; + onClose: () => void; +} + +export function SidebarAddingCustomGameModal({ + visible, + onClose, +}: SidebarAddingCustomGameModalProps) { + const { t } = useTranslation("sidebar"); + const { updateLibrary } = useLibrary(); + const { showSuccessToast, showErrorToast } = useToast(); + const navigate = useNavigate(); + + const [gameName, setGameName] = useState(""); + const [executablePath, setExecutablePath] = useState(""); + const [isAdding, setIsAdding] = useState(false); + + const handleSelectExecutable = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("custom_game_modal_executable"), + extensions: ["exe", "msi", "app", "deb", "rpm", "dmg"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + const selectedPath = filePaths[0]; + setExecutablePath(selectedPath); + + if (!gameName.trim()) { + const fileName = selectedPath.split(/[\\/]/).pop() || ""; + const gameNameFromFile = fileName.replace(/\.[^/.]+$/, ""); + setGameName(gameNameFromFile); + } + } + }; + + const handleGameNameChange = (event: React.ChangeEvent) => { + setGameName(event.target.value); + }; + + + + const handleAddGame = async () => { + if (!gameName.trim() || !executablePath.trim()) { + showErrorToast(t("custom_game_modal_fill_required")); + return; + } + + setIsAdding(true); + + try { + // Generate gradient URL only for hero image + const gameNameForSeed = gameName.trim(); + const iconUrl = ""; // Don't use gradient for icon + const logoImageUrl = ""; // Don't use gradient for logo + const libraryHeroImageUrl = generateRandomGradient(); // Only use gradient for hero + + const newGame = await window.electron.addCustomGameToLibrary( + gameNameForSeed, + executablePath, + iconUrl, + logoImageUrl, + libraryHeroImageUrl + ); + + showSuccessToast(t("custom_game_modal_success")); + updateLibrary(); + + const gameDetailsPath = buildGameDetailsPath({ + shop: "custom", + objectId: newGame.objectId, + title: newGame.title + }); + + navigate(gameDetailsPath); + + setGameName(""); + setExecutablePath(""); + onClose(); + } catch (error) { + console.error("Failed to add custom game:", error); + showErrorToast( + error instanceof Error + ? error.message + : t("custom_game_modal_failed") + ); + } finally { + setIsAdding(false); + } + }; + + const handleClose = () => { + if (!isAdding) { + setGameName(""); + setExecutablePath(""); + onClose(); + } + }; + + const isFormValid = gameName.trim() && executablePath.trim(); + + + + return ( + +
+
+ + + {t("custom_game_modal_browse")} + + } + /> + + + + + + +
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 0672f847..4f905c59 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -1,4 +1,5 @@ import SteamLogo from "@renderer/assets/steam-logo.svg?react"; +import PlayLogo from "@renderer/assets/play-logo.svg?react"; import { LibraryGame } from "@types"; import cn from "classnames"; import { useLocation } from "react-router-dom"; @@ -16,6 +17,11 @@ export function SidebarGameItem({ }: Readonly) { const location = useLocation(); + const isCustomGame = game.shop === "custom"; + const sidebarIcon = isCustomGame + ? game.libraryImageUrl || game.iconUrl + : game.iconUrl; + return (
  • handleSidebarGameClick(event, game)} > - {game.iconUrl ? ( + {sidebarIcon ? ( {game.title} + ) : isCustomGame ? ( + ) : ( )} diff --git a/src/renderer/src/components/sidebar/sidebar.scss b/src/renderer/src/components/sidebar/sidebar.scss index 45b3f598..0fec7c30 100644 --- a/src/renderer/src/components/sidebar/sidebar.scss +++ b/src/renderer/src/components/sidebar/sidebar.scss @@ -172,4 +172,24 @@ display: block; } } + + &__add-button { + background: none; + border: none; + color: globals.$muted-color; + cursor: pointer; + padding: 0; + + &:hover { + color: rgba(255, 255, 255, 0.8); + } + + &:active { + color: rgba(255, 255, 255, 0.5); + } + + svg { + display: block; + } + } } diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index 42e87e32..a7d946a2 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; +import { Tooltip } from "react-tooltip"; import type { LibraryGame } from "@types"; @@ -21,8 +22,9 @@ import { buildGameDetailsPath } from "@renderer/helpers"; import { SidebarProfile } from "./sidebar-profile"; import { sortBy } from "lodash-es"; import cn from "classnames"; -import { CommentDiscussionIcon, PlayIcon } from "@primer/octicons-react"; +import { CommentDiscussionIcon, PlayIcon, PlusIcon } from "@primer/octicons-react"; import { SidebarGameItem } from "./sidebar-game-item"; +import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal"; import { setFriendRequestCount } from "@renderer/features/user-details-slice"; import { useDispatch } from "react-redux"; @@ -63,11 +65,20 @@ export function Sidebar() { const { showWarningToast } = useToast(); const [showPlayableOnly, setShowPlayableOnly] = useState(false); + const [showAddGameModal, setShowAddGameModal] = useState(false); const handlePlayButtonClick = () => { setShowPlayableOnly(!showPlayableOnly); }; + const handleAddGameButtonClick = () => { + setShowAddGameModal(true); + }; + + const handleCloseAddGameModal = () => { + setShowAddGameModal(false); + }; + useEffect(() => { updateLibrary(); }, [lastPacket?.gameId, updateLibrary]); @@ -254,15 +265,30 @@ export function Sidebar() { {t("my_library")} - +
    + + +
    + + + + + ); } 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 ce2923b2..81abfa13 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -201,6 +201,12 @@ export function GameDetailsContextProvider({ dispatch(setHeaderTitle(gameTitle)); }, [objectId, gameTitle, dispatch]); + useEffect(() => { + if (game?.title && game.shop === "custom") { + dispatch(setHeaderTitle(game.title)); + } + }, [game?.title, game?.shop, dispatch]); + useEffect(() => { const unsubscribe = window.electron.onGamesRunning((gamesIds) => { const updatedIsGameRunning = diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 0744884c..f66bd53e 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -111,6 +111,21 @@ declare global { objectId: string, title: string ) => Promise; + addCustomGameToLibrary: ( + title: string, + executablePath: string, + iconUrl?: string, + logoImageUrl?: string, + libraryHeroImageUrl?: string + ) => Promise; + updateCustomGame: ( + shop: GameShop, + objectId: string, + title: string, + iconUrl?: string, + logoImageUrl?: string, + libraryHeroImageUrl?: string + ) => Promise; createGameShortcut: ( shop: GameShop, objectId: string, diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index eb7cebb0..7c45bafd 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -84,3 +84,23 @@ export const injectCustomCss = ( export const removeCustomCss = (target: HTMLElement = document.head) => { target.querySelector("#custom-css")?.remove(); }; + +export const generateRandomGradient = (): string => { + // Use a single consistent gradient with softer colors for custom games as placeholder + const color1 = '#2c3e50'; // Dark blue-gray + const color2 = '#34495e'; // Darker slate + + // Create SVG data URL that works in img tags + const svgContent = ` + + + + + + + + `; + + // Return as data URL that works in img tags + return `data:image/svg+xml;base64,${btoa(svgContent)}`; +}; 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 40436614..ba3fb7bd 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -1,18 +1,20 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { average } from "color.js"; import Color from "color"; +import { PencilIcon } from "@primer/octicons-react"; import { HeroPanel } from "./hero"; import { DescriptionHeader } from "./description-header/description-header"; import { GallerySlider } from "./gallery-slider/gallery-slider"; import { Sidebar } from "./sidebar/sidebar"; +import { EditCustomGameModal } from "./modals"; import { useTranslation } from "react-i18next"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; import { AuthPage } from "@shared"; import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif"; -import { useUserDetails } from "@renderer/hooks"; +import { useUserDetails, useLibrary } from "@renderer/hooks"; import { useSubscription } from "@renderer/hooks/use-subscription"; import "./game-details.scss"; @@ -28,11 +30,13 @@ export function GameDetailsContent() { gameColor, setGameColor, hasNSFWContentBlocked, + updateGame, } = useContext(gameDetailsContext); const { showHydraCloudModal } = useSubscription(); const { userDetails, hasActiveSubscription } = useUserDetails(); + const { updateLibrary } = useLibrary(); const { setShowCloudSyncModal, getGameArtifacts } = useContext(cloudSyncContext); @@ -53,10 +57,15 @@ export function GameDetailsContent() { return document.body.outerHTML; } + if (game?.shop === "custom") { + return ""; + } + return t("no_shop_details"); - }, [shopDetails, t]); + }, [shopDetails, t, game?.shop]); const [backdropOpacity, setBackdropOpacity] = useState(1); + const [showEditCustomGameModal, setShowEditCustomGameModal] = useState(false); const handleHeroLoad = async () => { const output = await average( @@ -92,10 +101,27 @@ export function GameDetailsContent() { setShowCloudSyncModal(true); }; + const handleEditCustomGameClick = () => { + setShowEditCustomGameModal(true); + }; + + const handleGameUpdated = (_updatedGame: any) => { + updateGame(); + updateLibrary(); + }; + useEffect(() => { getGameArtifacts(); }, [getGameArtifacts]); + const isCustomGame = game?.shop === "custom"; + const heroImage = isCustomGame + ? game?.libraryHeroImageUrl || game?.iconUrl || "" + : shopDetails?.assets?.libraryHeroImageUrl || ""; + const logoImage = isCustomGame + ? game?.logoImageUrl || "" // Don't use icon as fallback for custom games + : shopDetails?.assets?.logoImageUrl || ""; + return (
    {game?.title}
    - {game?.title} + {logoImage && ( + {game?.title} + )} - +
    + {game?.shop === "custom" && ( + + )} + + {game?.shop !== "custom" && ( + + )} +
    @@ -160,9 +203,18 @@ export function GameDetailsContent() { /> - + {game?.shop !== "custom" && } + + {game?.shop === "custom" && ( + setShowEditCustomGameModal(false)} + game={game} + onGameUpdated={handleGameUpdated} + /> + )} ); } diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss index 899d654a..46c0ae51 100644 --- a/src/renderer/src/pages/game-details/game-details.scss +++ b/src/renderer/src/pages/game-details/game-details.scss @@ -52,6 +52,43 @@ $hero-height: 300px; align-items: flex-end; } + &__hero-buttons { + display: flex; + gap: globals.$spacing-unit; + align-items: center; + + &--right { + margin-left: auto; + } + } + + &__edit-custom-game-button { + padding: calc(globals.$spacing-unit * 1.5); + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(20px); + border-radius: 8px; + transition: all ease 0.2s; + cursor: pointer; + min-height: 40px; + min-width: 40px; + display: flex; + align-items: center; + justify-content: center; + color: globals.$muted-color; + border: solid 1px globals.$border-color; + box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8); + animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1); + + &:active { + opacity: 0.9; + } + + &:hover { + background-color: rgba(0, 0, 0, 0.5); + color: globals.$body-color; + } + } + &__hero-logo-backdrop { width: 100%; height: 100%; diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.scss b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.scss index 00142d59..9429ef21 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.scss +++ b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.scss @@ -17,6 +17,7 @@ display: flex; align-items: center; gap: 8px; + width: fit-content; } &__manual-warning { diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx index 9cd246be..270ed030 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-playtime.tsx @@ -11,7 +11,6 @@ import "./hero-panel-playtime.scss"; export function HeroPanelPlaytime() { const [lastTimePlayed, setLastTimePlayed] = useState(""); - const { game, isGameRunning } = useContext(gameDetailsContext); const { t } = useTranslation("game_details"); @@ -89,19 +88,23 @@ export function HeroPanelPlaytime() { return ( <> -

    {game.hasManuallyUpdatedPlaytime && ( - )} @@ -119,7 +122,7 @@ export function HeroPanelPlaytime() { })}

    )} - + {game.hasManuallyUpdatedPlaytime && ( )} diff --git a/src/renderer/src/pages/game-details/modals/edit-custom-game-modal.scss b/src/renderer/src/pages/game-details/modals/edit-custom-game-modal.scss new file mode 100644 index 00000000..6f24d4ef --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-custom-game-modal.scss @@ -0,0 +1,46 @@ +@use "../../../scss/globals.scss"; + +.edit-custom-game-modal { + &__container { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + } + + &__form { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + } + + &__image-section { + display: flex; + flex-direction: column; + gap: globals.$spacing-unit; + } + + &__image-preview { + display: flex; + justify-content: center; + align-items: center; + padding: globals.$spacing-unit; + border: 1px dashed globals.$border-color; + border-radius: 8px; + background-color: rgba(255, 255, 255, 0.05); + } + + &__preview-image { + max-width: 120px; + max-height: 80px; + border-radius: 8px; + object-fit: cover; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + } + + &__actions { + display: flex; + gap: globals.$spacing-unit; + justify-content: flex-end; + margin-top: globals.$spacing-unit; + } +} \ No newline at end of file diff --git a/src/renderer/src/pages/game-details/modals/edit-custom-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-custom-game-modal.tsx new file mode 100644 index 00000000..a489b88c --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-custom-game-modal.tsx @@ -0,0 +1,311 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { ImageIcon } from "@primer/octicons-react"; + +import { Modal, TextField, Button } from "@renderer/components"; +import { useToast } from "@renderer/hooks"; +import type { Game } from "@types"; + +import "./edit-custom-game-modal.scss"; + +export interface EditCustomGameModalProps { + visible: boolean; + onClose: () => void; + game: Game; + onGameUpdated: (updatedGame: Game) => void; +} + +export function EditCustomGameModal({ + visible, + onClose, + game, + onGameUpdated, +}: EditCustomGameModalProps) { + const { t } = useTranslation("sidebar"); + const { showSuccessToast, showErrorToast } = useToast(); + + const [gameName, setGameName] = useState(""); + const [iconPath, setIconPath] = useState(""); + const [logoPath, setLogoPath] = useState(""); + const [heroPath, setHeroPath] = useState(""); + const [isUpdating, setIsUpdating] = useState(false); + + useEffect(() => { + if (game && visible) { + setGameName(game.title || ""); + + const currentIconPath = game.iconUrl?.startsWith("local:") + ? game.iconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.logoImageUrl?.startsWith("local:") + ? game.logoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.libraryHeroImageUrl?.startsWith("local:") + ? game.libraryHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } + }, [game, visible]); + + const handleGameNameChange = (event: React.ChangeEvent) => { + setGameName(event.target.value); + }; + + const handleSelectIcon = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setIconPath(filePaths[0]); + } + }; + + const handleSelectLogo = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setLogoPath(filePaths[0]); + } + }; + + const handleSelectHero = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setHeroPath(filePaths[0]); + } + }; + + const handleUpdateGame = async () => { + if (!gameName.trim()) { + showErrorToast(t("edit_custom_game_modal_fill_required")); + return; + } + + setIsUpdating(true); + + try { + // Preserve existing image URLs if not changed + const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl; + const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl; + const libraryHeroImageUrl = heroPath ? `local:${heroPath}` : game.libraryHeroImageUrl; + + const updatedGame = await window.electron.updateCustomGame( + game.shop, + game.objectId, + gameName.trim(), + iconUrl || undefined, + logoImageUrl || undefined, + libraryHeroImageUrl || undefined + ); + + showSuccessToast(t("edit_custom_game_modal_success")); + onGameUpdated(updatedGame); + onClose(); + } catch (error) { + console.error("Failed to update custom game:", error); + showErrorToast( + error instanceof Error + ? error.message + : t("edit_custom_game_modal_failed") + ); + } finally { + setIsUpdating(false); + } + }; + + const handleClose = () => { + if (!isUpdating) { + setGameName(game?.title || ""); + + const currentIconPath = game?.iconUrl?.startsWith("local:") + ? game.iconUrl.replace("local:", "") + : ""; + const currentLogoPath = game?.logoImageUrl?.startsWith("local:") + ? game.logoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game?.libraryHeroImageUrl?.startsWith("local:") + ? game.libraryHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + onClose(); + } + }; + + const isFormValid = gameName.trim(); + + const getIconPreviewUrl = () => { + return iconPath ? `local:${iconPath}` : null; + }; + + const getLogoPreviewUrl = () => { + return logoPath ? `local:${logoPath}` : null; + }; + + const getHeroPreviewUrl = () => { + return heroPath ? `local:${heroPath}` : null; + }; + + return ( + +
    +
    + + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {iconPath && ( +
    + {t("edit_custom_game_modal_icon_preview")} +
    + )} +
    + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {logoPath && ( +
    + {t("edit_custom_game_modal_logo_preview")} +
    + )} +
    + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {heroPath && ( +
    + {t("edit_custom_game_modal_hero_preview")} +
    + )} +
    +
    + +
    + + +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/renderer/src/pages/game-details/modals/index.ts b/src/renderer/src/pages/game-details/modals/index.ts index e02421b9..7cc779ef 100644 --- a/src/renderer/src/pages/game-details/modals/index.ts +++ b/src/renderer/src/pages/game-details/modals/index.ts @@ -1,3 +1,4 @@ export * from "./repacks-modal"; export * from "./download-settings-modal"; export * from "./game-options-modal"; +export * from "./edit-custom-game-modal"; diff --git a/src/types/game.types.ts b/src/types/game.types.ts index cc19f09c..ed8fb852 100644 --- a/src/types/game.types.ts +++ b/src/types/game.types.ts @@ -1,4 +1,4 @@ -export type GameShop = "steam" | "epic"; +export type GameShop = "steam" | "epic" | "custom"; export type ShortcutLocation = "desktop" | "start_menu"; diff --git a/src/types/level.types.ts b/src/types/level.types.ts index d749e777..dd9ba1dd 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -33,6 +33,8 @@ export interface User { export interface Game { title: string; iconUrl: string | null; + libraryHeroImageUrl: string | null; + logoImageUrl: string | null; playTimeInMilliseconds: number; unsyncedDeltaPlayTimeInMilliseconds?: number; lastTimePlayed: Date | null; From 3409b53268421c8b97fc3360d0e810fba81d5d65 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 16:22:12 +0300 Subject: [PATCH 02/24] Feat: Custom Games --- .../library/add-custom-game-to-library.ts | 17 ++--- .../events/library/add-game-to-library.ts | 2 + .../events/library/change-game-playtime.ts | 4 +- src/main/events/library/get-library.ts | 8 ++- src/main/events/library/update-custom-game.ts | 10 +-- .../events/torrenting/start-game-download.ts | 2 + src/main/index.ts | 63 ++++++++++++------ .../library-sync/upload-games-batch.ts | 3 +- src/preload/index.ts | 21 ++++-- .../sidebar-adding-custom-game-modal.scss | 2 +- .../sidebar-adding-custom-game-modal.tsx | 47 ++++++------- .../src/components/sidebar/sidebar.tsx | 10 ++- src/renderer/src/helpers.ts | 8 +-- .../game-details/game-details-content.tsx | 2 +- .../src/pages/game-details/game-details.scss | 2 +- .../modals/edit-custom-game-modal.scss | 2 +- .../modals/edit-custom-game-modal.tsx | 66 ++++++++++--------- 17 files changed, 158 insertions(+), 111 deletions(-) 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 382328a5..47fd3436 100644 --- a/src/main/events/library/add-custom-game-to-library.ts +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -1,13 +1,8 @@ import { registerEvent } from "../register-event"; -import { - gamesSublevel, - gamesShopAssetsSublevel, - levelKeys, -} from "@main/level"; +import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level"; import { randomUUID } from "crypto"; import type { GameShop } from "@types"; - const addCustomGameToLibrary = async ( _event: Electron.IpcMainInvokeEvent, title: string, @@ -21,12 +16,14 @@ const addCustomGameToLibrary = async ( const gameKey = levelKeys.game(shop, objectId); const existingGames = await gamesSublevel.iterator().all(); - const existingGame = existingGames.find(([_key, game]) => - game.executablePath === executablePath && !game.isDeleted + const existingGame = existingGames.find( + ([_key, game]) => game.executablePath === executablePath && !game.isDeleted ); if (existingGame) { - throw new Error("A game with this executable path already exists in your library"); + throw new Error( + "A game with this executable path already exists in your library" + ); } const assets = { @@ -65,4 +62,4 @@ const addCustomGameToLibrary = async ( return game; }; -registerEvent("addCustomGameToLibrary", addCustomGameToLibrary); \ No newline at end of file +registerEvent("addCustomGameToLibrary", addCustomGameToLibrary); diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 01495a39..2fa6d20e 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -30,6 +30,8 @@ const addGameToLibrary = async ( game = { title, iconUrl: gameAssets?.iconUrl ?? null, + libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null, + logoImageUrl: gameAssets?.logoImageUrl ?? null, objectId, shop, remoteId: null, diff --git a/src/main/events/library/change-game-playtime.ts b/src/main/events/library/change-game-playtime.ts index 20da70ec..8ad252bd 100644 --- a/src/main/events/library/change-game-playtime.ts +++ b/src/main/events/library/change-game-playtime.ts @@ -13,13 +13,13 @@ const changeGamePlaytime = async ( const gameKey = levelKeys.game(shop, objectId); const game = await gamesSublevel.get(gameKey); if (!game) return; - + if (game.remoteId) { await HydraApi.put(`/profile/games/${shop}/${objectId}/playtime`, { playTimeInSeconds, }); } - + await gamesSublevel.put(gameKey, { ...game, playTimeInMilliseconds: playTimeInSeconds * 1000, diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index ce859908..547045ae 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -18,11 +18,17 @@ const getLibrary = async (): Promise => { const download = await downloadsSublevel.get(key); const gameAssets = await gamesShopAssetsSublevel.get(key); + // 确保返回的对象符合 LibraryGame 类型 return { id: key, ...game, download: download ?? null, - ...gameAssets, + // 确保 gameAssets 中的可能为 null 的字段转换为 undefined + libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? undefined, + libraryImageUrl: gameAssets?.libraryImageUrl ?? undefined, + logoImageUrl: gameAssets?.logoImageUrl ?? undefined, + logoPosition: gameAssets?.logoPosition ?? undefined, + coverImageUrl: gameAssets?.coverImageUrl ?? undefined, }; }) ); diff --git a/src/main/events/library/update-custom-game.ts b/src/main/events/library/update-custom-game.ts index 4ff858a2..6152c0df 100644 --- a/src/main/events/library/update-custom-game.ts +++ b/src/main/events/library/update-custom-game.ts @@ -1,9 +1,5 @@ import { registerEvent } from "../register-event"; -import { - gamesSublevel, - gamesShopAssetsSublevel, - levelKeys, -} from "@main/level"; +import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level"; import type { GameShop } from "@types"; const updateCustomGame = async ( @@ -16,7 +12,7 @@ const updateCustomGame = async ( libraryHeroImageUrl?: string ) => { const gameKey = levelKeys.game(shop, objectId); - + const existingGame = await gamesSublevel.get(gameKey); if (!existingGame) { throw new Error("Game not found"); @@ -50,4 +46,4 @@ const updateCustomGame = async ( return updatedGame; }; -registerEvent("updateCustomGame", updateCustomGame); \ No newline at end of file +registerEvent("updateCustomGame", updateCustomGame); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 00281973..8216b519 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -53,6 +53,8 @@ const startGameDownload = async ( await gamesSublevel.put(gameKey, { title, iconUrl: gameAssets?.iconUrl ?? null, + libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null, + logoImageUrl: gameAssets?.logoImageUrl ?? null, objectId, shop, remoteId: null, diff --git a/src/main/index.ts b/src/main/index.ts index af9aa676..ab6980f9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -65,36 +65,61 @@ app.whenReady().then(async () => { }); protocol.handle("gradient", (request) => { - const gradientCss = decodeURIComponent(request.url.slice("gradient:".length)); - - const match = gradientCss.match(/linear-gradient\(([^,]+),\s*([^,]+),\s*([^)]+)\)/); - + const gradientCss = decodeURIComponent( + request.url.slice("gradient:".length) + ); + + const match = gradientCss.match( + /linear-gradient\(([^,]+),\s*([^,]+),\s*([^)]+)\)/ + ); + let direction = "45deg"; - let color1 = '#4a90e2'; - let color2 = '#7b68ee'; - + let color1 = "#4a90e2"; + let color2 = "#7b68ee"; + if (match) { direction = match[1].trim(); color1 = match[2].trim(); color2 = match[3].trim(); } - - let x1 = "0%", y1 = "0%", x2 = "100%", y2 = "100%"; - + + let x1 = "0%", + y1 = "0%", + x2 = "100%", + y2 = "100%"; + if (direction === "to right") { - x1 = "0%"; y1 = "0%"; x2 = "100%"; y2 = "0%"; + x1 = "0%"; + y1 = "0%"; + x2 = "100%"; + y2 = "0%"; } else if (direction === "to bottom") { - x1 = "0%"; y1 = "0%"; x2 = "0%"; y2 = "100%"; + x1 = "0%"; + y1 = "0%"; + x2 = "0%"; + y2 = "100%"; } else if (direction === "45deg") { - x1 = "0%"; y1 = "100%"; x2 = "100%"; y2 = "0%"; + x1 = "0%"; + y1 = "100%"; + x2 = "100%"; + y2 = "0%"; } else if (direction === "135deg") { - x1 = "0%"; y1 = "0%"; x2 = "100%"; y2 = "100%"; + x1 = "0%"; + y1 = "0%"; + x2 = "100%"; + y2 = "100%"; } else if (direction === "225deg") { - x1 = "100%"; y1 = "0%"; x2 = "0%"; y2 = "100%"; + x1 = "100%"; + y1 = "0%"; + x2 = "0%"; + y2 = "100%"; } else if (direction === "315deg") { - x1 = "100%"; y1 = "100%"; x2 = "0%"; y2 = "0%"; + x1 = "100%"; + y1 = "100%"; + x2 = "0%"; + y2 = "0%"; } - + const svgContent = ` @@ -106,9 +131,9 @@ app.whenReady().then(async () => { `; - + return new Response(svgContent, { - headers: { 'Content-Type': 'image/svg+xml' } + headers: { "Content-Type": "image/svg+xml" }, }); }); diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index 10d2c8dd..653b5f40 100644 --- a/src/main/services/library-sync/upload-games-batch.ts +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -11,7 +11,8 @@ export const uploadGamesBatch = async () => { .all() .then((results) => { return results.filter( - (game) => !game.isDeleted && game.remoteId === null && game.shop !== "custom" + (game) => + !game.isDeleted && game.remoteId === null && game.shop !== "custom" ); }); diff --git a/src/preload/index.ts b/src/preload/index.ts index b9ad8d98..8d297051 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -135,7 +135,14 @@ contextBridge.exposeInMainWorld("electron", { logoImageUrl?: string, libraryHeroImageUrl?: string ) => - ipcRenderer.invoke("addCustomGameToLibrary", title, executablePath, iconUrl, logoImageUrl, libraryHeroImageUrl), + ipcRenderer.invoke( + "addCustomGameToLibrary", + title, + executablePath, + iconUrl, + logoImageUrl, + libraryHeroImageUrl + ), updateCustomGame: ( shop: GameShop, objectId: string, @@ -144,7 +151,15 @@ contextBridge.exposeInMainWorld("electron", { logoImageUrl?: string, libraryHeroImageUrl?: string ) => - ipcRenderer.invoke("updateCustomGame", shop, objectId, title, iconUrl, logoImageUrl, libraryHeroImageUrl), + ipcRenderer.invoke( + "updateCustomGame", + shop, + objectId, + title, + iconUrl, + logoImageUrl, + libraryHeroImageUrl + ), createGameShortcut: ( shop: GameShop, objectId: string, @@ -493,6 +508,4 @@ contextBridge.exposeInMainWorld("electron", { }, closeEditorWindow: (themeId?: string) => ipcRenderer.invoke("closeEditorWindow", themeId), - - }); diff --git a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss index 48b84da3..942384fe 100644 --- a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss +++ b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.scss @@ -49,4 +49,4 @@ justify-content: flex-end; gap: calc(globals.$spacing-unit * 2); } -} \ No newline at end of file +} diff --git a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx index 771c5464..4b18a211 100644 --- a/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx +++ b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx @@ -5,7 +5,10 @@ import { FileDirectoryIcon } from "@primer/octicons-react"; import { Modal, TextField, Button } from "@renderer/components"; import { useLibrary, useToast } from "@renderer/hooks"; -import { buildGameDetailsPath, generateRandomGradient } from "@renderer/helpers"; +import { + buildGameDetailsPath, + generateRandomGradient, +} from "@renderer/helpers"; import "./sidebar-adding-custom-game-modal.scss"; @@ -41,7 +44,7 @@ export function SidebarAddingCustomGameModal({ if (filePaths && filePaths.length > 0) { const selectedPath = filePaths[0]; setExecutablePath(selectedPath); - + if (!gameName.trim()) { const fileName = selectedPath.split(/[\\/]/).pop() || ""; const gameNameFromFile = fileName.replace(/\.[^/.]+$/, ""); @@ -54,8 +57,6 @@ export function SidebarAddingCustomGameModal({ setGameName(event.target.value); }; - - const handleAddGame = async () => { if (!gameName.trim() || !executablePath.trim()) { showErrorToast(t("custom_game_modal_fill_required")); @@ -70,7 +71,7 @@ export function SidebarAddingCustomGameModal({ const iconUrl = ""; // Don't use gradient for icon const logoImageUrl = ""; // Don't use gradient for logo const libraryHeroImageUrl = generateRandomGradient(); // Only use gradient for hero - + const newGame = await window.electron.addCustomGameToLibrary( gameNameForSeed, executablePath, @@ -81,24 +82,22 @@ export function SidebarAddingCustomGameModal({ showSuccessToast(t("custom_game_modal_success")); updateLibrary(); - + const gameDetailsPath = buildGameDetailsPath({ shop: "custom", objectId: newGame.objectId, - title: newGame.title + title: newGame.title, }); - + navigate(gameDetailsPath); - + setGameName(""); setExecutablePath(""); onClose(); } catch (error) { console.error("Failed to add custom game:", error); showErrorToast( - error instanceof Error - ? error.message - : t("custom_game_modal_failed") + error instanceof Error ? error.message : t("custom_game_modal_failed") ); } finally { setIsAdding(false); @@ -115,8 +114,6 @@ export function SidebarAddingCustomGameModal({ const isFormValid = gameName.trim() && executablePath.trim(); - - return ( - - - -
    - -
    ); -} \ No newline at end of file +} diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index a7d946a2..f77066a2 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -22,7 +22,11 @@ import { buildGameDetailsPath } from "@renderer/helpers"; import { SidebarProfile } from "./sidebar-profile"; import { sortBy } from "lodash-es"; import cn from "classnames"; -import { CommentDiscussionIcon, PlayIcon, PlusIcon } from "@primer/octicons-react"; +import { + CommentDiscussionIcon, + PlayIcon, + PlusIcon, +} from "@primer/octicons-react"; import { SidebarGameItem } from "./sidebar-game-item"; import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal"; import { setFriendRequestCount } from "@renderer/features/user-details-slice"; @@ -265,7 +269,9 @@ export function Sidebar() { {t("my_library")} -
    +
    } /> - + {iconPath && (
    } /> - + {logoPath && (
    } /> - + {heroPath && (
    - -
    ); -} \ No newline at end of file +} From f4e84e46ccb39a77d98d1cbe378193776e7b0298 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 16:58:58 +0300 Subject: [PATCH 03/24] feat: custom game support/game info changing --- .../events/catalogue/save-game-shop-assets.ts | 10 +- src/main/events/index.ts | 1 + .../events/library/add-game-to-library.ts | 12 +- src/main/events/library/get-library.ts | 12 +- .../library/update-game-custom-assets.ts | 45 +++ .../library-sync/merge-with-remote-games.ts | 2 +- src/preload/index.ts | 17 + .../components/sidebar/sidebar-game-item.tsx | 2 +- .../game-details/game-details.context.tsx | 4 +- src/renderer/src/declaration.d.ts | 8 + .../game-details/game-details-content.tsx | 57 ++- .../game-details/modals/edit-game-modal.scss | 47 +++ .../game-details/modals/edit-game-modal.tsx | 367 ++++++++++++++++++ .../src/pages/game-details/modals/index.ts | 1 + src/types/level.types.ts | 3 + 15 files changed, 556 insertions(+), 32 deletions(-) create mode 100644 src/main/events/library/update-game-custom-assets.ts create mode 100644 src/renderer/src/pages/game-details/modals/edit-game-modal.scss create mode 100644 src/renderer/src/pages/game-details/modals/edit-game-modal.tsx diff --git a/src/main/events/catalogue/save-game-shop-assets.ts b/src/main/events/catalogue/save-game-shop-assets.ts index f0ec4343..46aab36a 100644 --- a/src/main/events/catalogue/save-game-shop-assets.ts +++ b/src/main/events/catalogue/save-game-shop-assets.ts @@ -10,7 +10,15 @@ const saveGameShopAssets = async ( ): Promise => { const key = levelKeys.game(shop, objectId); const existingAssets = await gamesShopAssetsSublevel.get(key); - return gamesShopAssetsSublevel.put(key, { ...existingAssets, ...assets }); + + // Preserve existing title if it differs from the incoming title (indicating it was customized) + const shouldPreserveTitle = existingAssets?.title && existingAssets.title !== assets.title; + + return gamesShopAssetsSublevel.put(key, { + ...existingAssets, + ...assets, + title: shouldPreserveTitle ? existingAssets.title : assets.title + }); }; registerEvent("saveGameShopAssets", saveGameShopAssets); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index ea7597ad..a1acd596 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -16,6 +16,7 @@ import "./hardware/check-folder-write-permission"; import "./library/add-game-to-library"; import "./library/add-custom-game-to-library"; import "./library/update-custom-game"; +import "./library/update-game-custom-assets"; import "./library/add-game-to-favorites"; import "./library/remove-game-from-favorites"; import "./library/create-game-shortcut"; diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 2fa6d20e..4fdeae30 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -43,12 +43,14 @@ const addGameToLibrary = async ( await gamesSublevel.put(gameKey, game); } - await createGame(game).catch(() => {}); + if (game) { + await createGame(game).catch(() => {}); - AchievementWatcherManager.firstSyncWithRemoteIfNeeded( - game.shop, - game.objectId - ); + AchievementWatcherManager.firstSyncWithRemoteIfNeeded( + game.shop, + game.objectId + ); + } }; registerEvent("addGameToLibrary", addGameToLibrary); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index 547045ae..ea2971b9 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -18,18 +18,14 @@ const getLibrary = async (): Promise => { const download = await downloadsSublevel.get(key); const gameAssets = await gamesShopAssetsSublevel.get(key); - // 确保返回的对象符合 LibraryGame 类型 return { id: key, ...game, download: download ?? null, - // 确保 gameAssets 中的可能为 null 的字段转换为 undefined - libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? undefined, - libraryImageUrl: gameAssets?.libraryImageUrl ?? undefined, - logoImageUrl: gameAssets?.logoImageUrl ?? undefined, - logoPosition: gameAssets?.logoPosition ?? undefined, - coverImageUrl: gameAssets?.coverImageUrl ?? undefined, - }; + ...gameAssets, + // Ensure compatibility with LibraryGame type + libraryHeroImageUrl: game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, + } as LibraryGame; }) ); }); diff --git a/src/main/events/library/update-game-custom-assets.ts b/src/main/events/library/update-game-custom-assets.ts new file mode 100644 index 00000000..99081a49 --- /dev/null +++ b/src/main/events/library/update-game-custom-assets.ts @@ -0,0 +1,45 @@ +import { registerEvent } from "../register-event"; +import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level"; +import type { GameShop } from "@types"; + +const updateGameCustomAssets = async ( + _event: Electron.IpcMainInvokeEvent, + shop: GameShop, + objectId: string, + title: string, + customIconUrl?: string | null, + customLogoImageUrl?: string | null, + customHeroImageUrl?: string | null +) => { + const gameKey = levelKeys.game(shop, objectId); + + const existingGame = await gamesSublevel.get(gameKey); + if (!existingGame) { + throw new Error("Game not found"); + } + + const updatedGame = { + ...existingGame, + title, + customIconUrl: customIconUrl !== undefined ? customIconUrl : existingGame.customIconUrl, + customLogoImageUrl: customLogoImageUrl !== undefined ? customLogoImageUrl : existingGame.customLogoImageUrl, + customHeroImageUrl: customHeroImageUrl !== undefined ? customHeroImageUrl : existingGame.customHeroImageUrl, + }; + + await gamesSublevel.put(gameKey, updatedGame); + + // Also update the shop assets for non-custom games + const existingAssets = await gamesShopAssetsSublevel.get(gameKey); + if (existingAssets) { + const updatedAssets = { + ...existingAssets, + title, // Update the title in shop assets as well + }; + + await gamesShopAssetsSublevel.put(gameKey, updatedAssets); + } + + return updatedGame; +}; + +registerEvent("updateGameCustomAssets", updateGameCustomAssets); \ No newline at end of file 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 c5bbe144..3e1398f0 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -57,7 +57,7 @@ export const mergeWithRemoteGames = async () => { await gamesShopAssetsSublevel.put(gameKey, { shop: game.shop, objectId: game.objectId, - title: game.title, + title: localGame?.title || game.title, // Preserve local title if it exists coverImageUrl: game.coverImageUrl, libraryHeroImageUrl: game.libraryHeroImageUrl, libraryImageUrl: game.libraryImageUrl, diff --git a/src/preload/index.ts b/src/preload/index.ts index 8d297051..ca918eee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -160,6 +160,23 @@ contextBridge.exposeInMainWorld("electron", { logoImageUrl, libraryHeroImageUrl ), + updateGameCustomAssets: ( + shop: GameShop, + objectId: string, + title: string, + customIconUrl?: string | null, + customLogoImageUrl?: string | null, + customHeroImageUrl?: string | null + ) => + ipcRenderer.invoke( + "updateGameCustomAssets", + shop, + objectId, + title, + customIconUrl, + customLogoImageUrl, + customHeroImageUrl + ), createGameShortcut: ( shop: GameShop, objectId: string, diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 4f905c59..108a5536 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -20,7 +20,7 @@ export function SidebarGameItem({ const isCustomGame = game.shop === "custom"; const sidebarIcon = isCustomGame ? game.libraryImageUrl || game.iconUrl - : game.iconUrl; + : game.customIconUrl || game.iconUrl; return (
  • { - if (game?.title && game.shop === "custom") { + if (game?.title) { dispatch(setHeaderTitle(game.title)); } - }, [game?.title, game?.shop, dispatch]); + }, [game?.title, dispatch]); useEffect(() => { const unsubscribe = window.electron.onGamesRunning((gamesIds) => { diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index f66bd53e..5af0d04f 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -126,6 +126,14 @@ declare global { logoImageUrl?: string, libraryHeroImageUrl?: string ) => Promise; + updateGameCustomAssets: ( + shop: GameShop, + objectId: string, + title: string, + customIconUrl?: string | null, + customLogoImageUrl?: string | null, + customHeroImageUrl?: string | null + ) => Promise; createGameShortcut: ( shop: GameShop, objectId: string, 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 80c95fc4..cf300ceb 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -7,7 +7,7 @@ import { HeroPanel } from "./hero"; import { DescriptionHeader } from "./description-header/description-header"; import { GallerySlider } from "./gallery-slider/gallery-slider"; import { Sidebar } from "./sidebar/sidebar"; -import { EditCustomGameModal } from "./modals"; +import { EditCustomGameModal, EditGameModal } from "./modals"; import { useTranslation } from "react-i18next"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; @@ -66,6 +66,7 @@ export function GameDetailsContent() { const [backdropOpacity, setBackdropOpacity] = useState(1); const [showEditCustomGameModal, setShowEditCustomGameModal] = useState(false); + const [showEditGameModal, setShowEditGameModal] = useState(false); const handleHeroLoad = async () => { const output = await average( @@ -105,6 +106,10 @@ export function GameDetailsContent() { setShowEditCustomGameModal(true); }; + const handleEditGameClick = () => { + setShowEditGameModal(true); + }; + const handleGameUpdated = (_updatedGame: any) => { updateGame(); updateLibrary(); @@ -115,12 +120,29 @@ export function GameDetailsContent() { }, [getGameArtifacts]); const isCustomGame = game?.shop === "custom"; + + // Helper function to get image with custom asset priority + const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined + ) => { + return customUrl || originalUrl || fallbackUrl || ""; + }; + const heroImage = isCustomGame ? game?.libraryHeroImageUrl || game?.iconUrl || "" - : shopDetails?.assets?.libraryHeroImageUrl || ""; + : getImageWithCustomPriority( + game?.customHeroImageUrl, + shopDetails?.assets?.libraryHeroImageUrl + ); + const logoImage = isCustomGame - ? game?.logoImageUrl || "" // Don't use icon as fallback for custom games - : shopDetails?.assets?.logoImageUrl || ""; + ? game?.logoImageUrl || "" + : getImageWithCustomPriority( + game?.customLogoImageUrl, + shopDetails?.assets?.logoImageUrl + ); return (
    - {game?.shop === "custom" && ( - - )} + {game?.shop !== "custom" && (
    ); } diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss new file mode 100644 index 00000000..f37ea44b --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -0,0 +1,47 @@ +.edit-game-modal__container { + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + max-width: 500px; +} + +.edit-game-modal__form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.edit-game-modal__image-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.edit-game-modal__image-preview { + display: flex; + justify-content: center; + align-items: center; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 8px; + background-color: var(--color-background-secondary); +} + +.edit-game-modal__preview-image { + max-width: 100%; + max-height: 120px; + object-fit: contain; + border-radius: 4px; +} + +.edit-game-modal__actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 8px; +} + +.edit-game-modal__actions button { + min-width: 100px; +} \ No newline at end of file diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx new file mode 100644 index 00000000..ff8e7c3c --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -0,0 +1,367 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { ImageIcon } from "@primer/octicons-react"; + +import { Modal, TextField, Button } from "@renderer/components"; +import { useToast } from "@renderer/hooks"; +import type { LibraryGame } from "@types"; + +import "./edit-game-modal.scss"; + +export interface EditGameModalProps { + visible: boolean; + onClose: () => void; + game: LibraryGame | null; + onGameUpdated: (updatedGame: any) => void; +} + +export function EditGameModal({ + visible, + onClose, + game, + onGameUpdated, +}: EditGameModalProps) { + const { t } = useTranslation("sidebar"); + const { showSuccessToast, showErrorToast } = useToast(); + + const [gameName, setGameName] = useState(""); + const [iconPath, setIconPath] = useState(""); + const [logoPath, setLogoPath] = useState(""); + const [heroPath, setHeroPath] = useState(""); + const [isUpdating, setIsUpdating] = useState(false); + + useEffect(() => { + if (game && visible) { + setGameName(game.title || ""); + + // For custom games, use existing logic + if (game.shop === "custom") { + const currentIconPath = game.iconUrl?.startsWith("local:") + ? game.iconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.logoImageUrl?.startsWith("local:") + ? game.logoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.libraryHeroImageUrl?.startsWith("local:") + ? game.libraryHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } else { + // For non-custom games, use custom asset paths if they exist + const currentIconPath = game.customIconUrl?.startsWith("local:") + ? game.customIconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.customLogoImageUrl?.startsWith("local:") + ? game.customLogoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.customHeroImageUrl?.startsWith("local:") + ? game.customHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } + } + }, [game, visible]); + + const handleGameNameChange = (event: React.ChangeEvent) => { + setGameName(event.target.value); + }; + + const handleSelectIcon = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setIconPath(filePaths[0]); + } + }; + + const handleSelectLogo = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setLogoPath(filePaths[0]); + } + }; + + const handleSelectHero = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setHeroPath(filePaths[0]); + } + }; + + const handleUpdateGame = async () => { + if (!game || !gameName.trim()) { + showErrorToast(t("edit_custom_game_modal_fill_required")); + return; + } + + setIsUpdating(true); + + try { + let updatedGame; + + if (game.shop === "custom") { + // For custom games, use existing logic + const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl; + const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl; + const libraryHeroImageUrl = heroPath + ? `local:${heroPath}` + : game.libraryHeroImageUrl; + + updatedGame = await window.electron.updateCustomGame( + game.shop, + game.objectId, + gameName.trim(), + iconUrl || undefined, + logoImageUrl || undefined, + libraryHeroImageUrl || undefined + ); + } else { + // For non-custom games, update custom assets + const customIconUrl = iconPath ? `local:${iconPath}` : null; + const customLogoImageUrl = logoPath ? `local:${logoPath}` : null; + const customHeroImageUrl = heroPath ? `local:${heroPath}` : null; + + updatedGame = await window.electron.updateGameCustomAssets( + game.shop, + game.objectId, + gameName.trim(), + customIconUrl, + customLogoImageUrl, + customHeroImageUrl + ); + } + + showSuccessToast(t("edit_custom_game_modal_success")); + onGameUpdated(updatedGame); + onClose(); + } catch (error) { + console.error("Failed to update game:", error); + showErrorToast( + error instanceof Error + ? error.message + : t("edit_custom_game_modal_failed") + ); + } finally { + setIsUpdating(false); + } + }; + + const handleClose = () => { + if (!isUpdating && game) { + setGameName(game.title || ""); + + if (game.shop === "custom") { + const currentIconPath = game.iconUrl?.startsWith("local:") + ? game.iconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.logoImageUrl?.startsWith("local:") + ? game.logoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.libraryHeroImageUrl?.startsWith("local:") + ? game.libraryHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } else { + const currentIconPath = game.customIconUrl?.startsWith("local:") + ? game.customIconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.customLogoImageUrl?.startsWith("local:") + ? game.customLogoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.customHeroImageUrl?.startsWith("local:") + ? game.customHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } + onClose(); + } + }; + + const isFormValid = gameName.trim(); + + const getIconPreviewUrl = () => { + return iconPath ? `local:${iconPath}` : null; + }; + + const getLogoPreviewUrl = () => { + return logoPath ? `local:${logoPath}` : null; + }; + + const getHeroPreviewUrl = () => { + return heroPath ? `local:${heroPath}` : null; + }; + + return ( + +
    +
    + + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {iconPath && ( +
    + {t("edit_custom_game_modal_icon_preview")} +
    + )} +
    + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {logoPath && ( +
    + {t("edit_custom_game_modal_logo_preview")} +
    + )} +
    + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {heroPath && ( +
    + {t("edit_custom_game_modal_hero_preview")} +
    + )} +
    +
    + +
    + + +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/renderer/src/pages/game-details/modals/index.ts b/src/renderer/src/pages/game-details/modals/index.ts index 7cc779ef..2e09806d 100644 --- a/src/renderer/src/pages/game-details/modals/index.ts +++ b/src/renderer/src/pages/game-details/modals/index.ts @@ -2,3 +2,4 @@ export * from "./repacks-modal"; export * from "./download-settings-modal"; export * from "./game-options-modal"; export * from "./edit-custom-game-modal"; +export * from "./edit-game-modal"; diff --git a/src/types/level.types.ts b/src/types/level.types.ts index dd9ba1dd..bf5429c5 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -35,6 +35,9 @@ export interface Game { iconUrl: string | null; libraryHeroImageUrl: string | null; logoImageUrl: string | null; + customIconUrl?: string | null; + customLogoImageUrl?: string | null; + customHeroImageUrl?: string | null; playTimeInMilliseconds: number; unsyncedDeltaPlayTimeInMilliseconds?: number; lastTimePlayed: Date | null; From 672ddff9f8b563e73d534265034e0dbf0814b090 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 17:15:59 +0300 Subject: [PATCH 04/24] security fix --- .../events/catalogue/save-game-shop-assets.ts | 13 +++++++------ src/main/events/library/get-library.ts | 3 ++- .../events/library/update-game-custom-assets.ts | 15 +++++++++++---- src/main/index.ts | 3 ++- .../pages/game-details/game-details-content.tsx | 10 +++++++--- .../game-details/modals/edit-game-modal.scss | 2 +- .../pages/game-details/modals/edit-game-modal.tsx | 2 +- 7 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/main/events/catalogue/save-game-shop-assets.ts b/src/main/events/catalogue/save-game-shop-assets.ts index 46aab36a..bf5f8b81 100644 --- a/src/main/events/catalogue/save-game-shop-assets.ts +++ b/src/main/events/catalogue/save-game-shop-assets.ts @@ -10,14 +10,15 @@ const saveGameShopAssets = async ( ): Promise => { const key = levelKeys.game(shop, objectId); const existingAssets = await gamesShopAssetsSublevel.get(key); - + // Preserve existing title if it differs from the incoming title (indicating it was customized) - const shouldPreserveTitle = existingAssets?.title && existingAssets.title !== assets.title; - - return gamesShopAssetsSublevel.put(key, { - ...existingAssets, + const shouldPreserveTitle = + existingAssets?.title && existingAssets.title !== assets.title; + + return gamesShopAssetsSublevel.put(key, { + ...existingAssets, ...assets, - title: shouldPreserveTitle ? existingAssets.title : assets.title + title: shouldPreserveTitle ? existingAssets.title : assets.title, }); }; diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index ea2971b9..6314f83d 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -24,7 +24,8 @@ const getLibrary = async (): Promise => { download: download ?? null, ...gameAssets, // Ensure compatibility with LibraryGame type - libraryHeroImageUrl: game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, + libraryHeroImageUrl: + game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, } as LibraryGame; }) ); diff --git a/src/main/events/library/update-game-custom-assets.ts b/src/main/events/library/update-game-custom-assets.ts index 99081a49..6049ef5d 100644 --- a/src/main/events/library/update-game-custom-assets.ts +++ b/src/main/events/library/update-game-custom-assets.ts @@ -21,9 +21,16 @@ const updateGameCustomAssets = async ( const updatedGame = { ...existingGame, title, - customIconUrl: customIconUrl !== undefined ? customIconUrl : existingGame.customIconUrl, - customLogoImageUrl: customLogoImageUrl !== undefined ? customLogoImageUrl : existingGame.customLogoImageUrl, - customHeroImageUrl: customHeroImageUrl !== undefined ? customHeroImageUrl : existingGame.customHeroImageUrl, + customIconUrl: + customIconUrl !== undefined ? customIconUrl : existingGame.customIconUrl, + customLogoImageUrl: + customLogoImageUrl !== undefined + ? customLogoImageUrl + : existingGame.customLogoImageUrl, + customHeroImageUrl: + customHeroImageUrl !== undefined + ? customHeroImageUrl + : existingGame.customHeroImageUrl, }; await gamesSublevel.put(gameKey, updatedGame); @@ -42,4 +49,4 @@ const updateGameCustomAssets = async ( return updatedGame; }; -registerEvent("updateGameCustomAssets", updateGameCustomAssets); \ No newline at end of file +registerEvent("updateGameCustomAssets", updateGameCustomAssets); diff --git a/src/main/index.ts b/src/main/index.ts index ab6980f9..9c390176 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -69,8 +69,9 @@ app.whenReady().then(async () => { request.url.slice("gradient:".length) ); + // Fixed regex to prevent ReDoS - removed nested quantifiers and backtracking const match = gradientCss.match( - /linear-gradient\(([^,]+),\s*([^,]+),\s*([^)]+)\)/ + /^linear-gradient\(([^,()]+),\s*([^,()]+),\s*([^,()]+)\)$/ ); let direction = "45deg"; 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 cf300ceb..b8681c5c 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -120,7 +120,7 @@ export function GameDetailsContent() { }, [getGameArtifacts]); const isCustomGame = game?.shop === "custom"; - + // Helper function to get image with custom asset priority const getImageWithCustomPriority = ( customUrl: string | null | undefined, @@ -136,7 +136,7 @@ export function GameDetailsContent() { game?.customHeroImageUrl, shopDetails?.assets?.libraryHeroImageUrl ); - + const logoImage = isCustomGame ? game?.logoImageUrl || "" : getImageWithCustomPriority( @@ -181,7 +181,11 @@ export function GameDetailsContent() { - } - /> - - {iconPath && ( -
    - {t("edit_custom_game_modal_icon_preview")} -
    - )} - - -
    - - - {t("edit_custom_game_modal_browse")} - - } - /> - - {logoPath && ( -
    - {t("edit_custom_game_modal_logo_preview")} -
    - )} -
    - -
    - - - {t("edit_custom_game_modal_browse")} - - } - /> - - {heroPath && ( -
    - {t("edit_custom_game_modal_hero_preview")} -
    - )} -
    - - -
    - - -
    - - - ); -} diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx index 185da424..1731e03c 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -4,14 +4,14 @@ import { ImageIcon } from "@primer/octicons-react"; import { Modal, TextField, Button } from "@renderer/components"; import { useToast } from "@renderer/hooks"; -import type { LibraryGame } from "@types"; +import type { LibraryGame, Game } from "@types"; import "./edit-game-modal.scss"; export interface EditGameModalProps { visible: boolean; onClose: () => void; - game: LibraryGame | null; + game: LibraryGame | Game | null; onGameUpdated: (updatedGame: any) => void; } @@ -20,7 +20,7 @@ export function EditGameModal({ onClose, game, onGameUpdated, -}: EditGameModalProps) { +}: Readonly) { const { t } = useTranslation("sidebar"); const { showSuccessToast, showErrorToast } = useToast(); @@ -30,13 +30,18 @@ export function EditGameModal({ const [heroPath, setHeroPath] = useState(""); const [isUpdating, setIsUpdating] = useState(false); + // Helper function to check if game is a custom game + const isCustomGame = (game: LibraryGame | Game): boolean => { + return game.shop === "custom"; + }; + // Helper function to extract local path from URL const extractLocalPath = (url: string | null | undefined): string => { return url?.startsWith("local:") ? url.replace("local:", "") : ""; }; // Helper function to set asset paths for custom games - const setCustomGameAssets = (game: LibraryGame) => { + const setCustomGameAssets = (game: LibraryGame | Game) => { setIconPath(extractLocalPath(game.iconUrl)); setLogoPath(extractLocalPath(game.logoImageUrl)); setHeroPath(extractLocalPath(game.libraryHeroImageUrl)); @@ -53,10 +58,10 @@ export function EditGameModal({ if (game && visible) { setGameName(game.title || ""); - if (game.shop === "custom") { + if (isCustomGame(game)) { setCustomGameAssets(game); } else { - setNonCustomGameAssets(game); + setNonCustomGameAssets(game as LibraryGame); } } }, [game, visible]); @@ -114,7 +119,7 @@ export function EditGameModal({ }; // Helper function to prepare custom game assets - const prepareCustomGameAssets = (game: LibraryGame) => { + const prepareCustomGameAssets = (game: LibraryGame | Game) => { const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl; const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl; const libraryHeroImageUrl = heroPath @@ -134,7 +139,7 @@ export function EditGameModal({ }; // Helper function to update custom game - const updateCustomGame = async (game: LibraryGame) => { + const updateCustomGame = async (game: LibraryGame | Game) => { const { iconUrl, logoImageUrl, libraryHeroImageUrl } = prepareCustomGameAssets(game); @@ -173,9 +178,9 @@ export function EditGameModal({ try { const updatedGame = - game.shop === "custom" + isCustomGame(game) ? await updateCustomGame(game) - : await updateNonCustomGame(game); + : await updateNonCustomGame(game as LibraryGame); showSuccessToast(t("edit_custom_game_modal_success")); onGameUpdated(updatedGame); @@ -193,13 +198,13 @@ export function EditGameModal({ }; // Helper function to reset form to initial state - const resetFormToInitialState = (game: LibraryGame) => { + const resetFormToInitialState = (game: LibraryGame | Game) => { setGameName(game.title || ""); - if (game.shop === "custom") { + if (isCustomGame(game)) { setCustomGameAssets(game); } else { - setNonCustomGameAssets(game); + setNonCustomGameAssets(game as LibraryGame); } }; @@ -212,16 +217,16 @@ export function EditGameModal({ const isFormValid = gameName.trim(); - const getIconPreviewUrl = () => { - return iconPath ? `local:${iconPath}` : null; + const getIconPreviewUrl = (): string | undefined => { + return iconPath ? `local:${iconPath}` : undefined; }; - const getLogoPreviewUrl = () => { - return logoPath ? `local:${logoPath}` : null; + const getLogoPreviewUrl = (): string | undefined => { + return logoPath ? `local:${logoPath}` : undefined; }; - const getHeroPreviewUrl = () => { - return heroPath ? `local:${heroPath}` : null; + const getHeroPreviewUrl = (): string | undefined => { + return heroPath ? `local:${heroPath}` : undefined; }; return ( @@ -265,7 +270,7 @@ export function EditGameModal({ {iconPath && (
    {t("edit_custom_game_modal_icon_preview")} @@ -296,7 +301,7 @@ export function EditGameModal({ {logoPath && (
    {t("edit_custom_game_modal_logo_preview")} @@ -327,7 +332,7 @@ export function EditGameModal({ {heroPath && (
    {t("edit_custom_game_modal_hero_preview")} diff --git a/src/renderer/src/pages/game-details/modals/index.ts b/src/renderer/src/pages/game-details/modals/index.ts index 2e09806d..724e0003 100644 --- a/src/renderer/src/pages/game-details/modals/index.ts +++ b/src/renderer/src/pages/game-details/modals/index.ts @@ -1,5 +1,4 @@ export * from "./repacks-modal"; export * from "./download-settings-modal"; export * from "./game-options-modal"; -export * from "./edit-custom-game-modal"; export * from "./edit-game-modal"; From 9ce1c40b2192f9a6203b51b1432ee6004c52fddb Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 19:48:05 +0300 Subject: [PATCH 10/24] redirect to home page when removing custom game from library --- src/renderer/src/pages/game-details/game-details.tsx | 1 + .../src/pages/game-details/modals/edit-game-modal.tsx | 7 +++---- .../src/pages/game-details/modals/game-options-modal.tsx | 7 +++++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index b966e6e7..f0778494 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -178,6 +178,7 @@ export default function GameDetails() { onClose={() => { setShowGameOptionsModal(false); }} + onNavigateHome={() => navigate("/")} /> )} diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx index 1731e03c..e33d59b2 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -177,10 +177,9 @@ export function EditGameModal({ setIsUpdating(true); try { - const updatedGame = - isCustomGame(game) - ? await updateCustomGame(game) - : await updateNonCustomGame(game as LibraryGame); + const updatedGame = isCustomGame(game) + ? await updateCustomGame(game) + : await updateNonCustomGame(game as LibraryGame); showSuccessToast(t("edit_custom_game_modal_success")); onGameUpdated(updatedGame); diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index 9c20acce..34f2690d 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -18,12 +18,14 @@ export interface GameOptionsModalProps { visible: boolean; game: LibraryGame; onClose: () => void; + onNavigateHome?: () => void; } export function GameOptionsModal({ visible, game, onClose, + onNavigateHome, }: Readonly) { const { t } = useTranslation("game_details"); @@ -90,6 +92,11 @@ export function GameOptionsModal({ await removeGameFromLibrary(game.shop, game.objectId); updateGame(); onClose(); + + // Redirect to home page if it's a custom game + if (game.shop === "custom" && onNavigateHome) { + onNavigateHome(); + } }; const handleChangeExecutableLocation = async () => { From bf9e3de0b5f7c28564e169af316bdf6a863ddffb Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 20:04:09 +0300 Subject: [PATCH 11/24] fixed background color in hero arent being properly used if game is custom --- .../src/pages/game-details/game-details-content.tsx | 11 ++++++++++- .../pages/game-details/modals/game-options-modal.tsx | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) 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 4597263b..9c2b5bd8 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -68,8 +68,17 @@ export function GameDetailsContent() { const [showEditGameModal, setShowEditGameModal] = useState(false); const handleHeroLoad = async () => { + // Use the same logic as heroImage to get the correct URL for both custom and non-custom games + const isCustomGame = game?.shop === "custom"; + const heroImageUrl = isCustomGame + ? game?.libraryHeroImageUrl || game?.iconUrl || "" + : getImageWithCustomPriority( + game?.customHeroImageUrl, + shopDetails?.assets?.libraryHeroImageUrl + ); + const output = await average( - shopDetails?.assets?.libraryHeroImageUrl ?? "", + heroImageUrl, { amount: 1, format: "hex", diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index 34f2690d..69d610d9 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -92,7 +92,7 @@ export function GameOptionsModal({ await removeGameFromLibrary(game.shop, game.objectId); updateGame(); onClose(); - + // Redirect to home page if it's a custom game if (game.shop === "custom" && onNavigateHome) { onNavigateHome(); From 9f4fd0ce61805e5f447c304ebf05c398874bb1e3 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 20:46:53 +0300 Subject: [PATCH 12/24] added proper image saving for custom games + edited game settings to hide buttons if game is custom --- src/main/events/index.ts | 2 + .../events/library/cleanup-unused-assets.ts | 73 +++++++++ .../events/library/copy-custom-game-asset.ts | 42 ++++++ src/preload/index.ts | 5 + src/renderer/src/declaration.d.ts | 5 + .../game-details/game-details-content.tsx | 11 +- .../game-details/modals/edit-game-modal.tsx | 39 ++++- .../modals/game-options-modal.tsx | 140 ++++++++++-------- 8 files changed, 242 insertions(+), 75 deletions(-) create mode 100644 src/main/events/library/cleanup-unused-assets.ts create mode 100644 src/main/events/library/copy-custom-game-asset.ts diff --git a/src/main/events/index.ts b/src/main/events/index.ts index a1acd596..deab53a6 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -39,7 +39,9 @@ import "./library/reset-game-achievements"; import "./library/change-game-playtime"; import "./library/toggle-automatic-cloud-sync"; import "./library/get-default-wine-prefix-selection-path"; +import "./library/cleanup-unused-assets"; import "./library/create-steam-shortcut"; +import "./library/copy-custom-game-asset"; import "./misc/open-checkout"; import "./misc/open-external"; import "./misc/show-open-dialog"; diff --git a/src/main/events/library/cleanup-unused-assets.ts b/src/main/events/library/cleanup-unused-assets.ts new file mode 100644 index 00000000..9cde548d --- /dev/null +++ b/src/main/events/library/cleanup-unused-assets.ts @@ -0,0 +1,73 @@ +import { ipcMain } from "electron"; +import fs from "fs"; +import path from "path"; +import { ASSETS_PATH } from "@main/constants"; + +const getCustomGamesAssetsPath = () => { + return path.join(ASSETS_PATH, "custom-games"); +}; + +const getAllCustomGameAssets = async (): Promise => { + const assetsPath = getCustomGamesAssetsPath(); + + if (!fs.existsSync(assetsPath)) { + return []; + } + + const files = await fs.promises.readdir(assetsPath); + return files.map(file => path.join(assetsPath, file)); +}; + +const getUsedAssetPaths = async (): Promise> => { + // Get all custom games from the level database + const { gamesSublevel } = await import("@main/level"); + const allGames = await gamesSublevel.iterator().all(); + + const customGames = allGames + .map(([_key, game]) => game) + .filter(game => game.shop === "custom" && !game.isDeleted); + + const usedPaths = new Set(); + + customGames.forEach(game => { + // Extract file paths from local URLs + if (game.iconUrl?.startsWith("local:")) { + usedPaths.add(game.iconUrl.replace("local:", "")); + } + if (game.logoImageUrl?.startsWith("local:")) { + usedPaths.add(game.logoImageUrl.replace("local:", "")); + } + if (game.libraryHeroImageUrl?.startsWith("local:")) { + usedPaths.add(game.libraryHeroImageUrl.replace("local:", "")); + } + }); + + return usedPaths; +}; + +export const cleanupUnusedAssets = async (): Promise<{ deletedCount: number; errors: string[] }> => { + try { + const allAssets = await getAllCustomGameAssets(); + const usedAssets = await getUsedAssetPaths(); + + const errors: string[] = []; + let deletedCount = 0; + + for (const assetPath of allAssets) { + if (!usedAssets.has(assetPath)) { + try { + await fs.promises.unlink(assetPath); + deletedCount++; + } catch (error) { + errors.push(`Failed to delete ${assetPath}: ${error}`); + } + } + } + + return { deletedCount, errors }; + } catch (error) { + throw new Error(`Failed to cleanup unused assets: ${error}`); + } +}; + +ipcMain.handle("cleanupUnusedAssets", cleanupUnusedAssets); \ No newline at end of file diff --git a/src/main/events/library/copy-custom-game-asset.ts b/src/main/events/library/copy-custom-game-asset.ts new file mode 100644 index 00000000..dfb7bf47 --- /dev/null +++ b/src/main/events/library/copy-custom-game-asset.ts @@ -0,0 +1,42 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "crypto"; +import { ASSETS_PATH } from "@main/constants"; + +const copyCustomGameAsset = async ( + _event: Electron.IpcMainInvokeEvent, + sourcePath: string, + assetType: "icon" | "logo" | "hero" +): Promise => { + if (!sourcePath || !fs.existsSync(sourcePath)) { + throw new Error("Source file does not exist"); + } + + // Ensure assets directory exists + if (!fs.existsSync(ASSETS_PATH)) { + fs.mkdirSync(ASSETS_PATH, { recursive: true }); + } + + // Create custom games assets subdirectory + const customGamesAssetsPath = path.join(ASSETS_PATH, "custom-games"); + if (!fs.existsSync(customGamesAssetsPath)) { + fs.mkdirSync(customGamesAssetsPath, { recursive: true }); + } + + // Get file extension + const fileExtension = path.extname(sourcePath); + + // Generate unique filename + const uniqueId = randomUUID(); + const fileName = `${assetType}-${uniqueId}${fileExtension}`; + const destinationPath = path.join(customGamesAssetsPath, fileName); + + // Copy the file + await fs.promises.copyFile(sourcePath, destinationPath); + + // Return the local URL format + return `local:${destinationPath}`; +}; + +registerEvent("copyCustomGameAsset", copyCustomGameAsset); \ No newline at end of file diff --git a/src/preload/index.ts b/src/preload/index.ts index ca918eee..3bcd4524 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -143,6 +143,11 @@ contextBridge.exposeInMainWorld("electron", { logoImageUrl, libraryHeroImageUrl ), + copyCustomGameAsset: ( + sourcePath: string, + assetType: "icon" | "logo" | "hero" + ) => ipcRenderer.invoke("copyCustomGameAsset", sourcePath, assetType), + cleanupUnusedAssets: () => ipcRenderer.invoke("cleanupUnusedAssets"), updateCustomGame: ( shop: GameShop, objectId: string, diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 5af0d04f..0515f1f0 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -126,6 +126,11 @@ declare global { logoImageUrl?: string, libraryHeroImageUrl?: string ) => Promise; + copyCustomGameAsset: ( + sourcePath: string, + assetType: "icon" | "logo" | "hero" + ) => Promise; + cleanupUnusedAssets: () => Promise<{ deletedCount: number; errors: string[] }>; updateGameCustomAssets: ( shop: GameShop, objectId: string, 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 9c2b5bd8..22f56630 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -77,13 +77,10 @@ export function GameDetailsContent() { shopDetails?.assets?.libraryHeroImageUrl ); - const output = await average( - heroImageUrl, - { - amount: 1, - format: "hex", - } - ); + const output = await average(heroImageUrl, { + amount: 1, + format: "hex", + }); const backgroundColor = output ? new Color(output).darken(0.7).toString() diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx index e33d59b2..b922a231 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -82,7 +82,18 @@ export function EditGameModal({ }); if (filePaths && filePaths.length > 0) { - setIconPath(filePaths[0]); + try { + // Copy the asset to the app's assets folder + const copiedAssetUrl = await window.electron.copyCustomGameAsset( + filePaths[0], + "icon" + ); + setIconPath(copiedAssetUrl.replace("local:", "")); + } catch (error) { + console.error("Failed to copy icon asset:", error); + // Fallback to original behavior + setIconPath(filePaths[0]); + } } }; @@ -98,7 +109,18 @@ export function EditGameModal({ }); if (filePaths && filePaths.length > 0) { - setLogoPath(filePaths[0]); + try { + // Copy the asset to the app's assets folder + const copiedAssetUrl = await window.electron.copyCustomGameAsset( + filePaths[0], + "logo" + ); + setLogoPath(copiedAssetUrl.replace("local:", "")); + } catch (error) { + console.error("Failed to copy logo asset:", error); + // Fallback to original behavior + setLogoPath(filePaths[0]); + } } }; @@ -114,7 +136,18 @@ export function EditGameModal({ }); if (filePaths && filePaths.length > 0) { - setHeroPath(filePaths[0]); + try { + // Copy the asset to the app's assets folder + const copiedAssetUrl = await window.electron.copyCustomGameAsset( + filePaths[0], + "hero" + ); + setHeroPath(copiedAssetUrl.replace("local:", "")); + } catch (error) { + console.error("Failed to copy hero asset:", error); + // Fallback to original behavior + setHeroPath(filePaths[0]); + } } }; diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index 69d610d9..ccbce05d 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -353,14 +353,16 @@ export function GameOptionsModal({ > {t("create_shortcut")} - + {game.shop !== "custom" && ( + + )} {shouldShowCreateStartMenuShortcut && (
    - - {t("enable_automatic_cloud_sync")} - - Hydra Cloud - -
    - } - checked={automaticCloudSync} - disabled={!hasActiveSubscription || !game.executablePath} - onChange={handleToggleAutomaticCloudSync} - /> + {game.shop !== "custom" && ( + + {t("enable_automatic_cloud_sync")} + + Hydra Cloud + + + } + checked={automaticCloudSync} + disabled={!hasActiveSubscription || !game.executablePath} + onChange={handleToggleAutomaticCloudSync} + /> + )} {shouldShowWinePrefixConfiguration && (
    @@ -448,33 +452,35 @@ export function GameOptionsModal({ />
    -
    -
    -

    {t("downloads_section_title")}

    -

    - {t("downloads_section_description")} -

    -
    + {game.shop !== "custom" && ( +
    +
    +

    {t("downloads_section_title")}

    +

    + {t("downloads_section_description")} +

    +
    -
    - - {game.download?.downloadPath && ( +
    - )} + {game.download?.downloadPath && ( + + )} +
    -
    + )}
    @@ -493,18 +499,20 @@ export function GameOptionsModal({ {t("remove_from_library")} - + {game.shop !== "custom" && ( + + )} - + {game.shop !== "custom" && ( + + )}
    From de70beb01e138e0974abea2f9c861477e2674c2a Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 21:06:15 +0300 Subject: [PATCH 13/24] Preload images for non-custom games. Added ability to restore images to default if game is non-custom --- .../events/library/cleanup-unused-assets.ts | 19 ++- .../events/library/copy-custom-game-asset.ts | 4 +- .../library/update-game-custom-assets.ts | 6 +- src/renderer/src/declaration.d.ts | 5 +- .../game-details/game-details-content.tsx | 1 + .../game-details/modals/edit-game-modal.tsx | 146 ++++++++++++++---- .../modals/game-options-modal.tsx | 4 +- 7 files changed, 137 insertions(+), 48 deletions(-) diff --git a/src/main/events/library/cleanup-unused-assets.ts b/src/main/events/library/cleanup-unused-assets.ts index 9cde548d..22490c07 100644 --- a/src/main/events/library/cleanup-unused-assets.ts +++ b/src/main/events/library/cleanup-unused-assets.ts @@ -9,27 +9,27 @@ const getCustomGamesAssetsPath = () => { const getAllCustomGameAssets = async (): Promise => { const assetsPath = getCustomGamesAssetsPath(); - + if (!fs.existsSync(assetsPath)) { return []; } const files = await fs.promises.readdir(assetsPath); - return files.map(file => path.join(assetsPath, file)); + return files.map((file) => path.join(assetsPath, file)); }; const getUsedAssetPaths = async (): Promise> => { // Get all custom games from the level database const { gamesSublevel } = await import("@main/level"); const allGames = await gamesSublevel.iterator().all(); - + const customGames = allGames .map(([_key, game]) => game) - .filter(game => game.shop === "custom" && !game.isDeleted); + .filter((game) => game.shop === "custom" && !game.isDeleted); const usedPaths = new Set(); - customGames.forEach(game => { + customGames.forEach((game) => { // Extract file paths from local URLs if (game.iconUrl?.startsWith("local:")) { usedPaths.add(game.iconUrl.replace("local:", "")); @@ -45,11 +45,14 @@ const getUsedAssetPaths = async (): Promise> => { return usedPaths; }; -export const cleanupUnusedAssets = async (): Promise<{ deletedCount: number; errors: string[] }> => { +export const cleanupUnusedAssets = async (): Promise<{ + deletedCount: number; + errors: string[]; +}> => { try { const allAssets = await getAllCustomGameAssets(); const usedAssets = await getUsedAssetPaths(); - + const errors: string[] = []; let deletedCount = 0; @@ -70,4 +73,4 @@ export const cleanupUnusedAssets = async (): Promise<{ deletedCount: number; err } }; -ipcMain.handle("cleanupUnusedAssets", cleanupUnusedAssets); \ No newline at end of file +ipcMain.handle("cleanupUnusedAssets", cleanupUnusedAssets); diff --git a/src/main/events/library/copy-custom-game-asset.ts b/src/main/events/library/copy-custom-game-asset.ts index dfb7bf47..07c3d6f7 100644 --- a/src/main/events/library/copy-custom-game-asset.ts +++ b/src/main/events/library/copy-custom-game-asset.ts @@ -26,7 +26,7 @@ const copyCustomGameAsset = async ( // Get file extension const fileExtension = path.extname(sourcePath); - + // Generate unique filename const uniqueId = randomUUID(); const fileName = `${assetType}-${uniqueId}${fileExtension}`; @@ -39,4 +39,4 @@ const copyCustomGameAsset = async ( return `local:${destinationPath}`; }; -registerEvent("copyCustomGameAsset", copyCustomGameAsset); \ No newline at end of file +registerEvent("copyCustomGameAsset", copyCustomGameAsset); diff --git a/src/main/events/library/update-game-custom-assets.ts b/src/main/events/library/update-game-custom-assets.ts index 6e504e92..74446fb0 100644 --- a/src/main/events/library/update-game-custom-assets.ts +++ b/src/main/events/library/update-game-custom-assets.ts @@ -21,9 +21,9 @@ const updateGameCustomAssets = async ( const updatedGame = { ...existingGame, title, - customIconUrl: customIconUrl ?? existingGame.customIconUrl, - customLogoImageUrl: customLogoImageUrl ?? existingGame.customLogoImageUrl, - customHeroImageUrl: customHeroImageUrl ?? existingGame.customHeroImageUrl, + customIconUrl: customIconUrl !== undefined ? customIconUrl : existingGame.customIconUrl, + customLogoImageUrl: customLogoImageUrl !== undefined ? customLogoImageUrl : existingGame.customLogoImageUrl, + customHeroImageUrl: customHeroImageUrl !== undefined ? customHeroImageUrl : existingGame.customHeroImageUrl, }; await gamesSublevel.put(gameKey, updatedGame); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 0515f1f0..45bdb20d 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -130,7 +130,10 @@ declare global { sourcePath: string, assetType: "icon" | "logo" | "hero" ) => Promise; - cleanupUnusedAssets: () => Promise<{ deletedCount: number; errors: string[] }>; + cleanupUnusedAssets: () => Promise<{ + deletedCount: number; + errors: string[]; + }>; updateGameCustomAssets: ( shop: GameShop, objectId: string, 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 22f56630..6f3c2ee2 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -232,6 +232,7 @@ export function GameDetailsContent() { visible={showEditGameModal} onClose={() => setShowEditGameModal(false)} game={game} + shopDetails={shopDetails} onGameUpdated={handleGameUpdated} /> diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx index b922a231..45653649 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -1,10 +1,10 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { ImageIcon } from "@primer/octicons-react"; +import { ImageIcon, ReplyIcon } from "@primer/octicons-react"; import { Modal, TextField, Button } from "@renderer/components"; import { useToast } from "@renderer/hooks"; -import type { LibraryGame, Game } from "@types"; +import type { LibraryGame, Game, ShopDetailsWithAssets } from "@types"; import "./edit-game-modal.scss"; @@ -12,6 +12,7 @@ export interface EditGameModalProps { visible: boolean; onClose: () => void; game: LibraryGame | Game | null; + shopDetails?: ShopDetailsWithAssets | null; onGameUpdated: (updatedGame: any) => void; } @@ -19,6 +20,7 @@ export function EditGameModal({ visible, onClose, game, + shopDetails, onGameUpdated, }: Readonly) { const { t } = useTranslation("sidebar"); @@ -29,6 +31,11 @@ export function EditGameModal({ const [logoPath, setLogoPath] = useState(""); const [heroPath, setHeroPath] = useState(""); const [isUpdating, setIsUpdating] = useState(false); + + // Store default image URLs for non-custom games + const [defaultIconUrl, setDefaultIconUrl] = useState(null); + const [defaultLogoUrl, setDefaultLogoUrl] = useState(null); + const [defaultHeroUrl, setDefaultHeroUrl] = useState(null); // Helper function to check if game is a custom game const isCustomGame = (game: LibraryGame | Game): boolean => { @@ -52,6 +59,11 @@ export function EditGameModal({ setIconPath(extractLocalPath(game.customIconUrl)); setLogoPath(extractLocalPath(game.customLogoImageUrl)); setHeroPath(extractLocalPath(game.customHeroImageUrl)); + + // Store default URLs for restore functionality from shopDetails.assets + setDefaultIconUrl(shopDetails?.assets?.iconUrl || game.iconUrl || null); + setDefaultLogoUrl(shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null); + setDefaultHeroUrl(shopDetails?.assets?.libraryHeroImageUrl || game.libraryHeroImageUrl || null); }; useEffect(() => { @@ -64,7 +76,7 @@ export function EditGameModal({ setNonCustomGameAssets(game as LibraryGame); } } - }, [game, visible]); + }, [game, visible, shopDetails]); const handleGameNameChange = (event: React.ChangeEvent) => { setGameName(event.target.value); @@ -151,6 +163,19 @@ export function EditGameModal({ } }; + // Helper functions to restore default images for non-custom games + const handleRestoreDefaultIcon = () => { + setIconPath(""); + }; + + const handleRestoreDefaultLogo = () => { + setLogoPath(""); + }; + + const handleRestoreDefaultHero = () => { + setHeroPath(""); + }; + // Helper function to prepare custom game assets const prepareCustomGameAssets = (game: LibraryGame | Game) => { const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl; @@ -235,6 +260,10 @@ export function EditGameModal({ if (isCustomGame(game)) { setCustomGameAssets(game); + // Clear default URLs for custom games + setDefaultIconUrl(null); + setDefaultLogoUrl(null); + setDefaultHeroUrl(null); } else { setNonCustomGameAssets(game as LibraryGame); } @@ -250,14 +279,26 @@ export function EditGameModal({ const isFormValid = gameName.trim(); const getIconPreviewUrl = (): string | undefined => { + if (!isCustomGame(game!)) { + // For non-custom games, show custom image if set, otherwise show default + return iconPath ? `local:${iconPath}` : defaultIconUrl || undefined; + } return iconPath ? `local:${iconPath}` : undefined; }; const getLogoPreviewUrl = (): string | undefined => { + if (!isCustomGame(game!)) { + // For non-custom games, show custom image if set, otherwise show default + return logoPath ? `local:${logoPath}` : defaultLogoUrl || undefined; + } return logoPath ? `local:${logoPath}` : undefined; }; const getHeroPreviewUrl = (): string | undefined => { + if (!isCustomGame(game!)) { + // For non-custom games, show custom image if set, otherwise show default + return heroPath ? `local:${heroPath}` : defaultHeroUrl || undefined; + } return heroPath ? `local:${heroPath}` : undefined; }; @@ -287,19 +328,32 @@ export function EditGameModal({ readOnly theme="dark" rightContent={ - +
    + + {!isCustomGame(game!) && iconPath && ( + + )} +
    } /> - {iconPath && ( + {(iconPath || (!isCustomGame(game!) && defaultIconUrl)) && (
    - - {t("edit_custom_game_modal_browse")} - +
    + + {!isCustomGame(game!) && logoPath && ( + + )} +
    } /> - {logoPath && ( + {(logoPath || (!isCustomGame(game!) && defaultLogoUrl)) && (
    - - {t("edit_custom_game_modal_browse")} - +
    + + {!isCustomGame(game!) && heroPath && ( + + )} +
    } /> - {heroPath && ( + {(heroPath || (!isCustomGame(game!) && defaultHeroUrl)) && (
    {t("remove_files")} From 607bc6407c9e2bb054de3cedd6fe588a05554797 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 21:16:46 +0300 Subject: [PATCH 14/24] fixed assertions + use ?? operators --- .../library/update-game-custom-assets.ts | 6 +-- .../game-details/modals/edit-game-modal.tsx | 40 +++++++++++-------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/main/events/library/update-game-custom-assets.ts b/src/main/events/library/update-game-custom-assets.ts index 74446fb0..866cd60e 100644 --- a/src/main/events/library/update-game-custom-assets.ts +++ b/src/main/events/library/update-game-custom-assets.ts @@ -21,9 +21,9 @@ const updateGameCustomAssets = async ( const updatedGame = { ...existingGame, title, - customIconUrl: customIconUrl !== undefined ? customIconUrl : existingGame.customIconUrl, - customLogoImageUrl: customLogoImageUrl !== undefined ? customLogoImageUrl : existingGame.customLogoImageUrl, - customHeroImageUrl: customHeroImageUrl !== undefined ? customHeroImageUrl : existingGame.customHeroImageUrl, + ...(customIconUrl !== undefined && { customIconUrl }), + ...(customLogoImageUrl !== undefined && { customLogoImageUrl }), + ...(customHeroImageUrl !== undefined && { customHeroImageUrl }), }; await gamesSublevel.put(gameKey, updatedGame); diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx index 45653649..ea34223c 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -31,7 +31,7 @@ export function EditGameModal({ const [logoPath, setLogoPath] = useState(""); const [heroPath, setHeroPath] = useState(""); const [isUpdating, setIsUpdating] = useState(false); - + // Store default image URLs for non-custom games const [defaultIconUrl, setDefaultIconUrl] = useState(null); const [defaultLogoUrl, setDefaultLogoUrl] = useState(null); @@ -59,11 +59,17 @@ export function EditGameModal({ setIconPath(extractLocalPath(game.customIconUrl)); setLogoPath(extractLocalPath(game.customLogoImageUrl)); setHeroPath(extractLocalPath(game.customHeroImageUrl)); - + // Store default URLs for restore functionality from shopDetails.assets setDefaultIconUrl(shopDetails?.assets?.iconUrl || game.iconUrl || null); - setDefaultLogoUrl(shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null); - setDefaultHeroUrl(shopDetails?.assets?.libraryHeroImageUrl || game.libraryHeroImageUrl || null); + setDefaultLogoUrl( + shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null + ); + setDefaultHeroUrl( + shopDetails?.assets?.libraryHeroImageUrl || + game.libraryHeroImageUrl || + null + ); }; useEffect(() => { @@ -235,7 +241,7 @@ export function EditGameModal({ setIsUpdating(true); try { - const updatedGame = isCustomGame(game) + const updatedGame = game && isCustomGame(game) ? await updateCustomGame(game) : await updateNonCustomGame(game as LibraryGame); @@ -279,7 +285,7 @@ export function EditGameModal({ const isFormValid = gameName.trim(); const getIconPreviewUrl = (): string | undefined => { - if (!isCustomGame(game!)) { + if (game && !isCustomGame(game)) { // For non-custom games, show custom image if set, otherwise show default return iconPath ? `local:${iconPath}` : defaultIconUrl || undefined; } @@ -287,7 +293,7 @@ export function EditGameModal({ }; const getLogoPreviewUrl = (): string | undefined => { - if (!isCustomGame(game!)) { + if (game && !isCustomGame(game)) { // For non-custom games, show custom image if set, otherwise show default return logoPath ? `local:${logoPath}` : defaultLogoUrl || undefined; } @@ -295,7 +301,7 @@ export function EditGameModal({ }; const getHeroPreviewUrl = (): string | undefined => { - if (!isCustomGame(game!)) { + if (game && !isCustomGame(game)) { // For non-custom games, show custom image if set, otherwise show default return heroPath ? `local:${heroPath}` : defaultHeroUrl || undefined; } @@ -328,7 +334,7 @@ export function EditGameModal({ readOnly theme="dark" rightContent={ -
    +
    - {!isCustomGame(game!) && iconPath && ( + {game && !isCustomGame(game) && iconPath && ( - {!isCustomGame(game!) && logoPath && ( + {game && !isCustomGame(game) && logoPath && ( - {!isCustomGame(game!) && heroPath && ( + {game && !isCustomGame(game) && heroPath && ( {game && !isCustomGame(game) && iconPath && ( {game && !isCustomGame(game) && logoPath && ( {game && !isCustomGame(game) && heroPath && (
    From de4119988c3f3d54405d54f271d6c08baa4b5ea6 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 22:30:16 +0300 Subject: [PATCH 16/24] avoiding non-native interactive elements + extracted ternary operation --- src/main/events/misc/delete-temp-file.ts | 2 +- src/main/events/misc/save-temp-file.ts | 11 +- .../game-details/game-details-content.tsx | 52 +++--- .../game-details/modals/edit-game-modal.scss | 22 ++- .../game-details/modals/edit-game-modal.tsx | 176 ++++++++++++------ 5 files changed, 170 insertions(+), 93 deletions(-) diff --git a/src/main/events/misc/delete-temp-file.ts b/src/main/events/misc/delete-temp-file.ts index b26dd975..7ca88fa1 100644 --- a/src/main/events/misc/delete-temp-file.ts +++ b/src/main/events/misc/delete-temp-file.ts @@ -15,4 +15,4 @@ const deleteTempFile = async ( } }; -registerEvent("deleteTempFile", deleteTempFile); \ No newline at end of file +registerEvent("deleteTempFile", deleteTempFile); diff --git a/src/main/events/misc/save-temp-file.ts b/src/main/events/misc/save-temp-file.ts index c9776430..8f253bf2 100644 --- a/src/main/events/misc/save-temp-file.ts +++ b/src/main/events/misc/save-temp-file.ts @@ -10,15 +10,18 @@ const saveTempFile = async ( ): Promise => { try { const tempDir = app.getPath("temp"); - const tempFilePath = path.join(tempDir, `hydra-temp-${Date.now()}-${fileName}`); - + const tempFilePath = path.join( + tempDir, + `hydra-temp-${Date.now()}-${fileName}` + ); + // Write the file data to temp directory fs.writeFileSync(tempFilePath, fileData); - + return tempFilePath; } catch (error) { throw new Error(`Failed to save temp file: ${error}`); } }; -registerEvent("saveTempFile", saveTempFile); \ No newline at end of file +registerEvent("saveTempFile", saveTempFile); 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 f4619514..4ef45b6f 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -145,6 +145,34 @@ export function GameDetailsContent() { shopDetails?.assets?.logoImageUrl ); + const renderGameLogo = () => { + if (isCustomGame) { + // For custom games, show logo image if available, otherwise show game title as text + if (logoImage) { + return ( + {game?.title} + ); + } else { + return ( +
    {game?.title}
    + ); + } + } else { + // For non-custom games, show logo image if available + return logoImage ? ( + {game?.title} + ) : null; + } + }; + return (
    - {isCustomGame ? ( - // For custom games, show logo image if available, otherwise show game title as text - logoImage ? ( - {game?.title} - ) : ( -
    - {game?.title} -
    - ) - ) : ( - // For non-custom games, show logo image if available - logoImage && ( - {game?.title} - ) - )} + {renderGameLogo()}
    {(iconPath || (game && !isCustomGame(game) && defaultIconUrl)) && ( -
    handleDragEnter(e, 'icon')} + onDragEnter={(e) => handleDragEnter(e, "icon")} onDragLeave={handleDragLeave} onDrop={handleIconDrop} + onClick={handleSelectIcon} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelectIcon(); + } + }} > {t("edit_game_modal_icon_preview")} - {dragOverTarget === 'icon' && ( + {dragOverTarget === "icon" && (
    Drop to replace icon
    @@ -521,15 +549,27 @@ export function EditGameModal({
    )} - {(!iconPath && !(game && !isCustomGame(game) && defaultIconUrl)) && ( -
    handleDragEnter(e, 'icon')} + onDragEnter={(e) => handleDragEnter(e, "icon")} onDragLeave={handleDragLeave} onDrop={handleIconDrop} + onClick={handleSelectIcon} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelectIcon(); + } + }} >
    @@ -576,21 +616,33 @@ export function EditGameModal({
    {(logoPath || (game && !isCustomGame(game) && defaultLogoUrl)) && ( -
    handleDragEnter(e, 'logo')} + onDragEnter={(e) => handleDragEnter(e, "logo")} onDragLeave={handleDragLeave} onDrop={handleLogoDrop} + onClick={handleSelectLogo} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelectLogo(); + } + }} > {t("edit_game_modal_logo_preview")} - {dragOverTarget === 'logo' && ( + {dragOverTarget === "logo" && (
    Drop to replace logo
    @@ -598,13 +650,15 @@ export function EditGameModal({
    )} - {(!logoPath && !(game && !isCustomGame(game) && defaultLogoUrl)) && ( -
    handleDragEnter(e, 'logo')} + onDragEnter={(e) => handleDragEnter(e, "logo")} onDragLeave={handleDragLeave} onDrop={handleLogoDrop} > @@ -653,12 +707,14 @@ export function EditGameModal({
    {(heroPath || (game && !isCustomGame(game) && defaultHeroUrl)) && ( -
    handleDragEnter(e, 'hero')} + onDragEnter={(e) => handleDragEnter(e, "hero")} onDragLeave={handleDragLeave} onDrop={handleHeroDrop} > @@ -667,7 +723,7 @@ export function EditGameModal({ alt={t("edit_game_modal_hero_preview")} className="edit-game-modal__preview-image" /> - {dragOverTarget === 'hero' && ( + {dragOverTarget === "hero" && (
    Drop to replace hero image
    @@ -675,13 +731,15 @@ export function EditGameModal({
    )} - {(!heroPath && !(game && !isCustomGame(game) && defaultHeroUrl)) && ( -
    handleDragEnter(e, 'hero')} + onDragEnter={(e) => handleDragEnter(e, "hero")} onDragLeave={handleDragLeave} onDrop={handleHeroDrop} > From 393a04d7c39ffa231e05db09cdd00882d8c45c4e Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 19 Sep 2025 22:39:08 +0300 Subject: [PATCH 17/24] Complete fix for non-native interactive elements --- .../game-details/modals/edit-game-modal.scss | 15 +++++ .../game-details/modals/edit-game-modal.tsx | 60 ++++++++----------- 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss index facb3fc4..bb6d5918 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -53,6 +53,21 @@ transform 0.2s ease; position: relative; + /* Reset button styles when used as button element */ + &[type="button"] { + font: inherit; + color: inherit; + text-align: inherit; + text-decoration: none; + outline: none; + cursor: pointer; + + &:focus { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } + } + &:hover { border-color: var(--color-primary); } diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx index 66418ab4..26d33a95 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -515,9 +515,8 @@ export function EditGameModal({
    {(iconPath || (game && !isCustomGame(game) && defaultIconUrl)) && ( -
    { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleSelectIcon(); - } - }} > Drop to replace icon
    )} -
    + )} {!iconPath && !(game && !isCustomGame(game) && defaultIconUrl) && ( -
    { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleSelectIcon(); - } - }} >
    Drop icon image here
    -
    + )}
    @@ -616,9 +602,8 @@ export function EditGameModal({
    {(logoPath || (game && !isCustomGame(game) && defaultLogoUrl)) && ( -
    { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleSelectLogo(); - } - }} > Drop to replace logo
    )} -
    + )} {!logoPath && !(game && !isCustomGame(game) && defaultLogoUrl) && ( -
    handleDragEnter(e, "logo")} onDragLeave={handleDragLeave} onDrop={handleLogoDrop} + onClick={handleSelectLogo} >
    Drop logo image here
    -
    + )}
    @@ -707,7 +689,9 @@ export function EditGameModal({
    {(heroPath || (game && !isCustomGame(game) && defaultHeroUrl)) && ( -
    handleDragEnter(e, "hero")} onDragLeave={handleDragLeave} onDrop={handleHeroDrop} + onClick={handleSelectHero} > Drop to replace hero image
    )} - + )} {!heroPath && !(game && !isCustomGame(game) && defaultHeroUrl) && ( -
    handleDragEnter(e, "hero")} onDragLeave={handleDragLeave} onDrop={handleHeroDrop} + onClick={handleSelectHero} >
    Drop hero image here
    -
    + )} From eeed34adcbc7678838a884a428e3db77185cb96c Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 20 Sep 2025 09:21:28 +0300 Subject: [PATCH 18/24] lint failing fix --- .../game-details/modals/edit-game-modal.scss | 2 +- .../game-details/modals/edit-game-modal.tsx | 52 +++++++++++-------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss index bb6d5918..3a5d8943 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -61,7 +61,7 @@ text-decoration: none; outline: none; cursor: pointer; - + &:focus { outline: 2px solid var(--color-primary); outline-offset: 2px; diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx index 26d33a95..614d458b 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { ImageIcon, ReplyIcon } from "@primer/octicons-react"; @@ -13,7 +13,7 @@ export interface EditGameModalProps { onClose: () => void; game: LibraryGame | Game | null; shopDetails?: ShopDetailsWithAssets | null; - onGameUpdated: (updatedGame: any) => void; + onGameUpdated: (updatedGame: LibraryGame | Game) => void; } export function EditGameModal({ @@ -48,29 +48,32 @@ export function EditGameModal({ }; // Helper function to set asset paths for custom games - const setCustomGameAssets = (game: LibraryGame | Game) => { + const setCustomGameAssets = useCallback((game: LibraryGame | Game) => { setIconPath(extractLocalPath(game.iconUrl)); setLogoPath(extractLocalPath(game.logoImageUrl)); setHeroPath(extractLocalPath(game.libraryHeroImageUrl)); - }; + }, []); // Helper function to set asset paths for non-custom games - const setNonCustomGameAssets = (game: LibraryGame) => { - setIconPath(extractLocalPath(game.customIconUrl)); - setLogoPath(extractLocalPath(game.customLogoImageUrl)); - setHeroPath(extractLocalPath(game.customHeroImageUrl)); + const setNonCustomGameAssets = useCallback( + (game: LibraryGame) => { + setIconPath(extractLocalPath(game.customIconUrl)); + setLogoPath(extractLocalPath(game.customLogoImageUrl)); + setHeroPath(extractLocalPath(game.customHeroImageUrl)); - // Store default URLs for restore functionality from shopDetails.assets - setDefaultIconUrl(shopDetails?.assets?.iconUrl || game.iconUrl || null); - setDefaultLogoUrl( - shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null - ); - setDefaultHeroUrl( - shopDetails?.assets?.libraryHeroImageUrl || - game.libraryHeroImageUrl || - null - ); - }; + // Store default URLs for restore functionality from shopDetails.assets + setDefaultIconUrl(shopDetails?.assets?.iconUrl || game.iconUrl || null); + setDefaultLogoUrl( + shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null + ); + setDefaultHeroUrl( + shopDetails?.assets?.libraryHeroImageUrl || + game.libraryHeroImageUrl || + null + ); + }, + [shopDetails] + ); useEffect(() => { if (game && visible) { @@ -82,7 +85,7 @@ export function EditGameModal({ setNonCustomGameAssets(game as LibraryGame); } } - }, [game, visible, shopDetails]); + }, [game, visible, shopDetails, setCustomGameAssets, setNonCustomGameAssets]); const handleGameNameChange = (event: React.ChangeEvent) => { setGameName(event.target.value); @@ -233,8 +236,13 @@ export function EditGameModal({ let filePath: string; // Try to get the path from the file object (Electron specific) - if ("path" in file && typeof (file as any).path === "string") { - filePath = (file as any).path; + // In Electron, File objects have a path property + interface ElectronFile extends File { + path?: string; + } + + if ("path" in file && typeof (file as ElectronFile).path === "string") { + filePath = (file as ElectronFile).path!; } else { // Fallback: create a temporary file from the file data const arrayBuffer = await file.arrayBuffer(); From 83e7f676bf59e3a5b37f5463d81f4504a41f5b9b Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Sun, 28 Sep 2025 15:14:25 +0100 Subject: [PATCH 19/24] fix: fixing carousel --- .env.example | 2 - package.json | 4 + src/locales/en/translation.json | 3 - src/locales/pt-BR/translation.json | 3 - src/renderer/src/app.scss | 6 +- src/renderer/src/components/badge/badge.scss | 9 +- src/renderer/src/components/hero/hero.scss | 64 +++- src/renderer/src/components/hero/hero.tsx | 1 + .../game-details/game-details.context.tsx | 5 - .../game-details.context.types.ts | 2 - .../achievements/achievements-content.tsx | 35 +- .../gallery-slider/gallery-slider.scss | 99 ++++-- .../gallery-slider/gallery-slider.tsx | 303 ++++++++++-------- .../game-details/game-details-content.tsx | 30 +- .../src/pages/game-details/game-details.scss | 13 +- .../pages/game-details/hero/hero-panel.scss | 10 +- .../pages/game-details/hero/hero-panel.tsx | 4 +- .../sidebar-section/sidebar-section.scss | 3 +- .../sidebar/game-language-section.scss | 85 +++++ .../sidebar/game-language-section.tsx | 110 ++++--- .../sidebar/game-prices-section.tsx | 86 ----- .../pages/game-details/sidebar/sidebar.scss | 45 ++- .../pages/game-details/sidebar/sidebar.tsx | 6 +- src/renderer/src/pages/home/home.scss | 45 +-- src/renderer/src/pages/home/home.tsx | 2 - .../profile/profile-content/friends-box.tsx | 12 +- .../profile-content/profile-content.scss | 24 ++ .../user-library-game-card.scss | 65 +++- .../src/pages/settings/settings-general.tsx | 8 - src/renderer/src/scss/globals.scss | 10 +- src/types/level.types.ts | 1 - yarn.lock | 86 ++++- 32 files changed, 701 insertions(+), 480 deletions(-) create mode 100644 src/renderer/src/pages/game-details/sidebar/game-language-section.scss delete mode 100755 src/renderer/src/pages/game-details/sidebar/game-prices-section.tsx diff --git a/.env.example b/.env.example index 8ea7af55..3f914eb3 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,3 @@ MAIN_VITE_AUTH_URL= MAIN_VITE_WS_URL= RENDERER_VITE_REAL_DEBRID_REFERRAL_ID= RENDERER_VITE_TORBOX_REFERRAL_CODE= -VITE_GG_DEALS_API_URL=https://api.gg.deals/v1/prices/by-steam-app-id -VITE_GG_DEALS_API_KEY= \ No newline at end of file diff --git a/package.json b/package.json index 3186535f..e21c962a 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,8 @@ "diskusage": "^1.2.0", "electron-log": "^5.2.4", "electron-updater": "^6.6.2", + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-react": "^8.6.0", "file-type": "^20.5.0", "framer-motion": "^12.15.0", "i18next": "^23.11.2", @@ -63,6 +65,8 @@ "lodash-es": "^4.17.21", "parse-torrent": "^11.0.18", "rc-virtual-list": "^3.18.3", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-hook-form": "^7.53.0", "react-i18next": "^14.1.0", "react-loading-skeleton": "^3.4.0", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 781cd946..ea7fff89 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Successfully signed in" }, "home": { - "featured": "Featured", "surprise_me": "Surprise me", "no_results": "No results found", "start_typing": "Starting typing to search...", @@ -241,7 +240,6 @@ "keyshop_price": "Keyshop price", "historical_retail": "Historical retail", "historical_keyshop": "Historical keyshop", - "supported_languages": "Supported languages", "language": "Language", "caption": "Caption", "audio": "Audio" @@ -290,7 +288,6 @@ "change": "Update", "notifications": "Notifications", "enable_download_notifications": "When a download is complete", - "gg_deals_api_key_description": "gg deals api key. Used to show the lowest price. (https://gg.deals/api/)", "enable_repack_list_notifications": "When a new repack is added", "real_debrid_api_token_label": "Real-Debrid API token", "quit_app_instead_hiding": "Don't hide Hydra when closing", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index fd6fbd97..7f7f8cc1 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -4,7 +4,6 @@ "successfully_signed_in": "Autenticado com sucesso" }, "home": { - "featured": "Destaques", "hot": "Populares", "weekly": "📅 Mais baixados da semana", "achievements": "🏆 Pra platinar", @@ -218,7 +217,6 @@ "keyshop_price": "Preço em keyshops", "historical_retail": "Preço histórico de lojas oficiais", "historical_keyshop": "Preço histórico em keyshops", - "supported_languages": "Idiomas suportados", "language": "Idioma", "caption": "Legenda", "audio": "Áudio" @@ -267,7 +265,6 @@ "change": "Explorar...", "notifications": "Notificações", "enable_download_notifications": "Quando um download for concluído", - "gg_deals_api_key_description": "gg deals api key. Usado para mostrar o menor preço. (https://gg.deals/api/)", "enable_repack_list_notifications": "Quando a lista de repacks for atualizada", "real_debrid_api_token_label": "Token de API do Real-Debrid", "quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar", diff --git a/src/renderer/src/app.scss b/src/renderer/src/app.scss index 18d46dd4..4c5374e8 100644 --- a/src/renderer/src/app.scss +++ b/src/renderer/src/app.scss @@ -10,16 +10,16 @@ } ::-webkit-scrollbar-track { - background-color: rgba(255, 255, 255, 0.03); + background-color: rgba(0, 0, 0, 0.2); } ::-webkit-scrollbar-thumb { - background-color: rgba(255, 255, 255, 0.08); + background-color: rgba(255, 255, 255, 0.15); border-radius: 24px; } ::-webkit-scrollbar-thumb:hover { - background-color: rgba(255, 255, 255, 0.16); + background-color: rgba(255, 255, 255, 0.25); } html, diff --git a/src/renderer/src/components/badge/badge.scss b/src/renderer/src/components/badge/badge.scss index 69c43b3e..f90f8749 100644 --- a/src/renderer/src/components/badge/badge.scss +++ b/src/renderer/src/components/badge/badge.scss @@ -4,9 +4,14 @@ color: globals.$muted-color; font-size: 10px; padding: calc(globals.$spacing-unit / 2) globals.$spacing-unit; - border: solid 1px globals.$muted-color; - border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; display: flex; gap: 4px; align-items: center; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: all ease 0.2s; } diff --git a/src/renderer/src/components/hero/hero.scss b/src/renderer/src/components/hero/hero.scss index ea14c059..f9ec4d36 100644 --- a/src/renderer/src/components/hero/hero.scss +++ b/src/renderer/src/components/hero/hero.scss @@ -2,16 +2,36 @@ .hero { width: 100%; - height: 280px; - min-height: 280px; - max-height: 280px; - border-radius: 4px; + height: 180px; + min-height: 150px; + border-radius: 0; color: #dadbe1; overflow: hidden; box-shadow: 0px 0px 15px 0px #000000; cursor: pointer; border: solid 1px globals.$border-color; z-index: 1; + flex-shrink: 0; + + @media (min-width: 480px) { + height: 220px; + min-height: 200px; + } + + @media (min-width: 768px) { + height: 300px; + min-height: 300px; + } + + @media (min-width: 1024px) and (min-height: 800px) { + height: 400px; + min-height: 400px; + } + + @media (min-width: 1024px) and (max-height: 799px) { + height: 300px; + min-height: 250px; + } &__media { object-fit: cover; @@ -47,10 +67,42 @@ &__content { width: 100%; height: 100%; - padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 3); - gap: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2); + gap: calc(globals.$spacing-unit); display: flex; flex-direction: column; justify-content: flex-end; + + @media (min-width: 768px) { + padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 3); + gap: calc(globals.$spacing-unit * 1.5); + } + + @media (min-width: 1024px) { + padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 3); + gap: calc(globals.$spacing-unit * 2); + } + } + + &__logo { + max-width: 100%; + height: auto; + width: 120px; + + @media (min-width: 480px) { + width: 150px; + } + + @media (min-width: 768px) { + width: 200px; + } + + @media (min-width: 1024px) and (min-height: 800px) { + width: 250px; + } + + @media (min-width: 1024px) and (max-height: 799px) { + width: 200px; + } } } diff --git a/src/renderer/src/components/hero/hero.tsx b/src/renderer/src/components/hero/hero.tsx index f177c598..ce73d144 100644 --- a/src/renderer/src/components/hero/hero.tsx +++ b/src/renderer/src/components/hero/hero.tsx @@ -53,6 +53,7 @@ export function Hero() { width="250px" alt={game.description ?? ""} loading="eager" + className="hero__logo" />

    {game.description}

    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 ce2923b2..864fd482 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -38,14 +38,12 @@ export const gameDetailsContext = createContext({ isGameRunning: false, isLoading: false, objectId: undefined, - gameColor: "", showRepacksModal: false, showGameOptionsModal: false, stats: null, achievements: null, hasNSFWContentBlocked: false, lastDownloadedOption: null, - setGameColor: () => {}, selectGameExecutable: async () => null, updateGame: async () => {}, setShowGameOptionsModal: () => {}, @@ -82,7 +80,6 @@ export function GameDetailsContextProvider({ const [stats, setStats] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [gameColor, setGameColor] = useState(""); const [isGameRunning, setIsGameRunning] = useState(false); const [showRepacksModal, setShowRepacksModal] = useState(false); const [showGameOptionsModal, setShowGameOptionsModal] = useState(false); @@ -286,7 +283,6 @@ export function GameDetailsContextProvider({ isGameRunning, isLoading, objectId, - gameColor, showGameOptionsModal, showRepacksModal, stats, @@ -294,7 +290,6 @@ export function GameDetailsContextProvider({ hasNSFWContentBlocked, lastDownloadedOption, setHasNSFWContentBlocked, - setGameColor, selectGameExecutable, updateGame, setShowRepacksModal, diff --git a/src/renderer/src/context/game-details/game-details.context.types.ts b/src/renderer/src/context/game-details/game-details.context.types.ts index 99c7b293..302460b7 100644 --- a/src/renderer/src/context/game-details/game-details.context.types.ts +++ b/src/renderer/src/context/game-details/game-details.context.types.ts @@ -16,14 +16,12 @@ export interface GameDetailsContext { isGameRunning: boolean; isLoading: boolean; objectId: string | undefined; - gameColor: string; showRepacksModal: boolean; showGameOptionsModal: boolean; stats: GameStats | null; achievements: UserAchievement[] | null; hasNSFWContentBlocked: boolean; lastDownloadedOption: GameRepack | null; - setGameColor: React.Dispatch>; selectGameExecutable: () => Promise; updateGame: () => Promise; setShowRepacksModal: React.Dispatch>; diff --git a/src/renderer/src/pages/achievements/achievements-content.tsx b/src/renderer/src/pages/achievements/achievements-content.tsx index 477925c7..e555212c 100644 --- a/src/renderer/src/pages/achievements/achievements-content.tsx +++ b/src/renderer/src/pages/achievements/achievements-content.tsx @@ -119,15 +119,8 @@ export function AchievementsContent({ const containerRef = useRef(null); const [isHeaderStuck, setIsHeaderStuck] = useState(false); - const { - gameTitle, - objectId, - shop, - shopDetails, - achievements, - gameColor, - setGameColor, - } = useContext(gameDetailsContext); + const { gameTitle, objectId, shop, shopDetails, achievements } = + useContext(gameDetailsContext); const dispatch = useAppDispatch(); @@ -136,22 +129,6 @@ export function AchievementsContent({ dispatch(setHeaderTitle(gameTitle)); }, [dispatch, gameTitle]); - const handleHeroLoad = async () => { - const output = await average( - shopDetails?.assets?.libraryHeroImageUrl ?? "", - { - amount: 1, - format: "hex", - } - ); - - const backgroundColor = output - ? (new Color(output).darken(0.7).toString() as string) - : ""; - - setGameColor(backgroundColor); - }; - const onScroll: React.UIEventHandler = (event) => { const heroHeight = heroRef.current?.clientHeight ?? 150; @@ -191,7 +168,6 @@ export function AchievementsContent({ src={shopDetails?.assets?.libraryHeroImageUrl ?? ""} className="achievements-content__achievements-list__image" alt={gameTitle} - onLoad={handleHeroLoad} />
    -
    +
    (null); - const mediaContainerRef = useRef(null); - const { t } = useTranslation("game_details"); const hasScreenshots = shopDetails && shopDetails.screenshots?.length; - const hasMovies = shopDetails && shopDetails.movies?.length; - const mediaCount = useMemo(() => { - if (!shopDetails) return 0; + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false }); + const [selectedIndex, setSelectedIndex] = useState(0); - if (shopDetails.screenshots && shopDetails.movies) { - return shopDetails.screenshots.length + shopDetails.movies.length; - } else if (shopDetails.movies) { - return shopDetails.movies.length; - } else if (shopDetails.screenshots) { - return shopDetails.screenshots.length; - } + const scrollPrev = useCallback(() => { + if (emblaApi) emblaApi.scrollPrev(); + }, [emblaApi]); - return 0; - }, [shopDetails]); + const scrollNext = useCallback(() => { + if (emblaApi) emblaApi.scrollNext(); + }, [emblaApi]); - const [mediaIndex, setMediaIndex] = useState(0); - const [showArrows, setShowArrows] = useState(false); + const scrollTo = useCallback( + (index: number) => { + if (emblaApi) emblaApi.scrollTo(index); + }, + [emblaApi] + ); - const showNextImage = () => { - setMediaIndex((index: number) => { - if (index === mediaCount - 1) return 0; + const scrollToPreview = useCallback( + (index: number, event: React.MouseEvent) => { + scrollTo(index); - return index + 1; - }); - }; + const button = event.currentTarget; + const previewContainer = button.parentElement; - const showPrevImage = () => { - setMediaIndex((index: number) => { - if (index === 0) return mediaCount - 1; + if (previewContainer) { + const containerRect = previewContainer.getBoundingClientRect(); + const buttonRect = button.getBoundingClientRect(); - return index - 1; - }); - }; + const isOffScreenLeft = buttonRect.left < containerRect.left; + const isOffScreenRight = buttonRect.right > containerRect.right; - useEffect(() => { - setMediaIndex(0); - }, [shopDetails]); - - useEffect(() => { - if (hasMovies && mediaContainerRef.current) { - mediaContainerRef.current.childNodes.forEach((node, index) => { - if (node instanceof HTMLVideoElement) { - if (index !== mediaIndex) { - node.pause(); - } + if (isOffScreenLeft || isOffScreenRight) { + button.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); } + } + }, + [scrollTo] + ); + + useEffect(() => { + if (!emblaApi) return; + + let isInitialLoad = true; + + const onSelect = () => { + const newIndex = emblaApi.selectedScrollSnap(); + setSelectedIndex(newIndex); + + if (!isInitialLoad) { + const videos = document.querySelectorAll(".gallery-slider__media"); + videos.forEach((video) => { + if (video instanceof HTMLVideoElement) { + video.pause(); + } + }); + } + + isInitialLoad = false; + }; + + emblaApi.on("select", onSelect); + onSelect(); + + return () => { + emblaApi.off("select", onSelect); + }; + }, [emblaApi]); + + const mediaItems = useMemo(() => { + const items: Array<{ + id: string; + type: "video" | "image"; + src?: string; + poster?: string; + videoSrc?: string; + alt: string; + }> = []; + + if (shopDetails?.movies) { + shopDetails.movies.forEach((video, index) => { + items.push({ + id: String(video.id), + type: "video", + poster: video.thumbnail, + videoSrc: video.mp4.max.startsWith("http://") + ? video.mp4.max.replace("http://", "https://") + : video.mp4.max, + alt: t("video", { number: String(index + 1) }), + }); }); } - }, [hasMovies, mediaContainerRef, mediaIndex]); - useEffect(() => { - if (scrollContainerRef.current) { - const container = scrollContainerRef.current; - const totalWidth = container.scrollWidth - container.clientWidth; - const itemWidth = totalWidth / (mediaCount - 1); - const scrollLeft = mediaIndex * itemWidth; - container.scrollLeft = scrollLeft; + if (shopDetails?.screenshots) { + shopDetails.screenshots.forEach((image, index) => { + items.push({ + id: String(image.id), + type: "image", + src: image.path_full, + alt: t("screenshot", { number: String(index + 1) }), + }); + }); } - }, [shopDetails, mediaIndex, mediaCount]); + + return items; + }, [shopDetails, t]); const previews = useMemo(() => { const screenshotPreviews = shopDetails?.screenshots?.map(({ id, path_thumbnail }) => ({ id, thumbnail: path_thumbnail, + type: "image" as const, })) ?? []; if (shopDetails?.movies) { const moviePreviews = shopDetails.movies.map(({ id, thumbnail }) => ({ id, thumbnail, + type: "video" as const, })); return [...moviePreviews, ...screenshotPreviews]; @@ -93,96 +147,87 @@ export function GallerySlider() { return screenshotPreviews; }, [shopDetails]); + if (!hasScreenshots) { + return null; + } + return ( - <> - {hasScreenshots && ( -
    -
    setShowArrows(true)} - onMouseLeave={() => setShowArrows(false)} - className="gallery-slider__animation-container" - ref={mediaContainerRef} - > - {shopDetails.movies && - shopDetails.movies.map((video) => ( +
    +
    +
    + {mediaItems.map((item) => ( +
    + {item.type === "video" ? ( - ))} - - {hasScreenshots && - shopDetails.screenshots?.map((image, i) => ( + ) : ( {t("screenshot", - ))} - - - - -
    - -
    - {previews.map((media, i) => ( - - ))} -
    + )} +
    + ))}
    - )} - + + + + +
    + +
    + {previews.map((media, i) => ( + + ))} +
    +
    ); } 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 40436614..1ce80da4 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -1,6 +1,4 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react"; -import { average } from "color.js"; -import Color from "color"; import { HeroPanel } from "./hero"; import { DescriptionHeader } from "./description-header/description-header"; @@ -21,14 +19,8 @@ export function GameDetailsContent() { const { t } = useTranslation("game_details"); - const { - objectId, - shopDetails, - game, - gameColor, - setGameColor, - hasNSFWContentBlocked, - } = useContext(gameDetailsContext); + const { objectId, shopDetails, game, hasNSFWContentBlocked } = + useContext(gameDetailsContext); const { showHydraCloudModal } = useSubscription(); @@ -58,22 +50,6 @@ export function GameDetailsContent() { const [backdropOpacity, setBackdropOpacity] = useState(1); - const handleHeroLoad = async () => { - const output = await average( - shopDetails?.assets?.libraryHeroImageUrl ?? "", - { - amount: 1, - format: "hex", - } - ); - - const backgroundColor = output - ? new Color(output).darken(0.7).toString() - : ""; - - setGameColor(backgroundColor); - }; - useEffect(() => { setBackdropOpacity(1); }, [objectId]); @@ -106,12 +82,10 @@ export function GameDetailsContent() { src={shopDetails?.assets?.libraryHeroImageUrl ?? ""} className="game-details__hero-image" alt={game?.title} - onLoad={handleHeroLoad} />
    diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss index 899d654a..e488db5f 100644 --- a/src/renderer/src/pages/game-details/game-details.scss +++ b/src/renderer/src/pages/game-details/game-details.scss @@ -18,7 +18,6 @@ $hero-height: 300px; &__wrapper { display: flex; flex-direction: column; - overflow: hidden; width: 100%; height: 100%; transition: all ease 0.3s; @@ -64,8 +63,8 @@ $hero-height: 300px; &__hero-image { width: 100%; - height: $hero-height; - min-height: $hero-height; + height: calc($hero-height + 72px); + min-height: calc($hero-height + 72px); object-fit: cover; object-position: top; transition: all ease 0.2s; @@ -74,8 +73,8 @@ $hero-height: 300px; @media (min-width: 1250px) { object-position: center; - height: 350px; - min-height: 350px; + height: calc(350px + 72px); + min-height: calc(350px + 72px); } } @@ -97,7 +96,6 @@ $hero-height: 300px; height: 100%; display: flex; flex-direction: column; - overflow: auto; z-index: 1; } @@ -105,6 +103,7 @@ $hero-height: 300px; display: flex; width: 100%; flex: 1; + min-width: 0; background: linear-gradient( 0deg, globals.$background-color 50%, @@ -115,6 +114,8 @@ $hero-height: 300px; &__description-content { width: 100%; height: 100%; + min-width: 0; + flex: 1; } &__description { diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.scss b/src/renderer/src/pages/game-details/hero/hero-panel.scss index 066ce196..4dd1cc22 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.scss +++ b/src/renderer/src/pages/game-details/hero/hero-panel.scss @@ -5,18 +5,24 @@ height: 72px; min-height: 72px; padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3); - background-color: globals.$dark-background-color; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: solid 1px rgba(255, 255, 255, 0.15); display: flex; align-items: center; justify-content: space-between; transition: all ease 0.2s; border-bottom: solid 1px globals.$border-color; - position: sticky; overflow: hidden; top: 0; z-index: 2; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); &--stuck { + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8); } diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.tsx b/src/renderer/src/pages/game-details/hero/hero-panel.tsx index 3a07daa1..7f8de0b0 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel.tsx @@ -14,7 +14,7 @@ export function HeroPanel() { const { formatDate } = useDate(); - const { game, repacks, gameColor } = useContext(gameDetailsContext); + const { game, repacks } = useContext(gameDetailsContext); const { lastPacket } = useDownload(); @@ -50,7 +50,7 @@ export function HeroPanel() { game?.download?.status === "paused"; return ( -
    +
    {getInfo()}
    diff --git a/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.scss b/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.scss index 5ea421c3..8674b044 100644 --- a/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.scss +++ b/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.scss @@ -2,8 +2,7 @@ .sidebar-section { &__button { - height: 72px; - padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2); display: flex; align-items: center; background-color: globals.$background-color; diff --git a/src/renderer/src/pages/game-details/sidebar/game-language-section.scss b/src/renderer/src/pages/game-details/sidebar/game-language-section.scss new file mode 100644 index 00000000..896316ec --- /dev/null +++ b/src/renderer/src/pages/game-details/sidebar/game-language-section.scss @@ -0,0 +1,85 @@ +@use "../../../scss/globals.scss"; + +.game-language-section { + background-color: rgba(255, 255, 255, 0.02); + overflow: hidden; + + &__header { + display: flex; + background-color: rgba(255, 255, 255, 0.05); + border-bottom: 1px solid globals.$border-color; + } + + &__header-item { + display: flex; + align-items: center; + color: globals.$muted-color; + font-size: globals.$small-font-size; + font-weight: 600; + flex: 1; + padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2.5); + + &--center { + justify-content: flex-start; + flex: 0 0 60px; + } + } + + &__content { + display: flex; + flex-direction: column; + } + + &__row { + display: flex; + transition: background-color 0.2s ease; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + + &:hover { + background-color: rgba(255, 255, 255, 0.03); + } + + &:last-child { + border-bottom: none; + } + } + + &__cell { + padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2.5); + font-size: globals.$body-font-size; + color: globals.$body-color; + display: flex; + align-items: center; + flex: 1; + + &--language { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &--center { + justify-content: flex-start; + flex: 0 0 60px; + } + } + + &__check { + color: globals.$body-color; + opacity: 0.8; + } + + &__cross { + color: globals.$body-color; + opacity: 0.8; + } + + @media (max-width: 320px) { + &__header, + &__cell { + padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 0.5); + font-size: calc(globals.$small-font-size * 0.9); + } + } +} diff --git a/src/renderer/src/pages/game-details/sidebar/game-language-section.tsx b/src/renderer/src/pages/game-details/sidebar/game-language-section.tsx index f67e4dfa..874a588e 100755 --- a/src/renderer/src/pages/game-details/sidebar/game-language-section.tsx +++ b/src/renderer/src/pages/game-details/sidebar/game-language-section.tsx @@ -1,65 +1,71 @@ -import { useContext } from "react"; +import { useContext, useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { CheckIcon, XIcon } from "@primer/octicons-react"; import { gameDetailsContext } from "@renderer/context/game-details/game-details.context"; import { SidebarSection } from "../sidebar-section/sidebar-section"; +import "./game-language-section.scss"; export function GameLanguageSection() { const { t } = useTranslation("game_details"); - const { shopDetails, objectId } = useContext(gameDetailsContext); + const { shopDetails } = useContext(gameDetailsContext); - const getLanguages = () => { - let languages = shopDetails?.supported_languages; - if (!languages) return []; - languages = languages?.split("
    ")[0]; - const arrayIdiomas = languages?.split(","); - const listLanguages: { - language: string; - caption: string; - audio: string; - }[] = []; - arrayIdiomas?.forEach((lang) => { - const objectLanguage = { - language: lang.replace("*", ""), - caption: "✔", - audio: lang.includes("*") ? "✔" : "", - }; - listLanguages.push(objectLanguage); - }); - return listLanguages; - }; + const languages = useMemo(() => { + const supportedLanguages = shopDetails?.supported_languages; + if (!supportedLanguages) return []; + + const languagesString = supportedLanguages.split("
    ")[0]; + const languageArray = languagesString?.split(",") || []; + + return languageArray.map((lang) => ({ + language: lang.replace("*", "").trim(), + hasAudio: lang.includes("*"), + })); + }, [shopDetails?.supported_languages]); + + if (languages.length === 0) { + return null; + } return ( -
    -

    {t("supported_languages")}

    - - - - - - - - - - {getLanguages().map((lang) => ( - - - - - - ))} - -
    {t("language")}{t("caption")}{t("audio")}
    {lang.language}{lang.caption}{lang.audio}
    -
    -
    - - Link Steam - +
    +
    +
    + {t("language")} +
    +
    + {t("caption")} +
    +
    + {t("audio")} +
    +
    + +
    + {languages.map((lang) => ( +
    +
    + {lang.language} +
    +
    + +
    +
    + {lang.hasAudio ? ( + + ) : ( + + )} +
    +
    + ))} +
    ); diff --git a/src/renderer/src/pages/game-details/sidebar/game-prices-section.tsx b/src/renderer/src/pages/game-details/sidebar/game-prices-section.tsx deleted file mode 100755 index 0753cdad..00000000 --- a/src/renderer/src/pages/game-details/sidebar/game-prices-section.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useCallback, useContext, useEffect, useState } from "react"; -import { SidebarSection } from "../sidebar-section/sidebar-section"; -import { useTranslation } from "react-i18next"; -import { gameDetailsContext } from "@renderer/context/game-details/game-details.context"; -import { useAppSelector } from "@renderer/hooks"; - -export function GamePricesSection() { - const userPreferences = useAppSelector( - (state) => state.userPreferences.value - ); - const { t } = useTranslation("game_details"); - const [priceData, setPriceData] = useState(null); - const [isLoadingPrices, setIsLoadingPrices] = useState(false); - const { objectId } = useContext(gameDetailsContext); - - const fetchGamePrices = useCallback(async (steamAppId: string) => { - setIsLoadingPrices(true); - try { - const apiKey = - userPreferences?.ggDealsApiKey || import.meta.env.VITE_GG_DEALS_API_KEY; - if (!apiKey) { - setPriceData(null); - setIsLoadingPrices(false); - return; - } - const url = `${import.meta.env.VITE_GG_DEALS_API_URL}/?ids=${steamAppId}&key=${apiKey}®ion=br`; - const response = await fetch(url); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - const data = await response.json(); - setPriceData(data.data?.[steamAppId] ?? null); - } catch (error) { - setPriceData(null); - } finally { - setIsLoadingPrices(false); - } - }, []); - - useEffect(() => { - if (objectId) { - fetchGamePrices(objectId.toString()); - } - }, [objectId, fetchGamePrices]); - - return ( - - {isLoadingPrices ? ( -
    {t("loading")}
    - ) : priceData ? ( -
    -
      -
    • - {t("retail_price")}: {t("currency_symbol")} - {priceData.prices.currentRetail} -
    • -
    • - {t("keyshop_price")}: {t("currency_symbol")} - {priceData.prices.currentKeyshops} -
    • -
    • - {t("historical_retail")}: {t("currency_symbol")} - {priceData.prices.historicalRetail} -
    • -
    • - {t("historical_keyshop")}: {t("currency_symbol")} - {priceData.prices.historicalKeyshops} -
    • -
    • - - {t("view_all_prices")} - -
    • -
    -
    - ) : ( -
    {t("no_prices_found")}
    - )} -
    - ); -} diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.scss b/src/renderer/src/pages/game-details/sidebar/sidebar.scss index 84386f12..d1c54f84 100755 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.scss +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.scss @@ -3,17 +3,30 @@ .content-sidebar { border-left: solid 1px globals.$border-color; background-color: globals.$dark-background-color; - width: 100%; height: 100%; + flex-shrink: 0; + width: 280px; @media (min-width: 1024px) { - max-width: 300px; - width: 100%; + width: 320px; } @media (min-width: 1280px) { - width: 100%; - max-width: 400px; + width: 380px; + } + + @media (min-width: 1440px) { + width: 420px; + } + + @media (max-width: 768px) { + width: 35%; + min-width: 220px; + } + + @media (max-width: 480px) { + width: 40%; + min-width: 200px; } } @@ -194,25 +207,3 @@ .achievements-placeholder__blur { filter: blur(4px); } - -.table-languages { - width: 100%; - border-collapse: collapse; - text-align: left; - - th, - td { - padding: globals.$spacing-unit; - border-bottom: solid 1px globals.$border-color; - } - - th { - font-size: globals.$small-font-size; - color: globals.$muted-color; - font-weight: normal; - } - - td { - font-size: globals.$body-font-size; - } -} diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx index af72dc92..0a24c418 100755 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx @@ -21,7 +21,6 @@ import { SidebarSection } from "../sidebar-section/sidebar-section"; import { buildGameAchievementPath } from "@renderer/helpers"; import { useSubscription } from "@renderer/hooks/use-subscription"; import "./sidebar.scss"; -import { GamePricesSection } from "./game-prices-section"; import { GameLanguageSection } from "./game-language-section"; const achievementsPlaceholder: UserAchievement[] = [ @@ -117,9 +116,6 @@ export function Sidebar() { return ( ); } diff --git a/src/renderer/src/pages/home/home.scss b/src/renderer/src/pages/home/home.scss index 878b84f1..497f074e 100644 --- a/src/renderer/src/pages/home/home.scss +++ b/src/renderer/src/pages/home/home.scss @@ -6,8 +6,8 @@ height: 100%; display: flex; flex-direction: column; - gap: calc(globals.$spacing-unit * 3); - padding: calc(globals.$spacing-unit * 3); + gap: calc(globals.$spacing-unit * 2); + padding: 0; flex: 1; overflow-y: auto; } @@ -17,6 +17,7 @@ gap: globals.$spacing-unit; justify-content: space-between; align-items: center; + padding: calc(globals.$spacing-unit * 3); } &__buttons-list { @@ -27,25 +28,6 @@ gap: globals.$spacing-unit; } - &__cards { - display: grid; - grid-template-columns: repeat(1, 1fr); - gap: calc(globals.$spacing-unit * 2); - transition: all ease 0.2s; - - @media (min-width: 768px) { - grid-template-columns: repeat(2, 1fr); - } - - @media (min-width: 1250px) { - grid-template-columns: repeat(3, 1fr); - } - - @media (min-width: 1600px) { - grid-template-columns: repeat(4, 1fr); - } - } - &__card-skeleton { width: 100%; height: 180px; @@ -99,5 +81,26 @@ &__title { display: flex; gap: globals.$spacing-unit; + padding: 0 calc(globals.$spacing-unit * 3); + } + + &__cards { + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: calc(globals.$spacing-unit * 2); + transition: all ease 0.2s; + padding: 0 calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 3); + + @media (min-width: 768px) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: 1250px) { + grid-template-columns: repeat(3, 1fr); + } + + @media (min-width: 1600px) { + grid-template-columns: repeat(4, 1fr); + } } } diff --git a/src/renderer/src/pages/home/home.tsx b/src/renderer/src/pages/home/home.tsx index ccf81566..e2f66283 100644 --- a/src/renderer/src/pages/home/home.tsx +++ b/src/renderer/src/pages/home/home.tsx @@ -97,8 +97,6 @@ export default function Home() { return (
    -

    {t("featured")}

    -
    diff --git a/src/renderer/src/pages/profile/profile-content/friends-box.tsx b/src/renderer/src/pages/profile/profile-content/friends-box.tsx index 8ab3808a..bee4b35c 100644 --- a/src/renderer/src/pages/profile/profile-content/friends-box.tsx +++ b/src/renderer/src/pages/profile/profile-content/friends-box.tsx @@ -31,10 +31,14 @@ export function FriendsBox() { return (
    -

    {t("friends")}

    - {userStats && ( - {numberFormatter.format(userStats.friendsCount)} - )} +
    +

    {t("friends")}

    + {userStats && ( + + {numberFormatter.format(userStats.friendsCount)} + + )} +
    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 bd580b74..c3c71d9a 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.scss +++ b/src/renderer/src/pages/profile/profile-content/profile-content.scss @@ -151,5 +151,29 @@ @container #{globals.$app-container} (min-width: 3000px) { grid-template-columns: repeat(12, 1fr); } + + &--drag-over { + background: rgba(255, 255, 255, 0.05); + border: 2px dashed rgba(255, 255, 255, 0.3); + position: relative; + transition: all ease 0.2s; + + &::before { + content: "Drop here to " attr(data-action); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: globals.$muted-color; + font-size: 14px; + font-weight: 500; + z-index: 10; + pointer-events: none; + background: rgba(0, 0, 0, 0.8); + padding: 8px 16px; + border-radius: 4px; + backdrop-filter: blur(10px); + } + } } } diff --git a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss index ab1f3456..f072fdd5 100644 --- a/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss +++ b/src/renderer/src/pages/profile/profile-content/user-library-game-card.scss @@ -7,10 +7,26 @@ position: relative; display: flex; transition: all ease 0.2s; + cursor: grab; &:hover { transform: scale(1.05); } + + &:active { + cursor: grabbing; + transform: scale(1.02); + } + + &[draggable="true"] { + cursor: grab; + + &:active { + cursor: grabbing; + opacity: 0.8; + transform: scale(1.02) rotate(2deg); + } + } } &__cover { @@ -75,29 +91,47 @@ } &__favorite-icon { - color: white; - background-color: rgba(0, 0, 0, 0.7); + color: rgba(255, 255, 255, 0.8); + 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: 50%; padding: 6px; display: flex; align-items: center; justify-content: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all ease 0.2s; + + &:hover { + background: rgba(0, 0, 0, 0.5); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + } } &__pin-button { - color: white; - background-color: rgba(0, 0, 0, 0.7); - border: none; + color: rgba(255, 255, 255, 0.8); + 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: 50%; padding: 6px; display: flex; align-items: center; justify-content: center; cursor: pointer; - transition: background-color 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: all ease 0.2s; &:hover { - background-color: rgba(0, 0, 0, 0.9); + background: rgba(0, 0, 0, 0.5); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); } &:disabled { @@ -107,14 +141,25 @@ } &__playtime { - background-color: globals.$background-color; - color: globals.$muted-color; - border: solid 1px globals.$border-color; + 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; + + &:hover { + background: rgba(0, 0, 0, 0.5); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + } } &__manual-playtime { color: globals.$warning-color; diff --git a/src/renderer/src/pages/settings/settings-general.tsx b/src/renderer/src/pages/settings/settings-general.tsx index b952dfa0..c698440d 100644 --- a/src/renderer/src/pages/settings/settings-general.tsx +++ b/src/renderer/src/pages/settings/settings-general.tsx @@ -35,7 +35,6 @@ export function SettingsGeneral() { const [form, setForm] = useState({ downloadsPath: "", - ggDealsApiKey: "", downloadNotificationsEnabled: false, repackUpdatesNotificationsEnabled: false, friendRequestNotificationsEnabled: false, @@ -101,7 +100,6 @@ export function SettingsGeneral() { setForm((prev) => ({ ...prev, downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath, - ggDealsApiKey: userPreferences.ggDealsApiKey ?? "", downloadNotificationsEnabled: userPreferences.downloadNotificationsEnabled ?? false, repackUpdatesNotificationsEnabled: @@ -208,12 +206,6 @@ export function SettingsGeneral() { } /> - handleChange({ ggDealsApiKey: e.target.value })} - /> - Date: Sun, 28 Sep 2025 15:17:37 +0100 Subject: [PATCH 20/24] fix: fixing carousel --- src/renderer/src/pages/achievements/achievements-content.tsx | 2 -- .../src/pages/game-details/gallery-slider/gallery-slider.scss | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/pages/achievements/achievements-content.tsx b/src/renderer/src/pages/achievements/achievements-content.tsx index e555212c..ab50f2f1 100644 --- a/src/renderer/src/pages/achievements/achievements-content.tsx +++ b/src/renderer/src/pages/achievements/achievements-content.tsx @@ -9,8 +9,6 @@ import { import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react"; import { gameDetailsContext } from "@renderer/context"; import type { ComparedAchievements } from "@types"; -import { average } from "color.js"; -import Color from "color"; import { Link } from "@renderer/components"; import { ComparedAchievementList } from "./compared-achievement-list"; import { AchievementList } from "./achievement-list"; diff --git a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss index a47e460b..c7932a34 100644 --- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss +++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss @@ -145,7 +145,7 @@ left: globals.$spacing-unit; transform: translateY(-50%) translateX(-100px); opacity: 0; - + .gallery-slider__viewport:hover & { transform: translateY(-50%) translateX(0); opacity: 1; @@ -156,7 +156,7 @@ right: globals.$spacing-unit; transform: translateY(-50%) translateX(100px); opacity: 0; - + .gallery-slider__viewport:hover & { transform: translateY(-50%) translateX(0); opacity: 1; From 889f3bb77311d4d9907473d20870ca8f2b307b62 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 28 Sep 2025 17:40:18 +0300 Subject: [PATCH 21/24] conflicts fix --- .../game-details/game-details-content.tsx | 35 ++----------------- 1 file changed, 2 insertions(+), 33 deletions(-) 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 eeeb67c7..46dfcd01 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -1,6 +1,4 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react"; -import { average } from "color.js"; -import Color from "color"; import { PencilIcon } from "@primer/octicons-react"; import { HeroPanel } from "./hero"; @@ -23,15 +21,8 @@ export function GameDetailsContent() { const { t } = useTranslation("game_details"); - const { - objectId, - shopDetails, - game, - gameColor, - setGameColor, - hasNSFWContentBlocked, - updateGame, - } = useContext(gameDetailsContext); + const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame } = + useContext(gameDetailsContext); const { showHydraCloudModal } = useSubscription(); @@ -67,28 +58,6 @@ export function GameDetailsContent() { const [backdropOpacity, setBackdropOpacity] = useState(1); const [showEditGameModal, setShowEditGameModal] = useState(false); - const handleHeroLoad = async () => { - // Use the same logic as heroImage to get the correct URL for both custom and non-custom games - const isCustomGame = game?.shop === "custom"; - const heroImageUrl = isCustomGame - ? game?.libraryHeroImageUrl || game?.iconUrl || "" - : getImageWithCustomPriority( - game?.customHeroImageUrl, - shopDetails?.assets?.libraryHeroImageUrl - ); - - const output = await average(heroImageUrl, { - amount: 1, - format: "hex", - }); - - const backgroundColor = output - ? new Color(output).darken(0.7).toString() - : ""; - - setGameColor(backgroundColor); - }; - useEffect(() => { setBackdropOpacity(1); }, [objectId]); From d78631a7f409fc37f4efc7e5a8c4c172243b8f35 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 28 Sep 2025 20:08:03 +0300 Subject: [PATCH 22/24] Fix: hide pin button if game is custom --- src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index 307de108..d8d98583 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -229,7 +229,7 @@ export function HeroPanelActions() { {game.favorite ? : } - {userDetails && ( + {userDetails && game.shop !== "custom" && ( -
    diff --git a/src/renderer/src/pages/game-details/description-header/description-header.scss b/src/renderer/src/pages/game-details/description-header/description-header.scss index a6faaee3..920e8068 100644 --- a/src/renderer/src/pages/game-details/description-header/description-header.scss +++ b/src/renderer/src/pages/game-details/description-header/description-header.scss @@ -1,13 +1,17 @@ @use "../../../scss/globals.scss"; .description-header { - width: 100%; - padding: calc(globals.$spacing-unit * 2); + width: calc(100% - calc(globals.$spacing-unit * 2)); + margin: calc(globals.$spacing-unit * 1) auto; + padding: calc(globals.$spacing-unit * 1.5); display: flex; justify-content: space-between; align-items: center; background-color: globals.$background-color; height: 72px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.03); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); &__info { display: flex; diff --git a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss index c7932a34..f66da32b 100644 --- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss +++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.scss @@ -2,7 +2,7 @@ .gallery-slider { &__container { - padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 1); width: 100%; display: flex; flex-direction: column; diff --git a/src/renderer/src/pages/game-details/game-details.scss b/src/renderer/src/pages/game-details/game-details.scss index a5566a96..786a8d30 100644 --- a/src/renderer/src/pages/game-details/game-details.scss +++ b/src/renderer/src/pages/game-details/game-details.scss @@ -43,12 +43,16 @@ $hero-height: 300px; } &__hero-content { - padding: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 1.5); height: 100%; width: 100%; display: flex; justify-content: space-between; align-items: flex-end; + + @media (min-width: 768px) { + padding: calc(globals.$spacing-unit * 2); + } } &__hero-buttons { @@ -116,14 +120,22 @@ $hero-height: 300px; } &__game-logo { - width: 300px; + width: 200px; align-self: flex-end; + + @media (min-width: 768px) { + width: 250px; + } + + @media (min-width: 1024px) { + width: 300px; + } } &__game-logo-text { - width: 300px; + width: 200px; align-self: flex-end; - font-size: 2.5rem; + font-size: 1.8rem; font-weight: bold; color: #ffffff; text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); @@ -132,6 +144,16 @@ $hero-height: 300px; word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; + + @media (min-width: 768px) { + width: 250px; + font-size: 2.2rem; + } + + @media (min-width: 1024px) { + width: 300px; + font-size: 2.5rem; + } } &__hero-image-skeleton { @@ -173,11 +195,19 @@ $hero-height: 300px; user-select: text; line-height: 22px; font-size: globals.$body-font-size; - padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5); width: 100%; margin-left: auto; margin-right: auto; + @media (min-width: 768px) { + padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2); + } + + @media (min-width: 1024px) { + padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2); + } + @media (min-width: 1280px) { width: 60%; } @@ -206,11 +236,19 @@ $hero-height: 300px; display: flex; flex-direction: column; gap: globals.$spacing-unit; - padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5); width: 100%; margin-left: auto; margin-right: auto; + @media (min-width: 768px) { + padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2); + } + + @media (min-width: 1024px) { + padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2); + } + @media (min-width: 1280px) { width: 60%; line-height: 22px; diff --git a/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx b/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx index 7355461a..6d5ef135 100644 --- a/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/change-game-playtime-modal.tsx @@ -149,17 +149,17 @@ export function ChangeGamePlaytimeModal({
    + + - -
    diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss index 3a5d8943..5400df07 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -12,6 +12,28 @@ gap: 16px; } +.edit-game-modal__asset-selector { + margin-bottom: 8px; +} + +.edit-game-modal__asset-tabs { + display: flex; + gap: 8px; + margin-bottom: 4px; + + button { + flex: 1; + min-width: 0; + } +} + +.edit-game-modal__asset-label { + font-size: 14px; + font-weight: 500; + color: var(--color-text); + margin-bottom: 4px; +} + .edit-game-modal__image-section { display: flex; flex-direction: column; diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx index 614d458b..04a27779 100644 --- a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { ImageIcon, ReplyIcon } from "@primer/octicons-react"; +import { ImageIcon, XIcon } from "@primer/octicons-react"; import { Modal, TextField, Button } from "@renderer/components"; import { useToast } from "@renderer/hooks"; @@ -16,6 +16,8 @@ export interface EditGameModalProps { onGameUpdated: (updatedGame: LibraryGame | Game) => void; } +type AssetType = "icon" | "logo" | "hero"; + export function EditGameModal({ visible, onClose, @@ -31,37 +33,32 @@ export function EditGameModal({ const [logoPath, setLogoPath] = useState(""); const [heroPath, setHeroPath] = useState(""); const [isUpdating, setIsUpdating] = useState(false); + const [selectedAssetType, setSelectedAssetType] = useState("icon"); - // Store default image URLs for non-custom games const [defaultIconUrl, setDefaultIconUrl] = useState(null); const [defaultLogoUrl, setDefaultLogoUrl] = useState(null); const [defaultHeroUrl, setDefaultHeroUrl] = useState(null); - // Helper function to check if game is a custom game const isCustomGame = (game: LibraryGame | Game): boolean => { return game.shop === "custom"; }; - // Helper function to extract local path from URL const extractLocalPath = (url: string | null | undefined): string => { return url?.startsWith("local:") ? url.replace("local:", "") : ""; }; - // Helper function to set asset paths for custom games const setCustomGameAssets = useCallback((game: LibraryGame | Game) => { setIconPath(extractLocalPath(game.iconUrl)); setLogoPath(extractLocalPath(game.logoImageUrl)); setHeroPath(extractLocalPath(game.libraryHeroImageUrl)); }, []); - // Helper function to set asset paths for non-custom games const setNonCustomGameAssets = useCallback( (game: LibraryGame) => { setIconPath(extractLocalPath(game.customIconUrl)); setLogoPath(extractLocalPath(game.customLogoImageUrl)); setHeroPath(extractLocalPath(game.customHeroImageUrl)); - // Store default URLs for restore functionality from shopDetails.assets setDefaultIconUrl(shopDetails?.assets?.iconUrl || game.iconUrl || null); setDefaultLogoUrl( shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null @@ -91,7 +88,47 @@ export function EditGameModal({ setGameName(event.target.value); }; - const handleSelectIcon = async () => { + const handleAssetTypeChange = (assetType: AssetType) => { + setSelectedAssetType(assetType); + }; + + const getAssetPath = (assetType: AssetType): string => { + switch (assetType) { + case "icon": + return iconPath; + case "logo": + return logoPath; + case "hero": + return heroPath; + } + }; + + const setAssetPath = (assetType: AssetType, path: string): void => { + switch (assetType) { + case "icon": + setIconPath(path); + break; + case "logo": + setLogoPath(path); + break; + case "hero": + setHeroPath(path); + break; + } + }; + + const getDefaultUrl = (assetType: AssetType): string | null => { + switch (assetType) { + case "icon": + return defaultIconUrl; + case "logo": + return defaultLogoUrl; + case "hero": + return defaultHeroUrl; + } + }; + + const handleSelectAsset = async (assetType: AssetType) => { const { filePaths } = await window.electron.showOpenDialog({ properties: ["openFile"], filters: [ @@ -104,91 +141,24 @@ export function EditGameModal({ if (filePaths && filePaths.length > 0) { try { - // Copy the asset to the app's assets folder const copiedAssetUrl = await window.electron.copyCustomGameAsset( filePaths[0], - "icon" + assetType ); - setIconPath(copiedAssetUrl.replace("local:", "")); + setAssetPath(assetType, copiedAssetUrl.replace("local:", "")); } catch (error) { - console.error("Failed to copy icon asset:", error); - // Fallback to original behavior - setIconPath(filePaths[0]); + console.error(`Failed to copy ${assetType} asset:`, error); + setAssetPath(assetType, filePaths[0]); } } }; - const handleSelectLogo = async () => { - const { filePaths } = await window.electron.showOpenDialog({ - properties: ["openFile"], - filters: [ - { - name: t("edit_game_modal_image_filter"), - extensions: ["jpg", "jpeg", "png", "gif", "webp"], - }, - ], - }); - - if (filePaths && filePaths.length > 0) { - try { - // Copy the asset to the app's assets folder - const copiedAssetUrl = await window.electron.copyCustomGameAsset( - filePaths[0], - "logo" - ); - setLogoPath(copiedAssetUrl.replace("local:", "")); - } catch (error) { - console.error("Failed to copy logo asset:", error); - // Fallback to original behavior - setLogoPath(filePaths[0]); - } - } + const handleRestoreDefault = (assetType: AssetType) => { + setAssetPath(assetType, ""); }; - const handleSelectHero = async () => { - const { filePaths } = await window.electron.showOpenDialog({ - properties: ["openFile"], - filters: [ - { - name: t("edit_game_modal_image_filter"), - extensions: ["jpg", "jpeg", "png", "gif", "webp"], - }, - ], - }); - - if (filePaths && filePaths.length > 0) { - try { - // Copy the asset to the app's assets folder - const copiedAssetUrl = await window.electron.copyCustomGameAsset( - filePaths[0], - "hero" - ); - setHeroPath(copiedAssetUrl.replace("local:", "")); - } catch (error) { - console.error("Failed to copy hero asset:", error); - // Fallback to original behavior - setHeroPath(filePaths[0]); - } - } - }; - - // Helper functions to restore default images for non-custom games - const handleRestoreDefaultIcon = () => { - setIconPath(""); - }; - - const handleRestoreDefaultLogo = () => { - setLogoPath(""); - }; - - const handleRestoreDefaultHero = () => { - setHeroPath(""); - }; - - // Drag and drop state const [dragOverTarget, setDragOverTarget] = useState(null); - // Drag and drop handlers const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -203,7 +173,6 @@ export function EditGameModal({ const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - // Only clear drag state if we're leaving the drop zone entirely if (!e.currentTarget.contains(e.relatedTarget as Node)) { setDragOverTarget(null); } @@ -220,10 +189,7 @@ export function EditGameModal({ return validTypes.includes(file.type); }; - const processDroppedFile = async ( - file: File, - assetType: "icon" | "logo" | "hero" - ) => { + const processDroppedFile = async (file: File, assetType: AssetType) => { setDragOverTarget(null); if (!validateImageFile(file)) { @@ -232,11 +198,8 @@ export function EditGameModal({ } try { - // In Electron, we need to get the file path differently let filePath: string; - // Try to get the path from the file object (Electron specific) - // In Electron, File objects have a path property interface ElectronFile extends File { path?: string; } @@ -244,11 +207,9 @@ export function EditGameModal({ if ("path" in file && typeof (file as ElectronFile).path === "string") { filePath = (file as ElectronFile).path!; } else { - // Fallback: create a temporary file from the file data const arrayBuffer = await file.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); - // Use a temporary file approach const tempFileName = `temp_${Date.now()}_${file.name}`; const tempPath = await window.electron.saveTempFile?.( tempFileName, @@ -264,31 +225,18 @@ export function EditGameModal({ filePath = tempPath; } - // Copy the asset to the app's assets folder using the file path const copiedAssetUrl = await window.electron.copyCustomGameAsset( filePath, assetType ); const assetPath = copiedAssetUrl.replace("local:", ""); - - switch (assetType) { - case "icon": - setIconPath(assetPath); - break; - case "logo": - setLogoPath(assetPath); - break; - case "hero": - setHeroPath(assetPath); - break; - } + setAssetPath(assetType, assetPath); showSuccessToast( `${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!` ); - // Clean up temporary file if we created one if (!("path" in file) && filePath) { try { await window.electron.deleteTempFile?.(filePath); @@ -304,7 +252,7 @@ export function EditGameModal({ } }; - const handleIconDrop = async (e: React.DragEvent) => { + const handleAssetDrop = async (e: React.DragEvent, assetType: AssetType) => { e.preventDefault(); e.stopPropagation(); setDragOverTarget(null); @@ -313,33 +261,7 @@ export function EditGameModal({ const files = Array.from(e.dataTransfer.files); if (files.length > 0) { - await processDroppedFile(files[0], "icon"); - } - }; - - const handleLogoDrop = async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setDragOverTarget(null); - - if (isUpdating) return; - - const files = Array.from(e.dataTransfer.files); - if (files.length > 0) { - await processDroppedFile(files[0], "logo"); - } - }; - - const handleHeroDrop = async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setDragOverTarget(null); - - if (isUpdating) return; - - const files = Array.from(e.dataTransfer.files); - if (files.length > 0) { - await processDroppedFile(files[0], "hero"); + await processDroppedFile(files[0], assetType); } }; @@ -444,28 +366,111 @@ export function EditGameModal({ const isFormValid = gameName.trim(); - const getIconPreviewUrl = (): string | undefined => { + const getPreviewUrl = (assetType: AssetType): string | undefined => { + const assetPath = getAssetPath(assetType); + const defaultUrl = getDefaultUrl(assetType); + if (game && !isCustomGame(game)) { - // For non-custom games, show custom image if set, otherwise show default - return iconPath ? `local:${iconPath}` : defaultIconUrl || undefined; + return assetPath ? `local:${assetPath}` : defaultUrl || undefined; } - return iconPath ? `local:${iconPath}` : undefined; + return assetPath ? `local:${assetPath}` : undefined; }; - const getLogoPreviewUrl = (): string | undefined => { - if (game && !isCustomGame(game)) { - // For non-custom games, show custom image if set, otherwise show default - return logoPath ? `local:${logoPath}` : defaultLogoUrl || undefined; - } - return logoPath ? `local:${logoPath}` : undefined; - }; + const renderImageSection = (assetType: AssetType) => { + const assetPath = getAssetPath(assetType); + const defaultUrl = getDefaultUrl(assetType); + const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl); + const isDragOver = dragOverTarget === assetType; - const getHeroPreviewUrl = (): string | undefined => { - if (game && !isCustomGame(game)) { - // For non-custom games, show custom image if set, otherwise show default - return heroPath ? `local:${heroPath}` : defaultHeroUrl || undefined; - } - return heroPath ? `local:${heroPath}` : undefined; + const getTranslationKey = (suffix: string) => + `edit_game_modal_${assetType}${suffix}`; + const getResolutionKey = () => `edit_game_modal_${assetType}_resolution`; + + return ( +
    + + + {game && !isCustomGame(game) && assetPath && ( + + )} +
    + } + /> +
    + {t(getResolutionKey())} +
    + + {hasImage && ( + + )} + + {!hasImage && ( + + )} +
    + ); }; return ( @@ -486,266 +491,39 @@ export function EditGameModal({ disabled={isUpdating} /> -
    - - - {game && !isCustomGame(game) && iconPath && ( - - )} -
    - } - /> -
    - {t("edit_game_modal_icon_resolution")} +
    +
    + {t("edit_game_modal_assets")}
    - - {(iconPath || (game && !isCustomGame(game) && defaultIconUrl)) && ( - - )} - - {!iconPath && !(game && !isCustomGame(game) && defaultIconUrl) && ( - - )} + {t("edit_game_modal_logo")} + + +
    -
    - - - {game && !isCustomGame(game) && logoPath && ( - - )} -
    - } - /> -
    - {t("edit_game_modal_logo_resolution")} -
    - - {(logoPath || (game && !isCustomGame(game) && defaultLogoUrl)) && ( - - )} - - {!logoPath && !(game && !isCustomGame(game) && defaultLogoUrl) && ( - - )} -
    - -
    - - - {game && !isCustomGame(game) && heroPath && ( - - )} -
    - } - /> -
    - {t("edit_game_modal_hero_resolution")} -
    - - {(heroPath || (game && !isCustomGame(game) && defaultHeroUrl)) && ( - - )} - - {!heroPath && !(game && !isCustomGame(game) && defaultHeroUrl) && ( - - )} -
    + {renderImageSection(selectedAssetType)}
    diff --git a/src/renderer/src/pages/game-details/modals/remove-from-library-modal.tsx b/src/renderer/src/pages/game-details/modals/remove-from-library-modal.tsx index 85cea8cd..eb421ec7 100644 --- a/src/renderer/src/pages/game-details/modals/remove-from-library-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/remove-from-library-modal.tsx @@ -31,12 +31,12 @@ export function RemoveGameFromLibraryModal({ onClose={onClose} >
    - -
    diff --git a/src/renderer/src/pages/game-details/modals/reset-achievements-modal.tsx b/src/renderer/src/pages/game-details/modals/reset-achievements-modal.tsx index fc71e2d0..b6eb38a2 100644 --- a/src/renderer/src/pages/game-details/modals/reset-achievements-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/reset-achievements-modal.tsx @@ -36,12 +36,12 @@ export function ResetAchievementsModal({ })} >
    - -
    diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx index be46e56a..9439d273 100644 --- a/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/delete-all-themes-modal.tsx @@ -38,12 +38,12 @@ export const DeleteAllThemesModal = ({ onClose={onClose} >
    - -
    diff --git a/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx index 5783ce01..c1a5a1e0 100644 --- a/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/delete-theme-modal.tsx @@ -41,12 +41,12 @@ export const DeleteThemeModal = ({ onClose={onClose} >
    - -
    diff --git a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx index db4abe8c..601e9568 100644 --- a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx @@ -77,12 +77,12 @@ export const ImportThemeModal = ({ onClose={onClose} >
    - -
    From 8f30f8a4ad09f20d761e4de9a93a7520dc3e128f Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 28 Sep 2025 20:17:02 +0300 Subject: [PATCH 24/24] fix: fixed edit game assets button not using translation --- src/locales/en/translation.json | 1 + src/renderer/src/pages/game-details/game-details-content.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index b1a9c61b..fbb7d479 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -272,6 +272,7 @@ "backup_unfrozen": "Backup unpinned", "backup_freeze_failed": "Failed to freeze backup", "backup_freeze_failed_description": "You must leave at least one free slot for automatic backups", + "edit_game_modal_button": "Customize game assets", "game_details": "Game Details", "currency_symbol": "$", "currency_country": "us", 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 46dfcd01..347e5a1c 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -172,7 +172,7 @@ export function GameDetailsContent() { type="button" className="game-details__edit-custom-game-button" onClick={handleEditGameClick} - title={t("edit_custom_game")} + title={t("edit_game_modal_button")} >