diff --git a/src/main/events/catalogue/save-game-shop-assets.ts b/src/main/events/catalogue/save-game-shop-assets.ts index f0ec4343..46aab36a 100644 --- a/src/main/events/catalogue/save-game-shop-assets.ts +++ b/src/main/events/catalogue/save-game-shop-assets.ts @@ -10,7 +10,15 @@ const saveGameShopAssets = async ( ): Promise => { const key = levelKeys.game(shop, objectId); const existingAssets = await gamesShopAssetsSublevel.get(key); - return gamesShopAssetsSublevel.put(key, { ...existingAssets, ...assets }); + + // Preserve existing title if it differs from the incoming title (indicating it was customized) + const shouldPreserveTitle = existingAssets?.title && existingAssets.title !== assets.title; + + return gamesShopAssetsSublevel.put(key, { + ...existingAssets, + ...assets, + title: shouldPreserveTitle ? existingAssets.title : assets.title + }); }; registerEvent("saveGameShopAssets", saveGameShopAssets); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index ea7597ad..a1acd596 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -16,6 +16,7 @@ import "./hardware/check-folder-write-permission"; import "./library/add-game-to-library"; import "./library/add-custom-game-to-library"; import "./library/update-custom-game"; +import "./library/update-game-custom-assets"; import "./library/add-game-to-favorites"; import "./library/remove-game-from-favorites"; import "./library/create-game-shortcut"; diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 2fa6d20e..4fdeae30 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -43,12 +43,14 @@ const addGameToLibrary = async ( await gamesSublevel.put(gameKey, game); } - await createGame(game).catch(() => {}); + if (game) { + await createGame(game).catch(() => {}); - AchievementWatcherManager.firstSyncWithRemoteIfNeeded( - game.shop, - game.objectId - ); + AchievementWatcherManager.firstSyncWithRemoteIfNeeded( + game.shop, + game.objectId + ); + } }; registerEvent("addGameToLibrary", addGameToLibrary); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index 547045ae..ea2971b9 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -18,18 +18,14 @@ const getLibrary = async (): Promise => { const download = await downloadsSublevel.get(key); const gameAssets = await gamesShopAssetsSublevel.get(key); - // 确保返回的对象符合 LibraryGame 类型 return { id: key, ...game, download: download ?? null, - // 确保 gameAssets 中的可能为 null 的字段转换为 undefined - libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? undefined, - libraryImageUrl: gameAssets?.libraryImageUrl ?? undefined, - logoImageUrl: gameAssets?.logoImageUrl ?? undefined, - logoPosition: gameAssets?.logoPosition ?? undefined, - coverImageUrl: gameAssets?.coverImageUrl ?? undefined, - }; + ...gameAssets, + // Ensure compatibility with LibraryGame type + libraryHeroImageUrl: game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl, + } as LibraryGame; }) ); }); diff --git a/src/main/events/library/update-game-custom-assets.ts b/src/main/events/library/update-game-custom-assets.ts new file mode 100644 index 00000000..99081a49 --- /dev/null +++ b/src/main/events/library/update-game-custom-assets.ts @@ -0,0 +1,45 @@ +import { registerEvent } from "../register-event"; +import { gamesSublevel, gamesShopAssetsSublevel, levelKeys } from "@main/level"; +import type { GameShop } from "@types"; + +const updateGameCustomAssets = async ( + _event: Electron.IpcMainInvokeEvent, + shop: GameShop, + objectId: string, + title: string, + customIconUrl?: string | null, + customLogoImageUrl?: string | null, + customHeroImageUrl?: string | null +) => { + const gameKey = levelKeys.game(shop, objectId); + + const existingGame = await gamesSublevel.get(gameKey); + if (!existingGame) { + throw new Error("Game not found"); + } + + const updatedGame = { + ...existingGame, + title, + customIconUrl: customIconUrl !== undefined ? customIconUrl : existingGame.customIconUrl, + customLogoImageUrl: customLogoImageUrl !== undefined ? customLogoImageUrl : existingGame.customLogoImageUrl, + customHeroImageUrl: customHeroImageUrl !== undefined ? customHeroImageUrl : existingGame.customHeroImageUrl, + }; + + await gamesSublevel.put(gameKey, updatedGame); + + // Also update the shop assets for non-custom games + const existingAssets = await gamesShopAssetsSublevel.get(gameKey); + if (existingAssets) { + const updatedAssets = { + ...existingAssets, + title, // Update the title in shop assets as well + }; + + await gamesShopAssetsSublevel.put(gameKey, updatedAssets); + } + + return updatedGame; +}; + +registerEvent("updateGameCustomAssets", updateGameCustomAssets); \ No newline at end of file diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index c5bbe144..3e1398f0 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -57,7 +57,7 @@ export const mergeWithRemoteGames = async () => { await gamesShopAssetsSublevel.put(gameKey, { shop: game.shop, objectId: game.objectId, - title: game.title, + title: localGame?.title || game.title, // Preserve local title if it exists coverImageUrl: game.coverImageUrl, libraryHeroImageUrl: game.libraryHeroImageUrl, libraryImageUrl: game.libraryImageUrl, diff --git a/src/preload/index.ts b/src/preload/index.ts index 8d297051..ca918eee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -160,6 +160,23 @@ contextBridge.exposeInMainWorld("electron", { logoImageUrl, libraryHeroImageUrl ), + updateGameCustomAssets: ( + shop: GameShop, + objectId: string, + title: string, + customIconUrl?: string | null, + customLogoImageUrl?: string | null, + customHeroImageUrl?: string | null + ) => + ipcRenderer.invoke( + "updateGameCustomAssets", + shop, + objectId, + title, + customIconUrl, + customLogoImageUrl, + customHeroImageUrl + ), createGameShortcut: ( shop: GameShop, objectId: string, diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 4f905c59..108a5536 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -20,7 +20,7 @@ export function SidebarGameItem({ const isCustomGame = game.shop === "custom"; const sidebarIcon = isCustomGame ? game.libraryImageUrl || game.iconUrl - : game.iconUrl; + : game.customIconUrl || game.iconUrl; return (
  • { - if (game?.title && game.shop === "custom") { + if (game?.title) { dispatch(setHeaderTitle(game.title)); } - }, [game?.title, game?.shop, dispatch]); + }, [game?.title, dispatch]); useEffect(() => { const unsubscribe = window.electron.onGamesRunning((gamesIds) => { diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index f66bd53e..5af0d04f 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -126,6 +126,14 @@ declare global { logoImageUrl?: string, libraryHeroImageUrl?: string ) => Promise; + updateGameCustomAssets: ( + shop: GameShop, + objectId: string, + title: string, + customIconUrl?: string | null, + customLogoImageUrl?: string | null, + customHeroImageUrl?: string | null + ) => Promise; createGameShortcut: ( shop: GameShop, objectId: string, diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index 80c95fc4..cf300ceb 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -7,7 +7,7 @@ import { HeroPanel } from "./hero"; import { DescriptionHeader } from "./description-header/description-header"; import { GallerySlider } from "./gallery-slider/gallery-slider"; import { Sidebar } from "./sidebar/sidebar"; -import { EditCustomGameModal } from "./modals"; +import { EditCustomGameModal, EditGameModal } from "./modals"; import { useTranslation } from "react-i18next"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; @@ -66,6 +66,7 @@ export function GameDetailsContent() { const [backdropOpacity, setBackdropOpacity] = useState(1); const [showEditCustomGameModal, setShowEditCustomGameModal] = useState(false); + const [showEditGameModal, setShowEditGameModal] = useState(false); const handleHeroLoad = async () => { const output = await average( @@ -105,6 +106,10 @@ export function GameDetailsContent() { setShowEditCustomGameModal(true); }; + const handleEditGameClick = () => { + setShowEditGameModal(true); + }; + const handleGameUpdated = (_updatedGame: any) => { updateGame(); updateLibrary(); @@ -115,12 +120,29 @@ export function GameDetailsContent() { }, [getGameArtifacts]); const isCustomGame = game?.shop === "custom"; + + // Helper function to get image with custom asset priority + const getImageWithCustomPriority = ( + customUrl: string | null | undefined, + originalUrl: string | null | undefined, + fallbackUrl?: string | null | undefined + ) => { + return customUrl || originalUrl || fallbackUrl || ""; + }; + const heroImage = isCustomGame ? game?.libraryHeroImageUrl || game?.iconUrl || "" - : shopDetails?.assets?.libraryHeroImageUrl || ""; + : getImageWithCustomPriority( + game?.customHeroImageUrl, + shopDetails?.assets?.libraryHeroImageUrl + ); + const logoImage = isCustomGame - ? game?.logoImageUrl || "" // Don't use icon as fallback for custom games - : shopDetails?.assets?.logoImageUrl || ""; + ? game?.logoImageUrl || "" + : getImageWithCustomPriority( + game?.customLogoImageUrl, + shopDetails?.assets?.logoImageUrl + ); return (
    - {game?.shop === "custom" && ( - - )} + {game?.shop !== "custom" && (
    ); } diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.scss b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss new file mode 100644 index 00000000..f37ea44b --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.scss @@ -0,0 +1,47 @@ +.edit-game-modal__container { + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + max-width: 500px; +} + +.edit-game-modal__form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.edit-game-modal__image-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.edit-game-modal__image-preview { + display: flex; + justify-content: center; + align-items: center; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 8px; + background-color: var(--color-background-secondary); +} + +.edit-game-modal__preview-image { + max-width: 100%; + max-height: 120px; + object-fit: contain; + border-radius: 4px; +} + +.edit-game-modal__actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 8px; +} + +.edit-game-modal__actions button { + min-width: 100px; +} \ No newline at end of file diff --git a/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx new file mode 100644 index 00000000..ff8e7c3c --- /dev/null +++ b/src/renderer/src/pages/game-details/modals/edit-game-modal.tsx @@ -0,0 +1,367 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { ImageIcon } from "@primer/octicons-react"; + +import { Modal, TextField, Button } from "@renderer/components"; +import { useToast } from "@renderer/hooks"; +import type { LibraryGame } from "@types"; + +import "./edit-game-modal.scss"; + +export interface EditGameModalProps { + visible: boolean; + onClose: () => void; + game: LibraryGame | null; + onGameUpdated: (updatedGame: any) => void; +} + +export function EditGameModal({ + visible, + onClose, + game, + onGameUpdated, +}: EditGameModalProps) { + const { t } = useTranslation("sidebar"); + const { showSuccessToast, showErrorToast } = useToast(); + + const [gameName, setGameName] = useState(""); + const [iconPath, setIconPath] = useState(""); + const [logoPath, setLogoPath] = useState(""); + const [heroPath, setHeroPath] = useState(""); + const [isUpdating, setIsUpdating] = useState(false); + + useEffect(() => { + if (game && visible) { + setGameName(game.title || ""); + + // For custom games, use existing logic + if (game.shop === "custom") { + const currentIconPath = game.iconUrl?.startsWith("local:") + ? game.iconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.logoImageUrl?.startsWith("local:") + ? game.logoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.libraryHeroImageUrl?.startsWith("local:") + ? game.libraryHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } else { + // For non-custom games, use custom asset paths if they exist + const currentIconPath = game.customIconUrl?.startsWith("local:") + ? game.customIconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.customLogoImageUrl?.startsWith("local:") + ? game.customLogoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.customHeroImageUrl?.startsWith("local:") + ? game.customHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } + } + }, [game, visible]); + + const handleGameNameChange = (event: React.ChangeEvent) => { + setGameName(event.target.value); + }; + + const handleSelectIcon = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setIconPath(filePaths[0]); + } + }; + + const handleSelectLogo = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setLogoPath(filePaths[0]); + } + }; + + const handleSelectHero = async () => { + const { filePaths } = await window.electron.showOpenDialog({ + properties: ["openFile"], + filters: [ + { + name: t("edit_custom_game_modal_image_filter"), + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (filePaths && filePaths.length > 0) { + setHeroPath(filePaths[0]); + } + }; + + const handleUpdateGame = async () => { + if (!game || !gameName.trim()) { + showErrorToast(t("edit_custom_game_modal_fill_required")); + return; + } + + setIsUpdating(true); + + try { + let updatedGame; + + if (game.shop === "custom") { + // For custom games, use existing logic + const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl; + const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl; + const libraryHeroImageUrl = heroPath + ? `local:${heroPath}` + : game.libraryHeroImageUrl; + + updatedGame = await window.electron.updateCustomGame( + game.shop, + game.objectId, + gameName.trim(), + iconUrl || undefined, + logoImageUrl || undefined, + libraryHeroImageUrl || undefined + ); + } else { + // For non-custom games, update custom assets + const customIconUrl = iconPath ? `local:${iconPath}` : null; + const customLogoImageUrl = logoPath ? `local:${logoPath}` : null; + const customHeroImageUrl = heroPath ? `local:${heroPath}` : null; + + updatedGame = await window.electron.updateGameCustomAssets( + game.shop, + game.objectId, + gameName.trim(), + customIconUrl, + customLogoImageUrl, + customHeroImageUrl + ); + } + + showSuccessToast(t("edit_custom_game_modal_success")); + onGameUpdated(updatedGame); + onClose(); + } catch (error) { + console.error("Failed to update game:", error); + showErrorToast( + error instanceof Error + ? error.message + : t("edit_custom_game_modal_failed") + ); + } finally { + setIsUpdating(false); + } + }; + + const handleClose = () => { + if (!isUpdating && game) { + setGameName(game.title || ""); + + if (game.shop === "custom") { + const currentIconPath = game.iconUrl?.startsWith("local:") + ? game.iconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.logoImageUrl?.startsWith("local:") + ? game.logoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.libraryHeroImageUrl?.startsWith("local:") + ? game.libraryHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } else { + const currentIconPath = game.customIconUrl?.startsWith("local:") + ? game.customIconUrl.replace("local:", "") + : ""; + const currentLogoPath = game.customLogoImageUrl?.startsWith("local:") + ? game.customLogoImageUrl.replace("local:", "") + : ""; + const currentHeroPath = game.customHeroImageUrl?.startsWith("local:") + ? game.customHeroImageUrl.replace("local:", "") + : ""; + + setIconPath(currentIconPath); + setLogoPath(currentLogoPath); + setHeroPath(currentHeroPath); + } + onClose(); + } + }; + + const isFormValid = gameName.trim(); + + const getIconPreviewUrl = () => { + return iconPath ? `local:${iconPath}` : null; + }; + + const getLogoPreviewUrl = () => { + return logoPath ? `local:${logoPath}` : null; + }; + + const getHeroPreviewUrl = () => { + return heroPath ? `local:${heroPath}` : null; + }; + + return ( + +
    +
    + + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {iconPath && ( +
    + {t("edit_custom_game_modal_icon_preview")} +
    + )} +
    + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {logoPath && ( +
    + {t("edit_custom_game_modal_logo_preview")} +
    + )} +
    + +
    + + + {t("edit_custom_game_modal_browse")} + + } + /> + + {heroPath && ( +
    + {t("edit_custom_game_modal_hero_preview")} +
    + )} +
    +
    + +
    + + +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/renderer/src/pages/game-details/modals/index.ts b/src/renderer/src/pages/game-details/modals/index.ts index 7cc779ef..2e09806d 100644 --- a/src/renderer/src/pages/game-details/modals/index.ts +++ b/src/renderer/src/pages/game-details/modals/index.ts @@ -2,3 +2,4 @@ export * from "./repacks-modal"; export * from "./download-settings-modal"; export * from "./game-options-modal"; export * from "./edit-custom-game-modal"; +export * from "./edit-game-modal"; diff --git a/src/types/level.types.ts b/src/types/level.types.ts index dd9ba1dd..bf5429c5 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -35,6 +35,9 @@ export interface Game { iconUrl: string | null; libraryHeroImageUrl: string | null; logoImageUrl: string | null; + customIconUrl?: string | null; + customLogoImageUrl?: string | null; + customHeroImageUrl?: string | null; playTimeInMilliseconds: number; unsyncedDeltaPlayTimeInMilliseconds?: number; lastTimePlayed: Date | null;