diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9f8de8f8..945f4cc8 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -102,6 +102,7 @@ "playing_now": "Playing now", "change": "Change", "repacks_modal_description": "Choose the repack you want to download", + "no_repacks_found": "No sources found for this game", "select_folder_hint": "To change the default folder, go to the <0>Settings", "download_now": "Download now", "no_shop_details": "Could not retrieve shop details.", @@ -123,6 +124,8 @@ "remove_from_library_title": "Are you sure?", "remove_from_library_description": "This will remove {{game}} from your library", "options": "Options", + "properties": "Properties", + "filter_by_source": "Filter by source:", "executable_section_title": "Executable", "executable_section_description": "Path of the file that will be executed when \"Play\" is clicked", "downloads_section_title": "Downloads", @@ -136,6 +139,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", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 22f5b533..b1bd01fa 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -90,6 +90,7 @@ "playing_now": "Jogando agora", "change": "Explorar", "repacks_modal_description": "Escolha o repack do jogo que deseja baixar", + "no_repacks_found": "Nenhuma fonte encontrada para este jogo", "select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes", "download_now": "Iniciar download", "no_shop_details": "Não foi possível obter os detalhes da loja.", @@ -108,6 +109,8 @@ "create_shortcut": "Criar atalho na área de trabalho", "remove_files": "Remover arquivos", "options": "Gerenciar", + "properties": "Propriedades", + "filter_by_source": "Filtrar por fonte:", "remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca", "remove_from_library_title": "Tem certeza?", "executable_section_title": "Executável", @@ -190,6 +193,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", diff --git a/src/renderer/src/components/confirm-modal/confirm-modal.scss b/src/renderer/src/components/confirm-modal/confirm-modal.scss new file mode 100644 index 00000000..e5bda187 --- /dev/null +++ b/src/renderer/src/components/confirm-modal/confirm-modal.scss @@ -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; + } +} diff --git a/src/renderer/src/components/confirm-modal/confirm-modal.tsx b/src/renderer/src/components/confirm-modal/confirm-modal.tsx new file mode 100644 index 00000000..d210c035 --- /dev/null +++ b/src/renderer/src/components/confirm-modal/confirm-modal.tsx @@ -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; + 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 ( + +
+ + + +
+
+ ); +} diff --git a/src/renderer/src/components/context-menu/context-menu.scss b/src/renderer/src/components/context-menu/context-menu.scss new file mode 100644 index 00000000..2c066440 --- /dev/null +++ b/src/renderer/src/components/context-menu/context-menu.scss @@ -0,0 +1,154 @@ +@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; + } + + &__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); + } +} \ No newline at end of file diff --git a/src/renderer/src/components/context-menu/context-menu.tsx b/src/renderer/src/components/context-menu/context-menu.tsx new file mode 100644 index 00000000..f1c2950e --- /dev/null +++ b/src/renderer/src/components/context-menu/context-menu.tsx @@ -0,0 +1,241 @@ +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; +} + +export function ContextMenu({ + items, + visible, + position, + onClose, + children, +}: ContextMenuProps) { + const menuRef = useRef(null); + const [adjustedPosition, setAdjustedPosition] = useState(position); + const [activeSubmenu, setActiveSubmenu] = useState(null); + const submenuCloseTimeout = useRef(null); + const itemRefs = useRef>({}); + const [submenuStyles, setSubmenuStyles] = useState>({}); + + 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 = ( +
+
    + {items.map((item) => ( +
  • (itemRefs.current[item.id] = el)} + className="context-menu__item-container" + onMouseEnter={() => item.submenu && handleSubmenuMouseEnter(item.id)} + onMouseLeave={() => item.submenu && handleSubmenuMouseLeave()} + > + {item.separator &&
    } + + + {item.submenu && activeSubmenu === item.id && ( +
    handleSubmenuMouseEnter(item.id)} + onMouseLeave={() => handleSubmenuMouseLeave()} + > +
      + {item.submenu.map((subItem) => ( +
    • + {subItem.separator &&
      } + +
    • + ))} +
    +
    + )} +
  • + ))} +
+ {children &&
{children}
} +
+ ); + + return createPortal(menuContent, document.body); +} \ No newline at end of file diff --git a/src/renderer/src/components/game-context-menu/game-context-menu.tsx b/src/renderer/src/components/game-context-menu/game-context-menu.tsx new file mode 100644 index 00000000..8b179ea5 --- /dev/null +++ b/src/renderer/src/components/game-context-menu/game-context-menu.tsx @@ -0,0 +1,219 @@ +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 { + 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 ? : , + onClick: handlePlayGame, + disabled: isDeleting, + }, + { + id: "favorite", + label: game.favorite ? t("remove_from_favorites") : t("add_to_favorites"), + icon: game.favorite ? : , + onClick: handleToggleFavorite, + disabled: isDeleting, + }, + ...(game.executablePath + ? [ + { + id: "shortcuts", + label: t("create_shortcut"), + icon: , + disabled: isDeleting, + submenu: [ + { + id: "desktop-shortcut", + label: t("create_shortcut"), + icon: , + onClick: () => handleCreateShortcut("desktop"), + disabled: isDeleting, + }, + { + id: "steam-shortcut", + label: t("create_steam_shortcut"), + icon: , + onClick: handleCreateSteamShortcut, + disabled: isDeleting, + }, + ...(shouldShowCreateStartMenuShortcut + ? [ + { + id: "start-menu-shortcut", + label: t("create_start_menu_shortcut"), + icon: , + onClick: () => handleCreateShortcut("start_menu"), + disabled: isDeleting, + }, + ] + : []), + ], + }, + ] + : []), + + { + id: "manage", + label: t("options"), + icon: , + disabled: isDeleting, + submenu: [ + ...(game.executablePath + ? [ + { + id: "open-folder", + label: t("open_folder"), + icon: , + onClick: handleOpenFolder, + disabled: isDeleting, + }, + ] + : []), + ...(game.executablePath + ? [ + { + id: "download-options", + label: t("open_download_options"), + icon: , + onClick: handleOpenDownloadOptions, + disabled: isDeleting || isGameDownloading || !hasRepacks, + }, + ] + : []), + ...(game.download?.downloadPath + ? [ + { + id: "download-location", + label: t("open_download_location"), + icon: , + onClick: handleOpenDownloadLocation, + disabled: isDeleting, + }, + ] + : []), + + { + id: "remove-library", + label: t("remove_from_library"), + icon: , + onClick: () => setShowConfirmRemoveLibrary(true), + disabled: isDeleting, + danger: true, + }, + ...(game.download?.downloadPath + ? [ + { + id: "remove-files", + label: t("remove_files"), + icon: , + onClick: () => setShowConfirmRemoveFiles(true), + disabled: isDeleting || isGameDownloading, + danger: true, + }, + ] + : []), + ], + }, + { + id: "properties", + label: t("properties"), + separator: true, + icon: , + onClick: () => handleOpenGameOptions(), + disabled: isDeleting, + }, + ]; + + return ( + <> + + + { + setShowConfirmRemoveLibrary(false); + onClose(); + }} + onConfirm={async () => { + await handleRemoveFromLibrary(); + }} + confirmLabel={t("remove")} + cancelLabel={t("cancel")} + confirmTheme="danger" + /> + + { + setShowConfirmRemoveFiles(false); + onClose(); + }} + onConfirm={async () => { + await handleRemoveFiles(); + }} + confirmLabel={t("remove")} + cancelLabel={t("cancel")} + confirmTheme="danger" + /> + + ); +} diff --git a/src/renderer/src/components/game-context-menu/use-game-actions.ts b/src/renderer/src/components/game-context-menu/use-game-actions.ts new file mode 100644 index 00000000..2c865126 --- /dev/null +++ b/src/renderer/src/components/game-context-menu/use-game-actions.ts @@ -0,0 +1,222 @@ +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) {} + } else { + navigate(path, { state: { openRepacks: true } }); + + try { + window.dispatchEvent( + new CustomEvent("hydra:openRepacks", { detail: { objectId: game.objectId } }) + ); + } catch (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) {} + } 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) { + } + }; + + 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) { + } + }; + + 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) {} + } 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) {} + } 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, + }; +} \ No newline at end of file diff --git a/src/renderer/src/components/index.ts b/src/renderer/src/components/index.ts index 8373e0dc..9970be42 100644 --- a/src/renderer/src/components/index.ts +++ b/src/renderer/src/components/index.ts @@ -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"; diff --git a/src/renderer/src/components/sidebar/sidebar-game-item.tsx b/src/renderer/src/components/sidebar/sidebar-game-item.tsx index 0672f847..37ef91c3 100644 --- a/src/renderer/src/components/sidebar/sidebar-game-item.tsx +++ b/src/renderer/src/components/sidebar/sidebar-game-item.tsx @@ -2,6 +2,8 @@ import SteamLogo from "@renderer/assets/steam-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; @@ -15,36 +17,64 @@ export function SidebarGameItem({ getGameTitle, }: Readonly) { 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 } }); + }; return ( -
  • - -
  • + + {getGameTitle(game)} + + + + + + ); } diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index ce2923b2..edfc503b 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -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"; @@ -94,6 +95,7 @@ export function GameDetailsContextProvider({ }, [getRepacksForObjectId, objectId]); const { i18n } = useTranslation("game_details"); + const location = useLocation(); const dispatch = useAppDispatch(); @@ -201,6 +203,16 @@ export function GameDetailsContextProvider({ dispatch(setHeaderTitle(gameTitle)); }, [objectId, gameTitle, dispatch]); + useEffect(() => { + const state: any = (location && (location.state as any)) || {}; + if (state.openRepacks) { + setShowRepacksModal(true); + try { + window.history.replaceState({}, document.title, location.pathname); + } catch (_e) {} + } + }, [location]); + useEffect(() => { const unsubscribe = window.electron.onGamesRunning((gamesIds) => { const updatedIsGameRunning = @@ -219,6 +231,53 @@ 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) { + } + }; + + 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) { + } + }; + + window.addEventListener("hydra:openGameOptions", handler as EventListener); + + return () => { + window.removeEventListener("hydra:openGameOptions", handler as EventListener); + }; + }, [objectId]); + + useEffect(() => { + const state: any = (location && (location.state as any)) || {}; + if (state.openGameOptions) { + setShowGameOptionsModal(true); + + try { + window.history.replaceState({}, document.title, location.pathname); + } catch (_e) {} + } + }, [location]); + const lastDownloadedOption = useMemo(() => { if (game?.download) { const repack = repacks.find((repack) => diff --git a/src/renderer/src/context/settings/settings.context.tsx b/src/renderer/src/context/settings/settings.context.tsx index 61a3fdb7..5c79f38d 100644 --- a/src/renderer/src/context/settings/settings.context.tsx +++ b/src/renderer/src/context/settings/settings.context.tsx @@ -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]); diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.scss b/src/renderer/src/pages/game-details/hero/hero-panel-actions.scss index 1f258ced..f8313370 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.scss +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.scss @@ -3,6 +3,10 @@ .hero-panel-actions { &__action { border: solid 1px globals.$muted-color; + + &--disabled { + opacity: 0.5; + } } &__container { diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index a3b75d2e..8b86af79 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -13,6 +13,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] = @@ -44,6 +45,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); @@ -166,8 +194,8 @@ export function HeroPanelActions() { + + + + ) : ( + filteredRepacks.map((repack) => { + const isLastDownloadedOption = checkIfLastDownloadedOption(repack); - return ( - - ); - })} + {hashesInDebrid[getHashFromMagnet(repack.uris[0]) ?? ""] && ( + + )} + + ); + }) + )}