mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
first commit
This commit is contained in:
commit
f1bdec484e
165 changed files with 20993 additions and 0 deletions
34
src/renderer/pages/catalogue/catalogue-home.css.ts
Normal file
34
src/renderer/pages/catalogue/catalogue-home.css.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const catalogueCategories = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
padding: `${SPACING_UNIT * 3}px`,
|
||||
flex: "1",
|
||||
});
|
||||
|
||||
export const cards = recipe({
|
||||
base: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
transition: "all ease 0.2s",
|
||||
},
|
||||
variants: {
|
||||
searching: {
|
||||
true: {
|
||||
pointerEvents: "none",
|
||||
opacity: vars.opacity.disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
76
src/renderer/pages/catalogue/catalogue.css.ts
Normal file
76
src/renderer/pages/catalogue/catalogue.css.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
|
||||
export const catalogueCategories = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const catalogueHeader = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
justifyContent: "space-between",
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
padding: `${SPACING_UNIT * 3}px`,
|
||||
flex: "1",
|
||||
overflowY: "auto",
|
||||
});
|
||||
|
||||
export const cards = recipe({
|
||||
base: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(1, 1fr)",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
transition: "all ease 0.2s",
|
||||
"@media": {
|
||||
"(min-width: 768px)": {
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
},
|
||||
"(min-width: 1250px)": {
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
},
|
||||
"(min-width: 1600px)": {
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
searching: {
|
||||
true: {
|
||||
pointerEvents: "none",
|
||||
opacity: vars.opacity.disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const cardSkeleton = style({
|
||||
width: "100%",
|
||||
height: "180px",
|
||||
boxShadow: "0px 0px 15px 0px #000000",
|
||||
overflow: "hidden",
|
||||
borderRadius: "4px",
|
||||
transition: "all ease 0.2s",
|
||||
zIndex: "1",
|
||||
":active": {
|
||||
opacity: vars.opacity.active,
|
||||
},
|
||||
});
|
||||
|
||||
export const noResults = style({
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
gridColumn: "1 / -1",
|
||||
});
|
143
src/renderer/pages/catalogue/catalogue.tsx
Normal file
143
src/renderer/pages/catalogue/catalogue.tsx
Normal file
|
@ -0,0 +1,143 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
import { Button, GameCard, Hero } from "@renderer/components";
|
||||
import type { CatalogueCategory, CatalogueEntry } from "@types";
|
||||
|
||||
import starsAnimation from "@renderer/assets/lottie/stars.json";
|
||||
|
||||
import * as styles from "./catalogue.css";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import Lottie from "lottie-react";
|
||||
|
||||
const categories: CatalogueCategory[] = ["trending", "recently_added"];
|
||||
|
||||
export function Catalogue() {
|
||||
const { t } = useTranslation("catalogue");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingRandomGame, setIsLoadingRandomGame] = useState(false);
|
||||
const randomGameObjectID = useRef<string | null>(null);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [catalogue, setCatalogue] = useState<
|
||||
Record<CatalogueCategory, CatalogueEntry[]>
|
||||
>({
|
||||
trending: [],
|
||||
recently_added: [],
|
||||
});
|
||||
|
||||
const getCatalogue = useCallback((category: CatalogueCategory) => {
|
||||
setIsLoading(true);
|
||||
|
||||
window.electron
|
||||
.getCatalogue(category)
|
||||
.then((catalogue) => {
|
||||
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const currentCategory = searchParams.get("category") || categories[0];
|
||||
|
||||
const handleSelectCategory = (category: CatalogueCategory) => {
|
||||
if (category !== currentCategory) {
|
||||
getCatalogue(category);
|
||||
navigate(`/?category=${category}`, { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
const getRandomGame = useCallback(() => {
|
||||
setIsLoadingRandomGame(true);
|
||||
|
||||
window.electron
|
||||
.getRandomGame()
|
||||
.then((objectID) => {
|
||||
randomGameObjectID.current = objectID;
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingRandomGame(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRandomizerClick = () => {
|
||||
const searchParams = new URLSearchParams({
|
||||
fromRandomizer: "1",
|
||||
});
|
||||
|
||||
navigate(
|
||||
`/game/steam/${randomGameObjectID.current}?${searchParams.toString()}`
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
getCatalogue(currentCategory as CatalogueCategory);
|
||||
getRandomGame();
|
||||
}, [getCatalogue, currentCategory, getRandomGame]);
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<section className={styles.content}>
|
||||
<h2>{t("featured")}</h2>
|
||||
|
||||
<Hero />
|
||||
|
||||
<section className={styles.catalogueHeader}>
|
||||
<div className={styles.catalogueCategories}>
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
theme={currentCategory === category ? "primary" : "outline"}
|
||||
onClick={() => handleSelectCategory(category)}
|
||||
>
|
||||
{t(category)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleRandomizerClick}
|
||||
theme="outline"
|
||||
disabled={isLoadingRandomGame}
|
||||
>
|
||||
<div style={{ width: 16, height: 16, position: "relative" }}>
|
||||
<Lottie
|
||||
animationData={starsAnimation}
|
||||
style={{ width: 70, position: "absolute", top: -28, left: -27 }}
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
{t("surprise_me")}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<h2>{t(currentCategory)}</h2>
|
||||
|
||||
<section className={styles.cards({})}>
|
||||
{isLoading
|
||||
? Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton key={index} className={styles.cardSkeleton} />
|
||||
))
|
||||
: catalogue[currentCategory as CatalogueCategory].map((result) => (
|
||||
<GameCard
|
||||
key={result.objectID}
|
||||
game={result}
|
||||
onClick={() =>
|
||||
navigate(`/game/${result.shop}/${result.objectID}`)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</section>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
}
|
87
src/renderer/pages/catalogue/search-results.tsx
Normal file
87
src/renderer/pages/catalogue/search-results.tsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
import { GameCard } from "@renderer/components";
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
import type { CatalogueEntry } from "@types";
|
||||
|
||||
import type { DebouncedFunc } from "lodash";
|
||||
import debounce from "lodash/debounce";
|
||||
|
||||
import { InboxIcon } from "@primer/octicons-react";
|
||||
import { clearSearch } from "@renderer/features";
|
||||
import { useAppDispatch } from "@renderer/hooks";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import * as styles from "./catalogue.css";
|
||||
|
||||
export function SearchResults() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { t } = useTranslation("catalogue");
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const debouncedFunc = useRef<DebouncedFunc<() => void | null>>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGameClick = (game: CatalogueEntry) => {
|
||||
dispatch(clearSearch());
|
||||
navigate(`/game/${game.shop}/${game.objectID}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
if (debouncedFunc.current) debouncedFunc.current.cancel();
|
||||
|
||||
debouncedFunc.current = debounce(() => {
|
||||
window.electron
|
||||
.searchGames(searchParams.get("query"))
|
||||
.then((results) => {
|
||||
setSearchResults(results);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, 300);
|
||||
|
||||
debouncedFunc.current();
|
||||
}, [searchParams, dispatch]);
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<section className={styles.content}>
|
||||
<section className={styles.cards({ searching: false })}>
|
||||
{isLoading &&
|
||||
Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton key={index} className={styles.cardSkeleton} />
|
||||
))}
|
||||
|
||||
{!isLoading && searchResults.length > 0 && (
|
||||
<>
|
||||
{searchResults.map((game) => (
|
||||
<GameCard
|
||||
key={game.objectID}
|
||||
game={game}
|
||||
onClick={() => handleGameClick(game)}
|
||||
disabled={!game.repacks.length}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!isLoading && searchResults.length === 0 && (
|
||||
<div className={styles.noResults}>
|
||||
<InboxIcon size={56} />
|
||||
|
||||
<p>{t("no_results")}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
}
|
10
src/renderer/pages/downloads/delete-modal.css.ts
Normal file
10
src/renderer/pages/downloads/delete-modal.css.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const deleteActionsButtonsCtn = style({
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
justifyContent: "end",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
43
src/renderer/pages/downloads/delete-modal.tsx
Normal file
43
src/renderer/pages/downloads/delete-modal.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
|
||||
import * as styles from "./delete-modal.css";
|
||||
|
||||
interface DeleteModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
deleteGame: () => void;
|
||||
}
|
||||
|
||||
export function DeleteModal({
|
||||
onClose,
|
||||
visible,
|
||||
deleteGame,
|
||||
}: DeleteModalProps) {
|
||||
const { t } = useTranslation("downloads");
|
||||
|
||||
const handleDeleteGame = () => {
|
||||
deleteGame();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("delete_modal_title")}
|
||||
description={t("delete_modal_description")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={styles.deleteActionsButtonsCtn}>
|
||||
<Button onClick={handleDeleteGame} theme="outline">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
90
src/renderer/pages/downloads/downloads.css.ts
Normal file
90
src/renderer/pages/downloads/downloads.css.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
export const downloadTitle = style({
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer",
|
||||
color: vars.color.bodyText,
|
||||
textAlign: "left",
|
||||
marginBottom: `${SPACING_UNIT}px`,
|
||||
fontSize: "16px",
|
||||
display: "block",
|
||||
":hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
});
|
||||
|
||||
export const downloads = style({
|
||||
width: "100%",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
marginTop: `${SPACING_UNIT * 3}px`,
|
||||
});
|
||||
|
||||
export const downloadCover = style({
|
||||
width: "280px",
|
||||
minWidth: "280px",
|
||||
height: "auto",
|
||||
objectFit: "cover",
|
||||
objectPosition: "center",
|
||||
borderRight: `solid 1px ${vars.color.borderColor}`,
|
||||
});
|
||||
|
||||
export const download = recipe({
|
||||
base: {
|
||||
width: "100%",
|
||||
backgroundColor: vars.color.background,
|
||||
display: "flex",
|
||||
borderRadius: "8px",
|
||||
border: `solid 1px ${vars.color.borderColor}`,
|
||||
overflow: "hidden",
|
||||
boxShadow: "0px 0px 15px 0px #000000",
|
||||
transition: "all ease 0.2s",
|
||||
height: "140px",
|
||||
minHeight: "140px",
|
||||
maxHeight: "140px",
|
||||
},
|
||||
variants: {
|
||||
cancelled: {
|
||||
true: {
|
||||
opacity: vars.opacity.disabled,
|
||||
":hover": {
|
||||
opacity: "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDetails = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: "1",
|
||||
justifyContent: "center",
|
||||
gap: `${SPACING_UNIT / 2}px`,
|
||||
fontSize: "14px",
|
||||
});
|
||||
|
||||
export const downloadRightContent = style({
|
||||
display: "flex",
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
flex: "1",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadActions = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadsContainer = style({
|
||||
display: "flex",
|
||||
padding: `${SPACING_UNIT * 3}px`,
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
});
|
272
src/renderer/pages/downloads/downloads.tsx
Normal file
272
src/renderer/pages/downloads/downloads.tsx
Normal file
|
@ -0,0 +1,272 @@
|
|||
import prettyBytes from "pretty-bytes";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { AsyncImage, Button, TextField } from "@renderer/components";
|
||||
import { formatDownloadProgress, steamUrlBuilder } from "@renderer/helpers";
|
||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||
import type { Game } from "@types";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
|
||||
import * as styles from "./downloads.css";
|
||||
import { DeleteModal } from "./delete-modal";
|
||||
|
||||
export function Downloads() {
|
||||
const { library, updateLibrary } = useLibrary();
|
||||
|
||||
const { t } = useTranslation("downloads");
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const gameToBeDeleted = useRef<number | null>(null);
|
||||
|
||||
const [filteredLibrary, setFilteredLibrary] = useState<Game[]>([]);
|
||||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
const {
|
||||
game: gameDownloading,
|
||||
progress,
|
||||
isDownloading,
|
||||
numPeers,
|
||||
numSeeds,
|
||||
pauseDownload,
|
||||
resumeDownload,
|
||||
cancelDownload,
|
||||
deleteGame,
|
||||
isGameDeleting,
|
||||
} = useDownload();
|
||||
|
||||
const libraryWithDownloadedGamesOnly = useMemo(() => {
|
||||
return library.filter((game) => game.status);
|
||||
}, [library]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredLibrary(libraryWithDownloadedGamesOnly);
|
||||
}, [libraryWithDownloadedGamesOnly]);
|
||||
|
||||
const openGameInstaller = (gameId: number) =>
|
||||
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
|
||||
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
const removeGame = (gameId: number) =>
|
||||
window.electron.removeGame(gameId).then(() => {
|
||||
updateLibrary();
|
||||
});
|
||||
|
||||
const getFinalDownloadSize = (game: Game) => {
|
||||
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
|
||||
|
||||
if (!game) return "N/A";
|
||||
if (game.fileSize) return prettyBytes(game.fileSize);
|
||||
|
||||
if (gameDownloading?.fileSize && isGameDownloading)
|
||||
return prettyBytes(gameDownloading.fileSize);
|
||||
|
||||
return game.repack?.fileSize ?? "N/A";
|
||||
};
|
||||
|
||||
const getGameInfo = (game: Game) => {
|
||||
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
|
||||
const finalDownloadSize = getFinalDownloadSize(game);
|
||||
|
||||
if (isGameDeleting(game?.id)) {
|
||||
return <p>{t("deleting")}</p>;
|
||||
}
|
||||
|
||||
if (isGameDownloading) {
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
|
||||
{gameDownloading?.status !== "downloading" ? (
|
||||
<p>{t(gameDownloading?.status)}</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{prettyBytes(gameDownloading?.bytesDownloaded)} /{" "}
|
||||
{finalDownloadSize}
|
||||
</p>
|
||||
<p>
|
||||
{numPeers} peers / {numSeeds} seeds
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === "seeding") {
|
||||
return (
|
||||
<>
|
||||
<p>{game?.repack.title}</p>
|
||||
<p>{t("completed")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (game?.status === "cancelled") return <p>{t("cancelled")}</p>;
|
||||
if (game?.status === "downloading_metadata")
|
||||
return <p>{t("starting_download")}</p>;
|
||||
|
||||
if (game?.status === "paused") {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(game.progress)}</p>
|
||||
<p>{t("paused")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteModal = (gameId: number) => {
|
||||
gameToBeDeleted.current = gameId;
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const getGameActions = (game: Game) => {
|
||||
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
|
||||
|
||||
const deleting = isGameDeleting(game.id);
|
||||
|
||||
if (isGameDownloading) {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => pauseDownload(game.id)} theme="outline">
|
||||
{t("pause")}
|
||||
</Button>
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === "paused") {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => resumeDownload(game.id)} theme="outline">
|
||||
{t("resume")}
|
||||
</Button>
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === "seeding") {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => openGameInstaller(game.id)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("install")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => openDeleteModal(game.id)} theme="outline">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === "downloading_metadata") {
|
||||
return (
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => navigate(`/game/${game.shop}/${game.objectID}`)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("download_again")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => removeGame(game.id)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("remove_from_list")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
setFilteredLibrary(
|
||||
libraryWithDownloadedGamesOnly.filter((game) =>
|
||||
game.title
|
||||
.toLowerCase()
|
||||
.includes(event.target.value.toLocaleLowerCase())
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.downloadsContainer}>
|
||||
<BinaryNotFoundModal
|
||||
visible={showBinaryNotFoundModal}
|
||||
onClose={() => setShowBinaryNotFoundModal(false)}
|
||||
/>
|
||||
<DeleteModal
|
||||
visible={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
deleteGame={() =>
|
||||
deleteGame(gameToBeDeleted.current).then(updateLibrary)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField placeholder={t("filter")} onChange={handleFilter} />
|
||||
|
||||
<ul className={styles.downloads}>
|
||||
{filteredLibrary.map((game) => {
|
||||
return (
|
||||
<li
|
||||
key={game.id}
|
||||
className={styles.download({
|
||||
cancelled: game.status === "cancelled",
|
||||
})}
|
||||
>
|
||||
<AsyncImage
|
||||
src={steamUrlBuilder.library(game.objectID)}
|
||||
className={styles.downloadCover}
|
||||
alt={game.title}
|
||||
/>
|
||||
<div className={styles.downloadRightContent}>
|
||||
<div className={styles.downloadDetails}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.downloadTitle}
|
||||
onClick={() =>
|
||||
navigate(`/game/${game.shop}/${game.objectID}`)
|
||||
}
|
||||
>
|
||||
{game.title}
|
||||
</button>
|
||||
|
||||
{getGameInfo(game)}
|
||||
</div>
|
||||
|
||||
<div className={styles.downloadActions}>
|
||||
{getGameActions(game)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
84
src/renderer/pages/game-details/description-header.tsx
Normal file
84
src/renderer/pages/game-details/description-header.tsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ShareAndroidIcon } from "@primer/octicons-react";
|
||||
|
||||
import { Button } from "@renderer/components";
|
||||
import type { ShopDetails } from "@types";
|
||||
|
||||
import * as styles from "./game-details.css";
|
||||
|
||||
const OPEN_HYDRA_URL = "https://open.hydralauncher.site";
|
||||
|
||||
export interface DescriptionHeaderProps {
|
||||
gameDetails: ShopDetails | null;
|
||||
}
|
||||
|
||||
export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
|
||||
const [clipboardLock, setClipboardLock] = useState(false);
|
||||
const { t, i18n } = useTranslation("game_details");
|
||||
|
||||
const { objectID, shop } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameDetails) return setClipboardLock(true);
|
||||
setClipboardLock(false);
|
||||
}, [gameDetails]);
|
||||
|
||||
const handleCopyToClipboard = () => {
|
||||
setClipboardLock(true);
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
p: btoa(
|
||||
JSON.stringify([
|
||||
objectID,
|
||||
shop,
|
||||
encodeURIComponent(gameDetails?.name),
|
||||
i18n.language,
|
||||
])
|
||||
),
|
||||
});
|
||||
|
||||
navigator.clipboard.writeText(
|
||||
OPEN_HYDRA_URL + `/?${searchParams.toString()}`
|
||||
);
|
||||
|
||||
const zero = performance.now();
|
||||
|
||||
requestAnimationFrame(function holdLock(time) {
|
||||
if (time - zero <= 3000) {
|
||||
requestAnimationFrame(holdLock);
|
||||
} else {
|
||||
setClipboardLock(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.descriptionHeader}>
|
||||
<section className={styles.descriptionHeaderInfo}>
|
||||
<p>
|
||||
{t("release_date", {
|
||||
date: gameDetails?.release_date.date,
|
||||
})}
|
||||
</p>
|
||||
<p>{t("publisher", { publisher: gameDetails?.publishers[0] })}</p>
|
||||
</section>
|
||||
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={handleCopyToClipboard}
|
||||
disabled={clipboardLock || !gameDetails}
|
||||
>
|
||||
{clipboardLock ? (
|
||||
t("copied_link_to_clipboard")
|
||||
) : (
|
||||
<>
|
||||
<ShareAndroidIcon />
|
||||
{t("copy_link_to_clipboard")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
89
src/renderer/pages/game-details/game-details-skeleton.tsx
Normal file
89
src/renderer/pages/game-details/game-details-skeleton.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
import { Button } from "@renderer/components";
|
||||
import * as styles from "./game-details.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShareAndroidIcon } from "@primer/octicons-react";
|
||||
|
||||
export function GameDetailsSkeleton() {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.hero}>
|
||||
<Skeleton className={styles.heroImageSkeleton} />
|
||||
</div>
|
||||
<div className={styles.descriptionHeader}>
|
||||
<section className={styles.descriptionHeaderInfo}>
|
||||
<Skeleton width={155} />
|
||||
<Skeleton width={135} />
|
||||
</section>
|
||||
</div>
|
||||
<div className={styles.descriptionContainer}>
|
||||
<div className={styles.descriptionContent}>
|
||||
<div className={styles.descriptionHeader}>
|
||||
<section className={styles.descriptionHeaderInfo}>
|
||||
<Skeleton width={145} />
|
||||
<Skeleton width={150} />
|
||||
</section>
|
||||
<Button theme="outline" disabled>
|
||||
<ShareAndroidIcon />
|
||||
{t("copy_link_to_clipboard")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.descriptionSkeleton}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Skeleton key={index} />
|
||||
))}
|
||||
<Skeleton className={styles.heroImageSkeleton} />
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<Skeleton key={index} />
|
||||
))}
|
||||
<Skeleton className={styles.heroImageSkeleton} />
|
||||
<Skeleton />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.contentSidebar}>
|
||||
<div className={styles.contentSidebarTitle}>
|
||||
<h3>HowLongToBeat</h3>
|
||||
</div>
|
||||
<ul className={styles.howLongToBeatCategoriesList}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className={styles.howLongToBeatCategorySkeleton}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<div
|
||||
className={styles.contentSidebarTitle}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<h3>{t("requirements")}</h3>
|
||||
</div>
|
||||
<div className={styles.requirementButtonContainer}>
|
||||
<Button
|
||||
className={styles.requirementButton}
|
||||
theme="primary"
|
||||
disabled
|
||||
>
|
||||
{t("minimum")}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.requirementButton}
|
||||
theme="outline"
|
||||
disabled
|
||||
>
|
||||
{t("recommended")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.requirementsDetailsSkeleton}>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton key={index} height={20} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
256
src/renderer/pages/game-details/game-details.css.ts
Normal file
256
src/renderer/pages/game-details/game-details.css.ts
Normal file
|
@ -0,0 +1,256 @@
|
|||
import { globalStyle, keyframes, style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
|
||||
export const slideIn = keyframes({
|
||||
"0%": { transform: `translateY(${40 + 16}px)` },
|
||||
"100%": { transform: "translateY(0)" },
|
||||
});
|
||||
|
||||
export const hero = style({
|
||||
width: "100%",
|
||||
height: "300px",
|
||||
minHeight: "300px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
transition: "all ease 0.2s",
|
||||
"@media": {
|
||||
"(min-width: 1250px)": {
|
||||
height: "350px",
|
||||
minHeight: "350px",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const heroContent = style({
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
});
|
||||
|
||||
export const heroBackdrop = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.3) 60%, transparent 100%)",
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
});
|
||||
|
||||
export const heroImage = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
objectPosition: "top",
|
||||
transition: "all ease 0.2s",
|
||||
"@media": {
|
||||
"(min-width: 1250px)": {
|
||||
objectPosition: "center",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const heroImageSkeleton = style({
|
||||
height: "300px",
|
||||
"@media": {
|
||||
"(min-width: 1250px)": {
|
||||
height: "350px",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const container = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
});
|
||||
|
||||
export const descriptionContainer = style({
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
flex: "1",
|
||||
});
|
||||
|
||||
export const descriptionContent = style({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
});
|
||||
|
||||
export const contentSidebar = style({
|
||||
borderLeft: `solid 1px ${vars.color.borderColor};`,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
"@media": {
|
||||
"(min-width: 768px)": {
|
||||
width: "100%",
|
||||
maxWidth: "200px",
|
||||
},
|
||||
"(min-width: 1024px)": {
|
||||
maxWidth: "300px",
|
||||
width: "100%",
|
||||
},
|
||||
"(min-width: 1280px)": {
|
||||
width: "100%",
|
||||
maxWidth: "400px",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const contentSidebarTitle = style({
|
||||
height: "72px",
|
||||
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor: vars.color.background,
|
||||
borderBottom: `solid 1px ${vars.color.borderColor}`,
|
||||
});
|
||||
|
||||
export const requirementButtonContainer = style({
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
});
|
||||
|
||||
export const requirementButton = style({
|
||||
border: `solid 1px ${vars.color.borderColor};`,
|
||||
borderLeft: "none",
|
||||
borderRight: "none",
|
||||
borderRadius: "0",
|
||||
width: "100%",
|
||||
});
|
||||
|
||||
export const requirementsDetails = style({
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
lineHeight: "22px",
|
||||
fontFamily: "'Fira Sans', sans-serif",
|
||||
fontSize: "16px",
|
||||
});
|
||||
|
||||
export const requirementsDetailsSkeleton = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
fontSize: "16px",
|
||||
});
|
||||
|
||||
export const description = style({
|
||||
userSelect: "text",
|
||||
lineHeight: "22px",
|
||||
fontFamily: "'Fira Sans', sans-serif",
|
||||
fontSize: "16px",
|
||||
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
|
||||
"@media": {
|
||||
"(min-width: 1280px)": {
|
||||
width: "60%",
|
||||
},
|
||||
},
|
||||
width: "100%",
|
||||
marginLeft: "auto",
|
||||
marginRight: "auto",
|
||||
});
|
||||
|
||||
export const descriptionSkeleton = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
|
||||
width: "100%",
|
||||
"@media": {
|
||||
"(min-width: 1280px)": {
|
||||
width: "60%",
|
||||
lineHeight: "22px",
|
||||
},
|
||||
},
|
||||
marginLeft: "auto",
|
||||
marginRight: "auto",
|
||||
});
|
||||
|
||||
export const descriptionHeader = style({
|
||||
width: "100%",
|
||||
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
|
||||
borderBottom: `solid 1px ${vars.color.borderColor}`,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
backgroundColor: vars.color.background,
|
||||
height: "72px",
|
||||
});
|
||||
|
||||
export const descriptionHeaderInfo = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
flexDirection: "column",
|
||||
fontSize: vars.size.bodyFontSize,
|
||||
});
|
||||
|
||||
export const howLongToBeatCategoriesList = style({
|
||||
margin: "0",
|
||||
padding: "16px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
});
|
||||
|
||||
export const howLongToBeatCategory = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
backgroundColor: vars.color.background,
|
||||
borderRadius: "8px",
|
||||
padding: `8px 16px`,
|
||||
border: `solid 1px ${vars.color.borderColor}`,
|
||||
});
|
||||
|
||||
export const howLongToBeatCategoryLabel = style({
|
||||
fontSize: vars.size.bodyFontSize,
|
||||
color: "#DADBE1",
|
||||
});
|
||||
|
||||
export const howLongToBeatCategorySkeleton = style({
|
||||
border: `solid 1px ${vars.color.borderColor}`,
|
||||
borderRadius: "8px",
|
||||
height: "76px",
|
||||
});
|
||||
|
||||
export const randomizerButton = style({
|
||||
animationName: slideIn,
|
||||
animationDuration: "0.4s",
|
||||
position: "fixed",
|
||||
bottom: 26 + 16,
|
||||
boxShadow: "rgba(255, 255, 255, 0.1) 0px 0px 10px 3px",
|
||||
border: `solid 1px ${vars.color.borderColor}`,
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
":hover": {
|
||||
backgroundColor: vars.color.background,
|
||||
boxShadow: "rgba(255, 255, 255, 0.1) 0px 0px 15px 5px",
|
||||
opacity: 1,
|
||||
},
|
||||
":active": {
|
||||
transform: "scale(0.98)",
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(".bb_tag", {
|
||||
marginTop: `${SPACING_UNIT * 2}px`,
|
||||
marginBottom: `${SPACING_UNIT * 2}px`,
|
||||
});
|
||||
|
||||
globalStyle(`${description} img`, {
|
||||
borderRadius: "5px",
|
||||
marginTop: `${SPACING_UNIT}px`,
|
||||
marginBottom: `${SPACING_UNIT * 3}px`,
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
});
|
||||
|
||||
globalStyle(`${description} a`, {
|
||||
color: vars.color.bodyText,
|
||||
});
|
||||
|
||||
globalStyle(`${requirementsDetails} a`, {
|
||||
display: "flex",
|
||||
color: vars.color.bodyText,
|
||||
});
|
287
src/renderer/pages/game-details/game-details.tsx
Normal file
287
src/renderer/pages/game-details/game-details.tsx
Normal file
|
@ -0,0 +1,287 @@
|
|||
import Color from "color";
|
||||
import { average } from "color.js";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import type {
|
||||
Game,
|
||||
GameShop,
|
||||
HowLongToBeatCategory,
|
||||
ShopDetails,
|
||||
SteamAppDetails,
|
||||
} from "@types";
|
||||
|
||||
import { AsyncImage, Button } from "@renderer/components";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers";
|
||||
import { useAppDispatch, useDownload } from "@renderer/hooks";
|
||||
|
||||
import starsAnimation from "@renderer/assets/lottie/stars.json";
|
||||
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SkeletonTheme } from "react-loading-skeleton";
|
||||
import { GameDetailsSkeleton } from "./game-details-skeleton";
|
||||
import * as styles from "./game-details.css";
|
||||
import { HeroPanel } from "./hero-panel";
|
||||
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
||||
import { RepacksModal } from "./repacks-modal";
|
||||
import Lottie from "lottie-react";
|
||||
import { DescriptionHeader } from "./description-header";
|
||||
|
||||
export function GameDetails() {
|
||||
const { objectID, shop } = useParams();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [color, setColor] = useState("");
|
||||
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
|
||||
const [howLongToBeat, setHowLongToBeat] = useState<{
|
||||
isLoading: boolean;
|
||||
data: HowLongToBeatCategory[] | null;
|
||||
}>({ isLoading: true, data: null });
|
||||
|
||||
const [game, setGame] = useState<Game | null>(null);
|
||||
const [isGamePlaying, setIsGamePlaying] = useState(false);
|
||||
const [activeRequirement, setActiveRequirement] =
|
||||
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const { t, i18n } = useTranslation("game_details");
|
||||
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
|
||||
const randomGameObjectID = useRef<string | null>(null);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { game: gameDownloading, startDownload, isDownloading } = useDownload();
|
||||
|
||||
const getRandomGame = useCallback(() => {
|
||||
window.electron.getRandomGame().then((objectID) => {
|
||||
randomGameObjectID.current = objectID;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleImageSettled = useCallback((url: string) => {
|
||||
average(url, { amount: 1, format: "hex" })
|
||||
.then((color) => {
|
||||
setColor(new Color(color).darken(0.6).toString() as string);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const getGame = useCallback(() => {
|
||||
window.electron
|
||||
.getGameByObjectID(objectID)
|
||||
.then((result) => setGame(result));
|
||||
}, [setGame, objectID]);
|
||||
|
||||
useEffect(() => {
|
||||
getGame();
|
||||
}, [getGame, gameDownloading?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
dispatch(setHeaderTitle(""));
|
||||
|
||||
getRandomGame();
|
||||
|
||||
window.electron
|
||||
.getGameShopDetails(objectID, "steam", getSteamLanguage(i18n.language))
|
||||
.then((result) => {
|
||||
if (!result) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
window.electron
|
||||
.getHowLongToBeat(objectID, "steam", result.name)
|
||||
.then((data) => {
|
||||
setHowLongToBeat({ isLoading: false, data });
|
||||
});
|
||||
|
||||
setGameDetails(result);
|
||||
dispatch(setHeaderTitle(result.name));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
getGame();
|
||||
setHowLongToBeat({ isLoading: true, data: null });
|
||||
}, [getGame, getRandomGame, dispatch, navigate, objectID, i18n.language]);
|
||||
|
||||
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (isGameDownloading)
|
||||
setGame((prev) => ({ ...prev, status: gameDownloading?.status }));
|
||||
}, [isGameDownloading, gameDownloading?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
const listeners = [
|
||||
window.electron.onGameClose(() => {
|
||||
if (isGamePlaying) setIsGamePlaying(false);
|
||||
}),
|
||||
window.electron.onPlaytime((gameId) => {
|
||||
if (gameId === game?.id) {
|
||||
if (!isGamePlaying) setIsGamePlaying(true);
|
||||
getGame();
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
listeners.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, [game?.id, isGamePlaying, getGame]);
|
||||
|
||||
const handleStartDownload = async (repackId: number) => {
|
||||
return startDownload(
|
||||
repackId,
|
||||
gameDetails.objectID,
|
||||
gameDetails.name,
|
||||
shop as GameShop
|
||||
).then(() => {
|
||||
getGame();
|
||||
setShowRepacksModal(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRandomizerClick = () => {
|
||||
if (!randomGameObjectID.current) return;
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
fromRandomizer: "1",
|
||||
});
|
||||
|
||||
navigate(
|
||||
`/game/steam/${randomGameObjectID.current}?${searchParams.toString()}`
|
||||
);
|
||||
};
|
||||
|
||||
const fromRandomizer = searchParams.get("fromRandomizer");
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
{gameDetails && (
|
||||
<RepacksModal
|
||||
visible={showRepacksModal}
|
||||
gameDetails={gameDetails}
|
||||
startDownload={handleStartDownload}
|
||||
onClose={() => setShowRepacksModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<GameDetailsSkeleton />
|
||||
) : (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.hero}>
|
||||
<AsyncImage
|
||||
src={steamUrlBuilder.libraryHero(objectID)}
|
||||
className={styles.heroImage}
|
||||
alt={game?.title}
|
||||
onSettled={handleImageSettled}
|
||||
/>
|
||||
<div className={styles.heroBackdrop}>
|
||||
<div className={styles.heroContent}>
|
||||
<AsyncImage
|
||||
src={steamUrlBuilder.logo(objectID)}
|
||||
style={{ width: 300, alignSelf: "flex-end" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeroPanel
|
||||
game={game}
|
||||
color={color}
|
||||
gameDetails={gameDetails}
|
||||
openRepacksModal={() => setShowRepacksModal(true)}
|
||||
getGame={getGame}
|
||||
isGamePlaying={isGamePlaying}
|
||||
/>
|
||||
|
||||
<div className={styles.descriptionContainer}>
|
||||
<div className={styles.descriptionContent}>
|
||||
<DescriptionHeader gameDetails={gameDetails} />
|
||||
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: gameDetails?.about_the_game ?? "",
|
||||
}}
|
||||
className={styles.description}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.contentSidebar}>
|
||||
<HowLongToBeatSection
|
||||
howLongToBeatData={howLongToBeat.data}
|
||||
isLoading={howLongToBeat.isLoading}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={styles.contentSidebarTitle}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<h3>{t("requirements")}</h3>
|
||||
</div>
|
||||
|
||||
<div className={styles.requirementButtonContainer}>
|
||||
<Button
|
||||
className={styles.requirementButton}
|
||||
onClick={() => setActiveRequirement("minimum")}
|
||||
theme={
|
||||
activeRequirement === "minimum" ? "primary" : "outline"
|
||||
}
|
||||
>
|
||||
{t("minimum")}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.requirementButton}
|
||||
onClick={() => setActiveRequirement("recommended")}
|
||||
theme={
|
||||
activeRequirement === "recommended" ? "primary" : "outline"
|
||||
}
|
||||
>
|
||||
{t("recommended")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.requirementsDetails}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
gameDetails?.pc_requirements?.[activeRequirement] ??
|
||||
t(`no_${activeRequirement}_requirements`, {
|
||||
title: gameDetails?.name,
|
||||
}),
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{fromRandomizer && (
|
||||
<Button
|
||||
className={styles.randomizerButton}
|
||||
onClick={handleRandomizerClick}
|
||||
theme="outline"
|
||||
>
|
||||
<div style={{ width: 16, height: 16, position: "relative" }}>
|
||||
<Lottie
|
||||
animationData={starsAnimation}
|
||||
style={{ width: 70, position: "absolute", top: -28, left: -27 }}
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
{t("next_suggestion")}
|
||||
</Button>
|
||||
)}
|
||||
</SkeletonTheme>
|
||||
);
|
||||
}
|
32
src/renderer/pages/game-details/hero-panel.css.ts
Normal file
32
src/renderer/pages/game-details/hero-panel.css.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
|
||||
export const panel = style({
|
||||
width: "100%",
|
||||
height: "72px",
|
||||
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
transition: "all ease 0.2s",
|
||||
borderBottom: `solid 1px ${vars.color.borderColor}`,
|
||||
boxShadow: "0px 0px 15px 0px #000000",
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
fontSize: vars.size.bodyFontSize,
|
||||
});
|
||||
|
||||
export const actions = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const downloadDetailsRow = style({
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
});
|
356
src/renderer/pages/game-details/hero-panel.tsx
Normal file
356
src/renderer/pages/game-details/hero-panel.tsx
Normal file
|
@ -0,0 +1,356 @@
|
|||
import { format } from "date-fns";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@renderer/components";
|
||||
import { useDownload, useLibrary } from "@renderer/hooks";
|
||||
import type { Game, ShopDetails } from "@types";
|
||||
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
|
||||
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
|
||||
import * as styles from "./hero-panel.css";
|
||||
import { useDate } from "@renderer/hooks/use-date";
|
||||
|
||||
export interface HeroPanelProps {
|
||||
game: Game | null;
|
||||
gameDetails: ShopDetails | null;
|
||||
color: string;
|
||||
isGamePlaying: boolean;
|
||||
openRepacksModal: () => void;
|
||||
getGame: () => void;
|
||||
}
|
||||
|
||||
export function HeroPanel({
|
||||
game,
|
||||
gameDetails,
|
||||
color,
|
||||
openRepacksModal,
|
||||
getGame,
|
||||
isGamePlaying,
|
||||
}: HeroPanelProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
|
||||
const [lastTimePlayed, setLastTimePlayed] = useState("");
|
||||
|
||||
const { formatDistance } = useDate();
|
||||
|
||||
const {
|
||||
game: gameDownloading,
|
||||
isDownloading,
|
||||
progress,
|
||||
eta,
|
||||
numPeers,
|
||||
numSeeds,
|
||||
resumeDownload,
|
||||
pauseDownload,
|
||||
cancelDownload,
|
||||
removeGame,
|
||||
isGameDeleting,
|
||||
} = useDownload();
|
||||
const { updateLibrary, library } = useLibrary();
|
||||
|
||||
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
|
||||
useState(false);
|
||||
|
||||
const gameOnLibrary = library.find(
|
||||
({ objectID }) => objectID === gameDetails?.objectID
|
||||
);
|
||||
|
||||
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
|
||||
|
||||
const updateLastTimePlayed = useCallback(() => {
|
||||
setLastTimePlayed(
|
||||
formatDistance(game.lastTimePlayed, new Date(), {
|
||||
addSuffix: true,
|
||||
})
|
||||
);
|
||||
}, [game?.lastTimePlayed, formatDistance]);
|
||||
|
||||
useEffect(() => {
|
||||
if (game?.lastTimePlayed) {
|
||||
updateLastTimePlayed();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
updateLastTimePlayed();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
}, [game?.lastTimePlayed, updateLastTimePlayed]);
|
||||
|
||||
const openGameInstaller = () => {
|
||||
window.electron.openGameInstaller(game.id).then((isBinaryInPath) => {
|
||||
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
|
||||
updateLibrary();
|
||||
});
|
||||
};
|
||||
|
||||
const openGame = () => {
|
||||
if (game.executablePath) {
|
||||
window.electron.openGame(game.id, game.executablePath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (game?.executablePath) {
|
||||
window.electron.openGame(game.id, game.executablePath);
|
||||
return;
|
||||
}
|
||||
|
||||
window.electron
|
||||
.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "Game executable (.exe)", extensions: ["exe"] }],
|
||||
})
|
||||
.then(({ filePaths }) => {
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const path = filePaths[0];
|
||||
window.electron.openGame(game.id, path);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const closeGame = () => {
|
||||
window.electron.closeGame(game.id);
|
||||
};
|
||||
|
||||
const finalDownloadSize = useMemo(() => {
|
||||
if (!game) return "N/A";
|
||||
if (game.fileSize) return prettyBytes(game.fileSize);
|
||||
|
||||
if (gameDownloading?.fileSize && isGameDownloading)
|
||||
return prettyBytes(gameDownloading.fileSize);
|
||||
|
||||
return game.repack?.fileSize ?? "N/A";
|
||||
}, [game, isGameDownloading, gameDownloading]);
|
||||
|
||||
const toggleLibraryGame = async () => {
|
||||
setToggleLibraryGameDisabled(true);
|
||||
|
||||
try {
|
||||
if (gameOnLibrary) {
|
||||
await window.electron.removeGame(gameOnLibrary.id);
|
||||
} else {
|
||||
await window.electron.addGameToLibrary(
|
||||
gameDetails.objectID,
|
||||
gameDetails.name,
|
||||
"steam"
|
||||
);
|
||||
}
|
||||
|
||||
await updateLibrary();
|
||||
} finally {
|
||||
setToggleLibraryGameDisabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getInfo = () => {
|
||||
if (!gameDetails) return null;
|
||||
|
||||
if (isGameDeleting(game?.id)) {
|
||||
return <p>{t("deleting")}</p>;
|
||||
}
|
||||
|
||||
if (isGameDownloading) {
|
||||
return (
|
||||
<>
|
||||
<p className={styles.downloadDetailsRow}>
|
||||
{progress}
|
||||
{eta && <small>{t("eta", { eta })}</small>}
|
||||
</p>
|
||||
|
||||
{gameDownloading?.status !== "downloading" ? (
|
||||
<>
|
||||
<p>{t(gameDownloading?.status)}</p>
|
||||
{eta && <small>{t("eta", { eta })}</small>}
|
||||
</>
|
||||
) : (
|
||||
<p className={styles.downloadDetailsRow}>
|
||||
{prettyBytes(gameDownloading?.bytesDownloaded)} /{" "}
|
||||
{finalDownloadSize}
|
||||
<small>
|
||||
{numPeers} peers / {numSeeds} seeds
|
||||
</small>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === "paused") {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{t("paused_progress", {
|
||||
progress: formatDownloadProgress(game.progress),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{prettyBytes(game.bytesDownloaded)} / {finalDownloadSize}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === "seeding") {
|
||||
if (!game.lastTimePlayed) {
|
||||
return <p>{t("not_played_yet", { title: game.title })}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{t("play_time", {
|
||||
amount: formatDistance(0, game.playTimeInMilliseconds),
|
||||
})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{t("last_time_played", {
|
||||
period: lastTimePlayed,
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const [latestRepack] = gameDetails.repacks;
|
||||
|
||||
if (latestRepack) {
|
||||
const lastUpdate = format(latestRepack.uploadDate!, "dd/MM/yyyy");
|
||||
const repacksCount = gameDetails.repacks.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{t("updated_at", { updated_at: lastUpdate })}</p>
|
||||
<p>{t("download_options", { count: repacksCount })}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <p>{t("no_downloads")}</p>;
|
||||
};
|
||||
|
||||
const getActions = () => {
|
||||
const deleting = isGameDeleting(game?.id);
|
||||
|
||||
const toggleGameOnLibraryButton = (
|
||||
<Button
|
||||
theme="outline"
|
||||
disabled={!gameDetails || toggleLibraryGameDisabled}
|
||||
onClick={toggleLibraryGame}
|
||||
>
|
||||
{gameOnLibrary ? <NoEntryIcon /> : <PlusCircleIcon />}
|
||||
{gameOnLibrary ? t("remove_from_library") : t("add_to_library")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (isGameDownloading) {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => pauseDownload(game.id)} theme="outline">
|
||||
{t("pause")}
|
||||
</Button>
|
||||
<Button onClick={() => cancelDownload(game.id)} theme="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === "paused") {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => resumeDownload(game.id)} theme="outline">
|
||||
{t("resume")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => cancelDownload(game.id).then(getGame)}
|
||||
theme="outline"
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === "seeding") {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={openGameInstaller}
|
||||
theme="outline"
|
||||
disabled={deleting || isGamePlaying}
|
||||
>
|
||||
{t("install")}
|
||||
</Button>
|
||||
|
||||
{isGamePlaying ? (
|
||||
<Button onClick={closeGame} theme="outline" disabled={deleting}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={openGame}
|
||||
theme="outline"
|
||||
disabled={deleting || isGamePlaying}
|
||||
>
|
||||
{t("play")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (game?.status === "cancelled") {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={openRepacksModal}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => removeGame(game.id).then(getGame)}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
>
|
||||
{t("remove_from_list")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (gameDetails && gameDetails.repacks.length) {
|
||||
return (
|
||||
<>
|
||||
{toggleGameOnLibraryButton}
|
||||
<Button onClick={openRepacksModal} theme="outline">
|
||||
{t("open_download_options")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return toggleGameOnLibraryButton;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BinaryNotFoundModal
|
||||
visible={showBinaryNotFoundModal}
|
||||
onClose={() => setShowBinaryNotFoundModal(false)}
|
||||
/>
|
||||
<div style={{ backgroundColor: color }} className={styles.panel}>
|
||||
<div className={styles.content}>{getInfo()}</div>
|
||||
<div className={styles.actions}>{getActions()}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
69
src/renderer/pages/game-details/how-long-to-beat-section.tsx
Normal file
69
src/renderer/pages/game-details/how-long-to-beat-section.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
import type { HowLongToBeatCategory } from "@types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { vars } from "@renderer/theme.css";
|
||||
import * as styles from "./game-details.css";
|
||||
|
||||
const durationTranslation: Record<string, string> = {
|
||||
Hours: "hours",
|
||||
Mins: "minutes",
|
||||
};
|
||||
|
||||
export interface HowLongToBeatSectionProps {
|
||||
howLongToBeatData: HowLongToBeatCategory[] | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function HowLongToBeatSection({
|
||||
howLongToBeatData,
|
||||
isLoading,
|
||||
}: HowLongToBeatSectionProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const getDuration = (duration: string) => {
|
||||
const [value, unit] = duration.split(" ");
|
||||
return `${value} ${t(durationTranslation[unit])}`;
|
||||
};
|
||||
|
||||
if (!howLongToBeatData && !isLoading) return null;
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<div className={styles.contentSidebarTitle}>
|
||||
<h3>HowLongToBeat</h3>
|
||||
</div>
|
||||
|
||||
<ul className={styles.howLongToBeatCategoriesList}>
|
||||
{howLongToBeatData
|
||||
? howLongToBeatData.map((category) => (
|
||||
<li key={category.title} className={styles.howLongToBeatCategory}>
|
||||
<p
|
||||
className={styles.howLongToBeatCategoryLabel}
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{category.title}
|
||||
</p>
|
||||
|
||||
<p className={styles.howLongToBeatCategoryLabel}>
|
||||
{getDuration(category.duration)}
|
||||
</p>
|
||||
|
||||
{category.accuracy !== "00" && (
|
||||
<small>
|
||||
{t("accuracy", { accuracy: category.accuracy })}
|
||||
</small>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
: Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className={styles.howLongToBeatCategorySkeleton}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
}
|
18
src/renderer/pages/game-details/repacks-modal.css.ts
Normal file
18
src/renderer/pages/game-details/repacks-modal.css.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
|
||||
export const repacks = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
flexDirection: "column",
|
||||
});
|
||||
|
||||
export const repackButton = style({
|
||||
display: "flex",
|
||||
textAlign: "left",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
color: vars.color.bodyText,
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
});
|
96
src/renderer/pages/game-details/repacks-modal.tsx
Normal file
96
src/renderer/pages/game-details/repacks-modal.tsx
Normal file
|
@ -0,0 +1,96 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
|
||||
import { Button, Modal, TextField } from "@renderer/components";
|
||||
import type { GameRepack, ShopDetails } from "@types";
|
||||
|
||||
import * as styles from "./repacks-modal.css";
|
||||
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
import { format } from "date-fns";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
|
||||
export interface RepacksModalProps {
|
||||
visible: boolean;
|
||||
gameDetails: ShopDetails;
|
||||
startDownload: (repackId: number) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RepacksModal({
|
||||
visible,
|
||||
gameDetails,
|
||||
startDownload,
|
||||
onClose,
|
||||
}: RepacksModalProps) {
|
||||
const [downloadStarting, setDownloadStarting] = useState(false);
|
||||
const [diskFreeSpace, setDiskFreeSpace] = useState<DiskSpace>(null);
|
||||
const [filteredRepacks, setFilteredRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredRepacks(gameDetails.repacks);
|
||||
}, [gameDetails.repacks]);
|
||||
|
||||
const getDiskFreeSpace = () => {
|
||||
window.electron.getDiskFreeSpace().then((result) => {
|
||||
setDiskFreeSpace(result);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getDiskFreeSpace();
|
||||
}, [visible]);
|
||||
|
||||
const handleRepackClick = (repack: GameRepack) => {
|
||||
setDownloadStarting(true);
|
||||
startDownload(repack.id).finally(() => {
|
||||
setDownloadStarting(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
setFilteredRepacks(
|
||||
gameDetails.repacks.filter((repack) =>
|
||||
repack.title
|
||||
.toLowerCase()
|
||||
.includes(event.target.value.toLocaleLowerCase())
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={`${gameDetails.name} Repacks`}
|
||||
description={t("space_left_on_disk", {
|
||||
space: prettyBytes(diskFreeSpace?.free ?? 0),
|
||||
})}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div style={{ marginBottom: `${SPACING_UNIT * 2}px` }}>
|
||||
<TextField placeholder={t("filter")} onChange={handleFilter} />
|
||||
</div>
|
||||
|
||||
<div className={styles.repacks}>
|
||||
{filteredRepacks.map((repack) => (
|
||||
<Button
|
||||
key={repack.id}
|
||||
theme="dark"
|
||||
onClick={() => handleRepackClick(repack)}
|
||||
disabled={downloadStarting}
|
||||
className={styles.repackButton}
|
||||
>
|
||||
<p style={{ color: "#DADBE1" }}>{repack.title}</p>
|
||||
<p style={{ fontSize: "12px" }}>
|
||||
{repack.fileSize} - {repack.repacker} -{" "}
|
||||
{format(repack.uploadDate, "dd/MM/yyyy")}
|
||||
</p>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
5
src/renderer/pages/index.ts
Normal file
5
src/renderer/pages/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export * from "./catalogue/catalogue";
|
||||
export * from "./game-details/game-details";
|
||||
export * from "./downloads/downloads";
|
||||
export * from "./catalogue/search-results";
|
||||
export * from "./settings/settings";
|
26
src/renderer/pages/settings/settings.css.ts
Normal file
26
src/renderer/pages/settings/settings.css.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const container = style({
|
||||
padding: "24px",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
backgroundColor: vars.color.background,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: `${SPACING_UNIT * 3}px`,
|
||||
border: `solid 1px ${vars.color.borderColor}`,
|
||||
boxShadow: "0px 0px 15px 0px #000000",
|
||||
borderRadius: "8px",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
});
|
||||
|
||||
export const downloadsPathField = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
});
|
101
src/renderer/pages/settings/settings.tsx
Normal file
101
src/renderer/pages/settings/settings.tsx
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Button, CheckboxField, TextField } from "@renderer/components";
|
||||
|
||||
import * as styles from "./settings.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserPreferences } from "@types";
|
||||
|
||||
export function Settings() {
|
||||
const [form, setForm] = useState({
|
||||
downloadsPath: "",
|
||||
downloadNotificationsEnabled: false,
|
||||
repackUpdatesNotificationsEnabled: false,
|
||||
});
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
window.electron.getDefaultDownloadsPath(),
|
||||
window.electron.getUserPreferences(),
|
||||
]).then(([path, userPreferences]) => {
|
||||
setForm({
|
||||
downloadsPath: userPreferences?.downloadsPath || path,
|
||||
downloadNotificationsEnabled:
|
||||
userPreferences?.downloadNotificationsEnabled,
|
||||
repackUpdatesNotificationsEnabled:
|
||||
userPreferences?.repackUpdatesNotificationsEnabled,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateUserPreferences = <T extends keyof UserPreferences>(
|
||||
field: T,
|
||||
value: UserPreferences[T]
|
||||
) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
window.electron.updateUserPreferences({
|
||||
[field]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleChooseDownloadsPath = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
defaultPath: form.downloadsPath,
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const path = filePaths[0];
|
||||
updateUserPreferences("downloadsPath", path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.downloadsPathField}>
|
||||
<TextField
|
||||
label={t("downloads_path")}
|
||||
value={form.downloadsPath}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Button
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
theme="outline"
|
||||
onClick={handleChooseDownloadsPath}
|
||||
>
|
||||
{t("change")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h3>{t("notifications")}</h3>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_download_notifications")}
|
||||
checked={form.downloadNotificationsEnabled}
|
||||
onChange={() =>
|
||||
updateUserPreferences(
|
||||
"downloadNotificationsEnabled",
|
||||
!form.downloadNotificationsEnabled
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_repack_list_notifications")}
|
||||
checked={form.repackUpdatesNotificationsEnabled}
|
||||
onChange={() =>
|
||||
updateUserPreferences(
|
||||
"repackUpdatesNotificationsEnabled",
|
||||
!form.repackUpdatesNotificationsEnabled
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
25
src/renderer/pages/shared-modals/binary-not-found-modal.tsx
Normal file
25
src/renderer/pages/shared-modals/binary-not-found-modal.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Modal } from "@renderer/components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface BinaryNotFoundModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const BinaryNotFoundModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
}: BinaryNotFoundModalProps) => {
|
||||
const { t } = useTranslation("binary_not_found_modal");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
onClose={onClose}
|
||||
>
|
||||
{t("instructions")}
|
||||
</Modal>
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue