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