mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
feat: add context menu for game items
- Added ContextMenu and GameContextMenu with actions (play, favorite, manage). - Integrated useGameActions hook and updated SidebarGameItem for right-click menu. - Enhanced game details and RepacksModal (filters, styling, disabled states).
This commit is contained in:
@@ -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</0>",
|
||||
"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",
|
||||
|
||||
@@ -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</0>",
|
||||
"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",
|
||||
|
||||
11
src/renderer/src/components/confirm-modal/confirm-modal.scss
Normal file
11
src/renderer/src/components/confirm-modal/confirm-modal.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
48
src/renderer/src/components/confirm-modal/confirm-modal.tsx
Normal file
48
src/renderer/src/components/confirm-modal/confirm-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
src/renderer/src/components/context-menu/context-menu.scss
Normal file
154
src/renderer/src/components/context-menu/context-menu.scss
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
241
src/renderer/src/components/context-menu/context-menu.tsx
Normal file
241
src/renderer/src/components/context-menu/context-menu.tsx
Normal file
@@ -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<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="context-menu"
|
||||
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);
|
||||
}
|
||||
@@ -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<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: 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: handleToggleFavorite,
|
||||
disabled: isDeleting,
|
||||
},
|
||||
...(game.executablePath
|
||||
? [
|
||||
{
|
||||
id: "shortcuts",
|
||||
label: t("create_shortcut"),
|
||||
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}
|
||||
/>
|
||||
|
||||
<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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<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 } });
|
||||
};
|
||||
|
||||
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",
|
||||
})}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
<img
|
||||
className="sidebar__game-icon"
|
||||
src={game.iconUrl}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className="sidebar__game-icon" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
<img
|
||||
className="sidebar__game-icon"
|
||||
src={game.iconUrl}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className="sidebar__game-icon" />
|
||||
)}
|
||||
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
.hero-panel-actions {
|
||||
&__action {
|
||||
border: solid 1px globals.$muted-color;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__container {
|
||||
|
||||
@@ -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() {
|
||||
<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")}
|
||||
|
||||
@@ -29,4 +29,61 @@
|
||||
&__repack-info {
|
||||
font-size: globals.$small-font-size;
|
||||
}
|
||||
|
||||
&__no-results {
|
||||
width: 100%;
|
||||
padding: calc(globals.$spacing-unit * 4) 0;
|
||||
text-align: center;
|
||||
color: globals.$muted-color;
|
||||
font-size: globals.$small-font-size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__no-results-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__no-results-text {
|
||||
color: globals.$muted-color;
|
||||
font-size: globals.$small-font-size;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__no-results-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__download-sources {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
margin-top: calc(globals.$spacing-unit * 1);
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
&__source-item {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
&__filter-label {
|
||||
font-size: globals.$small-font-size;
|
||||
font-weight: 500;
|
||||
margin-bottom: calc(globals.$spacing-unit / 2);
|
||||
color: globals.$body-color;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PlusCircleIcon } from "@primer/octicons-react";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
@@ -7,7 +9,10 @@ import {
|
||||
DebridBadge,
|
||||
Modal,
|
||||
TextField,
|
||||
CheckboxField,
|
||||
} from "@renderer/components";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import type { DownloadSource } from "@types";
|
||||
import type { GameRepack } from "@types";
|
||||
|
||||
import { DownloadSettingsModal } from "./download-settings-modal";
|
||||
@@ -36,6 +41,13 @@ export function RepacksModal({
|
||||
const [filteredRepacks, setFilteredRepacks] = useState<GameRepack[]>([]);
|
||||
const [repack, setRepack] = useState<GameRepack | null>(null);
|
||||
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
|
||||
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedFingerprints, setSelectedFingerprints] = useState<string[]>(
|
||||
[]
|
||||
);
|
||||
const [filterTerm, setFilterTerm] = useState("");
|
||||
|
||||
const [hashesInDebrid, setHashesInDebrid] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
@@ -46,6 +58,7 @@ export function RepacksModal({
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { formatDate } = useDate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getHashFromMagnet = (magnet: string) => {
|
||||
if (!magnet || typeof magnet !== "string") {
|
||||
@@ -90,8 +103,39 @@ export function RepacksModal({
|
||||
}, [repacks, hashesInDebrid]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredRepacks(sortedRepacks);
|
||||
}, [sortedRepacks, visible, game]);
|
||||
downloadSourcesTable.toArray().then((sources) => {
|
||||
const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker));
|
||||
const filteredSources = sources.filter(
|
||||
(s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint
|
||||
);
|
||||
setDownloadSources(filteredSources);
|
||||
});
|
||||
}, [sortedRepacks]);
|
||||
|
||||
useEffect(() => {
|
||||
const term = filterTerm.trim().toLowerCase();
|
||||
|
||||
const byTerm = sortedRepacks.filter((repack) => {
|
||||
if (!term) return true;
|
||||
const lowerTitle = repack.title.toLowerCase();
|
||||
const lowerRepacker = repack.repacker.toLowerCase();
|
||||
return (
|
||||
lowerTitle.includes(term) || lowerRepacker.includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
const bySource = byTerm.filter((repack) => {
|
||||
if (selectedFingerprints.length === 0) return true;
|
||||
|
||||
return downloadSources.some(
|
||||
(src) =>
|
||||
selectedFingerprints.includes(src.fingerprint) &&
|
||||
src.name === repack.repacker
|
||||
);
|
||||
});
|
||||
|
||||
setFilteredRepacks(bySource);
|
||||
}, [sortedRepacks, filterTerm, selectedFingerprints, downloadSources]);
|
||||
|
||||
const handleRepackClick = (repack: GameRepack) => {
|
||||
setRepack(repack);
|
||||
@@ -99,17 +143,14 @@ export function RepacksModal({
|
||||
};
|
||||
|
||||
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
const term = event.target.value.toLocaleLowerCase();
|
||||
setFilterTerm(event.target.value);
|
||||
};
|
||||
|
||||
setFilteredRepacks(
|
||||
sortedRepacks.filter((repack) => {
|
||||
const lowerCaseTitle = repack.title.toLowerCase();
|
||||
const lowerCaseRepacker = repack.repacker.toLowerCase();
|
||||
|
||||
return [lowerCaseTitle, lowerCaseRepacker].some((value) =>
|
||||
value.includes(term)
|
||||
);
|
||||
})
|
||||
const toggleFingerprint = (fingerprint: string) => {
|
||||
setSelectedFingerprints((prev) =>
|
||||
prev.includes(fingerprint)
|
||||
? prev.filter((f) => f !== fingerprint)
|
||||
: [...prev, fingerprint]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -134,37 +175,73 @@ export function RepacksModal({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="repacks-modal__filter-container">
|
||||
<TextField placeholder={t("filter")} onChange={handleFilter} />
|
||||
<div className="repacks-modal__filter-top">
|
||||
<TextField placeholder={t("filter")} onChange={handleFilter} />
|
||||
</div>
|
||||
|
||||
<div className="repacks-modal__download-sources">
|
||||
<p className="repacks-modal__filter-label">{t("filter_by_source")}</p>
|
||||
{downloadSources.map((source) => (
|
||||
<div key={source.fingerprint} className="repacks-modal__source-item">
|
||||
<CheckboxField
|
||||
label={source.name || source.url}
|
||||
checked={selectedFingerprints.includes(source.fingerprint)}
|
||||
onChange={() => toggleFingerprint(source.fingerprint)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="repacks-modal__repacks">
|
||||
{filteredRepacks.map((repack) => {
|
||||
const isLastDownloadedOption = checkIfLastDownloadedOption(repack);
|
||||
{filteredRepacks.length === 0 ? (
|
||||
<div className="repacks-modal__no-results">
|
||||
<div className="repacks-modal__no-results-content">
|
||||
<div className="repacks-modal__no-results-text">{t("no_repacks_found")}</div>
|
||||
<div className="repacks-modal__no-results-button">
|
||||
<Button
|
||||
type="button"
|
||||
theme="primary"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate("/settings?tab=2");
|
||||
}}
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
{t("add_download_source", { ns: "settings" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filteredRepacks.map((repack) => {
|
||||
const isLastDownloadedOption = checkIfLastDownloadedOption(repack);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={repack.id}
|
||||
theme="dark"
|
||||
onClick={() => handleRepackClick(repack)}
|
||||
className="repacks-modal__repack-button"
|
||||
>
|
||||
<p className="repacks-modal__repack-title">{repack.title}</p>
|
||||
return (
|
||||
<Button
|
||||
key={repack.id}
|
||||
theme="dark"
|
||||
onClick={() => handleRepackClick(repack)}
|
||||
className="repacks-modal__repack-button"
|
||||
>
|
||||
<p className="repacks-modal__repack-title">{repack.title}</p>
|
||||
|
||||
{isLastDownloadedOption && (
|
||||
<Badge>{t("last_downloaded_option")}</Badge>
|
||||
)}
|
||||
{isLastDownloadedOption && (
|
||||
<Badge>{t("last_downloaded_option")}</Badge>
|
||||
)}
|
||||
|
||||
<p className="repacks-modal__repack-info">
|
||||
{repack.fileSize} - {repack.repacker} -{" "}
|
||||
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
|
||||
</p>
|
||||
<p className="repacks-modal__repack-info">
|
||||
{repack.fileSize} - {repack.repacker} - {" "}
|
||||
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
|
||||
</p>
|
||||
|
||||
{hashesInDebrid[getHashFromMagnet(repack.uris[0]) ?? ""] && (
|
||||
<DebridBadge />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{hashesInDebrid[getHashFromMagnet(repack.uris[0]) ?? ""] && (
|
||||
<DebridBadge />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user