mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-19 09:13:57 +00:00
Merge branch 'main' into feat/context_game_menu
This commit is contained in:
committed by
GitHub
commit
a003153239
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,178 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FileDirectoryIcon } from "@primer/octicons-react";
|
||||
|
||||
import { Modal, TextField, Button } from "@renderer/components";
|
||||
import { useLibrary, useToast } from "@renderer/hooks";
|
||||
import {
|
||||
buildGameDetailsPath,
|
||||
generateRandomGradient,
|
||||
} from "@renderer/helpers";
|
||||
|
||||
import "./sidebar-adding-custom-game-modal.scss";
|
||||
|
||||
export interface SidebarAddingCustomGameModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SidebarAddingCustomGameModal({
|
||||
visible,
|
||||
onClose,
|
||||
}: Readonly<SidebarAddingCustomGameModalProps>) {
|
||||
const { t } = useTranslation("sidebar");
|
||||
const { updateLibrary } = useLibrary();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [gameName, setGameName] = useState("");
|
||||
const [executablePath, setExecutablePath] = useState("");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const handleSelectExecutable = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: t("custom_game_modal_executable"),
|
||||
extensions: ["exe", "msi", "app", "deb", "rpm", "dmg"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const selectedPath = filePaths[0];
|
||||
setExecutablePath(selectedPath);
|
||||
|
||||
if (!gameName.trim()) {
|
||||
const fileName = selectedPath.split(/[\\/]/).pop() || "";
|
||||
const gameNameFromFile = fileName.replace(/\.[^/.]+$/, "");
|
||||
setGameName(gameNameFromFile);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGameNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setGameName(event.target.value);
|
||||
};
|
||||
|
||||
const handleAddGame = async () => {
|
||||
if (!gameName.trim() || !executablePath.trim()) {
|
||||
showErrorToast(t("custom_game_modal_fill_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAdding(true);
|
||||
|
||||
try {
|
||||
// Generate gradient URL only for hero image
|
||||
const gameNameForSeed = gameName.trim();
|
||||
const iconUrl = ""; // Don't use gradient for icon
|
||||
const logoImageUrl = ""; // Don't use gradient for logo
|
||||
const libraryHeroImageUrl = generateRandomGradient(); // Only use gradient for hero
|
||||
|
||||
const newGame = await window.electron.addCustomGameToLibrary(
|
||||
gameNameForSeed,
|
||||
executablePath,
|
||||
iconUrl,
|
||||
logoImageUrl,
|
||||
libraryHeroImageUrl
|
||||
);
|
||||
|
||||
showSuccessToast(t("custom_game_modal_success"));
|
||||
updateLibrary();
|
||||
|
||||
const gameDetailsPath = buildGameDetailsPath({
|
||||
shop: "custom",
|
||||
objectId: newGame.objectId,
|
||||
title: newGame.title,
|
||||
});
|
||||
|
||||
navigate(gameDetailsPath);
|
||||
|
||||
setGameName("");
|
||||
setExecutablePath("");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to add custom game:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : t("custom_game_modal_failed")
|
||||
);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isAdding) {
|
||||
setGameName("");
|
||||
setExecutablePath("");
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const isFormValid = gameName.trim() && executablePath.trim();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("custom_game_modal")}
|
||||
description={t("custom_game_modal_description")}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="sidebar-adding-custom-game-modal__container">
|
||||
<div className="sidebar-adding-custom-game-modal__form">
|
||||
<TextField
|
||||
label={t("custom_game_modal_executable_path")}
|
||||
placeholder={t("custom_game_modal_select_executable")}
|
||||
value={executablePath}
|
||||
readOnly
|
||||
theme="dark"
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleSelectExecutable}
|
||||
disabled={isAdding}
|
||||
>
|
||||
<FileDirectoryIcon />
|
||||
{t("custom_game_modal_browse")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={t("custom_game_modal_title")}
|
||||
placeholder={t("custom_game_modal_enter_title")}
|
||||
value={gameName}
|
||||
onChange={handleGameNameChange}
|
||||
theme="dark"
|
||||
disabled={isAdding}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-adding-custom-game-modal__actions">
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isAdding}
|
||||
>
|
||||
{t("custom_game_modal_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme="primary"
|
||||
onClick={handleAddGame}
|
||||
disabled={!isFormValid || isAdding}
|
||||
>
|
||||
{isAdding
|
||||
? t("custom_game_modal_adding")
|
||||
: t("custom_game_modal_add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import PlayLogo from "@renderer/assets/play-logo.svg?react";
|
||||
import { LibraryGame } from "@types";
|
||||
import cn from "classnames";
|
||||
import { useLocation } from "react-router-dom";
|
||||
@@ -36,6 +37,19 @@ export function SidebarGameItem({
|
||||
setContextMenu({ visible: false, position: { x: 0, y: 0 } });
|
||||
};
|
||||
|
||||
const isCustomGame = game.shop === "custom";
|
||||
const sidebarIcon = isCustomGame
|
||||
? game.libraryImageUrl || game.iconUrl
|
||||
: game.customIconUrl || game.iconUrl;
|
||||
|
||||
// Determine fallback icon based on game type
|
||||
const getFallbackIcon = () => {
|
||||
if (isCustomGame) {
|
||||
return <PlayLogo className="sidebar__game-icon" />;
|
||||
}
|
||||
return <SteamLogo className="sidebar__game-icon" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<li
|
||||
@@ -52,15 +66,15 @@ export function SidebarGameItem({
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
{sidebarIcon ? (
|
||||
<img
|
||||
className="sidebar__game-icon"
|
||||
src={game.iconUrl}
|
||||
src={sidebarIcon}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className="sidebar__game-icon" />
|
||||
getFallbackIcon()
|
||||
)}
|
||||
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
|
||||
@@ -172,4 +172,24 @@
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&__add-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: globals.$muted-color;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
|
||||
import type { LibraryGame } from "@types";
|
||||
|
||||
@@ -21,8 +22,13 @@ import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { SidebarProfile } from "./sidebar-profile";
|
||||
import { sortBy } from "lodash-es";
|
||||
import cn from "classnames";
|
||||
import { CommentDiscussionIcon, PlayIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
CommentDiscussionIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { SidebarGameItem } from "./sidebar-game-item";
|
||||
import { SidebarAddingCustomGameModal } from "./sidebar-adding-custom-game-modal";
|
||||
import { setFriendRequestCount } from "@renderer/features/user-details-slice";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
@@ -63,11 +69,20 @@ export function Sidebar() {
|
||||
const { showWarningToast } = useToast();
|
||||
|
||||
const [showPlayableOnly, setShowPlayableOnly] = useState(false);
|
||||
const [showAddGameModal, setShowAddGameModal] = useState(false);
|
||||
|
||||
const handlePlayButtonClick = () => {
|
||||
setShowPlayableOnly(!showPlayableOnly);
|
||||
};
|
||||
|
||||
const handleAddGameButtonClick = () => {
|
||||
setShowAddGameModal(true);
|
||||
};
|
||||
|
||||
const handleCloseAddGameModal = () => {
|
||||
setShowAddGameModal(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateLibrary();
|
||||
}, [lastPacket?.gameId, updateLibrary]);
|
||||
@@ -254,15 +269,32 @@ export function Sidebar() {
|
||||
<small className="sidebar__section-title">
|
||||
{t("my_library")}
|
||||
</small>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("sidebar__play-button", {
|
||||
"sidebar__play-button--active": showPlayableOnly,
|
||||
})}
|
||||
onClick={handlePlayButtonClick}
|
||||
<div
|
||||
style={{ display: "flex", gap: "8px", alignItems: "center" }}
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__add-button"
|
||||
onClick={handleAddGameButtonClick}
|
||||
data-tooltip-id="add-custom-game-tooltip"
|
||||
data-tooltip-content={t("add_custom_game_tooltip")}
|
||||
data-tooltip-place="top"
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("sidebar__play-button", {
|
||||
"sidebar__play-button--active": showPlayableOnly,
|
||||
})}
|
||||
onClick={handlePlayButtonClick}
|
||||
data-tooltip-id="show-playable-only-tooltip"
|
||||
data-tooltip-content={t("show_playable_only_tooltip")}
|
||||
data-tooltip-place="top"
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
@@ -307,6 +339,14 @@ export function Sidebar() {
|
||||
className="sidebar__handle"
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
|
||||
<SidebarAddingCustomGameModal
|
||||
visible={showAddGameModal}
|
||||
onClose={handleCloseAddGameModal}
|
||||
/>
|
||||
|
||||
<Tooltip id="add-custom-game-tooltip" />
|
||||
<Tooltip id="show-playable-only-tooltip" />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,8 +201,7 @@ export function GameDetailsContextProvider({
|
||||
}, [objectId, gameTitle, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const state =
|
||||
(location && (location.state as Record<string, unknown>)) || {};
|
||||
const state = (location && (location.state as Record<string, unknown>)) || {};
|
||||
if (state.openRepacks) {
|
||||
setShowRepacksModal(true);
|
||||
try {
|
||||
@@ -213,6 +212,12 @@ export function GameDetailsContextProvider({
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
useEffect(() => {
|
||||
if (game?.title) {
|
||||
dispatch(setHeaderTitle(game.title));
|
||||
}
|
||||
}, [game?.title, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
|
||||
const updatedIsGameRunning =
|
||||
|
||||
33
src/renderer/src/declaration.d.ts
vendored
33
src/renderer/src/declaration.d.ts
vendored
@@ -112,6 +112,37 @@ declare global {
|
||||
objectId: string,
|
||||
title: string
|
||||
) => Promise<void>;
|
||||
addCustomGameToLibrary: (
|
||||
title: string,
|
||||
executablePath: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) => Promise<Game>;
|
||||
updateCustomGame: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
iconUrl?: string,
|
||||
logoImageUrl?: string,
|
||||
libraryHeroImageUrl?: string
|
||||
) => Promise<Game>;
|
||||
copyCustomGameAsset: (
|
||||
sourcePath: string,
|
||||
assetType: "icon" | "logo" | "hero"
|
||||
) => Promise<string>;
|
||||
cleanupUnusedAssets: () => Promise<{
|
||||
deletedCount: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
updateGameCustomAssets: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
title: string,
|
||||
customIconUrl?: string | null,
|
||||
customLogoImageUrl?: string | null,
|
||||
customHeroImageUrl?: string | null
|
||||
) => Promise<Game>;
|
||||
createGameShortcut: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
@@ -273,6 +304,8 @@ declare global {
|
||||
onCommonRedistProgress: (
|
||||
cb: (value: { log: string; complete: boolean }) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
saveTempFile: (fileName: string, fileData: Uint8Array) => Promise<string>;
|
||||
deleteTempFile: (filePath: string) => Promise<void>;
|
||||
platform: NodeJS.Platform;
|
||||
|
||||
/* Auto update */
|
||||
|
||||
@@ -84,3 +84,23 @@ export const injectCustomCss = (
|
||||
export const removeCustomCss = (target: HTMLElement = document.head) => {
|
||||
target.querySelector("#custom-css")?.remove();
|
||||
};
|
||||
|
||||
export const generateRandomGradient = (): string => {
|
||||
// Use a single consistent gradient with softer colors for custom games as placeholder
|
||||
const color1 = "#2c3e50"; // Dark blue-gray
|
||||
const color2 = "#34495e"; // Darker slate
|
||||
|
||||
// Create SVG data URL that works in img tags
|
||||
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${color1};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${color2};stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grad)" />
|
||||
</svg>`;
|
||||
|
||||
// Return as data URL that works in img tags
|
||||
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
|
||||
};
|
||||
|
||||
@@ -28,12 +28,12 @@ export function DeleteGameModal({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-game-modal__actions">
|
||||
<Button onClick={handleDeleteGame} theme="outline">
|
||||
{t("delete")}
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
<Button onClick={handleDeleteGame} theme="primary">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.description-header {
|
||||
width: 100%;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
width: calc(100% - calc(globals.$spacing-unit * 2));
|
||||
margin: calc(globals.$spacing-unit * 1) auto;
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: globals.$background-color;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
.gallery-slider {
|
||||
&__container {
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 1);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PencilIcon } from "@primer/octicons-react";
|
||||
|
||||
import { HeroPanel } from "./hero";
|
||||
import { DescriptionHeader } from "./description-header/description-header";
|
||||
import { GallerySlider } from "./gallery-slider/gallery-slider";
|
||||
import { Sidebar } from "./sidebar/sidebar";
|
||||
import { EditGameModal } from "./modals";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
import { AuthPage } from "@shared";
|
||||
|
||||
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { useUserDetails, useLibrary } from "@renderer/hooks";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import "./game-details.scss";
|
||||
|
||||
@@ -19,12 +21,13 @@ export function GameDetailsContent() {
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { objectId, shopDetails, game, hasNSFWContentBlocked } =
|
||||
const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame } =
|
||||
useContext(gameDetailsContext);
|
||||
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
|
||||
const { userDetails, hasActiveSubscription } = useUserDetails();
|
||||
const { updateLibrary } = useLibrary();
|
||||
|
||||
const { setShowCloudSyncModal, getGameArtifacts } =
|
||||
useContext(cloudSyncContext);
|
||||
@@ -45,10 +48,15 @@ export function GameDetailsContent() {
|
||||
return document.body.outerHTML;
|
||||
}
|
||||
|
||||
if (game?.shop === "custom") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return t("no_shop_details");
|
||||
}, [shopDetails, t]);
|
||||
}, [shopDetails, t, game?.shop]);
|
||||
|
||||
const [backdropOpacity, setBackdropOpacity] = useState(1);
|
||||
const [showEditGameModal, setShowEditGameModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBackdropOpacity(1);
|
||||
@@ -68,10 +76,72 @@ export function GameDetailsContent() {
|
||||
setShowCloudSyncModal(true);
|
||||
};
|
||||
|
||||
const handleEditGameClick = () => {
|
||||
setShowEditGameModal(true);
|
||||
};
|
||||
|
||||
const handleGameUpdated = (_updatedGame: any) => {
|
||||
updateGame();
|
||||
updateLibrary();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getGameArtifacts();
|
||||
}, [getGameArtifacts]);
|
||||
|
||||
const isCustomGame = game?.shop === "custom";
|
||||
|
||||
// Helper function to get image with custom asset priority
|
||||
const getImageWithCustomPriority = (
|
||||
customUrl: string | null | undefined,
|
||||
originalUrl: string | null | undefined,
|
||||
fallbackUrl?: string | null | undefined
|
||||
) => {
|
||||
return customUrl || originalUrl || fallbackUrl || "";
|
||||
};
|
||||
|
||||
const heroImage = isCustomGame
|
||||
? game?.libraryHeroImageUrl || game?.iconUrl || ""
|
||||
: getImageWithCustomPriority(
|
||||
game?.customHeroImageUrl,
|
||||
shopDetails?.assets?.libraryHeroImageUrl
|
||||
);
|
||||
|
||||
const logoImage = isCustomGame
|
||||
? game?.logoImageUrl || ""
|
||||
: getImageWithCustomPriority(
|
||||
game?.customLogoImageUrl,
|
||||
shopDetails?.assets?.logoImageUrl
|
||||
);
|
||||
|
||||
const renderGameLogo = () => {
|
||||
if (isCustomGame) {
|
||||
// For custom games, show logo image if available, otherwise show game title as text
|
||||
if (logoImage) {
|
||||
return (
|
||||
<img
|
||||
src={logoImage}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="game-details__game-logo-text">{game?.title}</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// For non-custom games, show logo image if available
|
||||
return logoImage ? (
|
||||
<img
|
||||
src={logoImage}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
|
||||
@@ -79,7 +149,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}
|
||||
/>
|
||||
@@ -95,26 +165,35 @@ export function GameDetailsContent() {
|
||||
style={{ opacity: backdropOpacity }}
|
||||
>
|
||||
<div className="game-details__hero-content">
|
||||
<img
|
||||
src={shopDetails?.assets?.logoImageUrl ?? ""}
|
||||
className="game-details__game-logo"
|
||||
alt={game?.title}
|
||||
/>
|
||||
{renderGameLogo()}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__cloud-sync-button"
|
||||
onClick={handleCloudSaveButtonClick}
|
||||
>
|
||||
<div className="game-details__cloud-icon-container">
|
||||
<img
|
||||
src={cloudIconAnimated}
|
||||
alt="Cloud icon"
|
||||
className="game-details__cloud-icon"
|
||||
/>
|
||||
</div>
|
||||
{t("cloud_save")}
|
||||
</button>
|
||||
<div className="game-details__hero-buttons game-details__hero-buttons--right">
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__edit-custom-game-button"
|
||||
onClick={handleEditGameClick}
|
||||
title={t("edit_game_modal_button")}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</button>
|
||||
|
||||
{game?.shop !== "custom" && (
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__cloud-sync-button"
|
||||
onClick={handleCloudSaveButtonClick}
|
||||
>
|
||||
<div className="game-details__cloud-icon-container">
|
||||
<img
|
||||
src={cloudIconAnimated}
|
||||
alt="Cloud icon"
|
||||
className="game-details__cloud-icon"
|
||||
/>
|
||||
</div>
|
||||
{t("cloud_save")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,9 +213,17 @@ export function GameDetailsContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sidebar />
|
||||
{game?.shop !== "custom" && <Sidebar />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EditGameModal
|
||||
visible={showEditGameModal}
|
||||
onClose={() => setShowEditGameModal(false)}
|
||||
game={game}
|
||||
shopDetails={shopDetails}
|
||||
onGameUpdated={handleGameUpdated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,12 +43,53 @@ $hero-height: 300px;
|
||||
}
|
||||
|
||||
&__hero-content {
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-buttons {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
align-items: center;
|
||||
|
||||
&--right {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__edit-custom-game-button {
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 8px;
|
||||
transition: all ease 0.2s;
|
||||
cursor: pointer;
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: globals.$muted-color;
|
||||
border: solid 1px globals.$border-color;
|
||||
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
|
||||
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
|
||||
|
||||
&:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: globals.$body-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-logo-backdrop {
|
||||
@@ -79,8 +120,40 @@ $hero-height: 300px;
|
||||
}
|
||||
|
||||
&__game-logo {
|
||||
width: 300px;
|
||||
width: 200px;
|
||||
align-self: flex-end;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
&__game-logo-text {
|
||||
width: 200px;
|
||||
align-self: flex-end;
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
|
||||
text-align: left;
|
||||
line-height: 1.2;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
width: 250px;
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 300px;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__hero-image-skeleton {
|
||||
@@ -122,11 +195,19 @@ $hero-height: 300px;
|
||||
user-select: text;
|
||||
line-height: 22px;
|
||||
font-size: globals.$body-font-size;
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5);
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 60%;
|
||||
}
|
||||
@@ -155,11 +236,19 @@ $hero-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit;
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5);
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 60%;
|
||||
line-height: 22px;
|
||||
|
||||
@@ -178,6 +178,7 @@ export default function GameDetails() {
|
||||
onClose={() => {
|
||||
setShowGameOptionsModal(false);
|
||||
}}
|
||||
onNavigateHome={() => navigate("/")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -257,7 +257,7 @@ export function HeroPanelActions() {
|
||||
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
|
||||
</Button>
|
||||
|
||||
{userDetails && (
|
||||
{userDetails && game.shop !== "custom" && (
|
||||
<Button
|
||||
onClick={toggleGamePinned}
|
||||
theme="outline"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
&__manual-warning {
|
||||
|
||||
@@ -90,7 +90,7 @@ export function HeroPanelPlaytime() {
|
||||
<>
|
||||
<p
|
||||
className="hero-panel-playtime__play-time"
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-place="right"
|
||||
data-tooltip-content={
|
||||
game.hasManuallyUpdatedPlaytime
|
||||
? t("manual_playtime_tooltip")
|
||||
|
||||
@@ -149,17 +149,17 @@ export function ChangeGamePlaytimeModal({
|
||||
</div>
|
||||
|
||||
<div className="change-game-playtime-modal__actions">
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleChangePlaytime}
|
||||
theme="outline"
|
||||
theme="primary"
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
{t("update_playtime")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
181
src/renderer/src/pages/game-details/modals/edit-game-modal.scss
Normal file
181
src/renderer/src/pages/game-details/modals/edit-game-modal.scss
Normal file
@@ -0,0 +1,181 @@
|
||||
.edit-game-modal__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.edit-game-modal__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.edit-game-modal__asset-selector {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.edit-game-modal__asset-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-game-modal__asset-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.edit-game-modal__image-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-game-modal__resolution-info {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: -4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.edit-game-modal__image-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-background-secondary);
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
rgba(255, 255, 255, 0.1) 25%,
|
||||
transparent 25%
|
||||
),
|
||||
linear-gradient(-45deg, rgba(255, 255, 255, 0.1) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
/* Reset button styles when used as button element */
|
||||
&[type="button"] {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: inherit;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-game-modal__drop-zone {
|
||||
min-height: 120px;
|
||||
cursor: pointer;
|
||||
border-style: dashed !important;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
background-color: rgba(var(--color-primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: var(--color-primary) !important;
|
||||
background-color: rgba(var(--color-primary-rgb), 0.1) !important;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(var(--color-primary-rgb), 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-game-modal__drop-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(var(--color-primary-rgb), 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
backdrop-filter: blur(2px);
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-game-modal__drop-zone-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-game-modal__icon-preview {
|
||||
max-width: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.edit-game-modal__preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 120px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.edit-game-modal__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.edit-game-modal__actions button {
|
||||
min-width: 100px;
|
||||
}
|
||||
552
src/renderer/src/pages/game-details/modals/edit-game-modal.tsx
Normal file
552
src/renderer/src/pages/game-details/modals/edit-game-modal.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ImageIcon, XIcon } from "@primer/octicons-react";
|
||||
|
||||
import { Modal, TextField, Button } from "@renderer/components";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import type { LibraryGame, Game, ShopDetailsWithAssets } from "@types";
|
||||
|
||||
import "./edit-game-modal.scss";
|
||||
|
||||
export interface EditGameModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
game: LibraryGame | Game | null;
|
||||
shopDetails?: ShopDetailsWithAssets | null;
|
||||
onGameUpdated: (updatedGame: LibraryGame | Game) => void;
|
||||
}
|
||||
|
||||
type AssetType = "icon" | "logo" | "hero";
|
||||
|
||||
export function EditGameModal({
|
||||
visible,
|
||||
onClose,
|
||||
game,
|
||||
shopDetails,
|
||||
onGameUpdated,
|
||||
}: Readonly<EditGameModalProps>) {
|
||||
const { t } = useTranslation("sidebar");
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [gameName, setGameName] = useState("");
|
||||
const [iconPath, setIconPath] = useState("");
|
||||
const [logoPath, setLogoPath] = useState("");
|
||||
const [heroPath, setHeroPath] = useState("");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [selectedAssetType, setSelectedAssetType] = useState<AssetType>("icon");
|
||||
|
||||
const [defaultIconUrl, setDefaultIconUrl] = useState<string | null>(null);
|
||||
const [defaultLogoUrl, setDefaultLogoUrl] = useState<string | null>(null);
|
||||
const [defaultHeroUrl, setDefaultHeroUrl] = useState<string | null>(null);
|
||||
|
||||
const isCustomGame = (game: LibraryGame | Game): boolean => {
|
||||
return game.shop === "custom";
|
||||
};
|
||||
|
||||
const extractLocalPath = (url: string | null | undefined): string => {
|
||||
return url?.startsWith("local:") ? url.replace("local:", "") : "";
|
||||
};
|
||||
|
||||
const setCustomGameAssets = useCallback((game: LibraryGame | Game) => {
|
||||
setIconPath(extractLocalPath(game.iconUrl));
|
||||
setLogoPath(extractLocalPath(game.logoImageUrl));
|
||||
setHeroPath(extractLocalPath(game.libraryHeroImageUrl));
|
||||
}, []);
|
||||
|
||||
const setNonCustomGameAssets = useCallback(
|
||||
(game: LibraryGame) => {
|
||||
setIconPath(extractLocalPath(game.customIconUrl));
|
||||
setLogoPath(extractLocalPath(game.customLogoImageUrl));
|
||||
setHeroPath(extractLocalPath(game.customHeroImageUrl));
|
||||
|
||||
setDefaultIconUrl(shopDetails?.assets?.iconUrl || game.iconUrl || null);
|
||||
setDefaultLogoUrl(
|
||||
shopDetails?.assets?.logoImageUrl || game.logoImageUrl || null
|
||||
);
|
||||
setDefaultHeroUrl(
|
||||
shopDetails?.assets?.libraryHeroImageUrl ||
|
||||
game.libraryHeroImageUrl ||
|
||||
null
|
||||
);
|
||||
},
|
||||
[shopDetails]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (game && visible) {
|
||||
setGameName(game.title || "");
|
||||
|
||||
if (isCustomGame(game)) {
|
||||
setCustomGameAssets(game);
|
||||
} else {
|
||||
setNonCustomGameAssets(game as LibraryGame);
|
||||
}
|
||||
}
|
||||
}, [game, visible, shopDetails, setCustomGameAssets, setNonCustomGameAssets]);
|
||||
|
||||
const handleGameNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setGameName(event.target.value);
|
||||
};
|
||||
|
||||
const handleAssetTypeChange = (assetType: AssetType) => {
|
||||
setSelectedAssetType(assetType);
|
||||
};
|
||||
|
||||
const getAssetPath = (assetType: AssetType): string => {
|
||||
switch (assetType) {
|
||||
case "icon":
|
||||
return iconPath;
|
||||
case "logo":
|
||||
return logoPath;
|
||||
case "hero":
|
||||
return heroPath;
|
||||
}
|
||||
};
|
||||
|
||||
const setAssetPath = (assetType: AssetType, path: string): void => {
|
||||
switch (assetType) {
|
||||
case "icon":
|
||||
setIconPath(path);
|
||||
break;
|
||||
case "logo":
|
||||
setLogoPath(path);
|
||||
break;
|
||||
case "hero":
|
||||
setHeroPath(path);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultUrl = (assetType: AssetType): string | null => {
|
||||
switch (assetType) {
|
||||
case "icon":
|
||||
return defaultIconUrl;
|
||||
case "logo":
|
||||
return defaultLogoUrl;
|
||||
case "hero":
|
||||
return defaultHeroUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAsset = async (assetType: AssetType) => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: t("edit_game_modal_image_filter"),
|
||||
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
try {
|
||||
const copiedAssetUrl = await window.electron.copyCustomGameAsset(
|
||||
filePaths[0],
|
||||
assetType
|
||||
);
|
||||
setAssetPath(assetType, copiedAssetUrl.replace("local:", ""));
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy ${assetType} asset:`, error);
|
||||
setAssetPath(assetType, filePaths[0]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreDefault = (assetType: AssetType) => {
|
||||
setAssetPath(assetType, "");
|
||||
};
|
||||
|
||||
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent, target: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverTarget(target);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setDragOverTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const validateImageFile = (file: File): boolean => {
|
||||
const validTypes = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
return validTypes.includes(file.type);
|
||||
};
|
||||
|
||||
const processDroppedFile = async (file: File, assetType: AssetType) => {
|
||||
setDragOverTarget(null);
|
||||
|
||||
if (!validateImageFile(file)) {
|
||||
showErrorToast("Invalid file type. Please select an image file.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let filePath: string;
|
||||
|
||||
interface ElectronFile extends File {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
if ("path" in file && typeof (file as ElectronFile).path === "string") {
|
||||
filePath = (file as ElectronFile).path!;
|
||||
} else {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
const tempFileName = `temp_${Date.now()}_${file.name}`;
|
||||
const tempPath = await window.electron.saveTempFile?.(
|
||||
tempFileName,
|
||||
uint8Array
|
||||
);
|
||||
|
||||
if (!tempPath) {
|
||||
throw new Error(
|
||||
"Unable to process file. Drag and drop may not be fully supported."
|
||||
);
|
||||
}
|
||||
|
||||
filePath = tempPath;
|
||||
}
|
||||
|
||||
const copiedAssetUrl = await window.electron.copyCustomGameAsset(
|
||||
filePath,
|
||||
assetType
|
||||
);
|
||||
|
||||
const assetPath = copiedAssetUrl.replace("local:", "");
|
||||
setAssetPath(assetType, assetPath);
|
||||
|
||||
showSuccessToast(
|
||||
`${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!`
|
||||
);
|
||||
|
||||
if (!("path" in file) && filePath) {
|
||||
try {
|
||||
await window.electron.deleteTempFile?.(filePath);
|
||||
} catch (cleanupError) {
|
||||
console.warn("Failed to clean up temporary file:", cleanupError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to process dropped ${assetType}:`, error);
|
||||
showErrorToast(
|
||||
`Failed to process dropped ${assetType}. Please try again.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssetDrop = async (e: React.DragEvent, assetType: AssetType) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverTarget(null);
|
||||
|
||||
if (isUpdating) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
await processDroppedFile(files[0], assetType);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to prepare custom game assets
|
||||
const prepareCustomGameAssets = (game: LibraryGame | Game) => {
|
||||
const iconUrl = iconPath ? `local:${iconPath}` : game.iconUrl;
|
||||
const logoImageUrl = logoPath ? `local:${logoPath}` : game.logoImageUrl;
|
||||
const libraryHeroImageUrl = heroPath
|
||||
? `local:${heroPath}`
|
||||
: game.libraryHeroImageUrl;
|
||||
|
||||
return { iconUrl, logoImageUrl, libraryHeroImageUrl };
|
||||
};
|
||||
|
||||
// Helper function to prepare non-custom game assets
|
||||
const prepareNonCustomGameAssets = () => {
|
||||
return {
|
||||
customIconUrl: iconPath ? `local:${iconPath}` : null,
|
||||
customLogoImageUrl: logoPath ? `local:${logoPath}` : null,
|
||||
customHeroImageUrl: heroPath ? `local:${heroPath}` : null,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to update custom game
|
||||
const updateCustomGame = async (game: LibraryGame | Game) => {
|
||||
const { iconUrl, logoImageUrl, libraryHeroImageUrl } =
|
||||
prepareCustomGameAssets(game);
|
||||
|
||||
return window.electron.updateCustomGame(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
gameName.trim(),
|
||||
iconUrl || undefined,
|
||||
logoImageUrl || undefined,
|
||||
libraryHeroImageUrl || undefined
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to update non-custom game
|
||||
const updateNonCustomGame = async (game: LibraryGame) => {
|
||||
const { customIconUrl, customLogoImageUrl, customHeroImageUrl } =
|
||||
prepareNonCustomGameAssets();
|
||||
|
||||
return window.electron.updateGameCustomAssets(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
gameName.trim(),
|
||||
customIconUrl,
|
||||
customLogoImageUrl,
|
||||
customHeroImageUrl
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateGame = async () => {
|
||||
if (!game || !gameName.trim()) {
|
||||
showErrorToast(t("edit_game_modal_fill_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
const updatedGame =
|
||||
game && isCustomGame(game)
|
||||
? await updateCustomGame(game)
|
||||
: await updateNonCustomGame(game as LibraryGame);
|
||||
|
||||
showSuccessToast(t("edit_game_modal_success"));
|
||||
onGameUpdated(updatedGame);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to update game:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : t("edit_game_modal_failed")
|
||||
);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to reset form to initial state
|
||||
const resetFormToInitialState = (game: LibraryGame | Game) => {
|
||||
setGameName(game.title || "");
|
||||
|
||||
if (isCustomGame(game)) {
|
||||
setCustomGameAssets(game);
|
||||
// Clear default URLs for custom games
|
||||
setDefaultIconUrl(null);
|
||||
setDefaultLogoUrl(null);
|
||||
setDefaultHeroUrl(null);
|
||||
} else {
|
||||
setNonCustomGameAssets(game as LibraryGame);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isUpdating && game) {
|
||||
resetFormToInitialState(game);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const isFormValid = gameName.trim();
|
||||
|
||||
const getPreviewUrl = (assetType: AssetType): string | undefined => {
|
||||
const assetPath = getAssetPath(assetType);
|
||||
const defaultUrl = getDefaultUrl(assetType);
|
||||
|
||||
if (game && !isCustomGame(game)) {
|
||||
return assetPath ? `local:${assetPath}` : defaultUrl || undefined;
|
||||
}
|
||||
return assetPath ? `local:${assetPath}` : undefined;
|
||||
};
|
||||
|
||||
const renderImageSection = (assetType: AssetType) => {
|
||||
const assetPath = getAssetPath(assetType);
|
||||
const defaultUrl = getDefaultUrl(assetType);
|
||||
const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl);
|
||||
const isDragOver = dragOverTarget === assetType;
|
||||
|
||||
const getTranslationKey = (suffix: string) =>
|
||||
`edit_game_modal_${assetType}${suffix}`;
|
||||
const getResolutionKey = () => `edit_game_modal_${assetType}_resolution`;
|
||||
|
||||
return (
|
||||
<div className="edit-game-modal__image-section">
|
||||
<TextField
|
||||
placeholder={t(`edit_game_modal_select_${assetType}`)}
|
||||
value={assetPath}
|
||||
readOnly
|
||||
theme="dark"
|
||||
rightContent={
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleSelectAsset(assetType)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<ImageIcon />
|
||||
{t("edit_game_modal_browse")}
|
||||
</Button>
|
||||
{game && !isCustomGame(game) && assetPath && (
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleRestoreDefault(assetType)}
|
||||
disabled={isUpdating}
|
||||
title={`Remove ${assetType}`}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="edit-game-modal__resolution-info">
|
||||
{t(getResolutionKey())}
|
||||
</div>
|
||||
|
||||
{hasImage && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(getTranslationKey("_drop_zone"))}
|
||||
className={`edit-game-modal__image-preview ${
|
||||
assetType === "icon" ? "edit-game-modal__icon-preview" : ""
|
||||
} ${isDragOver ? "edit-game-modal__drop-zone--active" : ""}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={(e) => handleDragEnter(e, assetType)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleAssetDrop(e, assetType)}
|
||||
onClick={() => handleSelectAsset(assetType)}
|
||||
>
|
||||
<img
|
||||
src={getPreviewUrl(assetType)}
|
||||
alt={t(getTranslationKey("_preview"))}
|
||||
className="edit-game-modal__preview-image"
|
||||
/>
|
||||
{isDragOver && (
|
||||
<div className="edit-game-modal__drop-overlay">
|
||||
<span>Drop to replace {assetType}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!hasImage && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(getTranslationKey("_drop_zone_empty"))}
|
||||
className={`edit-game-modal__image-preview ${
|
||||
assetType === "icon" ? "edit-game-modal__icon-preview" : ""
|
||||
} edit-game-modal__drop-zone ${
|
||||
isDragOver ? "edit-game-modal__drop-zone--active" : ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={(e) => handleDragEnter(e, assetType)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleAssetDrop(e, assetType)}
|
||||
onClick={() => handleSelectAsset(assetType)}
|
||||
>
|
||||
<div className="edit-game-modal__drop-zone-content">
|
||||
<ImageIcon />
|
||||
<span>Drop {assetType} image here</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("edit_game_modal")}
|
||||
description={t("edit_game_modal_description")}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="edit-game-modal__container">
|
||||
<div className="edit-game-modal__form">
|
||||
<TextField
|
||||
label={t("edit_game_modal_title")}
|
||||
placeholder={t("edit_game_modal_enter_title")}
|
||||
value={gameName}
|
||||
onChange={handleGameNameChange}
|
||||
theme="dark"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
|
||||
<div className="edit-game-modal__asset-selector">
|
||||
<div className="edit-game-modal__asset-label">
|
||||
{t("edit_game_modal_assets")}
|
||||
</div>
|
||||
<div className="edit-game-modal__asset-tabs">
|
||||
<Button
|
||||
type="button"
|
||||
theme={selectedAssetType === "icon" ? "primary" : "outline"}
|
||||
onClick={() => handleAssetTypeChange("icon")}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t("edit_game_modal_icon")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme={selectedAssetType === "logo" ? "primary" : "outline"}
|
||||
onClick={() => handleAssetTypeChange("logo")}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t("edit_game_modal_logo")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme={selectedAssetType === "hero" ? "primary" : "outline"}
|
||||
onClick={() => handleAssetTypeChange("hero")}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t("edit_game_modal_hero")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderImageSection(selectedAssetType)}
|
||||
</div>
|
||||
|
||||
<div className="edit-game-modal__actions">
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t("edit_game_modal_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme="primary"
|
||||
onClick={handleUpdateGame}
|
||||
disabled={!isFormValid || isUpdating}
|
||||
>
|
||||
{isUpdating
|
||||
? t("edit_game_modal_updating")
|
||||
: t("edit_game_modal_update")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,14 @@ export interface GameOptionsModalProps {
|
||||
visible: boolean;
|
||||
game: LibraryGame;
|
||||
onClose: () => void;
|
||||
onNavigateHome?: () => void;
|
||||
}
|
||||
|
||||
export function GameOptionsModal({
|
||||
visible,
|
||||
game,
|
||||
onClose,
|
||||
onNavigateHome,
|
||||
}: Readonly<GameOptionsModalProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
@@ -90,6 +92,11 @@ export function GameOptionsModal({
|
||||
await removeGameFromLibrary(game.shop, game.objectId);
|
||||
updateGame();
|
||||
onClose();
|
||||
|
||||
// Redirect to home page if it's a custom game
|
||||
if (game.shop === "custom" && onNavigateHome) {
|
||||
onNavigateHome();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeExecutableLocation = async () => {
|
||||
@@ -346,14 +353,16 @@ export function GameOptionsModal({
|
||||
>
|
||||
{t("create_shortcut")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateSteamShortcut}
|
||||
theme="outline"
|
||||
disabled={creatingSteamShortcut}
|
||||
>
|
||||
<SteamLogo />
|
||||
{t("create_steam_shortcut")}
|
||||
</Button>
|
||||
{game.shop !== "custom" && (
|
||||
<Button
|
||||
onClick={handleCreateSteamShortcut}
|
||||
theme="outline"
|
||||
disabled={creatingSteamShortcut}
|
||||
>
|
||||
<SteamLogo />
|
||||
{t("create_steam_shortcut")}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowCreateStartMenuShortcut && (
|
||||
<Button
|
||||
onClick={() => handleCreateShortcut("start_menu")}
|
||||
@@ -367,19 +376,21 @@ export function GameOptionsModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckboxField
|
||||
label={
|
||||
<div className="game-options-modal__cloud-sync-label">
|
||||
{t("enable_automatic_cloud_sync")}
|
||||
<span className="game-options-modal__cloud-sync-hydra-cloud">
|
||||
Hydra Cloud
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
checked={automaticCloudSync}
|
||||
disabled={!hasActiveSubscription || !game.executablePath}
|
||||
onChange={handleToggleAutomaticCloudSync}
|
||||
/>
|
||||
{game.shop !== "custom" && (
|
||||
<CheckboxField
|
||||
label={
|
||||
<div className="game-options-modal__cloud-sync-label">
|
||||
{t("enable_automatic_cloud_sync")}
|
||||
<span className="game-options-modal__cloud-sync-hydra-cloud">
|
||||
Hydra Cloud
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
checked={automaticCloudSync}
|
||||
disabled={!hasActiveSubscription || !game.executablePath}
|
||||
onChange={handleToggleAutomaticCloudSync}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldShowWinePrefixConfiguration && (
|
||||
<div className="game-options-modal__wine-prefix">
|
||||
@@ -441,33 +452,35 @@ export function GameOptionsModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="game-options-modal__downloads">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("downloads_section_title")}</h2>
|
||||
<h4 className="game-options-modal__header-description">
|
||||
{t("downloads_section_description")}
|
||||
</h4>
|
||||
</div>
|
||||
{game.shop !== "custom" && (
|
||||
<div className="game-options-modal__downloads">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("downloads_section_title")}</h2>
|
||||
<h4 className="game-options-modal__header-description">
|
||||
{t("downloads_section_description")}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="game-options-modal__row">
|
||||
<Button
|
||||
onClick={() => setShowRepacksModal(true)}
|
||||
theme="outline"
|
||||
disabled={deleting || isGameDownloading || !repacks.length}
|
||||
>
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
{game.download?.downloadPath && (
|
||||
<div className="game-options-modal__row">
|
||||
<Button
|
||||
onClick={handleOpenDownloadFolder}
|
||||
onClick={() => setShowRepacksModal(true)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
disabled={deleting || isGameDownloading || !repacks.length}
|
||||
>
|
||||
{t("open_download_location")}
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
)}
|
||||
{game.download?.downloadPath && (
|
||||
<Button
|
||||
onClick={handleOpenDownloadFolder}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("open_download_location")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="game-options-modal__danger-zone">
|
||||
<div className="game-options-modal__header">
|
||||
@@ -486,18 +499,20 @@ export function GameOptionsModal({
|
||||
{t("remove_from_library")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowResetAchievementsModal(true)}
|
||||
theme="danger"
|
||||
disabled={
|
||||
deleting ||
|
||||
isDeletingAchievements ||
|
||||
!hasAchievements ||
|
||||
!userDetails
|
||||
}
|
||||
>
|
||||
{t("reset_achievements")}
|
||||
</Button>
|
||||
{game.shop !== "custom" && (
|
||||
<Button
|
||||
onClick={() => setShowResetAchievementsModal(true)}
|
||||
theme="danger"
|
||||
disabled={
|
||||
deleting ||
|
||||
isDeletingAchievements ||
|
||||
!hasAchievements ||
|
||||
!userDetails
|
||||
}
|
||||
>
|
||||
{t("reset_achievements")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => setShowChangePlaytimeModal(true)}
|
||||
@@ -506,17 +521,21 @@ export function GameOptionsModal({
|
||||
{t("update_game_playtime")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
theme="danger"
|
||||
disabled={
|
||||
isGameDownloading || deleting || !game.download?.downloadPath
|
||||
}
|
||||
>
|
||||
{t("remove_files")}
|
||||
</Button>
|
||||
{game.shop !== "custom" && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
theme="danger"
|
||||
disabled={
|
||||
isGameDownloading ||
|
||||
deleting ||
|
||||
!game.download?.downloadPath
|
||||
}
|
||||
>
|
||||
{t("remove_files")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./repacks-modal";
|
||||
export * from "./download-settings-modal";
|
||||
export * from "./game-options-modal";
|
||||
export * from "./edit-game-modal";
|
||||
|
||||
@@ -31,12 +31,12 @@ export function RemoveGameFromLibraryModal({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="remove-from-library-modal__actions">
|
||||
<Button onClick={handleRemoveGame} theme="outline">
|
||||
{t("remove")}
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
<Button onClick={handleRemoveGame} theme="primary">
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -36,12 +36,12 @@ export function ResetAchievementsModal({
|
||||
})}
|
||||
>
|
||||
<div className="reset-achievements-modal__actions">
|
||||
<Button onClick={handleResetAchievements} theme="outline">
|
||||
{t("reset_achievements")}
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
<Button onClick={handleResetAchievements} theme="primary">
|
||||
{t("reset_achievements")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -38,12 +38,12 @@ export const DeleteAllThemesModal = ({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-all-themes-modal__container">
|
||||
<Button theme="outline" onClick={handleDeleteAllThemes}>
|
||||
{t("delete_all_themes")}
|
||||
<Button theme="outline" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button theme="primary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
<Button theme="primary" onClick={handleDeleteAllThemes}>
|
||||
{t("delete_all_themes")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -41,12 +41,12 @@ export const DeleteThemeModal = ({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-all-themes-modal__container">
|
||||
<Button theme="outline" onClick={handleDeleteTheme}>
|
||||
{t("delete_theme")}
|
||||
<Button theme="outline" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button theme="primary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
<Button theme="primary" onClick={handleDeleteTheme}>
|
||||
{t("delete_theme")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -77,12 +77,12 @@ export const ImportThemeModal = ({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-all-themes-modal__container">
|
||||
<Button theme="outline" onClick={handleImportTheme}>
|
||||
{t("import_theme")}
|
||||
<Button theme="outline" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button theme="primary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
<Button theme="primary" onClick={handleImportTheme}>
|
||||
{t("import_theme")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user