Merge branch 'main' into patch-1

This commit is contained in:
Hydra 2024-05-13 10:39:15 +01:00 committed by GitHub
commit 89a53f9b5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 480 additions and 508 deletions

View file

@ -1,31 +1,18 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const card = recipe({
base: {
width: "100%",
height: "180px",
boxShadow: "0px 0px 15px 0px #000000",
overflow: "hidden",
borderRadius: "4px",
transition: "all ease 0.2s",
border: `solid 1px ${vars.color.border}`,
cursor: "pointer",
zIndex: "1",
":active": {
opacity: vars.opacity.active,
},
},
variants: {
disabled: {
true: {
pointerEvents: "none",
boxShadow: "none",
opacity: vars.opacity.disabled,
filter: "grayscale(50%)",
},
},
export const card = style({
width: "100%",
height: "180px",
boxShadow: "0px 0px 15px 0px #000000",
overflow: "hidden",
borderRadius: "4px",
transition: "all ease 0.2s",
border: `solid 1px ${vars.color.border}`,
cursor: "pointer",
zIndex: "1",
":active": {
opacity: vars.opacity.active,
},
});
@ -48,7 +35,7 @@ export const cover = style({
zIndex: "-1",
transition: "all ease 0.2s",
selectors: {
[`${card({})}:hover &`]: {
[`${card}:hover &`]: {
transform: "scale(1.05)",
},
},
@ -64,7 +51,7 @@ export const content = style({
transition: "all ease 0.2s",
transform: "translateY(24px)",
selectors: {
[`${card({})}:hover &`]: {
[`${card}:hover &`]: {
transform: "translateY(0px)",
},
},

View file

@ -14,7 +14,6 @@ export interface GameCardProps
HTMLButtonElement
> {
game: CatalogueEntry;
disabled?: boolean;
}
const shopIcon = {
@ -22,7 +21,7 @@ const shopIcon = {
steam: <SteamLogo className={styles.shopIcon} />,
};
export function GameCard({ game, disabled, ...props }: GameCardProps) {
export function GameCard({ game, ...props }: GameCardProps) {
const { t } = useTranslation("game_card");
const repackersFriendlyNames = useAppSelector(
@ -34,12 +33,7 @@ export function GameCard({ game, disabled, ...props }: GameCardProps) {
);
return (
<button
{...props}
type="button"
className={styles.card({ disabled })}
disabled={disabled}
>
<button {...props} type="button" className={styles.card}>
<div className={styles.backdrop}>
<img src={game.cover} alt={game.title} className={styles.cover} />

View file

@ -6,7 +6,7 @@ export const hero = style({
height: "280px",
minHeight: "280px",
maxHeight: "280px",
borderRadius: "8px",
borderRadius: "4px",
color: "#DADBE1",
overflow: "hidden",
boxShadow: "0px 0px 15px 0px #000000",
@ -45,6 +45,7 @@ export const description = style({
textAlign: "left",
fontFamily: "'Fira Sans', sans-serif",
lineHeight: "20px",
marginTop: `${SPACING_UNIT * 2}px`,
});
export const content = style({

View file

@ -2,20 +2,28 @@ import { useNavigate } from "react-router-dom";
import * as styles from "./hero.css";
import { useEffect, useState } from "react";
import { ShopDetails } from "@types";
import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers";
import {
buildGameDetailsPath,
getSteamLanguage,
steamUrlBuilder,
} from "@renderer/helpers";
import { useTranslation } from "react-i18next";
const FEATURED_GAME_TITLE = "Horizon Forbidden West™ Complete Edition";
const FEATURED_GAME_ID = "2420110";
export function Hero() {
const [featuredGameDetails, setFeaturedGameDetails] =
useState<ShopDetails | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { i18n } = useTranslation();
const navigate = useNavigate();
useEffect(() => {
setIsLoading(true);
window.electron
.getGameShopDetails(
FEATURED_GAME_ID,
@ -24,19 +32,30 @@ export function Hero() {
)
.then((result) => {
setFeaturedGameDetails(result);
})
.finally(() => {
setIsLoading(false);
});
}, [i18n.language]);
return (
<button
type="button"
onClick={() => navigate(`/game/steam/${FEATURED_GAME_ID}`)}
onClick={() =>
navigate(
buildGameDetailsPath({
title: FEATURED_GAME_TITLE,
objectID: FEATURED_GAME_ID,
shop: "steam",
})
)
}
className={styles.hero}
>
<div className={styles.backdrop}>
<img
src="https://cdn2.steamgriddb.com/hero/4ef10445b952a8b3c93a9379d581146a.jpg"
alt={featuredGameDetails?.name}
alt={FEATURED_GAME_TITLE}
className={styles.heroMedia}
/>
@ -44,13 +63,14 @@ export function Hero() {
<img
src={steamUrlBuilder.logo(FEATURED_GAME_ID)}
width="250px"
alt={featuredGameDetails?.name}
style={{ marginBottom: 16 }}
alt={FEATURED_GAME_TITLE}
/>
<p className={styles.description}>
{featuredGameDetails?.short_description}
</p>
{!isLoading && featuredGameDetails && (
<p className={styles.description}>
{featuredGameDetails?.short_description}
</p>
)}
</div>
</div>
</button>

View file

@ -15,6 +15,7 @@ import XLogo from "@renderer/assets/x-icon.svg?react";
import * as styles from "./sidebar.css";
import { GameStatus, GameStatusHelper } from "@shared";
import { buildGameDetailsPath } from "@renderer/helpers";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@ -209,9 +210,7 @@ export function Sidebar() {
type="button"
className={styles.menuItemButton}
onClick={() =>
handleSidebarItemClick(
`/game/${game.shop}/${game.objectID}`
)
handleSidebarItemClick(buildGameDetailsPath(game))
}
>
<img

View file

@ -2,9 +2,11 @@ import type {
CatalogueCategory,
CatalogueEntry,
Game,
GameRepack,
GameShop,
HowLongToBeatCategory,
ShopDetails,
Steam250Game,
TorrentProgress,
UserPreferences,
} from "@types";
@ -40,7 +42,7 @@ declare global {
shop: GameShop,
language: string
) => Promise<ShopDetails | null>;
getRandomGame: () => Promise<string>;
getRandomGame: () => Promise<Steam250Game>;
getHowLongToBeat: (
objectID: string,
shop: GameShop,
@ -50,6 +52,7 @@ declare global {
take?: number,
prevCursor?: number
) => Promise<{ results: CatalogueEntry[]; cursor: number }>;
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
/* Library */
addGameToLibrary: (

View file

@ -1,3 +1,5 @@
import type { CatalogueEntry } from "@types";
export const steamUrlBuilder = {
library: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
@ -29,3 +31,11 @@ export const getSteamLanguage = (language: string) => {
return "english";
};
export const buildGameDetailsPath = (
game: Pick<CatalogueEntry, "title" | "shop" | "objectID">,
params: Record<string, string> = {}
) => {
const searchParams = new URLSearchParams({ title: game.title, ...params });
return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`;
};

View file

@ -57,6 +57,7 @@ i18n
},
})
.then(() => {
i18n.changeLanguage("pt-BR");
window.electron.updateUserPreferences({ language: i18n.language });
});

View file

@ -11,6 +11,7 @@ import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import * as styles from "../home/home.css";
import { ArrowLeftIcon, ArrowRightIcon } from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers";
export function Catalogue() {
const dispatch = useAppDispatch();
@ -31,7 +32,7 @@ export function Catalogue() {
const handleGameClick = (game: CatalogueEntry) => {
dispatch(clearSearch());
navigate(`/game/${game.shop}/${game.objectID}`);
navigate(buildGameDetailsPath(game));
};
useEffect(() => {

View file

@ -11,7 +11,7 @@ import * as styles from "./game-details.css";
const OPEN_HYDRA_URL = "https://open.hydralauncher.site";
export interface DescriptionHeaderProps {
gameDetails: ShopDetails | null;
gameDetails: ShopDetails;
}
export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
@ -64,7 +64,7 @@ export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
date: gameDetails?.release_date.date,
})}
</p>
<p>{t("publisher", { publisher: gameDetails?.publishers[0] })}</p>
<p>{t("publisher", { publisher: gameDetails.publishers[0] })}</p>
</section>
<Button

View file

@ -7,7 +7,7 @@ import * as styles from "./gallery-slider.css";
import { useTranslation } from "react-i18next";
export interface GallerySliderProps {
gameDetails: ShopDetails | null;
gameDetails: ShopDetails;
}
export function GallerySlider({ gameDetails }: GallerySliderProps) {
@ -20,14 +20,12 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
const hasMovies = gameDetails && gameDetails.movies?.length;
const [mediaCount] = useState<number>(() => {
if (gameDetails) {
if (gameDetails.screenshots && gameDetails.movies) {
return gameDetails.screenshots.length + gameDetails.movies.length;
} else if (gameDetails.movies) {
return gameDetails.movies.length;
} else if (gameDetails.screenshots) {
return gameDetails.screenshots.length;
}
if (gameDetails.screenshots && gameDetails.movies) {
return gameDetails.screenshots.length + gameDetails.movies.length;
} else if (gameDetails.movies) {
return gameDetails.movies.length;
} else if (gameDetails.screenshots) {
return gameDetails.screenshots.length;
}
return 0;

View file

@ -1,7 +1,10 @@
import Skeleton from "react-loading-skeleton";
import { Button } from "@renderer/components";
import * as styles from "./game-details.css";
import * as sidebarStyles from "./sidebar/sidebar.css";
import { useTranslation } from "react-i18next";
import { ShareAndroidIcon } from "@primer/octicons-react";
@ -43,41 +46,41 @@ export function GameDetailsSkeleton() {
<Skeleton />
</div>
</div>
<div className={styles.contentSidebar}>
<div className={styles.contentSidebarTitle}>
<div className={sidebarStyles.contentSidebar}>
<div className={sidebarStyles.contentSidebarTitle}>
<h3>HowLongToBeat</h3>
</div>
<ul className={styles.howLongToBeatCategoriesList}>
<ul className={sidebarStyles.howLongToBeatCategoriesList}>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton
key={index}
className={styles.howLongToBeatCategorySkeleton}
className={sidebarStyles.howLongToBeatCategorySkeleton}
/>
))}
</ul>
<div
className={styles.contentSidebarTitle}
className={sidebarStyles.contentSidebarTitle}
style={{ border: "none" }}
>
<h3>{t("requirements")}</h3>
</div>
<div className={styles.requirementButtonContainer}>
<div className={sidebarStyles.requirementButtonContainer}>
<Button
className={styles.requirementButton}
className={sidebarStyles.requirementButton}
theme="primary"
disabled
>
{t("minimum")}
</Button>
<Button
className={styles.requirementButton}
className={sidebarStyles.requirementButton}
theme="outline"
disabled
>
{t("recommended")}
</Button>
</div>
<div className={styles.requirementsDetailsSkeleton}>
<div className={sidebarStyles.requirementsDetailsSkeleton}>
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} height={20} />
))}

View file

@ -79,62 +79,6 @@ export const descriptionContent = style({
height: "100%",
});
export const contentSidebar = style({
borderLeft: `solid 1px ${vars.color.border};`,
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,
});
export const requirementButtonContainer = style({
width: "100%",
display: "flex",
});
export const requirementButton = style({
border: `solid 1px ${vars.color.border};`,
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",
@ -183,34 +127,6 @@ export const descriptionHeaderInfo = style({
flexDirection: "column",
});
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.border}`,
});
export const howLongToBeatCategoryLabel = style({
color: vars.color.muted,
});
export const howLongToBeatCategorySkeleton = style({
border: `solid 1px ${vars.color.border}`,
borderRadius: "8px",
height: "76px",
});
export const randomizerButton = style({
animationName: slideIn,
animationDuration: "0.2s",
@ -260,8 +176,3 @@ globalStyle(`${description} img`, {
globalStyle(`${description} a`, {
color: vars.color.bodyText,
});
globalStyle(`${requirementsDetails} a`, {
display: "flex",
color: vars.color.bodyText,
});

View file

@ -3,18 +3,21 @@ import { average } from "color.js";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import type {
Game,
GameRepack,
GameShop,
HowLongToBeatCategory,
ShopDetails,
SteamAppDetails,
import {
Steam250Game,
type Game,
type GameRepack,
type GameShop,
type ShopDetails,
} from "@types";
import { Button } from "@renderer/components";
import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers";
import {
buildGameDetailsPath,
getSteamLanguage,
steamUrlBuilder,
} from "@renderer/helpers";
import { useAppDispatch, useDownload } from "@renderer/hooks";
import starsAnimation from "@renderer/assets/lottie/stars.json";
@ -26,7 +29,6 @@ import { DescriptionHeader } from "./description-header";
import { GameDetailsSkeleton } from "./game-details-skeleton";
import * as styles from "./game-details.css";
import { HeroPanel } from "./hero";
import { HowLongToBeatSection } from "./how-long-to-beat-section";
import { RepacksModal } from "./repacks-modal";
import { vars } from "../../theme.css";
@ -37,18 +39,16 @@ import {
OnlineFixInstallationGuide,
} from "./installation-guides";
import { GallerySlider } from "./gallery-slider";
import { Sidebar } from "./sidebar/sidebar";
export function GameDetails() {
const { objectID, shop } = useParams();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingRandomGame, setIsLoadingRandomGame] = useState(false);
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [color, setColor] = useState({ dark: "", light: "" });
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
const [howLongToBeat, setHowLongToBeat] = useState<{
isLoading: boolean;
data: HowLongToBeatCategory[] | null;
}>({ isLoading: true, data: null });
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [game, setGame] = useState<Game | null>(null);
const [isGamePlaying, setIsGamePlaying] = useState(false);
@ -56,12 +56,12 @@ export function GameDetails() {
null | "onlinefix" | "DODI"
>(null);
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const fromRandomizer = searchParams.get("fromRandomizer");
const title = searchParams.get("title")!;
const { t, i18n } = useTranslation("game_details");
const [showRepacksModal, setShowRepacksModal] = useState(false);
@ -90,37 +90,35 @@ export function GameDetails() {
useEffect(() => {
getGame();
}, [getGame, gameDownloading?.id]);
useEffect(() => {
setGame(null);
setIsLoading(true);
setIsGamePlaying(false);
dispatch(setHeaderTitle(""));
dispatch(setHeaderTitle(title));
window.electron
.getGameShopDetails(objectID!, "steam", getSteamLanguage(i18n.language))
.then((result) => {
if (!result) {
navigate(-1);
return;
}
window.electron.getRandomGame().then((randomGame) => {
setRandomGame(randomGame);
});
window.electron
.getHowLongToBeat(objectID!, "steam", result.name)
.then((data) => {
setHowLongToBeat({ isLoading: false, data });
});
setGameDetails(result);
dispatch(setHeaderTitle(result.name));
setIsLoadingRandomGame(false);
Promise.all([
window.electron.getGameShopDetails(
objectID!,
"steam",
getSteamLanguage(i18n.language)
),
window.electron.searchGameRepacks(title),
])
.then(([appDetails, repacks]) => {
if (appDetails) setGameDetails(appDetails);
setRepacks(repacks);
})
.finally(() => {
setIsLoading(false);
});
getGame();
setHowLongToBeat({ isLoading: true, data: null });
}, [getGame, dispatch, navigate, objectID, i18n.language]);
}, [getGame, dispatch, navigate, title, objectID, i18n.language]);
const isGameDownloading = gameDownloading?.id === game?.id;
@ -154,55 +152,49 @@ export function GameDetails() {
repack: GameRepack,
downloadPath: string
) => {
if (gameDetails) {
return startDownload(
repack.id,
gameDetails.objectID,
gameDetails.name,
shop as GameShop,
downloadPath
).then(() => {
getGame();
setShowRepacksModal(false);
return startDownload(
repack.id,
objectID!,
title,
shop as GameShop,
downloadPath
).then(() => {
getGame();
setShowRepacksModal(false);
if (
repack.repacker === "onlinefix" &&
!window.localStorage.getItem(DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY)
) {
setShowInstructionsModal("onlinefix");
} else if (
repack.repacker === "DODI" &&
!window.localStorage.getItem(DONT_SHOW_DODI_INSTRUCTIONS_KEY)
) {
setShowInstructionsModal("DODI");
}
});
if (
repack.repacker === "onlinefix" &&
!window.localStorage.getItem(DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY)
) {
setShowInstructionsModal("onlinefix");
} else if (
repack.repacker === "DODI" &&
!window.localStorage.getItem(DONT_SHOW_DODI_INSTRUCTIONS_KEY)
) {
setShowInstructionsModal("DODI");
}
});
};
const handleRandomizerClick = () => {
if (randomGame) {
navigate(
buildGameDetailsPath(
{ ...randomGame, shop: "steam" },
{ fromRandomizer: "1" }
)
);
}
};
const handleRandomizerClick = async () => {
setIsLoadingRandomGame(true);
const randomGameObjectID = await window.electron.getRandomGame();
const searchParams = new URLSearchParams({
fromRandomizer: "1",
});
navigate(`/game/steam/${randomGameObjectID}?${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)}
/>
)}
<RepacksModal
visible={showRepacksModal}
repacks={repacks}
startDownload={handleStartDownload}
onClose={() => setShowRepacksModal(false)}
/>
<OnlineFixInstallationGuide
visible={showInstructionsModal === "onlinefix"}
@ -240,7 +232,9 @@ export function GameDetails() {
<HeroPanel
game={game}
color={color.dark}
gameDetails={gameDetails}
objectID={objectID!}
title={title}
repacks={repacks}
openRepacksModal={() => setShowRepacksModal(true)}
getGame={getGame}
isGamePlaying={isGamePlaying}
@ -248,63 +242,22 @@ export function GameDetails() {
<div className={styles.descriptionContainer}>
<div className={styles.descriptionContent}>
<DescriptionHeader gameDetails={gameDetails} />
<GallerySlider gameDetails={gameDetails} />
{gameDetails && <DescriptionHeader gameDetails={gameDetails} />}
{gameDetails && <GallerySlider gameDetails={gameDetails} />}
<div
dangerouslySetInnerHTML={{
__html: gameDetails?.about_the_game ?? "",
__html: gameDetails?.about_the_game ?? t("no_shop_details"),
}}
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>
<Sidebar
objectID={objectID!}
title={title}
gameDetails={gameDetails}
/>
</div>
</section>
)}
@ -314,7 +267,7 @@ export function GameDetails() {
className={styles.randomizerButton}
onClick={handleRandomizerClick}
theme="outline"
disabled={isLoadingRandomGame}
disabled={!randomGame}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie

View file

@ -3,7 +3,7 @@ import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks";
import type { Game, ShopDetails } from "@types";
import type { Game, GameRepack } from "@types";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -11,9 +11,11 @@ import * as styles from "./hero-panel-actions.css";
export interface HeroPanelActionsProps {
game: Game | null;
gameDetails: ShopDetails | null;
repacks: GameRepack[];
isGamePlaying: boolean;
isGameDownloading: boolean;
objectID: string;
title: string;
openRepacksModal: () => void;
openBinaryNotFoundModal: () => void;
getGame: () => void;
@ -21,9 +23,11 @@ export interface HeroPanelActionsProps {
export function HeroPanelActions({
game,
gameDetails,
isGamePlaying,
isGameDownloading,
repacks,
objectID,
title,
openRepacksModal,
openBinaryNotFoundModal,
getGame,
@ -69,12 +73,12 @@ export function HeroPanelActions({
try {
if (game) {
await removeGameFromLibrary(game.id);
} else if (gameDetails) {
} else {
const gameExecutablePath = await selectGameExecutable();
await window.electron.addGameToLibrary(
gameDetails.objectID,
gameDetails.name,
objectID,
title,
"steam",
gameExecutablePath
);
@ -123,7 +127,7 @@ export function HeroPanelActions({
const toggleGameOnLibraryButton = (
<Button
theme="outline"
disabled={!gameDetails || toggleLibraryGameDisabled}
disabled={toggleLibraryGameDisabled}
onClick={toggleGameOnLibrary}
className={styles.heroPanelAction}
>
@ -239,7 +243,7 @@ export function HeroPanelActions({
);
}
if (gameDetails && gameDetails.repacks.length) {
if (repacks.length) {
return (
<>
{toggleGameOnLibraryButton}

View file

@ -4,13 +4,13 @@ import { SPACING_UNIT, vars } from "../../../theme.css";
export const panel = style({
width: "100%",
height: "72px",
minHeight: "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.border}`,
boxShadow: "0px 0px 15px 0px #000000",
});
export const content = style({

View file

@ -3,7 +3,7 @@ import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDownload } from "@renderer/hooks";
import type { Game, ShopDetails } from "@types";
import type { Game, GameRepack } from "@types";
import { formatDownloadProgress } from "@renderer/helpers";
import { HeroPanelActions } from "./hero-panel-actions";
@ -15,20 +15,24 @@ import { HeroPanelPlaytime } from "./hero-panel-playtime";
export interface HeroPanelProps {
game: Game | null;
gameDetails: ShopDetails | null;
color: string;
isGamePlaying: boolean;
objectID: string;
title: string;
repacks: GameRepack[];
openRepacksModal: () => void;
getGame: () => void;
}
export function HeroPanel({
game,
gameDetails,
color,
repacks,
objectID,
title,
isGamePlaying,
openRepacksModal,
getGame,
isGamePlaying,
}: HeroPanelProps) {
const { t } = useTranslation("game_details");
@ -58,8 +62,6 @@ export function HeroPanel({
}, [game, isGameDownloading, gameDownloading]);
const getInfo = () => {
if (!gameDetails) return null;
if (isGameDeleting(game?.id ?? -1)) {
return <p>{t("deleting")}</p>;
}
@ -110,11 +112,11 @@ export function HeroPanel({
return <HeroPanelPlaytime game={game} isGamePlaying={isGamePlaying} />;
}
const [latestRepack] = gameDetails.repacks;
const [latestRepack] = repacks;
if (latestRepack) {
const lastUpdate = format(latestRepack.uploadDate!, "dd/MM/yyyy");
const repacksCount = gameDetails.repacks.length;
const repacksCount = repacks.length;
return (
<>
@ -139,7 +141,9 @@ export function HeroPanel({
<div className={styles.actions}>
<HeroPanelActions
game={game}
gameDetails={gameDetails}
repacks={repacks}
objectID={objectID}
title={title}
getGame={getGame}
openRepacksModal={openRepacksModal}
openBinaryNotFoundModal={() => setShowBinaryNotFoundModal(true)}

View file

@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import type { GameRepack, ShopDetails } from "@types";
import type { GameRepack } from "@types";
import * as styles from "./repacks-modal.css";
@ -13,14 +13,14 @@ import { SelectFolderModal } from "./select-folder-modal";
export interface RepacksModalProps {
visible: boolean;
gameDetails: ShopDetails;
repacks: GameRepack[];
startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
onClose: () => void;
}
export function RepacksModal({
visible,
gameDetails,
repacks,
startDownload,
onClose,
}: RepacksModalProps) {
@ -35,8 +35,8 @@ export function RepacksModal({
const { t } = useTranslation("game_details");
useEffect(() => {
setFilteredRepacks(gameDetails.repacks);
}, [gameDetails.repacks, visible]);
setFilteredRepacks(repacks);
}, [repacks, visible]);
const handleRepackClick = (repack: GameRepack) => {
setRepack(repack);
@ -47,7 +47,7 @@ export function RepacksModal({
const term = event.target.value.toLocaleLowerCase();
setFilteredRepacks(
gameDetails.repacks.filter((repack) => {
repacks.filter((repack) => {
const lowerCaseTitle = repack.title.toLowerCase();
const lowerCaseRepacker = repack.repacker.toLowerCase();
@ -63,14 +63,13 @@ export function RepacksModal({
<SelectFolderModal
visible={showSelectFolderModal}
onClose={() => setShowSelectFolderModal(false)}
gameDetails={gameDetails}
startDownload={startDownload}
repack={repack}
/>
<Modal
visible={visible}
title={`${gameDetails.name} Repacks`}
title={t("download_options")}
description={t("repacks_modal_description")}
onClose={onClose}
>

View file

@ -1,5 +1,5 @@
import { Button, Link, Modal, TextField } from "@renderer/components";
import { GameRepack, ShopDetails } from "@types";
import type { GameRepack } from "@types";
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
@ -10,7 +10,6 @@ import { formatBytes } from "@shared";
export interface SelectFolderModalProps {
visible: boolean;
gameDetails: ShopDetails;
onClose: () => void;
startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
repack: GameRepack | null;
@ -18,7 +17,6 @@ export interface SelectFolderModalProps {
export function SelectFolderModal({
visible,
gameDetails,
onClose,
startDownload,
repack,
@ -74,7 +72,7 @@ export function SelectFolderModal({
return (
<Modal
visible={visible}
title={t("installation_folder", { name: gameDetails.name })}
title={t("download_path")}
description={t("space_left_on_disk", {
space: formatBytes(diskFreeSpace?.free ?? 0),
})}
@ -82,12 +80,7 @@ export function SelectFolderModal({
>
<div className={styles.container}>
<div className={styles.downloadsPathField}>
<TextField
label={t("downloads_path")}
value={selectedPath}
readOnly
disabled
/>
<TextField value={selectedPath} readOnly disabled />
<Button
style={{ alignSelf: "flex-end" }}

View file

@ -1,8 +1,8 @@
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { useTranslation } from "react-i18next";
import type { HowLongToBeatCategory } from "@types";
import { vars } from "../../theme.css";
import * as styles from "./game-details.css";
import { vars } from "../../../theme.css";
import * as styles from "./sidebar.css";
const durationTranslation: Record<string, string> = {
Hours: "hours",

View file

@ -0,0 +1,92 @@
import { globalStyle, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const contentSidebar = style({
borderLeft: `solid 1px ${vars.color.border};`,
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,
});
export const requirementButtonContainer = style({
width: "100%",
display: "flex",
});
export const requirementButton = style({
border: `solid 1px ${vars.color.border};`,
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 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.border}`,
});
export const howLongToBeatCategoryLabel = style({
color: vars.color.muted,
});
export const howLongToBeatCategorySkeleton = style({
border: `solid 1px ${vars.color.border}`,
borderRadius: "8px",
height: "76px",
});
globalStyle(`${requirementsDetails} a`, {
display: "flex",
color: vars.color.bodyText,
});

View file

@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { HowLongToBeatSection } from "./how-long-to-beat-section";
import type {
HowLongToBeatCategory,
ShopDetails,
SteamAppDetails,
} from "@types";
import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components";
import * as styles from "./sidebar.css";
export interface SidebarProps {
objectID: string;
title: string;
gameDetails: ShopDetails | null;
}
export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
const [howLongToBeat, setHowLongToBeat] = useState<{
isLoading: boolean;
data: HowLongToBeatCategory[] | null;
}>({ isLoading: true, data: null });
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const { t } = useTranslation("game_details");
useEffect(() => {
setHowLongToBeat({ isLoading: true, data: null });
window.electron
.getHowLongToBeat(objectID, "steam", title)
.then((howLongToBeat) => {
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
})
.catch(() => {
setHowLongToBeat({ isLoading: false, data: null });
});
}, [objectID, title]);
return (
<aside 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,
}),
}}
/>
</aside>
);
}

View file

@ -1,17 +1,22 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, 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 {
Steam250Game,
type CatalogueCategory,
type CatalogueEntry,
} from "@types";
import starsAnimation from "@renderer/assets/lottie/stars.json";
import * as styles from "./home.css";
import { vars } from "../../theme.css";
import Lottie from "lottie-react";
import { buildGameDetailsPath } from "@renderer/helpers";
const categories: CatalogueCategory[] = ["trending", "recently_added"];
@ -20,8 +25,7 @@ export function Home() {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingRandomGame, setIsLoadingRandomGame] = useState(false);
const randomGameObjectID = useRef<string | null>(null);
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [searchParams] = useSearchParams();
@ -56,24 +60,22 @@ export function Home() {
};
const getRandomGame = useCallback(() => {
setIsLoadingRandomGame(true);
window.electron.getRandomGame().then((objectID) => {
if (objectID) {
randomGameObjectID.current = objectID;
setIsLoadingRandomGame(false);
}
window.electron.getRandomGame().then((game) => {
if (game) setRandomGame(game);
});
}, []);
const handleRandomizerClick = () => {
const searchParams = new URLSearchParams({
fromRandomizer: "1",
});
navigate(
`/game/steam/${randomGameObjectID.current}?${searchParams.toString()}`
);
if (randomGame) {
navigate(
buildGameDetailsPath(
{ ...randomGame, shop: "steam" },
{
fromRandomizer: "1",
}
)
);
}
};
useEffect(() => {
@ -105,7 +107,7 @@ export function Home() {
<Button
onClick={handleRandomizerClick}
theme="outline"
disabled={isLoadingRandomGame}
disabled={!randomGame}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie
@ -129,9 +131,7 @@ export function Home() {
<GameCard
key={result.objectID}
game={result}
onClick={() =>
navigate(`/game/${result.shop}/${result.objectID}`)
}
onClick={() => navigate(buildGameDetailsPath(result))}
/>
))}
</section>

View file

@ -14,6 +14,7 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import * as styles from "./home.css";
import { buildGameDetailsPath } from "@renderer/helpers";
export function SearchResults() {
const dispatch = useAppDispatch();
@ -30,7 +31,7 @@ export function SearchResults() {
const handleGameClick = (game: CatalogueEntry) => {
dispatch(clearSearch());
navigate(`/game/${game.shop}/${game.objectID}`);
navigate(buildGameDetailsPath(game));
};
useEffect(() => {

View file

@ -57,7 +57,7 @@ export function SettingsRealDebrid({
{form.useRealDebrid && (
<TextField
label={t("real_debrid_api_token_description")}
label={t("real_debrid_api_token_label")}
value={form.realDebridApiToken ?? ""}
type="password"
onChange={(event) =>