mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Merge branch 'hydralauncher:main' into main
This commit is contained in:
@@ -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=
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -290,7 +332,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",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Autenticado com sucesso"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Destaques",
|
||||
"hot": "Populares",
|
||||
"weekly": "📅 Mais baixados da semana",
|
||||
"achievements": "🏆 Pra platinar",
|
||||
@@ -218,7 +217,6 @@
|
||||
"keyshop_price": "Preço em keyshops",
|
||||
"historical_retail": "Preço histórico de lojas oficiais",
|
||||
"historical_keyshop": "Preço histórico em keyshops",
|
||||
"supported_languages": "Idiomas suportados",
|
||||
"language": "Idioma",
|
||||
"caption": "Legenda",
|
||||
"audio": "Áudio"
|
||||
@@ -267,7 +265,6 @@
|
||||
"change": "Explorar...",
|
||||
"notifications": "Notificações",
|
||||
"enable_download_notifications": "Quando um download for concluído",
|
||||
"gg_deals_api_key_description": "gg deals api key. Usado para mostrar o menor preço. (https://gg.deals/api/)",
|
||||
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
||||
"real_debrid_api_token_label": "Token de API do Real-Debrid",
|
||||
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar",
|
||||
|
||||
@@ -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": "Поиск",
|
||||
|
||||
@@ -10,7 +10,16 @@ const saveGameShopAssets = async (
|
||||
): Promise<void> => {
|
||||
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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
65
src/main/events/library/add-custom-game-to-library.ts
Normal file
65
src/main/events/library/add-custom-game-to-library.ts
Normal file
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
76
src/main/events/library/cleanup-unused-assets.ts
Normal file
76
src/main/events/library/cleanup-unused-assets.ts
Normal file
@@ -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<string[]> => {
|
||||
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<Set<string>> => {
|
||||
// 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<string>();
|
||||
|
||||
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);
|
||||
42
src/main/events/library/copy-custom-game-asset.ts
Normal file
42
src/main/events/library/copy-custom-game-asset.ts
Normal file
@@ -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<string> => {
|
||||
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);
|
||||
@@ -23,7 +23,10 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
|
||||
...game,
|
||||
download: download ?? null,
|
||||
...gameAssets,
|
||||
};
|
||||
// Ensure compatibility with LibraryGame type
|
||||
libraryHeroImageUrl:
|
||||
game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl,
|
||||
} as LibraryGame;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
49
src/main/events/library/update-custom-game.ts
Normal file
49
src/main/events/library/update-custom-game.ts
Normal file
@@ -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);
|
||||
45
src/main/events/library/update-game-custom-assets.ts
Normal file
45
src/main/events/library/update-game-custom-assets.ts
Normal file
@@ -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);
|
||||
18
src/main/events/misc/delete-temp-file.ts
Normal file
18
src/main/events/misc/delete-temp-file.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import fs from "node:fs";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const deleteTempFile = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
filePath: string
|
||||
): Promise<void> => {
|
||||
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);
|
||||
27
src/main/events/misc/save-temp-file.ts
Normal file
27
src/main/events/misc/save-temp-file.ts
Normal file
@@ -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<string> => {
|
||||
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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
|
||||
<stop offset="0%" style="stop-color:${color1};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${color2};stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grad)" />
|
||||
</svg>
|
||||
`;
|
||||
|
||||
return new Response(svgContent, {
|
||||
headers: { "Content-Type": "image/svg+xml" },
|
||||
});
|
||||
});
|
||||
|
||||
await loadState();
|
||||
|
||||
const language = await db
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
4
src/renderer/src/assets/play-logo.svg
Normal file
4
src/renderer/src/assets/play-logo.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 409 B |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export function Hero() {
|
||||
width="250px"
|
||||
alt={game.description ?? ""}
|
||||
loading="eager"
|
||||
className="hero__logo"
|
||||
/>
|
||||
<p className="hero__description">{game.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<SidebarAddingCustomGameModalProps>) {
|
||||
const { t } = useTranslation("sidebar");
|
||||
const { updateLibrary } = useLibrary();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [gameName, setGameName] = useState("");
|
||||
const [executablePath, setExecutablePath] = useState("");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const handleSelectExecutable = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: t("custom_game_modal_executable"),
|
||||
extensions: ["exe", "msi", "app", "deb", "rpm", "dmg"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const selectedPath = filePaths[0];
|
||||
setExecutablePath(selectedPath);
|
||||
|
||||
if (!gameName.trim()) {
|
||||
const fileName = selectedPath.split(/[\\/]/).pop() || "";
|
||||
const gameNameFromFile = fileName.replace(/\.[^/.]+$/, "");
|
||||
setGameName(gameNameFromFile);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGameNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("custom_game_modal")}
|
||||
description={t("custom_game_modal_description")}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="sidebar-adding-custom-game-modal__container">
|
||||
<div className="sidebar-adding-custom-game-modal__form">
|
||||
<TextField
|
||||
label={t("custom_game_modal_executable_path")}
|
||||
placeholder={t("custom_game_modal_select_executable")}
|
||||
value={executablePath}
|
||||
readOnly
|
||||
theme="dark"
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleSelectExecutable}
|
||||
disabled={isAdding}
|
||||
>
|
||||
<FileDirectoryIcon />
|
||||
{t("custom_game_modal_browse")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={t("custom_game_modal_title")}
|
||||
placeholder={t("custom_game_modal_enter_title")}
|
||||
value={gameName}
|
||||
onChange={handleGameNameChange}
|
||||
theme="dark"
|
||||
disabled={isAdding}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-adding-custom-game-modal__actions">
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isAdding}
|
||||
>
|
||||
{t("custom_game_modal_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme="primary"
|
||||
onClick={handleAddGame}
|
||||
disabled={!isFormValid || isAdding}
|
||||
>
|
||||
{isAdding
|
||||
? t("custom_game_modal_adding")
|
||||
: t("custom_game_modal_add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<SidebarGameItemProps>) {
|
||||
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 <PlayLogo className="sidebar__game-icon" />;
|
||||
}
|
||||
return <SteamLogo className="sidebar__game-icon" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
key={game.id}
|
||||
@@ -30,15 +44,15 @@ export function SidebarGameItem({
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
{sidebarIcon ? (
|
||||
<img
|
||||
className="sidebar__game-icon"
|
||||
src={game.iconUrl}
|
||||
src={sidebarIcon}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className="sidebar__game-icon" />
|
||||
getFallbackIcon()
|
||||
)}
|
||||
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<small className="sidebar__section-title">
|
||||
{t("my_library")}
|
||||
</small>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("sidebar__play-button", {
|
||||
"sidebar__play-button--active": showPlayableOnly,
|
||||
})}
|
||||
onClick={handlePlayButtonClick}
|
||||
<div
|
||||
style={{ display: "flex", gap: "8px", alignItems: "center" }}
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__add-button"
|
||||
onClick={handleAddGameButtonClick}
|
||||
data-tooltip-id="add-custom-game-tooltip"
|
||||
data-tooltip-content={t("add_custom_game_tooltip")}
|
||||
data-tooltip-place="top"
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("sidebar__play-button", {
|
||||
"sidebar__play-button--active": showPlayableOnly,
|
||||
})}
|
||||
onClick={handlePlayButtonClick}
|
||||
data-tooltip-id="show-playable-only-tooltip"
|
||||
data-tooltip-content={t("show_playable_only_tooltip")}
|
||||
data-tooltip-place="top"
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
@@ -307,6 +339,14 @@ export function Sidebar() {
|
||||
className="sidebar__handle"
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
|
||||
<SidebarAddingCustomGameModal
|
||||
visible={showAddGameModal}
|
||||
onClose={handleCloseAddGameModal}
|
||||
/>
|
||||
|
||||
<Tooltip id="add-custom-game-tooltip" />
|
||||
<Tooltip id="show-playable-only-tooltip" />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,14 +38,12 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||
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<GameStats | null>(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,
|
||||
|
||||
@@ -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<React.SetStateAction<string>>;
|
||||
selectGameExecutable: () => Promise<string | null>;
|
||||
updateGame: () => Promise<void>;
|
||||
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
33
src/renderer/src/declaration.d.ts
vendored
33
src/renderer/src/declaration.d.ts
vendored
@@ -112,6 +112,37 @@ declare global {
|
||||
objectId: string,
|
||||
title: string
|
||||
) => Promise<void>;
|
||||
addCustomGameToLibrary: (
|
||||
title: string,
|
||||
executablePath: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) => Promise<Game>;
|
||||
updateCustomGame: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) => Promise<Game>;
|
||||
copyCustomGameAsset: (
|
||||
sourcePath: string,
|
||||
assetType: "icon" | "logo" | "hero"
|
||||
) => Promise<string>;
|
||||
cleanupUnusedAssets: () => Promise<{
|
||||
deletedCount: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
updateGameCustomAssets: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
customIconUrl?: string | null,
|
||||
customLogoImageUrl?: string | null,
|
||||
customHeroImageUrl?: string | null
|
||||
) => Promise<Game>;
|
||||
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<string>;
|
||||
deleteTempFile: (filePath: string) => Promise<void>;
|
||||
platform: NodeJS.Platform;
|
||||
|
||||
/* Auto update */
|
||||
|
||||
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${color1};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${color2};stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grad)" />
|
||||
</svg>`;
|
||||
|
||||
// Return as data URL that works in img tags
|
||||
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
|
||||
};
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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<HTMLElement> = (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}
|
||||
/>
|
||||
|
||||
<section
|
||||
@@ -199,12 +173,7 @@ export function AchievementsContent({
|
||||
onScroll={onScroll}
|
||||
className="achievements-content__achievements-list__section"
|
||||
>
|
||||
<div
|
||||
className="achievements-content__achievements-list__section__container"
|
||||
style={{
|
||||
background: `linear-gradient(0deg, #151515 0%, ${gameColor} 100%)`,
|
||||
}}
|
||||
>
|
||||
<div className="achievements-content__achievements-list__section__container">
|
||||
<div
|
||||
ref={heroRef}
|
||||
className="achievements-content__achievements-list__section__container__hero"
|
||||
|
||||
@@ -28,12 +28,12 @@ export function DeleteGameModal({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-game-modal__actions">
|
||||
<Button onClick={handleDeleteGame} theme="outline">
|
||||
{t("delete")}
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
<Button onClick={handleDeleteGame} theme="primary">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const mediaContainerRef = useRef<HTMLDivElement>(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<HTMLButtonElement>) => {
|
||||
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 && (
|
||||
<div className="gallery-slider__container">
|
||||
<div
|
||||
onMouseEnter={() => setShowArrows(true)}
|
||||
onMouseLeave={() => setShowArrows(false)}
|
||||
className="gallery-slider__animation-container"
|
||||
ref={mediaContainerRef}
|
||||
>
|
||||
{shopDetails.movies &&
|
||||
shopDetails.movies.map((video) => (
|
||||
<div className="gallery-slider__container">
|
||||
<div className="gallery-slider__viewport" ref={emblaRef}>
|
||||
<div className="gallery-slider__container-inner">
|
||||
{mediaItems.map((item) => (
|
||||
<div key={item.id} className="gallery-slider__slide">
|
||||
{item.type === "video" ? (
|
||||
<video
|
||||
key={video.id}
|
||||
controls
|
||||
className="gallery-slider__media"
|
||||
poster={video.thumbnail}
|
||||
style={{ translate: `${-100 * mediaIndex}%` }}
|
||||
poster={item.poster}
|
||||
loop
|
||||
muted
|
||||
autoPlay
|
||||
tabIndex={-1}
|
||||
>
|
||||
<source src={video.mp4.max.replace("http", "https")} />
|
||||
<source src={item.videoSrc} />
|
||||
</video>
|
||||
))}
|
||||
|
||||
{hasScreenshots &&
|
||||
shopDetails.screenshots?.map((image, i) => (
|
||||
) : (
|
||||
<img
|
||||
key={image.id}
|
||||
className="gallery-slider__media"
|
||||
src={image.path_full}
|
||||
style={{ translate: `${-100 * mediaIndex}%` }}
|
||||
alt={t("screenshot", { number: i + 1 })}
|
||||
src={item.src}
|
||||
alt={item.alt}
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={showPrevImage}
|
||||
type="button"
|
||||
className={`gallery-slider__button gallery-slider__button--left ${
|
||||
showArrows
|
||||
? "gallery-slider__button--visible"
|
||||
: "gallery-slider__button--hidden"
|
||||
}`}
|
||||
aria-label={t("previous_screenshot")}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ChevronLeftIcon size={36} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={showNextImage}
|
||||
type="button"
|
||||
className={`gallery-slider__button gallery-slider__button--right ${
|
||||
showArrows
|
||||
? "gallery-slider__button--visible"
|
||||
: "gallery-slider__button--hidden"
|
||||
}`}
|
||||
aria-label={t("next_screenshot")}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ChevronRightIcon size={36} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="gallery-slider__preview" ref={scrollContainerRef}>
|
||||
{previews.map((media, i) => (
|
||||
<button
|
||||
key={media.id}
|
||||
type="button"
|
||||
className={`gallery-slider__preview-button ${
|
||||
mediaIndex === i
|
||||
? "gallery-slider__preview-button--active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setMediaIndex(i)}
|
||||
aria-label={t("open_screenshot", { number: i + 1 })}
|
||||
>
|
||||
<img
|
||||
src={media.thumbnail}
|
||||
className="gallery-slider__preview-image"
|
||||
alt={t("screenshot", { number: i + 1 })}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
<button
|
||||
onClick={scrollPrev}
|
||||
type="button"
|
||||
className="gallery-slider__button gallery-slider__button--left"
|
||||
aria-label={t("previous_screenshot")}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ChevronLeftIcon size={36} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={scrollNext}
|
||||
type="button"
|
||||
className="gallery-slider__button gallery-slider__button--right"
|
||||
aria-label={t("next_screenshot")}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ChevronRightIcon size={36} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="gallery-slider__preview">
|
||||
{previews.map((media, i) => (
|
||||
<button
|
||||
key={media.id}
|
||||
type="button"
|
||||
className={`gallery-slider__preview-button ${
|
||||
selectedIndex === i
|
||||
? "gallery-slider__preview-button--active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={(e) => scrollToPreview(i, e)}
|
||||
aria-label={t("open_screenshot", { number: String(i + 1) })}
|
||||
>
|
||||
<img
|
||||
src={media.thumbnail}
|
||||
className="gallery-slider__preview-image"
|
||||
alt={t("screenshot", { number: String(i + 1) })}
|
||||
/>
|
||||
{media.type === "video" && (
|
||||
<div className="gallery-slider__play-overlay">
|
||||
<PlayIcon size={20} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<img
|
||||
src={logoImage}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="game-details__game-logo-text">{game?.title}</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// For non-custom games, show logo image if available
|
||||
return logoImage ? (
|
||||
<img
|
||||
src={logoImage}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
|
||||
@@ -103,15 +149,13 @@ export function GameDetailsContent() {
|
||||
<section className="game-details__container">
|
||||
<div ref={heroRef} className="game-details__hero">
|
||||
<img
|
||||
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
|
||||
src={heroImage}
|
||||
className="game-details__hero-image"
|
||||
alt={game?.title}
|
||||
onLoad={handleHeroLoad}
|
||||
/>
|
||||
<div
|
||||
className="game-details__hero-backdrop"
|
||||
style={{
|
||||
backgroundColor: gameColor,
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
@@ -121,26 +165,35 @@ export function GameDetailsContent() {
|
||||
style={{ opacity: backdropOpacity }}
|
||||
>
|
||||
<div className="game-details__hero-content">
|
||||
<img
|
||||
src={shopDetails?.assets?.logoImageUrl ?? ""}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
{renderGameLogo()}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__cloud-sync-button"
|
||||
onClick={handleCloudSaveButtonClick}
|
||||
>
|
||||
<div className="game-details__cloud-icon-container">
|
||||
<img
|
||||
src={cloudIconAnimated}
|
||||
alt="Cloud icon"
|
||||
className="game-details__cloud-icon"
|
||||
/>
|
||||
</div>
|
||||
{t("cloud_save")}
|
||||
</button>
|
||||
<div className="game-details__hero-buttons game-details__hero-buttons--right">
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__edit-custom-game-button"
|
||||
onClick={handleEditGameClick}
|
||||
title={t("edit_game_modal_button")}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</button>
|
||||
|
||||
{game?.shop !== "custom" && (
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__cloud-sync-button"
|
||||
onClick={handleCloudSaveButtonClick}
|
||||
>
|
||||
<div className="game-details__cloud-icon-container">
|
||||
<img
|
||||
src={cloudIconAnimated}
|
||||
alt="Cloud icon"
|
||||
className="game-details__cloud-icon"
|
||||
/>
|
||||
</div>
|
||||
{t("cloud_save")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,9 +213,17 @@ export function GameDetailsContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sidebar />
|
||||
{game?.shop !== "custom" && <Sidebar />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EditGameModal
|
||||
visible={showEditGameModal}
|
||||
onClose={() => setShowEditGameModal(false)}
|
||||
game={game}
|
||||
shopDetails={shopDetails}
|
||||
onGameUpdated={handleGameUpdated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -178,6 +178,7 @@ export default function GameDetails() {
|
||||
onClose={() => {
|
||||
setShowGameOptionsModal(false);
|
||||
}}
|
||||
onNavigateHome={() => navigate("/")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ export function HeroPanelActions() {
|
||||
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
|
||||
</Button>
|
||||
|
||||
{userDetails && (
|
||||
{userDetails && game.shop !== "custom" && (
|
||||
<Button
|
||||
onClick={toggleGamePinned}
|
||||
theme="outline"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
&__manual-warning {
|
||||
|
||||
@@ -90,7 +90,7 @@ export function HeroPanelPlaytime() {
|
||||
<>
|
||||
<p
|
||||
className="hero-panel-playtime__play-time"
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-place="right"
|
||||
data-tooltip-content={
|
||||
game.hasManuallyUpdatedPlaytime
|
||||
? t("manual_playtime_tooltip")
|
||||
|
||||
@@ -5,18 +5,24 @@
|
||||
height: 72px;
|
||||
min-height: 72px;
|
||||
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
|
||||
background-color: globals.$dark-background-color;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: solid 1px rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all ease 0.2s;
|
||||
border-bottom: solid 1px globals.$border-color;
|
||||
position: sticky;
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&--stuck {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export function HeroPanel() {
|
||||
|
||||
const { formatDate } = useDate();
|
||||
|
||||
const { game, repacks, gameColor } = useContext(gameDetailsContext);
|
||||
const { game, repacks } = useContext(gameDetailsContext);
|
||||
|
||||
const { lastPacket } = useDownload();
|
||||
|
||||
@@ -50,7 +50,7 @@ export function HeroPanel() {
|
||||
game?.download?.status === "paused";
|
||||
|
||||
return (
|
||||
<div style={{ backgroundColor: gameColor }} className="hero-panel">
|
||||
<div className="hero-panel">
|
||||
<div className="hero-panel__content">{getInfo()}</div>
|
||||
<div className="hero-panel__actions">
|
||||
<HeroPanelActions />
|
||||
|
||||
@@ -149,17 +149,17 @@ export function ChangeGamePlaytimeModal({
|
||||
</div>
|
||||
|
||||
<div className="change-game-playtime-modal__actions">
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleChangePlaytime}
|
||||
theme="outline"
|
||||
theme="primary"
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
{t("update_playtime")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
181
src/renderer/src/pages/game-details/modals/edit-game-modal.scss
Normal file
181
src/renderer/src/pages/game-details/modals/edit-game-modal.scss
Normal file
@@ -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;
|
||||
}
|
||||
552
src/renderer/src/pages/game-details/modals/edit-game-modal.tsx
Normal file
552
src/renderer/src/pages/game-details/modals/edit-game-modal.tsx
Normal file
@@ -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<EditGameModalProps>) {
|
||||
const { t } = useTranslation("sidebar");
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [gameName, setGameName] = useState("");
|
||||
const [iconPath, setIconPath] = useState("");
|
||||
const [logoPath, setLogoPath] = useState("");
|
||||
const [heroPath, setHeroPath] = useState("");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [selectedAssetType, setSelectedAssetType] = useState<AssetType>("icon");
|
||||
|
||||
const [defaultIconUrl, setDefaultIconUrl] = useState<string | null>(null);
|
||||
const [defaultLogoUrl, setDefaultLogoUrl] = useState<string | null>(null);
|
||||
const [defaultHeroUrl, setDefaultHeroUrl] = useState<string | null>(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<HTMLInputElement>) => {
|
||||
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<string | null>(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 (
|
||||
<div className="edit-game-modal__image-section">
|
||||
<TextField
|
||||
placeholder={t(`edit_game_modal_select_${assetType}`)}
|
||||
value={assetPath}
|
||||
readOnly
|
||||
theme="dark"
|
||||
rightContent={
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleSelectAsset(assetType)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<ImageIcon />
|
||||
{t("edit_game_modal_browse")}
|
||||
</Button>
|
||||
{game && !isCustomGame(game) && assetPath && (
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleRestoreDefault(assetType)}
|
||||
disabled={isUpdating}
|
||||
title={`Remove ${assetType}`}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="edit-game-modal__resolution-info">
|
||||
{t(getResolutionKey())}
|
||||
</div>
|
||||
|
||||
{hasImage && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(getTranslationKey("_drop_zone"))}
|
||||
className={`edit-game-modal__image-preview ${
|
||||
assetType === "icon" ? "edit-game-modal__icon-preview" : ""
|
||||
} ${isDragOver ? "edit-game-modal__drop-zone--active" : ""}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={(e) => handleDragEnter(e, assetType)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleAssetDrop(e, assetType)}
|
||||
onClick={() => handleSelectAsset(assetType)}
|
||||
>
|
||||
<img
|
||||
src={getPreviewUrl(assetType)}
|
||||
alt={t(getTranslationKey("_preview"))}
|
||||
className="edit-game-modal__preview-image"
|
||||
/>
|
||||
{isDragOver && (
|
||||
<div className="edit-game-modal__drop-overlay">
|
||||
<span>Drop to replace {assetType}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!hasImage && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(getTranslationKey("_drop_zone_empty"))}
|
||||
className={`edit-game-modal__image-preview ${
|
||||
assetType === "icon" ? "edit-game-modal__icon-preview" : ""
|
||||
} edit-game-modal__drop-zone ${
|
||||
isDragOver ? "edit-game-modal__drop-zone--active" : ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={(e) => handleDragEnter(e, assetType)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleAssetDrop(e, assetType)}
|
||||
onClick={() => handleSelectAsset(assetType)}
|
||||
>
|
||||
<div className="edit-game-modal__drop-zone-content">
|
||||
<ImageIcon />
|
||||
<span>Drop {assetType} image here</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("edit_game_modal")}
|
||||
description={t("edit_game_modal_description")}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="edit-game-modal__container">
|
||||
<div className="edit-game-modal__form">
|
||||
<TextField
|
||||
label={t("edit_game_modal_title")}
|
||||
placeholder={t("edit_game_modal_enter_title")}
|
||||
value={gameName}
|
||||
onChange={handleGameNameChange}
|
||||
theme="dark"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
|
||||
<div className="edit-game-modal__asset-selector">
|
||||
<div className="edit-game-modal__asset-label">
|
||||
{t("edit_game_modal_assets")}
|
||||
</div>
|
||||
<div className="edit-game-modal__asset-tabs">
|
||||
<Button
|
||||
type="button"
|
||||
theme={selectedAssetType === "icon" ? "primary" : "outline"}
|
||||
onClick={() => handleAssetTypeChange("icon")}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t("edit_game_modal_icon")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme={selectedAssetType === "logo" ? "primary" : "outline"}
|
||||
onClick={() => handleAssetTypeChange("logo")}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t("edit_game_modal_logo")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme={selectedAssetType === "hero" ? "primary" : "outline"}
|
||||
onClick={() => handleAssetTypeChange("hero")}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t("edit_game_modal_hero")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderImageSection(selectedAssetType)}
|
||||
</div>
|
||||
|
||||
<div className="edit-game-modal__actions">
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t("edit_game_modal_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme="primary"
|
||||
onClick={handleUpdateGame}
|
||||
disabled={!isFormValid || isUpdating}
|
||||
>
|
||||
{isUpdating
|
||||
? t("edit_game_modal_updating")
|
||||
: t("edit_game_modal_update")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,14 @@ export interface GameOptionsModalProps {
|
||||
visible: boolean;
|
||||
game: LibraryGame;
|
||||
onClose: () => void;
|
||||
onNavigateHome?: () => void;
|
||||
}
|
||||
|
||||
export function GameOptionsModal({
|
||||
visible,
|
||||
game,
|
||||
onClose,
|
||||
onNavigateHome,
|
||||
}: Readonly<GameOptionsModalProps>) {
|
||||
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")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateSteamShortcut}
|
||||
theme="outline"
|
||||
disabled={creatingSteamShortcut}
|
||||
>
|
||||
<SteamLogo />
|
||||
{t("create_steam_shortcut")}
|
||||
</Button>
|
||||
{game.shop !== "custom" && (
|
||||
<Button
|
||||
onClick={handleCreateSteamShortcut}
|
||||
theme="outline"
|
||||
disabled={creatingSteamShortcut}
|
||||
>
|
||||
<SteamLogo />
|
||||
{t("create_steam_shortcut")}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowCreateStartMenuShortcut && (
|
||||
<Button
|
||||
onClick={() => handleCreateShortcut("start_menu")}
|
||||
@@ -367,19 +376,21 @@ export function GameOptionsModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckboxField
|
||||
label={
|
||||
<div className="game-options-modal__cloud-sync-label">
|
||||
{t("enable_automatic_cloud_sync")}
|
||||
<span className="game-options-modal__cloud-sync-hydra-cloud">
|
||||
Hydra Cloud
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
checked={automaticCloudSync}
|
||||
disabled={!hasActiveSubscription || !game.executablePath}
|
||||
onChange={handleToggleAutomaticCloudSync}
|
||||
/>
|
||||
{game.shop !== "custom" && (
|
||||
<CheckboxField
|
||||
label={
|
||||
<div className="game-options-modal__cloud-sync-label">
|
||||
{t("enable_automatic_cloud_sync")}
|
||||
<span className="game-options-modal__cloud-sync-hydra-cloud">
|
||||
Hydra Cloud
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
checked={automaticCloudSync}
|
||||
disabled={!hasActiveSubscription || !game.executablePath}
|
||||
onChange={handleToggleAutomaticCloudSync}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldShowWinePrefixConfiguration && (
|
||||
<div className="game-options-modal__wine-prefix">
|
||||
@@ -441,33 +452,35 @@ export function GameOptionsModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="game-options-modal__downloads">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("downloads_section_title")}</h2>
|
||||
<h4 className="game-options-modal__header-description">
|
||||
{t("downloads_section_description")}
|
||||
</h4>
|
||||
</div>
|
||||
{game.shop !== "custom" && (
|
||||
<div className="game-options-modal__downloads">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("downloads_section_title")}</h2>
|
||||
<h4 className="game-options-modal__header-description">
|
||||
{t("downloads_section_description")}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="game-options-modal__row">
|
||||
<Button
|
||||
onClick={() => setShowRepacksModal(true)}
|
||||
theme="outline"
|
||||
disabled={deleting || isGameDownloading || !repacks.length}
|
||||
>
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
{game.download?.downloadPath && (
|
||||
<div className="game-options-modal__row">
|
||||
<Button
|
||||
onClick={handleOpenDownloadFolder}
|
||||
onClick={() => setShowRepacksModal(true)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
disabled={deleting || isGameDownloading || !repacks.length}
|
||||
>
|
||||
{t("open_download_location")}
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
)}
|
||||
{game.download?.downloadPath && (
|
||||
<Button
|
||||
onClick={handleOpenDownloadFolder}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("open_download_location")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="game-options-modal__danger-zone">
|
||||
<div className="game-options-modal__header">
|
||||
@@ -486,18 +499,20 @@ export function GameOptionsModal({
|
||||
{t("remove_from_library")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowResetAchievementsModal(true)}
|
||||
theme="danger"
|
||||
disabled={
|
||||
deleting ||
|
||||
isDeletingAchievements ||
|
||||
!hasAchievements ||
|
||||
!userDetails
|
||||
}
|
||||
>
|
||||
{t("reset_achievements")}
|
||||
</Button>
|
||||
{game.shop !== "custom" && (
|
||||
<Button
|
||||
onClick={() => setShowResetAchievementsModal(true)}
|
||||
theme="danger"
|
||||
disabled={
|
||||
deleting ||
|
||||
isDeletingAchievements ||
|
||||
!hasAchievements ||
|
||||
!userDetails
|
||||
}
|
||||
>
|
||||
{t("reset_achievements")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => setShowChangePlaytimeModal(true)}
|
||||
@@ -506,17 +521,21 @@ export function GameOptionsModal({
|
||||
{t("update_game_playtime")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
theme="danger"
|
||||
disabled={
|
||||
isGameDownloading || deleting || !game.download?.downloadPath
|
||||
}
|
||||
>
|
||||
{t("remove_files")}
|
||||
</Button>
|
||||
{game.shop !== "custom" && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
theme="danger"
|
||||
disabled={
|
||||
isGameDownloading ||
|
||||
deleting ||
|
||||
!game.download?.downloadPath
|
||||
}
|
||||
>
|
||||
{t("remove_files")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./repacks-modal";
|
||||
export * from "./download-settings-modal";
|
||||
export * from "./game-options-modal";
|
||||
export * from "./edit-game-modal";
|
||||
|
||||
@@ -31,12 +31,12 @@ export function RemoveGameFromLibraryModal({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="remove-from-library-modal__actions">
|
||||
<Button onClick={handleRemoveGame} theme="outline">
|
||||
{t("remove")}
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
<Button onClick={handleRemoveGame} theme="primary">
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -36,12 +36,12 @@ export function ResetAchievementsModal({
|
||||
})}
|
||||
>
|
||||
<div className="reset-achievements-modal__actions">
|
||||
<Button onClick={handleResetAchievements} theme="outline">
|
||||
{t("reset_achievements")}
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
<Button onClick={handleResetAchievements} theme="primary">
|
||||
{t("reset_achievements")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("<br>")[0];
|
||||
const arrayIdiomas = languages?.split(",");
|
||||
const listLanguages: {
|
||||
language: string;
|
||||
caption: string;
|
||||
audio: string;
|
||||
}[] = [];
|
||||
arrayIdiomas?.forEach((lang) => {
|
||||
const objectLanguage = {
|
||||
language: lang.replace("<strong>*</strong>", ""),
|
||||
caption: "✔",
|
||||
audio: lang.includes("*") ? "✔" : "",
|
||||
};
|
||||
listLanguages.push(objectLanguage);
|
||||
});
|
||||
return listLanguages;
|
||||
};
|
||||
const languages = useMemo(() => {
|
||||
const supportedLanguages = shopDetails?.supported_languages;
|
||||
if (!supportedLanguages) return [];
|
||||
|
||||
const languagesString = supportedLanguages.split("<br>")[0];
|
||||
const languageArray = languagesString?.split(",") || [];
|
||||
|
||||
return languageArray.map((lang) => ({
|
||||
language: lang.replace("<strong>*</strong>", "").trim(),
|
||||
hasAudio: lang.includes("*"),
|
||||
}));
|
||||
}, [shopDetails?.supported_languages]);
|
||||
|
||||
if (languages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarSection title={t("language")}>
|
||||
<div>
|
||||
<h4>{t("supported_languages")}</h4>
|
||||
<table className="table-languages">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("language")}</th>
|
||||
<th>{t("caption")}</th>
|
||||
<th>{t("audio")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{getLanguages().map((lang) => (
|
||||
<tr key={lang.language}>
|
||||
<td>{lang.language}</td>
|
||||
<td>{lang.caption}</td>
|
||||
<td>{lang.audio}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="list__item"
|
||||
href={`https://store.steampowered.com/app/${objectId}`}
|
||||
>
|
||||
Link Steam
|
||||
</a>
|
||||
<div className="game-language-section">
|
||||
<div className="game-language-section__header">
|
||||
<div className="game-language-section__header-item">
|
||||
<span>{t("language")}</span>
|
||||
</div>
|
||||
<div className="game-language-section__header-item game-language-section__header-item--center">
|
||||
<span>{t("caption")}</span>
|
||||
</div>
|
||||
<div className="game-language-section__header-item game-language-section__header-item--center">
|
||||
<span>{t("audio")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="game-language-section__content">
|
||||
{languages.map((lang) => (
|
||||
<div key={lang.language} className="game-language-section__row">
|
||||
<div
|
||||
className="game-language-section__cell game-language-section__cell--language"
|
||||
title={lang.language}
|
||||
>
|
||||
{lang.language}
|
||||
</div>
|
||||
<div className="game-language-section__cell game-language-section__cell--center">
|
||||
<CheckIcon size={14} className="game-language-section__check" />
|
||||
</div>
|
||||
<div className="game-language-section__cell game-language-section__cell--center">
|
||||
{lang.hasAudio ? (
|
||||
<CheckIcon
|
||||
size={14}
|
||||
className="game-language-section__check"
|
||||
/>
|
||||
) : (
|
||||
<XIcon size={14} className="game-language-section__cross" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarSection>
|
||||
);
|
||||
|
||||
@@ -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<any>(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 (
|
||||
<SidebarSection title={t("prices")}>
|
||||
{isLoadingPrices ? (
|
||||
<div>{t("loading")}</div>
|
||||
) : priceData ? (
|
||||
<div>
|
||||
<ul className="">
|
||||
<li>
|
||||
<b>{t("retail_price")}</b>: {t("currency_symbol")}
|
||||
{priceData.prices.currentRetail}
|
||||
</li>
|
||||
<li>
|
||||
<b>{t("keyshop_price")}</b>: {t("currency_symbol")}
|
||||
{priceData.prices.currentKeyshops}
|
||||
</li>
|
||||
<li>
|
||||
<b>{t("historical_retail")}</b>: {t("currency_symbol")}
|
||||
{priceData.prices.historicalRetail}
|
||||
</li>
|
||||
<li>
|
||||
<b>{t("historical_keyshop")}</b>: {t("currency_symbol")}
|
||||
{priceData.prices.historicalKeyshops}
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href={priceData.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="list__item"
|
||||
>
|
||||
{t("view_all_prices")}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div>{t("no_prices_found")}</div>
|
||||
)}
|
||||
</SidebarSection>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<aside className="content-sidebar">
|
||||
<GameLanguageSection />
|
||||
<GamePricesSection />
|
||||
|
||||
{userDetails === null && (
|
||||
<SidebarSection title={t("achievements")}>
|
||||
<div className="achievements-placeholder">
|
||||
@@ -268,6 +264,8 @@ export function Sidebar() {
|
||||
}}
|
||||
/>
|
||||
</SidebarSection>
|
||||
|
||||
<GameLanguageSection />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,8 +97,6 @@ export default function Home() {
|
||||
return (
|
||||
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
|
||||
<section className="home__content">
|
||||
<h2>{t("featured")}</h2>
|
||||
|
||||
<Hero />
|
||||
|
||||
<section className="home__header">
|
||||
|
||||
@@ -31,10 +31,14 @@ export function FriendsBox() {
|
||||
return (
|
||||
<div>
|
||||
<div className="friends-box__section-header">
|
||||
<h2>{t("friends")}</h2>
|
||||
{userStats && (
|
||||
<span>{numberFormatter.format(userStats.friendsCount)}</span>
|
||||
)}
|
||||
<div className="profile-content__section-title-group">
|
||||
<h2>{t("friends")}</h2>
|
||||
{userStats && (
|
||||
<span className="profile-content__section-badge">
|
||||
{numberFormatter.format(userStats.friendsCount)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="friends-box__box">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,12 +38,12 @@ export const DeleteAllThemesModal = ({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-all-themes-modal__container">
|
||||
<Button theme="outline" onClick={handleDeleteAllThemes}>
|
||||
{t("delete_all_themes")}
|
||||
<Button theme="outline" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button theme="primary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
<Button theme="primary" onClick={handleDeleteAllThemes}>
|
||||
{t("delete_all_themes")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -41,12 +41,12 @@ export const DeleteThemeModal = ({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-all-themes-modal__container">
|
||||
<Button theme="outline" onClick={handleDeleteTheme}>
|
||||
{t("delete_theme")}
|
||||
<Button theme="outline" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button theme="primary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
<Button theme="primary" onClick={handleDeleteTheme}>
|
||||
{t("delete_theme")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -77,12 +77,12 @@ export const ImportThemeModal = ({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-all-themes-modal__container">
|
||||
<Button theme="outline" onClick={handleImportTheme}>
|
||||
{t("import_theme")}
|
||||
<Button theme="outline" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button theme="primary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
<Button theme="primary" onClick={handleImportTheme}>
|
||||
{t("import_theme")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={t("gg_deals_api_key_description")}
|
||||
value={form.ggDealsApiKey}
|
||||
onChange={(e) => handleChange({ ggDealsApiKey: e.target.value })}
|
||||
/>
|
||||
|
||||
<SelectField
|
||||
label={t("language")}
|
||||
value={form.language}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
$background-color: #1c1c1c;
|
||||
$dark-background-color: #151515;
|
||||
$background-color: #121212;
|
||||
$dark-background-color: #0d0d0d;
|
||||
|
||||
$muted-color: #c0c1c7;
|
||||
$body-color: #8e919b;
|
||||
$muted-color: #f0f1f7;
|
||||
$body-color: #d0d1d7;
|
||||
|
||||
$border-color: rgba(255, 255, 255, 0.15);
|
||||
$border-color: rgba(255, 255, 255, 0.08);
|
||||
$success-color: #1c9749;
|
||||
$danger-color: #801d1e;
|
||||
$error-color: #e11d48;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type GameShop = "steam" | "epic";
|
||||
export type GameShop = "steam" | "epic" | "custom";
|
||||
|
||||
export type ShortcutLocation = "desktop" | "start_menu";
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@ export interface User {
|
||||
export interface Game {
|
||||
title: string;
|
||||
iconUrl: string | null;
|
||||
libraryHeroImageUrl: string | null;
|
||||
logoImageUrl: string | null;
|
||||
customIconUrl?: string | null;
|
||||
customLogoImageUrl?: string | null;
|
||||
customHeroImageUrl?: string | null;
|
||||
playTimeInMilliseconds: number;
|
||||
unsyncedDeltaPlayTimeInMilliseconds?: number;
|
||||
lastTimePlayed: Date | null;
|
||||
@@ -84,7 +89,6 @@ export type AchievementCustomNotificationPosition =
|
||||
|
||||
export interface UserPreferences {
|
||||
downloadsPath?: string | null;
|
||||
ggDealsApiKey?: string | null;
|
||||
language?: string;
|
||||
realDebridApiToken?: string | null;
|
||||
torBoxApiToken?: string | null;
|
||||
|
||||
86
yarn.lock
86
yarn.lock
@@ -912,6 +912,11 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.9.2":
|
||||
version "7.28.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
|
||||
integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
|
||||
|
||||
"@babel/template@^7.22.15", "@babel/template@^7.24.0":
|
||||
version "7.24.0"
|
||||
resolved "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz"
|
||||
@@ -2043,6 +2048,21 @@
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438"
|
||||
integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==
|
||||
|
||||
"@react-dnd/asap@^5.0.1":
|
||||
version "5.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488"
|
||||
integrity sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==
|
||||
|
||||
"@react-dnd/invariant@^4.0.1":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-4.0.2.tgz#b92edffca10a26466643349fac7cdfb8799769df"
|
||||
integrity sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==
|
||||
|
||||
"@react-dnd/shallowequal@^4.0.1":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz#d1b4befa423f692fa4abf1c79209702e7d8ae4b4"
|
||||
integrity sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==
|
||||
|
||||
"@reduxjs/toolkit@^2.2.3":
|
||||
version "2.2.5"
|
||||
resolved "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz"
|
||||
@@ -4451,6 +4471,15 @@ dmg-license@^1.0.11:
|
||||
smart-buffer "^4.0.2"
|
||||
verror "^1.10.0"
|
||||
|
||||
dnd-core@^16.0.1:
|
||||
version "16.0.1"
|
||||
resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-16.0.1.tgz#a1c213ed08961f6bd1959a28bb76f1a868360d19"
|
||||
integrity sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==
|
||||
dependencies:
|
||||
"@react-dnd/asap" "^5.0.1"
|
||||
"@react-dnd/invariant" "^4.0.1"
|
||||
redux "^4.2.0"
|
||||
|
||||
doctrine@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
|
||||
@@ -4609,6 +4638,29 @@ electron@^32.3.3:
|
||||
"@types/node" "^20.9.0"
|
||||
extract-zip "^2.0.1"
|
||||
|
||||
embla-carousel-autoplay@^8.6.0:
|
||||
version "8.6.0"
|
||||
resolved "https://registry.yarnpkg.com/embla-carousel-autoplay/-/embla-carousel-autoplay-8.6.0.tgz#bc86c97de00d52ec34b05058736ef50af6e0d0e4"
|
||||
integrity sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==
|
||||
|
||||
embla-carousel-react@^8.6.0:
|
||||
version "8.6.0"
|
||||
resolved "https://registry.yarnpkg.com/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz#b737042a32761c38d6614593653b3ac619477bd1"
|
||||
integrity sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==
|
||||
dependencies:
|
||||
embla-carousel "8.6.0"
|
||||
embla-carousel-reactive-utils "8.6.0"
|
||||
|
||||
embla-carousel-reactive-utils@8.6.0:
|
||||
version "8.6.0"
|
||||
resolved "https://registry.yarnpkg.com/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz#607f1d8ab9921c906a555c206251b2c6db687223"
|
||||
integrity sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==
|
||||
|
||||
embla-carousel@8.6.0:
|
||||
version "8.6.0"
|
||||
resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-8.6.0.tgz#abcedff2bff36992ea8ac27cd30080ca5b6a3f58"
|
||||
integrity sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==
|
||||
|
||||
emoji-regex@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
|
||||
@@ -5735,6 +5787,13 @@ hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2:
|
||||
dependencies:
|
||||
function-bind "^1.1.2"
|
||||
|
||||
hoist-non-react-statics@^3.3.2:
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
||||
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
|
||||
dependencies:
|
||||
react-is "^16.7.0"
|
||||
|
||||
hosted-git-info@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz#827b82867e9ff1c8d0c4d9d53880397d2c86d224"
|
||||
@@ -7506,6 +7565,24 @@ rc-virtual-list@^3.18.3:
|
||||
rc-resize-observer "^1.0.0"
|
||||
rc-util "^5.36.0"
|
||||
|
||||
react-dnd-html5-backend@^16.0.1:
|
||||
version "16.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz#87faef15845d512a23b3c08d29ecfd34871688b6"
|
||||
integrity sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==
|
||||
dependencies:
|
||||
dnd-core "^16.0.1"
|
||||
|
||||
react-dnd@^16.0.1:
|
||||
version "16.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-16.0.1.tgz#2442a3ec67892c60d40a1559eef45498ba26fa37"
|
||||
integrity sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==
|
||||
dependencies:
|
||||
"@react-dnd/invariant" "^4.0.1"
|
||||
"@react-dnd/shallowequal" "^4.0.1"
|
||||
dnd-core "^16.0.1"
|
||||
fast-deep-equal "^3.1.3"
|
||||
hoist-non-react-statics "^3.3.2"
|
||||
|
||||
react-dom@^18.2.0:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
|
||||
@@ -7527,7 +7604,7 @@ react-i18next@^14.1.0:
|
||||
"@babel/runtime" "^7.23.9"
|
||||
html-parse-stringify "^3.0.1"
|
||||
|
||||
react-is@^16.13.1:
|
||||
react-is@^16.13.1, react-is@^16.7.0:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
@@ -7641,6 +7718,13 @@ redux-thunk@^3.1.0:
|
||||
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3"
|
||||
integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==
|
||||
|
||||
redux@^4.2.0:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197"
|
||||
integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.9.2"
|
||||
|
||||
redux@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b"
|
||||
|
||||
Reference in New Issue
Block a user