first commit

This commit is contained in:
Hydra 2024-04-18 08:46:06 +01:00
commit f1bdec484e
165 changed files with 20993 additions and 0 deletions

View 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,
},
},
},
});

View 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",
});

View 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>
);
}

View 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>
);
}

View 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`,
});

View 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>
);
}

View 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%",
});

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
});

View 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>
);
}

View 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",
});

View 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>
</>
);
}

View 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>
);
}

View 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`,
});

View 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>
);
}

View 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";

View 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`,
});

View 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>
);
}

View 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>
);
};