mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-18 00:33:59 +00:00
Feat: Custom Games
This commit is contained in:
@@ -28,7 +28,60 @@
|
||||
"friends": "Friends",
|
||||
"need_help": "Need help?",
|
||||
"favorites": "Favorites",
|
||||
"playable_button_title": "Show only games you can play now"
|
||||
"playable_button_title": "Show only games you can play now",
|
||||
"add_custom_game_tooltip": "Add Custom Game",
|
||||
"show_playable_only_tooltip": "Show Playable Only",
|
||||
"custom_game_modal": "Add Custom Game",
|
||||
"custom_game_modal_description": "Add a custom game to your library by selecting an executable file",
|
||||
"custom_game_modal_executable_path": "Executable Path",
|
||||
"custom_game_modal_select_executable": "Select executable file",
|
||||
"custom_game_modal_game_name": "Game Name",
|
||||
"custom_game_modal_enter_name": "Enter game name",
|
||||
"custom_game_modal_image": "Game Image",
|
||||
"custom_game_modal_select_image": "Select game image",
|
||||
"custom_game_modal_image_preview": "Game image preview",
|
||||
"custom_game_modal_browse": "Browse",
|
||||
"custom_game_modal_cancel": "Cancel",
|
||||
"custom_game_modal_add": "Add Game",
|
||||
"custom_game_modal_adding": "Adding Game...",
|
||||
"custom_game_modal_fill_required": "Please fill in all required fields",
|
||||
"custom_game_modal_success": "Custom game added successfully",
|
||||
"custom_game_modal_failed": "Failed to add custom game",
|
||||
"custom_game_modal_executable": "Executable",
|
||||
"custom_game_modal_image_filter": "Image",
|
||||
"custom_game_modal_icon": "Game Icon",
|
||||
"custom_game_modal_select_icon": "Select game icon",
|
||||
"custom_game_modal_icon_preview": "Game icon preview",
|
||||
"custom_game_modal_logo": "Game Logo",
|
||||
"custom_game_modal_select_logo": "Select game logo",
|
||||
"custom_game_modal_logo_preview": "Game logo preview",
|
||||
"custom_game_modal_hero": "Library Hero Image",
|
||||
"custom_game_modal_select_hero": "Select library hero image",
|
||||
"custom_game_modal_hero_preview": "Library hero image preview",
|
||||
"edit_custom_game_modal": "Edit Custom Game",
|
||||
"edit_custom_game_modal_description": "Edit your custom game details",
|
||||
"edit_custom_game_modal_game_name": "Game Name",
|
||||
"edit_custom_game_modal_enter_name": "Enter game name",
|
||||
"edit_custom_game_modal_image": "Game Image",
|
||||
"edit_custom_game_modal_select_image": "Select game image",
|
||||
"edit_custom_game_modal_browse": "Browse",
|
||||
"edit_custom_game_modal_image_preview": "Game image preview",
|
||||
"edit_custom_game_modal_icon": "Game Icon",
|
||||
"edit_custom_game_modal_select_icon": "Select game icon",
|
||||
"edit_custom_game_modal_icon_preview": "Game icon preview",
|
||||
"edit_custom_game_modal_logo": "Game Logo",
|
||||
"edit_custom_game_modal_select_logo": "Select game logo",
|
||||
"edit_custom_game_modal_logo_preview": "Game logo preview",
|
||||
"edit_custom_game_modal_hero": "Library Hero Image",
|
||||
"edit_custom_game_modal_select_hero": "Select library hero image",
|
||||
"edit_custom_game_modal_hero_preview": "Library hero image preview",
|
||||
"edit_custom_game_modal_cancel": "Cancel",
|
||||
"edit_custom_game_modal_update": "Update Game",
|
||||
"edit_custom_game_modal_updating": "Updating Game...",
|
||||
"edit_custom_game_modal_fill_required": "Please fill in all required fields",
|
||||
"edit_custom_game_modal_success": "Custom game updated successfully",
|
||||
"edit_custom_game_modal_failed": "Failed to update custom game",
|
||||
"edit_custom_game_modal_image_filter": "Image"
|
||||
},
|
||||
"header": {
|
||||
"search": "Search games",
|
||||
|
||||
@@ -28,7 +28,58 @@
|
||||
"friends": "Друзья",
|
||||
"need_help": "Нужна помощь?",
|
||||
"favorites": "Избранное",
|
||||
"playable_button_title": "Показать только игры, в которые можно играть сейчас"
|
||||
"playable_button_title": "Показать только игры, в которые можно играть сейчас",
|
||||
"custom_game_modal": "Добавить пользовательскую игру",
|
||||
"custom_game_modal_description": "Добавьте пользовательскую игру в библиотеку, выбрав исполняемый файл",
|
||||
"custom_game_modal_executable_path": "Путь к исполняемому файлу",
|
||||
"custom_game_modal_select_executable": "Выберите исполняемый файл",
|
||||
"custom_game_modal_game_name": "Название игры",
|
||||
"custom_game_modal_enter_name": "Введите название игры",
|
||||
"custom_game_modal_image": "Изображение игры",
|
||||
"custom_game_modal_select_image": "Выберите изображение игры",
|
||||
"custom_game_modal_image_preview": "Предварительный просмотр изображения игры",
|
||||
"custom_game_modal_browse": "Обзор",
|
||||
"custom_game_modal_cancel": "Отмена",
|
||||
"custom_game_modal_add": "Добавить игру",
|
||||
"custom_game_modal_adding": "Добавление игры...",
|
||||
"custom_game_modal_fill_required": "Пожалуйста, заполните все обязательные поля",
|
||||
"custom_game_modal_success": "Пользовательская игра успешно добавлена",
|
||||
"custom_game_modal_failed": "Не удалось добавить пользовательскую игру",
|
||||
"custom_game_modal_executable": "Исполняемый файл",
|
||||
"custom_game_modal_image_filter": "Изображение",
|
||||
"custom_game_modal_icon": "Иконка игры",
|
||||
"custom_game_modal_select_icon": "Выберите иконку игры",
|
||||
"custom_game_modal_icon_preview": "Предпросмотр иконки игры",
|
||||
"custom_game_modal_logo": "Логотип игры",
|
||||
"custom_game_modal_select_logo": "Выберите логотип игры",
|
||||
"custom_game_modal_logo_preview": "Предпросмотр логотипа игры",
|
||||
"custom_game_modal_hero": "Изображение героя библиотеки",
|
||||
"custom_game_modal_select_hero": "Выберите изображение героя библиотеки",
|
||||
"custom_game_modal_hero_preview": "Предпросмотр изображения героя библиотеки",
|
||||
"edit_custom_game_modal": "Редактировать пользовательскую игру",
|
||||
"edit_custom_game_modal_description": "Редактируйте детали вашей пользовательской игры",
|
||||
"edit_custom_game_modal_game_name": "Название игры",
|
||||
"edit_custom_game_modal_enter_name": "Введите название игры",
|
||||
"edit_custom_game_modal_image": "Изображение игры",
|
||||
"edit_custom_game_modal_select_image": "Выберите изображение игры",
|
||||
"edit_custom_game_modal_browse": "Обзор",
|
||||
"edit_custom_game_modal_image_preview": "Предпросмотр изображения игры",
|
||||
"edit_custom_game_modal_icon": "Иконка игры",
|
||||
"edit_custom_game_modal_select_icon": "Выберите иконку игры",
|
||||
"edit_custom_game_modal_icon_preview": "Предпросмотр иконки игры",
|
||||
"edit_custom_game_modal_logo": "Логотип игры",
|
||||
"edit_custom_game_modal_select_logo": "Выберите логотип игры",
|
||||
"edit_custom_game_modal_logo_preview": "Предпросмотр логотипа игры",
|
||||
"edit_custom_game_modal_hero": "Изображение героя библиотеки",
|
||||
"edit_custom_game_modal_select_hero": "Выберите изображение героя библиотеки",
|
||||
"edit_custom_game_modal_hero_preview": "Предпросмотр изображения героя библиотеки",
|
||||
"edit_custom_game_modal_cancel": "Отмена",
|
||||
"edit_custom_game_modal_update": "Обновить игру",
|
||||
"edit_custom_game_modal_updating": "Обновление игры...",
|
||||
"edit_custom_game_modal_fill_required": "Пожалуйста, заполните все обязательные поля",
|
||||
"edit_custom_game_modal_success": "Пользовательская игра успешно обновлена",
|
||||
"edit_custom_game_modal_failed": "Не удалось обновить пользовательскую игру",
|
||||
"edit_custom_game_modal_image_filter": "Изображение"
|
||||
},
|
||||
"header": {
|
||||
"search": "Поиск",
|
||||
|
||||
@@ -14,6 +14,8 @@ import "./catalogue/get-developers";
|
||||
import "./hardware/get-disk-free-space";
|
||||
import "./hardware/check-folder-write-permission";
|
||||
import "./library/add-game-to-library";
|
||||
import "./library/add-custom-game-to-library";
|
||||
import "./library/update-custom-game";
|
||||
import "./library/add-game-to-favorites";
|
||||
import "./library/remove-game-from-favorites";
|
||||
import "./library/create-game-shortcut";
|
||||
|
||||
68
src/main/events/library/add-custom-game-to-library.ts
Normal file
68
src/main/events/library/add-custom-game-to-library.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import {
|
||||
gamesSublevel,
|
||||
gamesShopAssetsSublevel,
|
||||
levelKeys,
|
||||
} from "@main/level";
|
||||
import { randomUUID } from "crypto";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
|
||||
const addCustomGameToLibrary = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
title: string,
|
||||
executablePath: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) => {
|
||||
const objectId = randomUUID();
|
||||
const shop: GameShop = "custom";
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const existingGames = await gamesSublevel.iterator().all();
|
||||
const existingGame = existingGames.find(([_key, game]) =>
|
||||
game.executablePath === executablePath && !game.isDeleted
|
||||
);
|
||||
|
||||
if (existingGame) {
|
||||
throw new Error("A game with this executable path already exists in your library");
|
||||
}
|
||||
|
||||
const assets = {
|
||||
objectId,
|
||||
shop,
|
||||
title,
|
||||
iconUrl: iconUrl || null,
|
||||
libraryHeroImageUrl: libraryHeroImageUrl || "",
|
||||
libraryImageUrl: iconUrl || "",
|
||||
logoImageUrl: logoImageUrl || "",
|
||||
logoPosition: null,
|
||||
coverImageUrl: iconUrl || "",
|
||||
};
|
||||
await gamesShopAssetsSublevel.put(gameKey, assets);
|
||||
|
||||
const game = {
|
||||
title,
|
||||
iconUrl: iconUrl || null,
|
||||
logoImageUrl: logoImageUrl || null,
|
||||
libraryHeroImageUrl: libraryHeroImageUrl || null,
|
||||
objectId,
|
||||
shop,
|
||||
remoteId: null,
|
||||
isDeleted: false,
|
||||
playTimeInMilliseconds: 0,
|
||||
lastTimePlayed: null,
|
||||
executablePath,
|
||||
launchOptions: null,
|
||||
favorite: false,
|
||||
automaticCloudSync: false,
|
||||
hasManuallyUpdatedPlaytime: false,
|
||||
};
|
||||
|
||||
await gamesSublevel.put(gameKey, game);
|
||||
|
||||
return game;
|
||||
};
|
||||
|
||||
registerEvent("addCustomGameToLibrary", addCustomGameToLibrary);
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
53
src/main/events/library/update-custom-game.ts
Normal file
53
src/main/events/library/update-custom-game.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import {
|
||||
gamesSublevel,
|
||||
gamesShopAssetsSublevel,
|
||||
levelKeys,
|
||||
} from "@main/level";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const updateCustomGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) => {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const existingGame = await gamesSublevel.get(gameKey);
|
||||
if (!existingGame) {
|
||||
throw new Error("Game not found");
|
||||
}
|
||||
|
||||
const updatedGame = {
|
||||
...existingGame,
|
||||
title,
|
||||
iconUrl: iconUrl || null,
|
||||
logoImageUrl: logoImageUrl || null,
|
||||
libraryHeroImageUrl: libraryHeroImageUrl || null,
|
||||
};
|
||||
|
||||
await gamesSublevel.put(gameKey, updatedGame);
|
||||
|
||||
const existingAssets = await gamesShopAssetsSublevel.get(gameKey);
|
||||
if (existingAssets) {
|
||||
const updatedAssets = {
|
||||
...existingAssets,
|
||||
title,
|
||||
iconUrl: iconUrl || null,
|
||||
libraryHeroImageUrl: libraryHeroImageUrl || "",
|
||||
libraryImageUrl: iconUrl || "",
|
||||
logoImageUrl: logoImageUrl || "",
|
||||
coverImageUrl: iconUrl || "",
|
||||
};
|
||||
|
||||
await gamesShopAssetsSublevel.put(gameKey, updatedAssets);
|
||||
}
|
||||
|
||||
return updatedGame;
|
||||
};
|
||||
|
||||
registerEvent("updateCustomGame", updateCustomGame);
|
||||
@@ -64,6 +64,54 @@ app.whenReady().then(async () => {
|
||||
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
|
||||
});
|
||||
|
||||
protocol.handle("gradient", (request) => {
|
||||
const gradientCss = decodeURIComponent(request.url.slice("gradient:".length));
|
||||
|
||||
const match = gradientCss.match(/linear-gradient\(([^,]+),\s*([^,]+),\s*([^)]+)\)/);
|
||||
|
||||
let direction = "45deg";
|
||||
let color1 = '#4a90e2';
|
||||
let color2 = '#7b68ee';
|
||||
|
||||
if (match) {
|
||||
direction = match[1].trim();
|
||||
color1 = match[2].trim();
|
||||
color2 = match[3].trim();
|
||||
}
|
||||
|
||||
let x1 = "0%", y1 = "0%", x2 = "100%", y2 = "100%";
|
||||
|
||||
if (direction === "to right") {
|
||||
x1 = "0%"; y1 = "0%"; x2 = "100%"; y2 = "0%";
|
||||
} else if (direction === "to bottom") {
|
||||
x1 = "0%"; y1 = "0%"; x2 = "0%"; y2 = "100%";
|
||||
} else if (direction === "45deg") {
|
||||
x1 = "0%"; y1 = "100%"; x2 = "100%"; y2 = "0%";
|
||||
} else if (direction === "135deg") {
|
||||
x1 = "0%"; y1 = "0%"; x2 = "100%"; y2 = "100%";
|
||||
} else if (direction === "225deg") {
|
||||
x1 = "100%"; y1 = "0%"; x2 = "0%"; y2 = "100%";
|
||||
} else if (direction === "315deg") {
|
||||
x1 = "100%"; y1 = "100%"; x2 = "0%"; y2 = "0%";
|
||||
}
|
||||
|
||||
const svgContent = `
|
||||
<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
|
||||
|
||||
@@ -44,6 +44,8 @@ export const mergeWithRemoteGames = async () => {
|
||||
remoteId: game.id,
|
||||
shop: game.shop,
|
||||
iconUrl: game.iconUrl,
|
||||
libraryHeroImageUrl: game.libraryHeroImageUrl,
|
||||
logoImageUrl: game.logoImageUrl,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
playTimeInMilliseconds: game.playTimeInMilliseconds,
|
||||
hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime,
|
||||
|
||||
@@ -11,7 +11,7 @@ export const uploadGamesBatch = async () => {
|
||||
.all()
|
||||
.then((results) => {
|
||||
return results.filter(
|
||||
(game) => !game.isDeleted && game.remoteId === null
|
||||
(game) => !game.isDeleted && game.remoteId === null && game.shop !== "custom"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -128,6 +128,23 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
),
|
||||
addGameToLibrary: (shop: GameShop, objectId: string, title: string) =>
|
||||
ipcRenderer.invoke("addGameToLibrary", shop, objectId, title),
|
||||
addCustomGameToLibrary: (
|
||||
title: string,
|
||||
executablePath: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) =>
|
||||
ipcRenderer.invoke("addCustomGameToLibrary", title, executablePath, iconUrl, logoImageUrl, libraryHeroImageUrl),
|
||||
updateCustomGame: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) =>
|
||||
ipcRenderer.invoke("updateCustomGame", shop, objectId, title, iconUrl, logoImageUrl, libraryHeroImageUrl),
|
||||
createGameShortcut: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
@@ -476,4 +493,6 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
},
|
||||
closeEditorWindow: (themeId?: string) =>
|
||||
ipcRenderer.invoke("closeEditorWindow", themeId),
|
||||
|
||||
|
||||
});
|
||||
|
||||
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 |
@@ -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,183 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FileDirectoryIcon } from "@primer/octicons-react";
|
||||
|
||||
import { Modal, TextField, Button } from "@renderer/components";
|
||||
import { useLibrary, useToast } from "@renderer/hooks";
|
||||
import { buildGameDetailsPath, generateRandomGradient } from "@renderer/helpers";
|
||||
|
||||
import "./sidebar-adding-custom-game-modal.scss";
|
||||
|
||||
export interface SidebarAddingCustomGameModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SidebarAddingCustomGameModal({
|
||||
visible,
|
||||
onClose,
|
||||
}: SidebarAddingCustomGameModalProps) {
|
||||
const { t } = useTranslation("sidebar");
|
||||
const { updateLibrary } = useLibrary();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [gameName, setGameName] = useState("");
|
||||
const [executablePath, setExecutablePath] = useState("");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const handleSelectExecutable = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: t("custom_game_modal_executable"),
|
||||
extensions: ["exe", "msi", "app", "deb", "rpm", "dmg"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const selectedPath = filePaths[0];
|
||||
setExecutablePath(selectedPath);
|
||||
|
||||
if (!gameName.trim()) {
|
||||
const fileName = selectedPath.split(/[\\/]/).pop() || "";
|
||||
const gameNameFromFile = fileName.replace(/\.[^/.]+$/, "");
|
||||
setGameName(gameNameFromFile);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGameNameChange = (event: React.ChangeEvent<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_game_name")}
|
||||
placeholder={t("custom_game_modal_enter_name")}
|
||||
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,11 @@ export function SidebarGameItem({
|
||||
}: Readonly<SidebarGameItemProps>) {
|
||||
const location = useLocation();
|
||||
|
||||
const isCustomGame = game.shop === "custom";
|
||||
const sidebarIcon = isCustomGame
|
||||
? game.libraryImageUrl || game.iconUrl
|
||||
: game.iconUrl;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={game.id}
|
||||
@@ -30,13 +36,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"
|
||||
/>
|
||||
) : isCustomGame ? (
|
||||
<PlayLogo className="sidebar__game-icon" />
|
||||
) : (
|
||||
<SteamLogo className="sidebar__game-icon" />
|
||||
)}
|
||||
|
||||
@@ -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,9 @@ import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { SidebarProfile } from "./sidebar-profile";
|
||||
import { sortBy } from "lodash-es";
|
||||
import cn from "classnames";
|
||||
import { CommentDiscussionIcon, PlayIcon } from "@primer/octicons-react";
|
||||
import { CommentDiscussionIcon, PlayIcon, PlusIcon } from "@primer/octicons-react";
|
||||
import { SidebarGameItem } from "./sidebar-game-item";
|
||||
import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal";
|
||||
import { setFriendRequestCount } from "@renderer/features/user-details-slice";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
@@ -63,11 +65,20 @@ export function Sidebar() {
|
||||
const { showWarningToast } = useToast();
|
||||
|
||||
const [showPlayableOnly, setShowPlayableOnly] = useState(false);
|
||||
const [showAddGameModal, setShowAddGameModal] = useState(false);
|
||||
|
||||
const handlePlayButtonClick = () => {
|
||||
setShowPlayableOnly(!showPlayableOnly);
|
||||
};
|
||||
|
||||
const handleAddGameButtonClick = () => {
|
||||
setShowAddGameModal(true);
|
||||
};
|
||||
|
||||
const handleCloseAddGameModal = () => {
|
||||
setShowAddGameModal(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateLibrary();
|
||||
}, [lastPacket?.gameId, updateLibrary]);
|
||||
@@ -254,15 +265,30 @@ export function Sidebar() {
|
||||
<small className="sidebar__section-title">
|
||||
{t("my_library")}
|
||||
</small>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("sidebar__play-button", {
|
||||
"sidebar__play-button--active": showPlayableOnly,
|
||||
})}
|
||||
onClick={handlePlayButtonClick}
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<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 +333,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,6 +201,12 @@ export function GameDetailsContextProvider({
|
||||
dispatch(setHeaderTitle(gameTitle));
|
||||
}, [objectId, gameTitle, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (game?.title && game.shop === "custom") {
|
||||
dispatch(setHeaderTitle(game.title));
|
||||
}
|
||||
}, [game?.title, game?.shop, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
|
||||
const updatedIsGameRunning =
|
||||
|
||||
15
src/renderer/src/declaration.d.ts
vendored
15
src/renderer/src/declaration.d.ts
vendored
@@ -111,6 +111,21 @@ 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>;
|
||||
createGameShortcut: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { average } from "color.js";
|
||||
import Color from "color";
|
||||
import { PencilIcon } from "@primer/octicons-react";
|
||||
|
||||
import { HeroPanel } from "./hero";
|
||||
import { DescriptionHeader } from "./description-header/description-header";
|
||||
import { GallerySlider } from "./gallery-slider/gallery-slider";
|
||||
import { Sidebar } from "./sidebar/sidebar";
|
||||
import { EditCustomGameModal } from "./modals";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
import { AuthPage } from "@shared";
|
||||
|
||||
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { useUserDetails, useLibrary } from "@renderer/hooks";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import "./game-details.scss";
|
||||
|
||||
@@ -28,11 +30,13 @@ export function GameDetailsContent() {
|
||||
gameColor,
|
||||
setGameColor,
|
||||
hasNSFWContentBlocked,
|
||||
updateGame,
|
||||
} = useContext(gameDetailsContext);
|
||||
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
|
||||
const { userDetails, hasActiveSubscription } = useUserDetails();
|
||||
const { updateLibrary } = useLibrary();
|
||||
|
||||
const { setShowCloudSyncModal, getGameArtifacts } =
|
||||
useContext(cloudSyncContext);
|
||||
@@ -53,10 +57,15 @@ export function GameDetailsContent() {
|
||||
return document.body.outerHTML;
|
||||
}
|
||||
|
||||
if (game?.shop === "custom") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return t("no_shop_details");
|
||||
}, [shopDetails, t]);
|
||||
}, [shopDetails, t, game?.shop]);
|
||||
|
||||
const [backdropOpacity, setBackdropOpacity] = useState(1);
|
||||
const [showEditCustomGameModal, setShowEditCustomGameModal] = useState(false);
|
||||
|
||||
const handleHeroLoad = async () => {
|
||||
const output = await average(
|
||||
@@ -92,10 +101,27 @@ export function GameDetailsContent() {
|
||||
setShowCloudSyncModal(true);
|
||||
};
|
||||
|
||||
const handleEditCustomGameClick = () => {
|
||||
setShowEditCustomGameModal(true);
|
||||
};
|
||||
|
||||
const handleGameUpdated = (_updatedGame: any) => {
|
||||
updateGame();
|
||||
updateLibrary();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getGameArtifacts();
|
||||
}, [getGameArtifacts]);
|
||||
|
||||
const isCustomGame = game?.shop === "custom";
|
||||
const heroImage = isCustomGame
|
||||
? game?.libraryHeroImageUrl || game?.iconUrl || ""
|
||||
: shopDetails?.assets?.libraryHeroImageUrl || "";
|
||||
const logoImage = isCustomGame
|
||||
? game?.logoImageUrl || "" // Don't use icon as fallback for custom games
|
||||
: shopDetails?.assets?.logoImageUrl || "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
|
||||
@@ -103,7 +129,7 @@ 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}
|
||||
@@ -121,26 +147,43 @@ 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}
|
||||
/>
|
||||
{logoImage && (
|
||||
<img
|
||||
src={logoImage}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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">
|
||||
{game?.shop === "custom" && (
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__edit-custom-game-button"
|
||||
onClick={handleEditCustomGameClick}
|
||||
title={t("edit_custom_game")}
|
||||
>
|
||||
<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 +203,18 @@ export function GameDetailsContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sidebar />
|
||||
{game?.shop !== "custom" && <Sidebar />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{game?.shop === "custom" && (
|
||||
<EditCustomGameModal
|
||||
visible={showEditCustomGameModal}
|
||||
onClose={() => setShowEditCustomGameModal(false)}
|
||||
game={game}
|
||||
onGameUpdated={handleGameUpdated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,43 @@ $hero-height: 300px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&__hero-buttons {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
align-items: center;
|
||||
|
||||
&--right {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__edit-custom-game-button {
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 8px;
|
||||
transition: all ease 0.2s;
|
||||
cursor: pointer;
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: globals.$muted-color;
|
||||
border: solid 1px globals.$border-color;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
|
||||
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
|
||||
&:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: globals.$body-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-logo-backdrop {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
&__manual-warning {
|
||||
|
||||
@@ -11,7 +11,6 @@ import "./hero-panel-playtime.scss";
|
||||
|
||||
export function HeroPanelPlaytime() {
|
||||
const [lastTimePlayed, setLastTimePlayed] = useState("");
|
||||
|
||||
|
||||
const { game, isGameRunning } = useContext(gameDetailsContext);
|
||||
const { t } = useTranslation("game_details");
|
||||
@@ -89,19 +88,23 @@ export function HeroPanelPlaytime() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<p
|
||||
<p
|
||||
className="hero-panel-playtime__play-time"
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-place="right"
|
||||
data-tooltip-content={
|
||||
game.hasManuallyUpdatedPlaytime
|
||||
? t("manual_playtime_tooltip")
|
||||
: undefined
|
||||
}
|
||||
data-tooltip-id={game.hasManuallyUpdatedPlaytime ? "manual-playtime-warning" : undefined}
|
||||
data-tooltip-id={
|
||||
game.hasManuallyUpdatedPlaytime
|
||||
? "manual-playtime-warning"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{game.hasManuallyUpdatedPlaytime && (
|
||||
<AlertFillIcon
|
||||
size={16}
|
||||
<AlertFillIcon
|
||||
size={16}
|
||||
className="hero-panel-playtime__manual-warning"
|
||||
/>
|
||||
)}
|
||||
@@ -119,7 +122,7 @@ export function HeroPanelPlaytime() {
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
{game.hasManuallyUpdatedPlaytime && (
|
||||
<Tooltip
|
||||
id="manual-playtime-warning"
|
||||
@@ -127,7 +130,6 @@ export function HeroPanelPlaytime() {
|
||||
zIndex: 9999,
|
||||
}}
|
||||
openOnClick={false}
|
||||
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.edit-custom-game-modal {
|
||||
&__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__image-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__image-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: globals.$spacing-unit;
|
||||
border: 1px dashed globals.$border-color;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&__preview-image {
|
||||
max-width: 120px;
|
||||
max-height: 80px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
justify-content: flex-end;
|
||||
margin-top: globals.$spacing-unit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ImageIcon } from "@primer/octicons-react";
|
||||
|
||||
import { Modal, TextField, Button } from "@renderer/components";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import type { Game } from "@types";
|
||||
|
||||
import "./edit-custom-game-modal.scss";
|
||||
|
||||
export interface EditCustomGameModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
game: Game;
|
||||
onGameUpdated: (updatedGame: Game) => void;
|
||||
}
|
||||
|
||||
export function EditCustomGameModal({
|
||||
visible,
|
||||
onClose,
|
||||
game,
|
||||
onGameUpdated,
|
||||
}: EditCustomGameModalProps) {
|
||||
const { t } = useTranslation("sidebar");
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [gameName, setGameName] = useState("");
|
||||
const [iconPath, setIconPath] = useState("");
|
||||
const [logoPath, setLogoPath] = useState("");
|
||||
const [heroPath, setHeroPath] = useState("");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (game && visible) {
|
||||
setGameName(game.title || "");
|
||||
|
||||
const currentIconPath = game.iconUrl?.startsWith("local:")
|
||||
? game.iconUrl.replace("local:", "")
|
||||
: "";
|
||||
const currentLogoPath = game.logoImageUrl?.startsWith("local:")
|
||||
? game.logoImageUrl.replace("local:", "")
|
||||
: "";
|
||||
const currentHeroPath = game.libraryHeroImageUrl?.startsWith("local:")
|
||||
? game.libraryHeroImageUrl.replace("local:", "")
|
||||
: "";
|
||||
|
||||
setIconPath(currentIconPath);
|
||||
setLogoPath(currentLogoPath);
|
||||
setHeroPath(currentHeroPath);
|
||||
}
|
||||
}, [game, visible]);
|
||||
|
||||
const handleGameNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setGameName(event.target.value);
|
||||
};
|
||||
|
||||
const handleSelectIcon = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: t("edit_custom_game_modal_image_filter"),
|
||||
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
setIconPath(filePaths[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectLogo = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: t("edit_custom_game_modal_image_filter"),
|
||||
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
setLogoPath(filePaths[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHero = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: t("edit_custom_game_modal_image_filter"),
|
||||
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
setHeroPath(filePaths[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateGame = async () => {
|
||||
if (!gameName.trim()) {
|
||||
showErrorToast(t("edit_custom_game_modal_fill_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
// Preserve existing image URLs if not changed
|
||||
const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl;
|
||||
const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl;
|
||||
const libraryHeroImageUrl = heroPath ? `local:${heroPath}` : game.libraryHeroImageUrl;
|
||||
|
||||
const updatedGame = await window.electron.updateCustomGame(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
gameName.trim(),
|
||||
iconUrl || undefined,
|
||||
logoImageUrl || undefined,
|
||||
libraryHeroImageUrl || undefined
|
||||
);
|
||||
|
||||
showSuccessToast(t("edit_custom_game_modal_success"));
|
||||
onGameUpdated(updatedGame);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to update custom game:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("edit_custom_game_modal_failed")
|
||||
);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isUpdating) {
|
||||
setGameName(game?.title || "");
|
||||
|
||||
const currentIconPath = game?.iconUrl?.startsWith("local:")
|
||||
? game.iconUrl.replace("local:", "")
|
||||
: "";
|
||||
const currentLogoPath = game?.logoImageUrl?.startsWith("local:")
|
||||
? game.logoImageUrl.replace("local:", "")
|
||||
: "";
|
||||
const currentHeroPath = game?.libraryHeroImageUrl?.startsWith("local:")
|
||||
? game.libraryHeroImageUrl.replace("local:", "")
|
||||
: "";
|
||||
|
||||
setIconPath(currentIconPath);
|
||||
setLogoPath(currentLogoPath);
|
||||
setHeroPath(currentHeroPath);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const isFormValid = gameName.trim();
|
||||
|
||||
const getIconPreviewUrl = () => {
|
||||
return iconPath ? `local:${iconPath}` : null;
|
||||
};
|
||||
|
||||
const getLogoPreviewUrl = () => {
|
||||
return logoPath ? `local:${logoPath}` : null;
|
||||
};
|
||||
|
||||
const getHeroPreviewUrl = () => {
|
||||
return heroPath ? `local:${heroPath}` : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("edit_custom_game_modal")}
|
||||
description={t("edit_custom_game_modal_description")}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="edit-custom-game-modal__container">
|
||||
<div className="edit-custom-game-modal__form">
|
||||
<TextField
|
||||
label={t("edit_custom_game_modal_game_name")}
|
||||
placeholder={t("edit_custom_game_modal_enter_name")}
|
||||
value={gameName}
|
||||
onChange={handleGameNameChange}
|
||||
theme="dark"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
|
||||
<div className="edit-custom-game-modal__image-section">
|
||||
<TextField
|
||||
label={t("edit_custom_game_modal_icon")}
|
||||
placeholder={t("edit_custom_game_modal_select_icon")}
|
||||
value={iconPath}
|
||||
readOnly
|
||||
theme="dark"
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleSelectIcon}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<ImageIcon />
|
||||
{t("edit_custom_game_modal_browse")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{iconPath && (
|
||||
<div className="edit-custom-game-modal__image-preview">
|
||||
<img
|
||||
src={getIconPreviewUrl()!}
|
||||
alt={t("edit_custom_game_modal_icon_preview")}
|
||||
className="edit-custom-game-modal__preview-image"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="edit-custom-game-modal__image-section">
|
||||
<TextField
|
||||
label={t("edit_custom_game_modal_logo")}
|
||||
placeholder={t("edit_custom_game_modal_select_logo")}
|
||||
value={logoPath}
|
||||
readOnly
|
||||
theme="dark"
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleSelectLogo}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<ImageIcon />
|
||||
{t("edit_custom_game_modal_browse")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{logoPath && (
|
||||
<div className="edit-custom-game-modal__image-preview">
|
||||
<img
|
||||
src={getLogoPreviewUrl()!}
|
||||
alt={t("edit_custom_game_modal_logo_preview")}
|
||||
className="edit-custom-game-modal__preview-image"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="edit-custom-game-modal__image-section">
|
||||
<TextField
|
||||
label={t("edit_custom_game_modal_hero")}
|
||||
placeholder={t("edit_custom_game_modal_select_hero")}
|
||||
value={heroPath}
|
||||
readOnly
|
||||
theme="dark"
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleSelectHero}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<ImageIcon />
|
||||
{t("edit_custom_game_modal_browse")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{heroPath && (
|
||||
<div className="edit-custom-game-modal__image-preview">
|
||||
<img
|
||||
src={getHeroPreviewUrl()!}
|
||||
alt={t("edit_custom_game_modal_hero_preview")}
|
||||
className="edit-custom-game-modal__preview-image"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-custom-game-modal__actions">
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t("edit_custom_game_modal_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme="primary"
|
||||
onClick={handleUpdateGame}
|
||||
disabled={!isFormValid || isUpdating}
|
||||
>
|
||||
{isUpdating ? t("edit_custom_game_modal_updating") : t("edit_custom_game_modal_update")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./repacks-modal";
|
||||
export * from "./download-settings-modal";
|
||||
export * from "./game-options-modal";
|
||||
export * from "./edit-custom-game-modal";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type GameShop = "steam" | "epic";
|
||||
export type GameShop = "steam" | "epic" | "custom";
|
||||
|
||||
export type ShortcutLocation = "desktop" | "start_menu";
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface User {
|
||||
export interface Game {
|
||||
title: string;
|
||||
iconUrl: string | null;
|
||||
libraryHeroImageUrl: string | null;
|
||||
logoImageUrl: string | null;
|
||||
playTimeInMilliseconds: number;
|
||||
unsyncedDeltaPlayTimeInMilliseconds?: number;
|
||||
lastTimePlayed: Date | null;
|
||||
|
||||
Reference in New Issue
Block a user