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 52a9473d..4340e41a 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...", @@ -28,7 +27,50 @@ "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_title": "Title", + "custom_game_modal_enter_title": "Enter title", + "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_success": "Custom game added successfully", + "custom_game_modal_failed": "Failed to add custom game", + "custom_game_modal_executable": "Executable", + "edit_game_modal": "Customize Assets", + "edit_game_modal_description": "Customize game assets and details", + "edit_game_modal_title": "Title", + "edit_game_modal_enter_title": "Enter title", + "edit_game_modal_image": "Image", + "edit_game_modal_select_image": "Select image", + "edit_game_modal_browse": "Browse", + "edit_game_modal_image_preview": "Image preview", + "edit_game_modal_icon": "Icon", + "edit_game_modal_select_icon": "Select icon", + "edit_game_modal_icon_preview": "Icon preview", + "edit_game_modal_logo": "Logo", + "edit_game_modal_select_logo": "Select logo", + "edit_game_modal_logo_preview": "Logo preview", + "edit_game_modal_hero": "Library Hero", + "edit_game_modal_select_hero": "Select library hero image", + "edit_game_modal_hero_preview": "Library hero image preview", + "edit_game_modal_cancel": "Cancel", + "edit_game_modal_update": "Update", + "edit_game_modal_updating": "Updating...", + "edit_game_modal_fill_required": "Please fill in all required fields", + "edit_game_modal_success": "Assets updated successfully", + "edit_game_modal_failed": "Failed to update assets", + "edit_game_modal_image_filter": "Image", + "edit_game_modal_icon_resolution": "Recommended resolution: 256x256px", + "edit_game_modal_logo_resolution": "Recommended resolution: 640x360px", + "edit_game_modal_hero_resolution": "Recommended resolution: 1920x620px", + "edit_game_modal_assets": "Assets" }, "header": { "search": "Search games", @@ -231,6 +273,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", @@ -241,7 +284,6 @@ "keyshop_price": "Keyshop price", "historical_retail": "Historical retail", "historical_keyshop": "Historical keyshop", - "supported_languages": "Supported languages", "language": "Language", "caption": "Caption", "audio": "Audio", @@ -292,7 +334,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 d7e67eb1..99e6e8c4 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", @@ -269,7 +267,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/locales/ru/translation.json b/src/locales/ru/translation.json index b1714804..58235989 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -29,7 +29,47 @@ "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_title": "Название игры", + "custom_game_modal_enter_title": "Введите название игры", + "custom_game_modal_browse": "Обзор", + "custom_game_modal_cancel": "Отмена", + "custom_game_modal_add": "Добавить игру", + "custom_game_modal_adding": "Добавление игры...", + "custom_game_modal_success": "Пользовательская игра успешно добавлена", + "custom_game_modal_failed": "Не удалось добавить пользовательскую игру", + "custom_game_modal_executable": "Исполняемый файл", + "edit_game_modal": "Настроить ресурсы", + "edit_game_modal_description": "Настройте ресурсы и детали игры", + "edit_game_modal_title": "Название", + "edit_game_modal_enter_title": "Введите название", + "edit_game_modal_image": "Изображение", + "edit_game_modal_select_image": "Выберите изображение", + "edit_game_modal_browse": "Обзор", + "edit_game_modal_image_preview": "Предпросмотр изображения", + "edit_game_modal_icon": "Иконка", + "edit_game_modal_select_icon": "Выберите иконку", + "edit_game_modal_icon_preview": "Предпросмотр иконки", + "edit_game_modal_logo": "Логотип", + "edit_game_modal_select_logo": "Выберите логотип", + "edit_game_modal_logo_preview": "Предпросмотр логотипа", + "edit_game_modal_hero": "Изображение обложку игры", + "edit_game_modal_select_hero": "Выберите обложку игры", + "edit_game_modal_hero_preview": "Предпросмотр обложки игры", + "edit_game_modal_cancel": "Отмена", + "edit_game_modal_update": "Обновить", + "edit_game_modal_updating": "Обновление...", + "edit_game_modal_fill_required": "Пожалуйста, заполните все обязательные поля", + "edit_game_modal_success": "Ресурсы успешно обновлены", + "edit_game_modal_failed": "Не удалось обновить ресурсы", + "edit_game_modal_image_filter": "Изображение", + "edit_game_modal_icon_resolution": "Рекомендуемое разрешение: 256x256px", + "edit_game_modal_logo_resolution": "Рекомендуемое разрешение: 640x360px", + "edit_game_modal_hero_resolution": "Рекомендуемое разрешение: 1920x620px" }, "header": { "search": "Поиск", diff --git a/src/main/events/catalogue/save-game-shop-assets.ts b/src/main/events/catalogue/save-game-shop-assets.ts index f0ec4343..bf5f8b81 100644 --- a/src/main/events/catalogue/save-game-shop-assets.ts +++ b/src/main/events/catalogue/save-game-shop-assets.ts @@ -10,7 +10,16 @@ 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 6bd74b69..d4c461f8 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -14,6 +14,9 @@ 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/update-game-custom-assets"; import "./library/add-game-to-favorites"; import "./library/remove-game-from-favorites"; import "./library/toggle-game-pin"; @@ -37,7 +40,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"; @@ -46,6 +51,8 @@ import "./misc/show-item-in-folder"; import "./misc/get-badges"; import "./misc/install-common-redist"; import "./misc/can-install-common-redist"; +import "./misc/save-temp-file"; +import "./misc/delete-temp-file"; import "./torrenting/cancel-game-download"; import "./torrenting/pause-game-download"; import "./torrenting/resume-game-download"; 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..47fd3436 --- /dev/null +++ b/src/main/events/library/add-custom-game-to-library.ts @@ -0,0 +1,65 @@ +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); diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 01495a39..4fdeae30 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, @@ -41,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/change-game-playtime.ts b/src/main/events/library/change-game-playtime.ts index ff37c33e..8ad252bd 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/cleanup-unused-assets.ts b/src/main/events/library/cleanup-unused-assets.ts new file mode 100644 index 00000000..22490c07 --- /dev/null +++ b/src/main/events/library/cleanup-unused-assets.ts @@ -0,0 +1,76 @@ +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); 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..07c3d6f7 --- /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); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index ce859908..6314f83d 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -23,7 +23,10 @@ const getLibrary = async (): Promise => { ...game, download: download ?? null, ...gameAssets, - }; + // Ensure compatibility with LibraryGame type + libraryHeroImageUrl: + game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, + } as LibraryGame; }) ); }); 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..6152c0df --- /dev/null +++ b/src/main/events/library/update-custom-game.ts @@ -0,0 +1,49 @@ +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); 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..866cd60e --- /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 !== undefined && { customIconUrl }), + ...(customLogoImageUrl !== undefined && { customLogoImageUrl }), + ...(customHeroImageUrl !== undefined && { 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); diff --git a/src/main/events/misc/delete-temp-file.ts b/src/main/events/misc/delete-temp-file.ts new file mode 100644 index 00000000..7ca88fa1 --- /dev/null +++ b/src/main/events/misc/delete-temp-file.ts @@ -0,0 +1,18 @@ +import fs from "node:fs"; +import { registerEvent } from "../register-event"; + +const deleteTempFile = async ( + _event: Electron.IpcMainInvokeEvent, + filePath: string +): Promise => { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (error) { + // Silently fail - temp files will be cleaned up by OS eventually + console.warn(`Failed to delete temp file: ${error}`); + } +}; + +registerEvent("deleteTempFile", deleteTempFile); diff --git a/src/main/events/misc/save-temp-file.ts b/src/main/events/misc/save-temp-file.ts new file mode 100644 index 00000000..8f253bf2 --- /dev/null +++ b/src/main/events/misc/save-temp-file.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; +import { app } from "electron"; +import { registerEvent } from "../register-event"; + +const saveTempFile = async ( + _event: Electron.IpcMainInvokeEvent, + fileName: string, + fileData: Uint8Array +): Promise => { + try { + const tempDir = app.getPath("temp"); + 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); 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 af197a6b..106feaf0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -64,6 +64,71 @@ app.whenReady().then(async () => { return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString()); }); + protocol.handle("gradient", (request) => { + const gradientCss = decodeURIComponent( + request.url.slice("gradient:".length) + ); + + // Parse gradient CSS safely without regex to prevent ReDoS + let direction = "45deg"; + let color1 = "#4a90e2"; + let color2 = "#7b68ee"; + + // Simple string parsing approach - more secure than regex + if ( + gradientCss.startsWith("linear-gradient(") && + gradientCss.endsWith(")") + ) { + const content = gradientCss.slice(16, -1); // Remove "linear-gradient(" and ")" + const parts = content.split(",").map((part) => part.trim()); + + if (parts.length >= 3) { + direction = parts[0]; + color1 = parts[1]; + color2 = parts[2]; + } + } + + let x1 = "0%", + y1 = "0%", + x2 = "100%", + y2 = "100%"; + + if (direction === "to right") { + y2 = "0%"; + } else if (direction === "to bottom") { + x2 = "0%"; + } else if (direction === "45deg") { + y1 = "100%"; + y2 = "0%"; + } else if (direction === "225deg") { + x1 = "100%"; + x2 = "0%"; + } else if (direction === "315deg") { + x1 = "100%"; + y1 = "100%"; + x2 = "0%"; + y2 = "0%"; + } + // Note: "135deg" case removed as it uses all default values + + 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 152e1138..b5b2d551 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -46,6 +46,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, @@ -58,7 +60,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/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts index d4febfea..f0af90ba 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) => + !game.isDeleted && game.remoteId === null && game.shop !== "custom" ); }); diff --git a/src/preload/index.ts b/src/preload/index.ts index ca275c91..e536f8c7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -128,6 +128,64 @@ 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 + ), + copyCustomGameAsset: ( + sourcePath: string, + assetType: "icon" | "logo" | "hero" + ) => ipcRenderer.invoke("copyCustomGameAsset", sourcePath, assetType), + saveTempFile: (fileName: string, fileData: Uint8Array) => + ipcRenderer.invoke("saveTempFile", fileName, fileData), + deleteTempFile: (filePath: string) => + ipcRenderer.invoke("deleteTempFile", filePath), + cleanupUnusedAssets: () => ipcRenderer.invoke("cleanupUnusedAssets"), + updateCustomGame: ( + shop: GameShop, + objectId: string, + title: string, + iconUrl?: string, + logoImageUrl?: string, + libraryHeroImageUrl?: string + ) => + ipcRenderer.invoke( + "updateCustomGame", + shop, + objectId, + title, + iconUrl, + 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/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/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/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/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..942384fe --- /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); + } +} 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..f50bd814 --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-adding-custom-game-modal.tsx @@ -0,0 +1,178 @@ +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, +}: Readonly) { + 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")} + + } + /> + + +
+ +
+ + +
+
+
+ ); +} diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 0672f847..7733aee0 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,19 @@ export function SidebarGameItem({ }: Readonly) { const location = useLocation(); + const isCustomGame = game.shop === "custom"; + const sidebarIcon = isCustomGame + ? game.libraryImageUrl || game.iconUrl + : game.customIconUrl || game.iconUrl; + + // Determine fallback icon based on game type + const getFallbackIcon = () => { + if (isCustomGame) { + return ; + } + return ; + }; + return (
  • handleSidebarGameClick(event, game)} > - {game.iconUrl ? ( + {sidebarIcon ? ( {game.title} ) : ( - + getFallbackIcon() )} 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..f77066a2 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,13 @@ 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 +69,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 +269,32 @@ 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..6fe01663 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); @@ -201,6 +198,12 @@ export function GameDetailsContextProvider({ dispatch(setHeaderTitle(gameTitle)); }, [objectId, gameTitle, dispatch]); + useEffect(() => { + if (game?.title) { + dispatch(setHeaderTitle(game.title)); + } + }, [game?.title, dispatch]); + useEffect(() => { const unsubscribe = window.electron.onGamesRunning((gamesIds) => { const updatedIsGameRunning = @@ -286,7 +289,6 @@ export function GameDetailsContextProvider({ isGameRunning, isLoading, objectId, - gameColor, showGameOptionsModal, showRepacksModal, stats, @@ -294,7 +296,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/declaration.d.ts b/src/renderer/src/declaration.d.ts index 87b2d63d..81d18940 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -112,6 +112,37 @@ 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; + copyCustomGameAsset: ( + sourcePath: string, + assetType: "icon" | "logo" | "hero" + ) => Promise; + cleanupUnusedAssets: () => Promise<{ + deletedCount: number; + errors: string[]; + }>; + updateGameCustomAssets: ( + shop: GameShop, + objectId: string, + title: string, + customIconUrl?: string | null, + customLogoImageUrl?: string | null, + customHeroImageUrl?: string | null + ) => Promise; createGameShortcut: ( shop: GameShop, objectId: string, @@ -273,6 +304,8 @@ declare global { onCommonRedistProgress: ( cb: (value: { log: string; complete: boolean }) => void ) => () => Electron.IpcRenderer; + saveTempFile: (fileName: string, fileData: Uint8Array) => Promise; + deleteTempFile: (filePath: string) => Promise; platform: NodeJS.Platform; /* Auto update */ diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index eb7cebb0..01b4d6cc 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/achievements/achievements-content.tsx b/src/renderer/src/pages/achievements/achievements-content.tsx index 477925c7..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"; @@ -119,15 +117,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 +127,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 +166,6 @@ export function AchievementsContent({ src={shopDetails?.assets?.libraryHeroImageUrl ?? ""} className="achievements-content__achievements-list__image" alt={gameTitle} - onLoad={handleHeroLoad} />
    -
    +
    - -
    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 732cd7d4..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,36 +2,46 @@ .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; align-items: center; } - &__media { + &__viewport { width: 100%; height: 100%; - display: block; - flex-shrink: 0; - flex-grow: 0; - transition: translate 0.3s ease-in-out; - border-radius: 4px; - align-self: center; - } - - &__animation-container { - width: 100%; - height: 100%; - display: flex; position: relative; overflow: hidden; + border-radius: 8px; @media (min-width: 1280px) { width: 60%; } } + &__container-inner { + display: flex; + height: 100%; + } + + &__slide { + flex: 0 0 100%; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + } + + &__media { + width: 100%; + height: 100%; + display: block; + border-radius: 4px; + object-fit: cover; + } + &__preview { width: 100%; padding: globals.$spacing-unit 0; @@ -68,6 +78,7 @@ border-radius: 4px; border: solid 1px globals.$border-color; overflow: hidden; + position: relative; &:hover { opacity: 0.8; @@ -83,49 +94,73 @@ display: flex; } + &__play-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: rgba(0, 0, 0, 0.4); + border-radius: 50%; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + color: white; + transition: all 0.2s ease; + pointer-events: none; + + .gallery-slider__preview-button:hover & { + background-color: rgba(0, 0, 0, 0.6); + transform: translate(-50%, -50%) scale(1.1); + } + } + &__button { position: absolute; - align-self: center; + top: 50%; + transform: translateY(-50%); cursor: pointer; background-color: rgba(0, 0, 0, 0.4); - transition: all 0.2s ease-in-out; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); border-radius: 50%; color: globals.$muted-color; width: 48px; height: 48px; + border: none; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; &:hover { background-color: rgba(0, 0, 0, 0.6); } &:active { - transform: scale(0.95); + transform: translateY(-50%) scale(0.95); } &--left { - left: 0; - margin-left: globals.$spacing-unit; - transform: translateX(calc(-1 * (48px + globals.$spacing-unit))); + left: globals.$spacing-unit; + transform: translateY(-50%) translateX(-100px); + opacity: 0; - &.gallery-slider__button--visible { - transform: translateX(0); + .gallery-slider__viewport:hover & { + transform: translateY(-50%) translateX(0); opacity: 1; } } &--right { - right: 0; - margin-right: globals.$spacing-unit; - transform: translateX(calc(48px + globals.$spacing-unit)); + right: globals.$spacing-unit; + transform: translateY(-50%) translateX(100px); + opacity: 0; - &.gallery-slider__button--visible { - transform: translateX(0); + .gallery-slider__viewport:hover & { + transform: translateY(-50%) translateX(0); opacity: 1; } } - - &--hidden { - opacity: 0; - } } } diff --git a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx index 740ee1fa..4bf8dc48 100644 --- a/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx +++ b/src/renderer/src/pages/game-details/gallery-slider/gallery-slider.tsx @@ -1,90 +1,144 @@ -import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useContext, useCallback, useMemo, useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react"; +import { + ChevronRightIcon, + ChevronLeftIcon, + PlayIcon, +} from "@primer/octicons-react"; +import useEmblaCarousel from "embla-carousel-react"; import { gameDetailsContext } from "@renderer/context"; import "./gallery-slider.scss"; export function GallerySlider() { const { shopDetails } = useContext(gameDetailsContext); - - const scrollContainerRef = useRef(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..347e5a1c 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,18 @@ 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 { EditGameModal } 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"; @@ -21,18 +21,13 @@ export function GameDetailsContent() { const { t } = useTranslation("game_details"); - const { - objectId, - shopDetails, - game, - gameColor, - setGameColor, - hasNSFWContentBlocked, - } = useContext(gameDetailsContext); + const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame } = + useContext(gameDetailsContext); const { showHydraCloudModal } = useSubscription(); const { userDetails, hasActiveSubscription } = useUserDetails(); + const { updateLibrary } = useLibrary(); const { setShowCloudSyncModal, getGameArtifacts } = useContext(cloudSyncContext); @@ -53,26 +48,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 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); - }; + const [showEditGameModal, setShowEditGameModal] = useState(false); useEffect(() => { setBackdropOpacity(1); @@ -92,10 +76,72 @@ export function GameDetailsContent() { setShowCloudSyncModal(true); }; + const handleEditGameClick = () => { + setShowEditGameModal(true); + }; + + const handleGameUpdated = (_updatedGame: any) => { + updateGame(); + updateLibrary(); + }; + useEffect(() => { getGameArtifacts(); }, [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 || "" + : getImageWithCustomPriority( + game?.customHeroImageUrl, + shopDetails?.assets?.libraryHeroImageUrl + ); + + const logoImage = isCustomGame + ? game?.logoImageUrl || "" + : getImageWithCustomPriority( + game?.customLogoImageUrl, + 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 (
    {game?.title}
    @@ -121,26 +165,35 @@ export function GameDetailsContent() { style={{ opacity: backdropOpacity }} >
    - {game?.title} + {renderGameLogo()} - +
    + + + {game?.shop !== "custom" && ( + + )} +
    @@ -160,9 +213,17 @@ export function GameDetailsContent() { />
    - + {game?.shop !== "custom" && }
    + + setShowEditGameModal(false)} + game={game} + shopDetails={shopDetails} + 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..786a8d30 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; @@ -44,12 +43,53 @@ $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 { + 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 { @@ -64,8 +104,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,14 +114,46 @@ $hero-height: 300px; @media (min-width: 1250px) { object-position: center; - height: 350px; - min-height: 350px; + height: calc(350px + 72px); + min-height: calc(350px + 72px); } } &__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: 200px; + align-self: flex-end; + font-size: 1.8rem; + font-weight: bold; + color: #ffffff; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); + text-align: left; + line-height: 1.2; + 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 { @@ -97,7 +169,6 @@ $hero-height: 300px; height: 100%; display: flex; flex-direction: column; - overflow: auto; z-index: 1; } @@ -105,6 +176,7 @@ $hero-height: 300px; display: flex; width: 100%; flex: 1; + min-width: 0; background: linear-gradient( 0deg, globals.$background-color 50%, @@ -115,17 +187,27 @@ $hero-height: 300px; &__description-content { width: 100%; height: 100%; + min-width: 0; + flex: 1; } &__description { 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%; } @@ -154,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/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/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/modals/edit-game-modal.scss b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss new file mode 100644 index 00000000..5400df07 --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -0,0 +1,181 @@ +.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__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; + gap: 8px; +} + +.edit-game-modal__resolution-info { + font-size: 12px; + color: var(--color-text-secondary); + margin-top: -4px; + margin-bottom: 4px; +} + +.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); + background-image: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.1) 25%, + transparent 25% + ), + linear-gradient(-45deg, rgba(255, 255, 255, 0.1) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%), + linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%); + background-size: 16px 16px; + background-position: + 0 0, + 0 8px, + 8px -8px, + -8px 0px; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + 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); + } +} + +.edit-game-modal__drop-zone { + min-height: 120px; + cursor: pointer; + border-style: dashed !important; + + &:hover { + border-color: var(--color-primary); + background-color: rgba(var(--color-primary-rgb), 0.05); + } + + &--active { + border-color: var(--color-primary) !important; + background-color: rgba(var(--color-primary-rgb), 0.1) !important; + transform: scale(1.02); + box-shadow: 0 4px 12px rgba(var(--color-primary-rgb), 0.3); + } +} + +.edit-game-modal__drop-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(var(--color-primary-rgb), 0.9); + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + color: white; + font-weight: 600; + font-size: 14px; + backdrop-filter: blur(2px); + animation: fadeIn 0.2s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.edit-game-modal__drop-zone-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + color: var(--color-text-secondary); + font-size: 14px; + + svg { + width: 24px; + height: 24px; + opacity: 0.6; + } +} + +.edit-game-modal__icon-preview { + max-width: 200px; + margin: 0 auto; +} + +.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; +} 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..04a27779 --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -0,0 +1,552 @@ +import { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { ImageIcon, XIcon } from "@primer/octicons-react"; + +import { Modal, TextField, Button } from "@renderer/components"; +import { useToast } from "@renderer/hooks"; +import type { LibraryGame, Game, ShopDetailsWithAssets } from "@types"; + +import "./edit-game-modal.scss"; + +export interface EditGameModalProps { + visible: boolean; + onClose: () => void; + game: LibraryGame | Game | null; + shopDetails?: ShopDetailsWithAssets | null; + onGameUpdated: (updatedGame: LibraryGame | Game) => void; +} + +type AssetType = "icon" | "logo" | "hero"; + +export function EditGameModal({ + visible, + onClose, + game, + shopDetails, + onGameUpdated, +}: Readonly) { + 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); + const [selectedAssetType, setSelectedAssetType] = useState("icon"); + + const [defaultIconUrl, setDefaultIconUrl] = useState(null); + const [defaultLogoUrl, setDefaultLogoUrl] = useState(null); + const [defaultHeroUrl, setDefaultHeroUrl] = useState(null); + + const isCustomGame = (game: LibraryGame | Game): boolean => { + return game.shop === "custom"; + }; + + const extractLocalPath = (url: string | null | undefined): string => { + return url?.startsWith("local:") ? url.replace("local:", "") : ""; + }; + + const setCustomGameAssets = useCallback((game: LibraryGame | Game) => { + setIconPath(extractLocalPath(game.iconUrl)); + setLogoPath(extractLocalPath(game.logoImageUrl)); + setHeroPath(extractLocalPath(game.libraryHeroImageUrl)); + }, []); + + const setNonCustomGameAssets = useCallback( + (game: LibraryGame) => { + setIconPath(extractLocalPath(game.customIconUrl)); + setLogoPath(extractLocalPath(game.customLogoImageUrl)); + setHeroPath(extractLocalPath(game.customHeroImageUrl)); + + 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) { + setGameName(game.title || ""); + + if (isCustomGame(game)) { + setCustomGameAssets(game); + } else { + setNonCustomGameAssets(game as LibraryGame); + } + } + }, [game, visible, shopDetails, setCustomGameAssets, setNonCustomGameAssets]); + + const handleGameNameChange = (event: React.ChangeEvent) => { + setGameName(event.target.value); + }; + + 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: [ + { + name: t("edit_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + try { + const copiedAssetUrl = await window.electron.copyCustomGameAsset( + filePaths[0], + assetType + ); + setAssetPath(assetType, copiedAssetUrl.replace("local:", "")); + } catch (error) { + console.error(`Failed to copy ${assetType} asset:`, error); + setAssetPath(assetType, filePaths[0]); + } + } + }; + + const handleRestoreDefault = (assetType: AssetType) => { + setAssetPath(assetType, ""); + }; + + const [dragOverTarget, setDragOverTarget] = useState(null); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragEnter = (e: React.DragEvent, target: string) => { + e.preventDefault(); + e.stopPropagation(); + setDragOverTarget(target); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setDragOverTarget(null); + } + }; + + const validateImageFile = (file: File): boolean => { + const validTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", + ]; + return validTypes.includes(file.type); + }; + + const processDroppedFile = async (file: File, assetType: AssetType) => { + setDragOverTarget(null); + + if (!validateImageFile(file)) { + showErrorToast("Invalid file type. Please select an image file."); + return; + } + + try { + let filePath: string; + + interface ElectronFile extends File { + path?: string; + } + + if ("path" in file && typeof (file as ElectronFile).path === "string") { + filePath = (file as ElectronFile).path!; + } else { + const arrayBuffer = await file.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + + const tempFileName = `temp_${Date.now()}_${file.name}`; + const tempPath = await window.electron.saveTempFile?.( + tempFileName, + uint8Array + ); + + if (!tempPath) { + throw new Error( + "Unable to process file. Drag and drop may not be fully supported." + ); + } + + filePath = tempPath; + } + + const copiedAssetUrl = await window.electron.copyCustomGameAsset( + filePath, + assetType + ); + + const assetPath = copiedAssetUrl.replace("local:", ""); + setAssetPath(assetType, assetPath); + + showSuccessToast( + `${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!` + ); + + if (!("path" in file) && filePath) { + try { + await window.electron.deleteTempFile?.(filePath); + } catch (cleanupError) { + console.warn("Failed to clean up temporary file:", cleanupError); + } + } + } catch (error) { + console.error(`Failed to process dropped ${assetType}:`, error); + showErrorToast( + `Failed to process dropped ${assetType}. Please try again.` + ); + } + }; + + const handleAssetDrop = async (e: React.DragEvent, assetType: AssetType) => { + e.preventDefault(); + e.stopPropagation(); + setDragOverTarget(null); + + if (isUpdating) return; + + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + await processDroppedFile(files[0], assetType); + } + }; + + // Helper function to prepare custom game assets + const prepareCustomGameAssets = (game: LibraryGame | Game) => { + const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl; + const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl; + const libraryHeroImageUrl = heroPath + ? `local:${heroPath}` + : game.libraryHeroImageUrl; + + return { iconUrl, logoImageUrl, libraryHeroImageUrl }; + }; + + // Helper function to prepare non-custom game assets + const prepareNonCustomGameAssets = () => { + return { + customIconUrl: iconPath ? `local:${iconPath}` : null, + customLogoImageUrl: logoPath ? `local:${logoPath}` : null, + customHeroImageUrl: heroPath ? `local:${heroPath}` : null, + }; + }; + + // Helper function to update custom game + const updateCustomGame = async (game: LibraryGame | Game) => { + const { iconUrl, logoImageUrl, libraryHeroImageUrl } = + prepareCustomGameAssets(game); + + return window.electron.updateCustomGame( + game.shop, + game.objectId, + gameName.trim(), + iconUrl || undefined, + logoImageUrl || undefined, + libraryHeroImageUrl || undefined + ); + }; + + // Helper function to update non-custom game + const updateNonCustomGame = async (game: LibraryGame) => { + const { customIconUrl, customLogoImageUrl, customHeroImageUrl } = + prepareNonCustomGameAssets(); + + return window.electron.updateGameCustomAssets( + game.shop, + game.objectId, + gameName.trim(), + customIconUrl, + customLogoImageUrl, + customHeroImageUrl + ); + }; + + const handleUpdateGame = async () => { + if (!game || !gameName.trim()) { + showErrorToast(t("edit_game_modal_fill_required")); + return; + } + + setIsUpdating(true); + + try { + const updatedGame = + game && isCustomGame(game) + ? await updateCustomGame(game) + : await updateNonCustomGame(game as LibraryGame); + + showSuccessToast(t("edit_game_modal_success")); + onGameUpdated(updatedGame); + onClose(); + } catch (error) { + console.error("Failed to update game:", error); + showErrorToast( + error instanceof Error ? error.message : t("edit_game_modal_failed") + ); + } finally { + setIsUpdating(false); + } + }; + + // Helper function to reset form to initial state + const resetFormToInitialState = (game: LibraryGame | Game) => { + setGameName(game.title || ""); + + if (isCustomGame(game)) { + setCustomGameAssets(game); + // Clear default URLs for custom games + setDefaultIconUrl(null); + setDefaultLogoUrl(null); + setDefaultHeroUrl(null); + } else { + setNonCustomGameAssets(game as LibraryGame); + } + }; + + const handleClose = () => { + if (!isUpdating && game) { + resetFormToInitialState(game); + onClose(); + } + }; + + const isFormValid = gameName.trim(); + + const getPreviewUrl = (assetType: AssetType): string | undefined => { + const assetPath = getAssetPath(assetType); + const defaultUrl = getDefaultUrl(assetType); + + if (game && !isCustomGame(game)) { + return assetPath ? `local:${assetPath}` : defaultUrl || undefined; + } + return assetPath ? `local:${assetPath}` : 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 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 ( + +
    +
    + + +
    +
    + {t("edit_game_modal_assets")} +
    +
    + + + +
    +
    + + {renderImageSection(selectedAssetType)} +
    + +
    + + +
    +
    +
    + ); +} 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..e658fbb8 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 () => { @@ -346,14 +353,16 @@ export function GameOptionsModal({ > {t("create_shortcut")} - + {game.shop !== "custom" && ( + + )} {shouldShowCreateStartMenuShortcut && ( - {game.download?.downloadPath && ( +
    - )} + {game.download?.downloadPath && ( + + )} +
    - + )}
    @@ -486,18 +499,20 @@ export function GameOptionsModal({ {t("remove_from_library")} - + {game.shop !== "custom" && ( + + )} - + {game.shop !== "custom" && ( + + )}
    diff --git a/src/renderer/src/pages/game-details/modals/index.ts b/src/renderer/src/pages/game-details/modals/index.ts index e02421b9..724e0003 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-game-modal"; 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/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/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} >
    - -
    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 })} - /> -