chore: merging with main

This commit is contained in:
Chubby Granny Chaser
2024-12-23 19:27:57 +00:00
66 changed files with 1829 additions and 852 deletions

View File

@@ -97,6 +97,14 @@ export function App() {
};
}, [clearDownload, setLastPacket, updateLibrary]);
useEffect(() => {
const unsubscribe = window.electron.onHardDelete(() => {
updateLibrary();
});
return () => unsubscribe();
}, [updateLibrary]);
useEffect(() => {
const cachedUserDetails = window.localStorage.getItem("userDetails");

View File

@@ -0,0 +1,68 @@
@use "../../scss/globals.scss";
.dropdown-menu {
&__content {
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 6px;
min-width: 200px;
flex-direction: column;
align-items: center;
}
&__group {
width: 100%;
padding: 4px;
}
&__title-bar {
width: 100%;
padding: 4px 12px;
font-size: 14px;
font-weight: 500;
color: globals.$muted-color;
}
&__separator {
width: 100%;
height: 1px;
background-color: globals.$border-color;
}
&__item {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 8px;
border-radius: 4px;
padding: 5px 12px;
cursor: pointer;
transition: background-color 0.1s ease-in-out;
font-size: 14px;
}
&__item--disabled {
cursor: default;
opacity: 0.6;
}
&:not(&__item--disabled) &__item:hover {
background-color: globals.$background-color;
color: globals.$muted-color;
}
&__item:focus {
background-color: globals.$background-color;
outline: none;
}
&__item-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
}

View File

@@ -0,0 +1,81 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import "./dropdown-menu.scss";
export interface DropdownMenuItem {
icon?: React.ReactNode;
label: string;
disabled?: boolean;
show?: boolean;
onClick?: () => void;
}
interface DropdownMenuProps {
children: React.ReactNode;
title?: string;
loop?: boolean;
items: DropdownMenuItem[];
sideOffset?: number;
side?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
alignOffset?: number;
}
export function DropdownMenu({
children,
title,
items,
sideOffset = 5,
side = "bottom",
loop = true,
align = "center",
alignOffset = 0,
}: DropdownMenuProps) {
return (
<DropdownMenuPrimitive.Root>
<DropdownMenuPrimitive.Trigger asChild>
<div aria-label={title}>{children}</div>
</DropdownMenuPrimitive.Trigger>
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
sideOffset={sideOffset}
side={side}
loop={loop}
align={align}
alignOffset={alignOffset}
className="dropdown-menu__content"
>
{title && (
<DropdownMenuPrimitive.Group className="dropdown-menu__group">
<div className="dropdown-menu__title-bar">{title}</div>
</DropdownMenuPrimitive.Group>
)}
<DropdownMenuPrimitive.Separator className="dropdown-menu__separator" />
<DropdownMenuPrimitive.Group className="dropdown-menu__group">
{items.map(
(item) =>
item.show !== false && (
<DropdownMenuPrimitive.Item
key={item.label}
aria-label={item.label}
onSelect={item.onClick}
className={`dropdown-menu__item ${item.disabled ? "dropdown-menu__item--disabled" : ""}`}
disabled={item.disabled}
>
{item.icon && (
<div className="dropdown-menu__item-icon">
{item.icon}
</div>
)}
{item.label}
</DropdownMenuPrimitive.Item>
)
)}
</DropdownMenuPrimitive.Group>
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
</DropdownMenuPrimitive.Root>
);
}

View File

@@ -8,6 +8,7 @@ import type {
ShopDetails,
Steam250Game,
DownloadProgress,
SeedingStatus,
UserPreferences,
StartGameDownloadPayload,
RealDebridUser,
@@ -44,9 +45,15 @@ declare global {
cancelGameDownload: (gameId: number) => Promise<void>;
pauseGameDownload: (gameId: number) => Promise<void>;
resumeGameDownload: (gameId: number) => Promise<void>;
pauseGameSeed: (gameId: number) => Promise<void>;
resumeGameSeed: (gameId: number) => Promise<void>;
onDownloadProgress: (
cb: (value: DownloadProgress) => void
) => () => Electron.IpcRenderer;
onSeedingStatus: (
cb: (value: SeedingStatus[]) => void
) => () => Electron.IpcRenderer;
onHardDelete: (cb: () => void) => () => Electron.IpcRenderer;
/* Catalogue */
searchGames: (

View File

@@ -66,6 +66,16 @@ export function useDownload() {
updateLibrary();
});
const pauseSeeding = async (gameId: number) => {
await window.electron.pauseGameSeed(gameId);
await updateLibrary();
};
const resumeSeeding = async (gameId: number) => {
await window.electron.resumeGameSeed(gameId);
await updateLibrary();
};
const calculateETA = () => {
if (!lastPacket || lastPacket.timeRemaining < 0) return "";
@@ -96,6 +106,8 @@ export function useDownload() {
removeGameFromLibrary,
removeGameInstaller,
isGameDeleting,
pauseSeeding,
resumeSeeding,
clearDownload: () => dispatch(clearDownload()),
setLastPacket: (packet: DownloadProgress) =>
dispatch(setLastPacket(packet)),

View File

@@ -114,7 +114,7 @@ export default function Catalogue() {
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
.map(([key, value]) => ({
label: key,
value: value.toString(),
value: value,
checked: filters.tags.includes(value),
}));
}, [steamUserTags, filters.tags, language]);

View File

@@ -8,10 +8,10 @@ export interface FilterSectionProps {
title: string;
items: {
label: string;
value: string;
value: string | number;
checked: boolean;
}[];
onSelect: (value: string) => void;
onSelect: (value: string | number) => void;
color: string;
onClear: () => void;
}

View File

@@ -38,22 +38,24 @@ export function Pagination({
<Button
theme="outline"
onClick={() => onPageChange(page - 1)}
style={{ width: 40 }}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
disabled={page === 1}
>
<ChevronLeftIcon />
</Button>
<div
style={{
width: 40,
justifyContent: "center",
display: "flex",
alignItems: "center",
}}
>
<span style={{ fontSize: 16 }}>...</span>
</div>
{page > 2 && (
<div
style={{
width: 40,
justifyContent: "center",
display: "flex",
alignItems: "center",
}}
>
<span style={{ fontSize: 16 }}>...</span>
</div>
)}
{/* Page Buttons */}
{Array.from(
@@ -63,17 +65,31 @@ export function Pagination({
<Button
theme={page === pageNumber ? "primary" : "outline"}
key={pageNumber}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
onClick={() => onPageChange(pageNumber)}
>
{pageNumber}
</Button>
))}
{page < totalPages - 1 && (
<div
style={{
width: 40,
justifyContent: "center",
display: "flex",
alignItems: "center",
}}
>
<span style={{ fontSize: 16 }}>...</span>
</div>
)}
{/* Next Button */}
<Button
theme="outline"
onClick={() => onPageChange(page + 1)}
style={{ width: 40 }}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }}
disabled={page === totalPages}
>
<ChevronRightIcon />

View File

@@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom";
import type { LibraryGame } from "@types";
import type { LibraryGame, SeedingStatus } from "@types";
import { Badge, Button } from "@renderer/components";
import {
@@ -15,12 +15,28 @@ import { useAppSelector, useDownload } from "@renderer/hooks";
import * as styles from "./download-group.css";
import { useTranslation } from "react-i18next";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { useMemo } from "react";
import {
DropdownMenu,
DropdownMenuItem,
} from "@renderer/components/dropdown-menu/dropdown-menu";
import {
ColumnsIcon,
DownloadIcon,
LinkIcon,
PlayIcon,
ThreeBarsIcon,
TrashIcon,
UnlinkIcon,
XCircleIcon,
} from "@primer/octicons-react";
export interface DownloadGroupProps {
library: LibraryGame[];
title: string;
openDeleteGameModal: (gameId: number) => void;
openGameInstaller: (gameId: number) => void;
seedingStatus: SeedingStatus[];
}
export function DownloadGroup({
@@ -28,6 +44,7 @@ export function DownloadGroup({
title,
openDeleteGameModal,
openGameInstaller,
seedingStatus,
}: DownloadGroupProps) {
const navigate = useNavigate();
@@ -44,6 +61,8 @@ export function DownloadGroup({
resumeDownload,
cancelDownload,
isGameDeleting,
pauseSeeding,
resumeSeeding,
} = useDownload();
const getFinalDownloadSize = (game: LibraryGame) => {
@@ -57,9 +76,20 @@ export function DownloadGroup({
return "N/A";
};
const seedingMap = useMemo(() => {
const map = new Map<number, SeedingStatus>();
seedingStatus.forEach((seed) => {
map.set(seed.gameId, seed);
});
return map;
}, [seedingStatus]);
const getGameInfo = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const finalDownloadSize = getFinalDownloadSize(game);
const seedingStatus = seedingMap.get(game.id);
if (isGameDeleting(game.id)) {
return <p>{t("deleting")}</p>;
@@ -98,7 +128,17 @@ export function DownloadGroup({
}
if (game.progress === 1) {
return <p>{t("completed")}</p>;
const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0);
return game.status === "seeding" &&
game.downloader === Downloader.Torrent ? (
<>
<p>{t("seeding")}</p>
{uploadSpeed && <p>{uploadSpeed}/s</p>}
</>
) : (
<p>{t("completed")}</p>
);
}
if (game.status === "paused") {
@@ -125,59 +165,74 @@ export function DownloadGroup({
return <p>{t(game.status as string)}</p>;
};
const getGameActions = (game: LibraryGame) => {
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
const isGameDownloading = lastPacket?.game.id === game.id;
const deleting = isGameDeleting(game.id);
if (game.progress === 1) {
return (
<>
<Button
onClick={() => openGameInstaller(game.id)}
theme="outline"
disabled={deleting}
>
{t("install")}
</Button>
<Button onClick={() => openDeleteGameModal(game.id)} theme="outline">
{t("delete")}
</Button>
</>
);
return [
{
label: t("install"),
disabled: deleting,
onClick: () => openGameInstaller(game.id),
icon: <DownloadIcon />,
},
{
label: t("stop_seeding"),
disabled: deleting,
icon: <UnlinkIcon />,
show:
game.status === "seeding" && game.downloader === Downloader.Torrent,
onClick: () => pauseSeeding(game.id),
},
{
label: t("resume_seeding"),
disabled: deleting,
icon: <LinkIcon />,
show:
game.status !== "seeding" && game.downloader === Downloader.Torrent,
onClick: () => resumeSeeding(game.id),
},
{
label: t("delete"),
disabled: deleting,
icon: <TrashIcon />,
onClick: () => openDeleteGameModal(game.id),
},
];
}
if (isGameDownloading || game.status === "active") {
return (
<>
<Button onClick={() => pauseDownload(game.id)} theme="outline">
{t("pause")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
return [
{
label: t("pause"),
onClick: () => pauseDownload(game.id),
icon: <ColumnsIcon />,
},
{
label: t("cancel"),
onClick: () => cancelDownload(game.id),
icon: <XCircleIcon />,
},
];
}
return (
<>
<Button
onClick={() => resumeDownload(game.id)}
theme="outline"
disabled={
game.downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken
}
>
{t("resume")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
return [
{
label: t("resume"),
disabled:
game.downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken,
onClick: () => resumeDownload(game.id),
icon: <PlayIcon />,
},
{
label: t("cancel"),
onClick: () => cancelDownload(game.id),
icon: <XCircleIcon />,
},
];
};
if (!library.length) return null;
@@ -207,7 +262,11 @@ export function DownloadGroup({
<ul className={styles.downloads}>
{library.map((game) => {
return (
<li key={game.id} className={styles.download}>
<li
key={game.id}
className={styles.download}
style={{ position: "relative" }}
>
<div className={styles.downloadCover}>
<div className={styles.downloadCoverBackdrop}>
<img
@@ -243,9 +302,28 @@ export function DownloadGroup({
{getGameInfo(game)}
</div>
<div className={styles.downloadActions}>
{getGameActions(game)}
</div>
{getGameActions(game) !== null && (
<DropdownMenu
align="end"
items={getGameActions(game)}
sideOffset={-75}
>
<Button
style={{
position: "absolute",
top: "12px",
right: "12px",
borderRadius: "50%",
border: "none",
padding: "8px",
minHeight: "unset",
}}
theme="outline"
>
<ThreeBarsIcon />
</Button>
</DropdownMenu>
)}
</div>
</li>
);

View File

@@ -2,12 +2,12 @@ import { useTranslation } from "react-i18next";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
import * as styles from "./downloads.css";
import { DeleteGameModal } from "./delete-game-modal";
import { DownloadGroup } from "./download-group";
import type { LibraryGame } from "@types";
import type { LibraryGame, SeedingStatus } from "@types";
import { orderBy } from "lodash-es";
import { ArrowDownIcon } from "@primer/octicons-react";
@@ -21,15 +21,23 @@ export default function Downloads() {
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const { removeGameInstaller } = useDownload();
const { removeGameInstaller, pauseSeeding } = useDownload();
const handleDeleteGame = async () => {
if (gameToBeDeleted.current)
if (gameToBeDeleted.current) {
await pauseSeeding(gameToBeDeleted.current);
await removeGameInstaller(gameToBeDeleted.current);
}
};
const { lastPacket } = useDownload();
const [seedingStatus, setSeedingStatus] = useState<SeedingStatus[]>([]);
useEffect(() => {
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
}, []);
const handleOpenGameInstaller = (gameId: number) =>
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
@@ -119,9 +127,10 @@ export default function Downloads() {
<DownloadGroup
key={group.title}
title={group.title}
library={group.library}
library={orderBy(group.library, ["updatedAt"], ["desc"])}
openDeleteGameModal={handleOpenDeleteGameModal}
openGameInstaller={handleOpenGameInstaller}
seedingStatus={seedingStatus}
/>
))}
</div>

View File

@@ -179,7 +179,7 @@ export function ProfileContent() {
game.achievementCount > 0 && (
<div
style={{
color: "white",
color: "#fff",
width: "100%",
display: "flex",
flexDirection: "column",
@@ -235,6 +235,8 @@ export function ProfileContent() {
borderRadius: 4,
width: "100%",
height: "100%",
minWidth: "100%",
minHeight: "100%",
}}
/>
</button>

View File

@@ -19,6 +19,7 @@ export function SettingsBehavior() {
runAtStartup: false,
startMinimized: false,
disableNsfwAlert: false,
seedAfterDownloadComplete: false,
showHiddenAchievementsDescription: false,
});
@@ -31,6 +32,7 @@ export function SettingsBehavior() {
runAtStartup: userPreferences.runAtStartup,
startMinimized: userPreferences.startMinimized,
disableNsfwAlert: userPreferences.disableNsfwAlert,
seedAfterDownloadComplete: userPreferences.seedAfterDownloadComplete,
showHiddenAchievementsDescription:
userPreferences.showHiddenAchievementsDescription,
});
@@ -100,6 +102,16 @@ export function SettingsBehavior() {
}
/>
<CheckboxField
label={t("seed_after_download_complete")}
checked={form.seedAfterDownloadComplete}
onChange={() =>
handleChange({
seedAfterDownloadComplete: !form.seedAfterDownloadComplete,
})
}
/>
<CheckboxField
label={t("show_hidden_achievement_description")}
checked={form.showHiddenAchievementsDescription}