Merge pull request #1792 from caduHD4/feat/context_game_menu
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-2022) (push) Has been cancelled

Feat: context game menu in sidebar
This commit is contained in:
Chubby Granny Chaser
2025-10-02 10:50:38 +01:00
committed by GitHub
16 changed files with 1150 additions and 32 deletions

View File

@@ -166,11 +166,13 @@
"open_folder": "Open folder",
"open_download_location": "See downloaded files",
"create_shortcut": "Create desktop shortcut",
"create_shortcut_simple": "Create shortcut",
"clear": "Clear",
"remove_files": "Remove files",
"remove_from_library_title": "Are you sure?",
"remove_from_library_description": "This will remove {{game}} from your library",
"options": "Options",
"properties": "Properties",
"executable_section_title": "Executable",
"executable_section_description": "Path of the file that will be executed when \"Play\" is clicked",
"downloads_section_title": "Downloads",
@@ -184,6 +186,13 @@
"create_shortcut_success": "Shortcut created successfully",
"you_might_need_to_restart_steam": "You might need to restart Steam to see the changes",
"create_shortcut_error": "Error creating shortcut",
"add_to_favorites": "Add to favorites",
"remove_from_favorites": "Remove from favorites",
"failed_update_favorites": "Failed to update favorites",
"game_removed_from_library": "Game removed from library",
"failed_remove_from_library": "Failed to remove from library",
"files_removed_success": "Files removed successfully",
"failed_remove_files": "Failed to remove files",
"nsfw_content_title": "This game contains inappropriate content",
"nsfw_content_description": "{{title}} contains content that may not be suitable for all ages. Are you sure you want to continue?",
"allow_nsfw_content": "Continue",

View File

@@ -97,7 +97,6 @@
"open_download_location": "Lihat file yang diunduh",
"create_shortcut": "Buat pintasan desktop",
"remove_files": "Hapus file",
"remove_from_library_title": "Apa kamu yakin?",
"remove_from_library_description": "Ini akan menghapus {{game}} dari perpustakaan kamu",
"options": "Opsi",
"executable_section_title": "Eksekusi",

View File

@@ -120,8 +120,10 @@
"open_folder": "Abrir pasta",
"open_download_location": "Ver arquivos baixados",
"create_shortcut": "Criar atalho na área de trabalho",
"create_shortcut_simple": "Criar atalho",
"remove_files": "Remover arquivos",
"options": "Gerenciar",
"properties": "Propriedades",
"remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca",
"remove_from_library_title": "Tem certeza?",
"executable_section_title": "Executável",
@@ -204,6 +206,13 @@
"download_error_not_cached_on_hydra": "Este download não está disponível no Nimbus.",
"game_removed_from_favorites": "Jogo removido dos favoritos",
"game_added_to_favorites": "Jogo adicionado aos favoritos",
"add_to_favorites": "Adicionar aos favoritos",
"remove_from_favorites": "Remover dos favoritos",
"failed_update_favorites": "Falha ao atualizar favoritos",
"game_removed_from_library": "Jogo removido da biblioteca",
"failed_remove_from_library": "Falha ao remover da biblioteca",
"files_removed_success": "Arquivos removidos com sucesso",
"failed_remove_files": "Falha ao remover arquivos",
"automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados",
"create_start_menu_shortcut": "Criar atalho no Menu Iniciar",
"invalid_wine_prefix_path": "Caminho do prefixo Wine inválido",

View File

@@ -0,0 +1,11 @@
@use "../../scss/globals.scss";
.confirm-modal {
&__actions {
display: flex;
width: 100%;
justify-content: flex-end;
align-items: center;
gap: globals.$spacing-unit;
}
}

View File

@@ -0,0 +1,48 @@
import { useTranslation } from "react-i18next";
import { Button, Modal } from "@renderer/components";
import "./confirm-modal.scss";
export interface ConfirmModalProps {
visible: boolean;
title: string;
description?: string;
onClose: () => void;
onConfirm: () => Promise<void> | void;
confirmLabel?: string;
cancelLabel?: string;
confirmTheme?: "primary" | "outline" | "danger";
confirmDisabled?: boolean;
}
export function ConfirmModal({
visible,
title,
description,
onClose,
onConfirm,
confirmLabel,
cancelLabel,
confirmTheme = "outline",
confirmDisabled = false,
}: ConfirmModalProps) {
const { t } = useTranslation();
const handleConfirm = async () => {
await onConfirm();
onClose();
};
return (
<Modal visible={visible} title={title} description={description} onClose={onClose}>
<div className="confirm-modal__actions">
<Button onClick={handleConfirm} theme={confirmTheme} disabled={confirmDisabled}>
{confirmLabel || t("confirm")}
</Button>
<Button onClick={onClose} theme="primary">
{cancelLabel || t("cancel")}
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,159 @@
@use "../../scss/globals.scss";
.context-menu {
position: fixed;
z-index: 1000;
background-color: globals.$background-color;
border: 1px solid globals.$border-color;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
padding: 4px 0;
min-width: 180px;
backdrop-filter: blur(8px);
&__list {
list-style: none;
margin: 0;
padding: 0;
}
&__item-container {
position: relative;
padding-right: 8px;
}
&__item {
width: 100%;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1.5);
padding: 8px 12px;
background: transparent;
border: none;
color: globals.$body-color;
cursor: pointer;
font-size: globals.$body-font-size;
text-align: left;
transition: background-color 0.15s ease;
&:hover:not(&--disabled) {
background-color: rgba(255, 255, 255, 0.1);
}
&:active:not(&--disabled) {
background-color: rgba(255, 255, 255, 0.15);
}
&--disabled {
color: globals.$muted-color;
cursor: not-allowed;
opacity: 0.6;
}
&--danger {
color: globals.$danger-color;
&:hover:not(.context-menu__item--disabled) {
background-color: rgba(128, 29, 30, 0.1);
}
.context-menu__item-icon {
color: globals.$danger-color;
}
}
&--has-submenu {
position: relative;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
&--active {
background-color: rgba(255, 255, 255, 0.1);
}
}
&__item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
}
&__item-label {
flex: 1;
white-space: nowrap;
}
&__item-arrow {
font-size: 10px;
color: globals.$muted-color;
margin-left: auto;
}
&__submenu {
position: absolute;
left: calc(100% - 2px);
top: 0;
background-color: globals.$background-color;
border: 1px solid globals.$border-color;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
padding: 4px 0;
min-width: 160px;
backdrop-filter: blur(8px);
margin-left: 0;
z-index: 1200;
pointer-events: auto;
max-height: 60vh;
overflow-y: auto;
}
&__content {
border-top: 1px solid globals.$border-color;
padding: 8px 12px;
margin-top: 4px;
}
&__item + &__item {
border-top: 1px solid transparent;
}
&__item--danger:first-of-type {
border-top: 1px solid globals.$border-color;
margin-top: 4px;
}
&--game-not-installed &__submenu &__item--danger:first-of-type {
border-top: none;
margin-top: 0;
}
&__separator {
height: 1px;
background: globals.$border-color;
margin: 6px 8px;
border-radius: 1px;
}
}
.context-menu {
animation: contextMenuFadeIn 0.15s ease-out;
}
@keyframes contextMenuFadeIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}

View File

@@ -0,0 +1,254 @@
import React, { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import cn from "classnames";
import "./context-menu.scss";
export interface ContextMenuItemData {
id: string;
label: string;
icon?: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
danger?: boolean;
separator?: boolean;
submenu?: ContextMenuItemData[];
}
export interface ContextMenuProps {
items: ContextMenuItemData[];
visible: boolean;
position: { x: number; y: number };
onClose: () => void;
children?: React.ReactNode;
className?: string;
}
export function ContextMenu({
items,
visible,
position,
onClose,
children,
className,
}: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [adjustedPosition, setAdjustedPosition] = useState(position);
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const submenuCloseTimeout = useRef<number | null>(null);
const itemRefs = useRef<Record<string, HTMLLIElement | null>>({});
const [submenuStyles, setSubmenuStyles] = useState<
Record<string, React.CSSProperties>
>({});
useEffect(() => {
if (!visible) return;
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [visible, onClose]);
useEffect(() => {
if (!visible || !menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = position.x;
let adjustedY = position.y;
if (position.x + rect.width > viewportWidth) {
adjustedX = viewportWidth - rect.width - 10;
}
if (position.y + rect.height > viewportHeight) {
adjustedY = viewportHeight - rect.height - 10;
}
setAdjustedPosition({ x: adjustedX, y: adjustedY });
}, [visible, position]);
useEffect(() => {
if (!visible) {
setActiveSubmenu(null);
}
}, [visible]);
const handleItemClick = (item: ContextMenuItemData) => {
if (item.disabled) return;
if (item.submenu) {
setActiveSubmenu(activeSubmenu === item.id ? null : item.id);
return;
}
if (item.onClick) {
item.onClick();
onClose();
}
};
const handleSubmenuMouseEnter = (itemId: string) => {
if (submenuCloseTimeout.current) {
window.clearTimeout(submenuCloseTimeout.current);
submenuCloseTimeout.current = null;
}
setActiveSubmenu(itemId);
};
const handleSubmenuMouseLeave = () => {
if (submenuCloseTimeout.current) {
window.clearTimeout(submenuCloseTimeout.current);
}
submenuCloseTimeout.current = window.setTimeout(() => {
setActiveSubmenu(null);
submenuCloseTimeout.current = null;
}, 120);
};
useEffect(() => {
if (!activeSubmenu) return;
const parentEl = itemRefs.current[activeSubmenu];
if (!parentEl) return;
const submenuEl = parentEl.querySelector(
".context-menu__submenu"
) as HTMLElement | null;
if (!submenuEl) return;
const parentRect = parentEl.getBoundingClientRect();
const submenuWidth = submenuEl.offsetWidth;
const submenuHeight = submenuEl.offsetHeight;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const styles: React.CSSProperties = {};
if (parentRect.right + submenuWidth > viewportWidth - 8) {
styles.left = "auto";
styles.right = "calc(100% - 2px)";
} else {
styles.left = "calc(100% - 2px)";
styles.right = undefined;
}
const overflowBottom = parentRect.top + submenuHeight - viewportHeight;
if (overflowBottom > 0) {
const topAdjust = Math.min(overflowBottom + 8, parentRect.top - 8);
styles.top = `${-topAdjust}px`;
} else {
styles.top = undefined;
}
setSubmenuStyles((prev) => ({ ...prev, [activeSubmenu]: styles }));
}, [activeSubmenu]);
if (!visible) return null;
const menuContent = (
<div
ref={menuRef}
className={cn("context-menu", className)}
style={{
left: adjustedPosition.x,
top: adjustedPosition.y,
}}
>
<ul className="context-menu__list">
{items.map((item) => (
<li
key={item.id}
ref={(el) => (itemRefs.current[item.id] = el)}
className="context-menu__item-container"
onMouseEnter={() =>
item.submenu && handleSubmenuMouseEnter(item.id)
}
onMouseLeave={() => item.submenu && handleSubmenuMouseLeave()}
>
{item.separator && <div className="context-menu__separator" />}
<button
type="button"
className={cn("context-menu__item", {
"context-menu__item--disabled": item.disabled,
"context-menu__item--danger": item.danger,
"context-menu__item--has-submenu": item.submenu,
"context-menu__item--active": activeSubmenu === item.id,
})}
onClick={() => handleItemClick(item)}
disabled={item.disabled}
>
{item.icon && (
<span className="context-menu__item-icon">{item.icon}</span>
)}
<span className="context-menu__item-label">{item.label}</span>
{item.submenu && (
<span className="context-menu__item-arrow"></span>
)}
</button>
{item.submenu && activeSubmenu === item.id && (
<div
className="context-menu__submenu"
style={submenuStyles[item.id] || undefined}
onMouseEnter={() => handleSubmenuMouseEnter(item.id)}
onMouseLeave={() => handleSubmenuMouseLeave()}
>
<ul className="context-menu__list">
{item.submenu.map((subItem) => (
<li
key={subItem.id}
className="context-menu__item-container"
>
{subItem.separator && (
<div className="context-menu__separator" />
)}
<button
type="button"
className={cn("context-menu__item", {
"context-menu__item--disabled": subItem.disabled,
"context-menu__item--danger": subItem.danger,
})}
onClick={() => handleItemClick(subItem)}
disabled={subItem.disabled}
>
{subItem.icon && (
<span className="context-menu__item-icon">
{subItem.icon}
</span>
)}
<span className="context-menu__item-label">
{subItem.label}
</span>
</button>
</li>
))}
</ul>
</div>
)}
</li>
))}
</ul>
{children && <div className="context-menu__content">{children}</div>}
</div>
);
return createPortal(menuContent, document.body);
}

View File

@@ -0,0 +1,231 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import {
PlayIcon,
DownloadIcon,
HeartIcon,
HeartFillIcon,
GearIcon,
PencilIcon,
FileDirectoryIcon,
LinkIcon,
TrashIcon,
XIcon,
} from "@primer/octicons-react";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { LibraryGame } from "@types";
import { ContextMenu, ContextMenuItemData, ContextMenuProps } from "..";
import { ConfirmModal } from "@renderer/components/confirm-modal/confirm-modal";
import { useGameActions } from "..";
interface GameContextMenuProps extends Omit<ContextMenuProps, "items"> {
game: LibraryGame;
}
export function GameContextMenu({
game,
visible,
position,
onClose,
}: GameContextMenuProps) {
const { t } = useTranslation("game_details");
const [showConfirmRemoveLibrary, setShowConfirmRemoveLibrary] =
useState(false);
const [showConfirmRemoveFiles, setShowConfirmRemoveFiles] = useState(false);
const {
canPlay,
isDeleting,
isGameDownloading,
hasRepacks,
shouldShowCreateStartMenuShortcut,
handlePlayGame,
handleToggleFavorite,
handleCreateShortcut,
handleCreateSteamShortcut,
handleOpenFolder,
handleOpenDownloadOptions,
handleOpenDownloadLocation,
handleRemoveFromLibrary,
handleRemoveFiles,
handleOpenGameOptions,
} = useGameActions(game);
const items: ContextMenuItemData[] = [
{
id: "play",
label: canPlay ? t("play") : t("download"),
icon: canPlay ? <PlayIcon size={16} /> : <DownloadIcon size={16} />,
onClick: () => {
void handlePlayGame();
},
disabled: isDeleting,
},
{
id: "favorite",
label: game.favorite ? t("remove_from_favorites") : t("add_to_favorites"),
icon: game.favorite ? (
<HeartFillIcon size={16} />
) : (
<HeartIcon size={16} />
),
onClick: () => {
void handleToggleFavorite();
},
disabled: isDeleting,
},
...(game.executablePath
? [
{
id: "shortcuts",
label: t("create_shortcut_simple"),
icon: <LinkIcon size={16} />,
disabled: isDeleting,
submenu: [
{
id: "desktop-shortcut",
label: t("create_shortcut"),
icon: <LinkIcon size={16} />,
onClick: () => handleCreateShortcut("desktop"),
disabled: isDeleting,
},
{
id: "steam-shortcut",
label: t("create_steam_shortcut"),
icon: <SteamLogo style={{ width: 16, height: 16 }} />,
onClick: handleCreateSteamShortcut,
disabled: isDeleting,
},
...(shouldShowCreateStartMenuShortcut
? [
{
id: "start-menu-shortcut",
label: t("create_start_menu_shortcut"),
icon: <LinkIcon size={16} />,
onClick: () => handleCreateShortcut("start_menu"),
disabled: isDeleting,
},
]
: []),
],
},
]
: []),
{
id: "manage",
label: t("options"),
icon: <GearIcon size={16} />,
disabled: isDeleting,
submenu: [
...(game.executablePath
? [
{
id: "open-folder",
label: t("open_folder"),
icon: <FileDirectoryIcon size={16} />,
onClick: handleOpenFolder,
disabled: isDeleting,
},
]
: []),
...(game.executablePath
? [
{
id: "download-options",
label: t("open_download_options"),
icon: <PlayIcon size={16} />,
onClick: handleOpenDownloadOptions,
disabled: isDeleting || isGameDownloading || !hasRepacks,
},
]
: []),
...(game.download?.downloadPath
? [
{
id: "download-location",
label: t("open_download_location"),
icon: <FileDirectoryIcon size={16} />,
onClick: handleOpenDownloadLocation,
disabled: isDeleting,
},
]
: []),
{
id: "remove-library",
label: t("remove_from_library"),
icon: <XIcon size={16} />,
onClick: () => setShowConfirmRemoveLibrary(true),
disabled: isDeleting,
danger: true,
},
...(game.download?.downloadPath
? [
{
id: "remove-files",
label: t("remove_files"),
icon: <TrashIcon size={16} />,
onClick: () => setShowConfirmRemoveFiles(true),
disabled: isDeleting || isGameDownloading,
danger: true,
},
]
: []),
],
},
{
id: "properties",
label: t("properties"),
separator: true,
icon: <PencilIcon size={16} />,
onClick: () => handleOpenGameOptions(),
disabled: isDeleting,
},
];
return (
<>
<ContextMenu
items={items}
visible={visible}
position={position}
onClose={onClose}
className={
!game.executablePath ? "context-menu--game-not-installed" : undefined
}
/>
<ConfirmModal
visible={showConfirmRemoveLibrary}
title={t("remove_from_library_title")}
description={t("remove_from_library_description", { game: game.title })}
onClose={() => {
setShowConfirmRemoveLibrary(false);
onClose();
}}
onConfirm={async () => {
await handleRemoveFromLibrary();
}}
confirmLabel={t("remove")}
cancelLabel={t("cancel")}
confirmTheme="danger"
/>
<ConfirmModal
visible={showConfirmRemoveFiles}
title={t("remove_files")}
description={t("delete_modal_description", { ns: "downloads" })}
onClose={() => {
setShowConfirmRemoveFiles(false);
onClose();
}}
onConfirm={async () => {
await handleRemoveFiles();
}}
confirmLabel={t("remove")}
cancelLabel={t("cancel")}
confirmTheme="danger"
/>
</>
);
}

View File

@@ -0,0 +1,256 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LibraryGame, ShortcutLocation } from "@types";
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
import { useNavigate, useLocation } from "react-router-dom";
import { buildGameDetailsPath } from "@renderer/helpers";
import { logger } from "@renderer/logger";
export function useGameActions(game: LibraryGame) {
const { t } = useTranslation("game_details");
const { showSuccessToast, showErrorToast } = useToast();
const { updateLibrary } = useLibrary();
const navigate = useNavigate();
const location = useLocation();
const {
removeGameInstaller,
removeGameFromLibrary,
isGameDeleting,
lastPacket,
cancelDownload,
} = useDownload();
const [creatingSteamShortcut, setCreatingSteamShortcut] = useState(false);
const canPlay = Boolean(game.executablePath);
const isDeleting = isGameDeleting(game.id);
const isGameDownloading =
game.download?.status === "active" && lastPacket?.gameId === game.id;
const hasRepacks = true;
const shouldShowCreateStartMenuShortcut =
window.electron.platform === "win32";
const handlePlayGame = async () => {
if (!canPlay) {
const path = buildGameDetailsPath({
...game,
objectId: game.objectId,
});
if (location.pathname === path) {
try {
window.dispatchEvent(
new CustomEvent("hydra:openRepacks", {
detail: { objectId: game.objectId },
})
);
} catch (e) {
void e;
}
} else {
navigate(path, { state: { openRepacks: true } });
try {
window.dispatchEvent(
new CustomEvent("hydra:openRepacks", {
detail: { objectId: game.objectId },
})
);
} catch (e) {
void e;
}
}
return;
}
try {
await window.electron.openGame(
game.shop,
game.objectId,
game.executablePath!,
game.launchOptions
);
} catch (error) {
showErrorToast("Failed to start game");
logger.error("Failed to start game", error);
}
};
const handleToggleFavorite = async () => {
try {
if (game.favorite) {
await window.electron.removeGameFromFavorites(game.shop, game.objectId);
showSuccessToast(t("game_removed_from_favorites"));
} else {
await window.electron.addGameToFavorites(game.shop, game.objectId);
showSuccessToast(t("game_added_to_favorites"));
}
updateLibrary();
try {
window.dispatchEvent(
new CustomEvent("hydra:game-favorite-toggled", {
detail: { shop: game.shop, objectId: game.objectId },
})
);
} catch (e) {
void e;
}
} catch (error) {
showErrorToast(t("failed_update_favorites"));
logger.error("Failed to toggle favorite", error);
}
};
const handleCreateShortcut = async (location: ShortcutLocation) => {
try {
const success = await window.electron.createGameShortcut(
game.shop,
game.objectId,
location
);
if (success) {
showSuccessToast(t("create_shortcut_success"));
} else {
showErrorToast(t("create_shortcut_error"));
}
} catch (error) {
showErrorToast(t("create_shortcut_error"));
logger.error("Failed to create shortcut", error);
}
};
const handleCreateSteamShortcut = async () => {
try {
setCreatingSteamShortcut(true);
await window.electron.createSteamShortcut(game.shop, game.objectId);
showSuccessToast(
t("create_shortcut_success"),
t("you_might_need_to_restart_steam")
);
} catch (error) {
logger.error("Failed to create Steam shortcut", error);
showErrorToast(t("create_shortcut_error"));
} finally {
setCreatingSteamShortcut(false);
}
};
const handleOpenFolder = async () => {
try {
await window.electron.openGameExecutablePath(game.shop, game.objectId);
} catch (error) {
showErrorToast("Failed to open folder");
logger.error("Failed to open folder", error);
}
};
const handleOpenDownloadOptions = () => {
const path = buildGameDetailsPath({
...game,
objectId: game.objectId,
});
navigate(path, { state: { openRepacks: true } });
try {
window.dispatchEvent(
new CustomEvent("hydra:openRepacks", {
detail: { objectId: game.objectId },
})
);
} catch (e) {
void e;
}
};
const handleOpenGameOptions = () => {
const path = buildGameDetailsPath({
...game,
objectId: game.objectId,
});
navigate(path, { state: { openGameOptions: true } });
try {
window.dispatchEvent(
new CustomEvent("hydra:openGameOptions", {
detail: { objectId: game.objectId },
})
);
} catch (e) {
void e;
}
};
const handleOpenDownloadLocation = async () => {
try {
await window.electron.openGameInstallerPath(game.shop, game.objectId);
} catch (error) {
showErrorToast("Failed to open download location");
logger.error("Failed to open download location", error);
}
};
const handleRemoveFromLibrary = async () => {
try {
if (isGameDownloading) {
await cancelDownload(game.shop, game.objectId);
}
await removeGameFromLibrary(game.shop, game.objectId);
updateLibrary();
showSuccessToast(t("game_removed_from_library"));
try {
window.dispatchEvent(
new CustomEvent("hydra:game-removed-from-library", {
detail: { shop: game.shop, objectId: game.objectId },
})
);
} catch (e) {
void e;
}
} catch (error) {
showErrorToast(t("failed_remove_from_library"));
logger.error("Failed to remove from library", error);
}
};
const handleRemoveFiles = async () => {
try {
await removeGameInstaller(game.shop, game.objectId);
updateLibrary();
showSuccessToast(t("files_removed_success"));
try {
window.dispatchEvent(
new CustomEvent("hydra:game-files-removed", {
detail: { shop: game.shop, objectId: game.objectId },
})
);
} catch (e) {
void e;
}
} catch (error) {
showErrorToast(t("failed_remove_files"));
logger.error("Failed to remove files", error);
}
};
return {
canPlay,
isDeleting,
isGameDownloading,
hasRepacks,
shouldShowCreateStartMenuShortcut,
creatingSteamShortcut,
handlePlayGame,
handleToggleFavorite,
handleCreateShortcut,
handleCreateSteamShortcut,
handleOpenFolder,
handleOpenDownloadOptions,
handleOpenDownloadLocation,
handleRemoveFromLibrary,
handleRemoveFiles,
handleOpenGameOptions,
};
}

View File

@@ -15,3 +15,6 @@ export * from "./badge/badge";
export * from "./confirmation-modal/confirmation-modal";
export * from "./suspense-wrapper/suspense-wrapper";
export * from "./debrid-badge/debrid-badge";
export * from "./context-menu/context-menu";
export * from "./game-context-menu/game-context-menu";
export * from "./game-context-menu/use-game-actions";

View File

@@ -3,6 +3,8 @@ import PlayLogo from "@renderer/assets/play-logo.svg?react";
import { LibraryGame } from "@types";
import cn from "classnames";
import { useLocation } from "react-router-dom";
import { useState } from "react";
import { GameContextMenu } from "..";
interface SidebarGameItemProps {
game: LibraryGame;
@@ -16,6 +18,24 @@ export function SidebarGameItem({
getGameTitle,
}: Readonly<SidebarGameItemProps>) {
const location = useLocation();
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
position: { x: number; y: number };
}>({ visible: false, position: { x: 0, y: 0 } });
const handleContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({
visible: true,
position: { x: event.clientX, y: event.clientY },
});
};
const handleCloseContextMenu = () => {
setContextMenu({ visible: false, position: { x: 0, y: 0 } });
};
const isCustomGame = game.shop === "custom";
const sidebarIcon = isCustomGame
@@ -31,34 +51,44 @@ export function SidebarGameItem({
};
return (
<li
key={game.id}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active":
location.pathname === `/game/${game.shop}/${game.objectId}`,
"sidebar__menu-item--muted": game.download?.status === "removed",
})}
>
<button
type="button"
className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)}
<>
<li
key={game.id}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active":
location.pathname === `/game/${game.shop}/${game.objectId}`,
"sidebar__menu-item--muted": game.download?.status === "removed",
})}
>
{sidebarIcon ? (
<img
className="sidebar__game-icon"
src={sidebarIcon}
alt={game.title}
loading="lazy"
/>
) : (
getFallbackIcon()
)}
<button
type="button"
className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)}
onContextMenu={handleContextMenu}
>
{sidebarIcon ? (
<img
className="sidebar__game-icon"
src={sidebarIcon}
alt={game.title}
loading="lazy"
/>
) : (
getFallbackIcon()
)}
<span className="sidebar__menu-item-button-label">
{getGameTitle(game)}
</span>
</button>
</li>
<span className="sidebar__menu-item-button-label">
{getGameTitle(game)}
</span>
</button>
</li>
<GameContextMenu
game={game}
visible={contextMenu.visible}
position={contextMenu.position}
onClose={handleCloseContextMenu}
/>
</>
);
}

View File

@@ -26,6 +26,7 @@ import type {
} from "@types";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { GameDetailsContext } from "./game-details.context.types";
import { SteamContentDescriptor } from "@shared";
@@ -91,6 +92,7 @@ export function GameDetailsContextProvider({
}, [getRepacksForObjectId, objectId]);
const { i18n } = useTranslation("game_details");
const location = useLocation();
const dispatch = useAppDispatch();
@@ -177,7 +179,7 @@ export function GameDetailsContextProvider({
if (abortController.signal.aborted) return;
setAchievements(achievements);
})
.catch(() => {});
.catch(() => void 0);
}
}, [
updateGame,
@@ -198,6 +200,18 @@ export function GameDetailsContextProvider({
dispatch(setHeaderTitle(gameTitle));
}, [objectId, gameTitle, dispatch]);
useEffect(() => {
const state = (location && (location.state as Record<string, unknown>)) || {};
if (state.openRepacks) {
setShowRepacksModal(true);
try {
window.history.replaceState({}, document.title, location.pathname);
} catch (_e) {
void _e;
}
}
}, [location]);
useEffect(() => {
if (game?.title) {
dispatch(setHeaderTitle(game.title));
@@ -222,6 +236,61 @@ export function GameDetailsContextProvider({
};
}, [game?.id, isGameRunning, updateGame]);
useEffect(() => {
const handler = (ev: Event) => {
try {
const detail = (ev as CustomEvent).detail || {};
if (detail.objectId && detail.objectId === objectId) {
setShowRepacksModal(true);
}
} catch (e) {
void e;
}
};
window.addEventListener("hydra:openRepacks", handler as EventListener);
return () => {
window.removeEventListener("hydra:openRepacks", handler as EventListener);
};
}, [objectId]);
useEffect(() => {
const handler = (ev: Event) => {
try {
const detail = (ev as CustomEvent).detail || {};
if (detail.objectId && detail.objectId === objectId) {
setShowGameOptionsModal(true);
}
} catch (e) {
void e;
}
};
window.addEventListener("hydra:openGameOptions", handler as EventListener);
return () => {
window.removeEventListener(
"hydra:openGameOptions",
handler as EventListener
);
};
}, [objectId]);
useEffect(() => {
const state =
(location && (location.state as Record<string, unknown>)) || {};
if (state.openGameOptions) {
setShowGameOptionsModal(true);
try {
window.history.replaceState({}, document.title, location.pathname);
} catch (_e) {
void _e;
}
}
}, [location]);
const lastDownloadedOption = useMemo(() => {
if (game?.download) {
const repack = repacks.find((repack) =>

View File

@@ -63,6 +63,7 @@ export function SettingsContextProvider({
const [searchParams] = useSearchParams();
const defaultSourceUrl = searchParams.get("urls");
const defaultTab = searchParams.get("tab");
const defaultAppearanceTheme = searchParams.get("theme");
const defaultAppearanceAuthorId = searchParams.get("authorId");
const defaultAppearanceAuthorName = searchParams.get("authorName");
@@ -77,6 +78,13 @@ export function SettingsContextProvider({
}
}, [defaultSourceUrl]);
useEffect(() => {
if (defaultTab) {
const idx = Number(defaultTab);
if (!Number.isNaN(idx)) setCurrentCategoryIndex(idx);
}
}, [defaultTab]);
useEffect(() => {
if (appearance.theme) setCurrentCategoryIndex(3);
}, [appearance.theme]);

View File

@@ -3,6 +3,10 @@
.hero-panel-actions {
&__action {
border: solid 1px globals.$muted-color;
&--disabled {
opacity: 0.5;
}
}
&__container {

View File

@@ -20,6 +20,7 @@ import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context";
import "./hero-panel-actions.scss";
import { useEffect } from "react";
export function HeroPanelActions() {
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
@@ -52,6 +53,33 @@ export function HeroPanelActions() {
const { t } = useTranslation("game_details");
useEffect(() => {
const onFavoriteToggled = () => {
updateLibrary();
updateGame();
};
const onGameRemoved = () => {
updateLibrary();
updateGame();
};
const onFilesRemoved = () => {
updateLibrary();
updateGame();
};
window.addEventListener("hydra:game-favorite-toggled", onFavoriteToggled as EventListener);
window.addEventListener("hydra:game-removed-from-library", onGameRemoved as EventListener);
window.addEventListener("hydra:game-files-removed", onFilesRemoved as EventListener);
return () => {
window.removeEventListener("hydra:game-favorite-toggled", onFavoriteToggled as EventListener);
window.removeEventListener("hydra:game-removed-from-library", onGameRemoved as EventListener);
window.removeEventListener("hydra:game-files-removed", onFilesRemoved as EventListener);
};
}, [updateLibrary, updateGame]);
const addGameToLibrary = async () => {
setToggleLibraryGameDisabled(true);
@@ -197,8 +225,8 @@ export function HeroPanelActions() {
<Button
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={isGameDownloading || !repacks.length}
className="hero-panel-actions__action"
disabled={isGameDownloading}
className={`hero-panel-actions__action ${!repacks.length ? 'hero-panel-actions__action--disabled' : ''}`}
>
<DownloadIcon />
{t("download")}

View File

@@ -277,4 +277,4 @@ export function RepacksModal({
</Modal>
</>
);
}
}