fix: fixing multiple state update when scrolling

This commit is contained in:
Chubby Granny Chaser 2024-06-11 23:59:58 +01:00
parent d2aef7ca98
commit c68cb3211d
No known key found for this signature in database
57 changed files with 536 additions and 568 deletions

View File

@ -7,6 +7,7 @@ globalStyle("*", {
globalStyle("::-webkit-scrollbar", {
width: "9px",
backgroundColor: vars.color.darkBackground,
});
globalStyle("::-webkit-scrollbar-track", {

View File

@ -10,7 +10,6 @@ import {
} from "@renderer/hooks";
import * as styles from "./app.css";
import { themeClass } from "./theme.css";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import {
@ -21,8 +20,6 @@ import {
closeToast,
} from "@renderer/features";
document.body.classList.add(themeClass);
export interface AppProps {
children: React.ReactNode;
}

View File

@ -1,5 +1,6 @@
import { keyframes } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT } from "../../theme.css";
export const backdropFadeIn = keyframes({

View File

@ -1,4 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../theme.css";
export const badge = style({

View File

@ -1,4 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const bottomPanel = style({

View File

@ -1,6 +1,7 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const checkboxField = style({
display: "flex",
flexDirection: "row",

View File

@ -1,4 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const card = style({

View File

@ -1,4 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const hero = style({

View File

@ -1,5 +1,6 @@
import { keyframes, style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const scaleFadeIn = keyframes({

View File

@ -14,6 +14,7 @@ export interface ModalProps {
onClose: () => void;
large?: boolean;
children: React.ReactNode;
clickOutsideToClose?: boolean;
}
export function Modal({
@ -23,6 +24,7 @@ export function Modal({
onClose,
large,
children,
clickOutsideToClose = true,
}: ModalProps) {
const [isClosing, setIsClosing] = useState(false);
const modalContentRef = useRef<HTMLDivElement | null>(null);
@ -60,6 +62,18 @@ export function Modal({
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}
return () => {};
}, [handleCloseClick, visible]);
useEffect(() => {
if (clickOutsideToClose) {
const onMouseDown = (e: MouseEvent) => {
if (!isTopMostModal()) return;
if (modalContentRef.current) {
@ -73,17 +87,15 @@ export function Modal({
}
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("mousedown", onMouseDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("mousedown", onMouseDown);
};
}
return () => {};
}, [handleCloseClick, visible]);
}, [clickOutsideToClose, handleCloseClick]);
if (!visible) return null;

View File

@ -1,7 +1,8 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const select = recipe({
base: {
display: "inline-flex",

View File

@ -1,4 +1,5 @@
import { AppsIcon, GearIcon, HomeIcon } from "@primer/octicons-react";
import { DownloadIcon } from "./download-icon";
export const routes = [

View File

@ -1,5 +1,6 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const sidebar = recipe({
@ -11,6 +12,7 @@ export const sidebar = recipe({
transition: "opacity ease 0.2s",
borderRight: `solid 1px ${vars.color.border}`,
position: "relative",
overflow: "hidden",
},
variants: {
resizing: {
@ -123,3 +125,46 @@ export const section = style({
flexDirection: "column",
paddingBottom: `${SPACING_UNIT}px`,
});
export const profileButton = style({
display: "flex",
cursor: "pointer",
transition: "all ease 0.1s",
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
alignItems: "center",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
color: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px #000000",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const profileAvatar = style({
width: "30px",
height: "30px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
position: "relative",
});
export const profileButtonInformation = style({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
});
export const statusBadge = style({
width: "9px",
height: "9px",
borderRadius: "50%",
backgroundColor: vars.color.danger,
position: "absolute",
bottom: "-2px",
right: "-3px",
zIndex: "1",
});

View File

@ -13,6 +13,7 @@ import * as styles from "./sidebar.css";
import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { PersonIcon } from "@primer/octicons-react";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@ -143,93 +144,114 @@ export function Sidebar() {
};
return (
<aside
ref={sidebarRef}
className={styles.sidebar({ resizing: isResizing })}
style={{
width: sidebarWidth,
minWidth: sidebarWidth,
maxWidth: sidebarWidth,
}}
>
<div
className={styles.content({
macos: window.electron.platform === "darwin",
})}
<>
<aside
ref={sidebarRef}
className={styles.sidebar({ resizing: isResizing })}
style={{
width: sidebarWidth,
minWidth: sidebarWidth,
maxWidth: sidebarWidth,
}}
>
{window.electron.platform === "darwin" && <h2>Hydra</h2>}
<button type="button" className={styles.profileButton}>
<div className={styles.profileAvatar}>
{/* <PersonIcon /> */}
<img
src="https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.pinimg.com%2F736x%2Fbd%2F19%2F2f%2Fbd192f2723f7d81013f04903d9e0428b.jpg&f=1&nofb=1&ipt=dce89b7ad791596a8b78b07c3c27f3f54fbf608493fb7217b4eb4ba4ca7904d1&ipo=images"
alt="Avatar"
style={{ width: "100%", height: "100%", borderRadius: "50%" }}
/>
<section className={styles.section}>
<ul className={styles.menu}>
{routes.map(({ nameKey, path, render }) => (
<li
key={nameKey}
className={styles.menuItem({
active: location.pathname === path,
})}
>
<button
type="button"
className={styles.menuItemButton}
onClick={() => handleSidebarItemClick(path)}
<div className={styles.statusBadge} />
</div>
<div className={styles.profileButtonInformation}>
<p style={{ fontWeight: "bold" }}>hydra</p>
<p style={{ fontSize: 12 }}>Jogando ABC</p>
</div>
</button>
<div
className={styles.content({
macos: window.electron.platform === "darwin",
})}
>
{window.electron.platform === "darwin" && <h2>Hydra</h2>}
<section className={styles.section}>
<ul className={styles.menu}>
{routes.map(({ nameKey, path, render }) => (
<li
key={nameKey}
className={styles.menuItem({
active: location.pathname === path,
})}
>
{render(isDownloading)}
<span>{t(nameKey)}</span>
</button>
</li>
))}
</ul>
</section>
<button
type="button"
className={styles.menuItemButton}
onClick={() => handleSidebarItemClick(path)}
>
{render(isDownloading)}
<span>{t(nameKey)}</span>
</button>
</li>
))}
</ul>
</section>
<section className={styles.section}>
<small className={styles.sectionTitle}>{t("my_library")}</small>
<section className={styles.section}>
<small className={styles.sectionTitle}>{t("my_library")}</small>
<TextField
placeholder={t("filter")}
onChange={handleFilter}
theme="dark"
/>
<TextField
placeholder={t("filter")}
onChange={handleFilter}
theme="dark"
/>
<ul className={styles.menu}>
{filteredLibrary.map((game) => (
<li
key={game.id}
className={styles.menuItem({
active:
location.pathname === `/game/${game.shop}/${game.objectID}`,
muted: game.status === "removed",
})}
>
<button
type="button"
className={styles.menuItemButton}
onClick={(event) => handleSidebarGameClick(event, game)}
<ul className={styles.menu}>
{filteredLibrary.map((game) => (
<li
key={game.id}
className={styles.menuItem({
active:
location.pathname ===
`/game/${game.shop}/${game.objectID}`,
muted: game.status === "removed",
})}
>
{game.iconUrl ? (
<img
className={styles.gameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.gameIcon} />
)}
<button
type="button"
className={styles.menuItemButton}
onClick={(event) => handleSidebarGameClick(event, game)}
>
{game.iconUrl ? (
<img
className={styles.gameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.gameIcon} />
)}
<span className={styles.menuItemButtonLabel}>
{getGameTitle(game)}
</span>
</button>
</li>
))}
</ul>
</section>
</div>
<span className={styles.menuItemButtonLabel}>
{getGameTitle(game)}
</span>
</button>
</li>
))}
</ul>
</section>
</div>
<button
type="button"
className={styles.handle}
onMouseDown={handleMouseDown}
/>
</aside>
<button
type="button"
className={styles.handle}
onMouseDown={handleMouseDown}
/>
</aside>
</>
);
}

View File

@ -1,7 +1,8 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const textFieldContainer = style({
flex: "1",
gap: `${SPACING_UNIT}px`,

View File

@ -8,32 +8,7 @@ import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks";
import type { Game, GameRepack, GameShop, ShopDetails } from "@types";
import { useTranslation } from "react-i18next";
import {
DODIInstallationGuide,
DONT_SHOW_DODI_INSTRUCTIONS_KEY,
DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY,
OnlineFixInstallationGuide,
RepacksModal,
} from "./modals";
import { Downloader } from "@shared";
import { GameOptionsModal } from "./modals/game-options-modal";
export interface GameDetailsContext {
game: Game | null;
shopDetails: ShopDetails | null;
repacks: GameRepack[];
shop: GameShop;
gameTitle: string;
isGameRunning: boolean;
isLoading: boolean;
objectID: string | undefined;
gameColor: string;
setGameColor: React.Dispatch<React.SetStateAction<string>>;
openRepacksModal: () => void;
openGameOptionsModal: () => void;
selectGameExecutable: () => Promise<string | null>;
updateGame: () => Promise<void>;
}
import { GameDetailsContext } from "./game-details.context.types";
export const gameDetailsContext = createContext<GameDetailsContext>({
game: null,
@ -45,11 +20,13 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
isLoading: false,
objectID: undefined,
gameColor: "",
showRepacksModal: false,
showGameOptionsModal: false,
setGameColor: () => {},
openRepacksModal: () => {},
openGameOptionsModal: () => {},
selectGameExecutable: async () => null,
updateGame: async () => {},
setShowGameOptionsModal: () => {},
setShowRepacksModal: () => {},
});
const { Provider } = gameDetailsContext;
@ -70,9 +47,6 @@ export function GameDetailsContextProvider({
const [isLoading, setIsLoading] = useState(false);
const [gameColor, setGameColor] = useState("");
const [showInstructionsModal, setShowInstructionsModal] = useState<
null | "onlinefix" | "DODI"
>(null);
const [isGameRunning, setisGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
@ -85,7 +59,7 @@ export function GameDetailsContextProvider({
const dispatch = useAppDispatch();
const { startDownload, lastPacket } = useDownload();
const { lastPacket } = useDownload();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
@ -152,37 +126,6 @@ export function GameDetailsContextProvider({
};
}, [game?.id, isGameRunning, updateGame]);
const handleStartDownload = async (
repack: GameRepack,
downloader: Downloader,
downloadPath: string
) => {
await startDownload({
repackId: repack.id,
objectID: objectID!,
title: gameTitle,
downloader,
shop: shop as GameShop,
downloadPath,
});
await updateGame();
setShowRepacksModal(false);
setShowGameOptionsModal(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");
}
};
const getDownloadsPath = async () => {
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath();
@ -211,9 +154,6 @@ export function GameDetailsContextProvider({
});
};
const openRepacksModal = () => setShowRepacksModal(true);
const openGameOptionsModal = () => setShowGameOptionsModal(true);
return (
<Provider
value={{
@ -226,42 +166,16 @@ export function GameDetailsContextProvider({
isLoading,
objectID,
gameColor,
showGameOptionsModal,
showRepacksModal,
setGameColor,
openRepacksModal,
openGameOptionsModal,
selectGameExecutable,
updateGame,
setShowRepacksModal,
setShowGameOptionsModal,
}}
>
<>
<RepacksModal
visible={showRepacksModal}
startDownload={handleStartDownload}
onClose={() => setShowRepacksModal(false)}
/>
<OnlineFixInstallationGuide
visible={showInstructionsModal === "onlinefix"}
onClose={() => setShowInstructionsModal(null)}
/>
<DODIInstallationGuide
visible={showInstructionsModal === "DODI"}
onClose={() => setShowInstructionsModal(null)}
/>
{game && (
<GameOptionsModal
visible={showGameOptionsModal}
game={game}
onClose={() => {
setShowGameOptionsModal(false);
}}
/>
)}
{children}
</>
{children}
</Provider>
);
}

View File

@ -0,0 +1,20 @@
import type { Game, GameRepack, GameShop, ShopDetails } from "@types";
export interface GameDetailsContext {
game: Game | null;
shopDetails: ShopDetails | null;
repacks: GameRepack[];
shop: GameShop;
gameTitle: string;
isGameRunning: boolean;
isLoading: boolean;
objectID: string | undefined;
gameColor: string;
showRepacksModal: boolean;
showGameOptionsModal: boolean;
setGameColor: React.Dispatch<React.SetStateAction<string>>;
selectGameExecutable: () => Promise<string | null>;
updateGame: () => Promise<void>;
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;
setShowGameOptionsModal: React.Dispatch<React.SetStateAction<boolean>>;
}

View File

@ -0,0 +1 @@
export * from "./game-details/game-details.context";

View File

@ -6,13 +6,14 @@ import type { CatalogueEntry } from "@types";
import { clearSearch } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { SPACING_UNIT, vars } from "../../theme.css";
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";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
export function Catalogue() {
const dispatch = useAppDispatch();

View File

@ -1,6 +1,7 @@
import { SPACING_UNIT } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../theme.css";
export const deleteActionsButtonsCtn = style({
display: "flex",
width: "100%",

View File

@ -1,6 +1,7 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const downloadTitleWrapper = style({
display: "flex",
alignItems: "center",
@ -71,7 +72,7 @@ export const download = style({
borderRadius: "8px",
border: `solid 1px ${vars.color.border}`,
overflow: "hidden",
boxShadow: "0px 0px 15px 0px #000000",
boxShadow: "0px 0px 5px 0px #000000",
transition: "all ease 0.2s",
height: "140px",
minHeight: "140px",

View File

@ -1,6 +1,7 @@
import { SPACING_UNIT } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../theme.css";
export const downloadsContainer = style({
display: "flex",
padding: `${SPACING_UNIT * 3}px`,

View File

@ -0,0 +1,19 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const descriptionHeader = style({
width: "100%",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
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",
});

View File

@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next";
import * as styles from "./game-details.css";
import * as styles from "./description-header.css";
import { useContext } from "react";
import { gameDetailsContext } from "./game-details.context";
import { gameDetailsContext } from "@renderer/context";
export function DescriptionHeader() {
const { shopDetails } = useContext(gameDetailsContext);

View File

@ -1,7 +1,8 @@
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const gallerySliderContainer = style({
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
width: "100%",

View File

@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
import * as styles from "./gallery-slider.css";
import { gameDetailsContext } from "./game-details.context";
import { gameDetailsContext } from "@renderer/context";
export function GallerySlider() {
const { shopDetails } = useContext(gameDetailsContext);

View File

@ -0,0 +1,116 @@
import { useContext, useEffect, useRef, useState } from "react";
import { average } from "color.js";
import Color from "color";
import { steamUrlBuilder } from "@renderer/helpers";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
import { GallerySlider } from "./gallery-slider/gallery-slider";
import { Sidebar } from "./sidebar/sidebar";
import * as styles from "./game-details.css";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context";
export function GameDetailsContent() {
const containerRef = useRef<HTMLDivElement | null>(null);
const [isHeaderStuck, setIsHeaderStuck] = useState(false);
const { t } = useTranslation("game_details");
const { objectID, shopDetails, game, gameColor, setGameColor } =
useContext(gameDetailsContext);
const [backdropOpactiy, setBackdropOpacity] = useState(1);
const handleHeroLoad = async () => {
const output = await average(steamUrlBuilder.libraryHero(objectID!), {
amount: 1,
format: "hex",
});
const backgroundColor = output
? (new Color(output).darken(0.7).toString() as string)
: "";
setGameColor(backgroundColor);
};
useEffect(() => {
setBackdropOpacity(1);
}, [objectID]);
const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
const scrollY = (event.target as HTMLDivElement).scrollTop;
const opacity = Math.max(0, 1 - scrollY / styles.HERO_HEIGHT);
if (scrollY >= styles.HERO_HEIGHT && !isHeaderStuck) {
setIsHeaderStuck(true);
}
if (scrollY <= styles.HERO_HEIGHT && isHeaderStuck) {
setIsHeaderStuck(false);
}
setBackdropOpacity(opacity);
};
return (
<div className={styles.wrapper}>
<img
src={steamUrlBuilder.libraryHero(objectID!)}
className={styles.heroImage}
alt={game?.title}
onLoad={handleHeroLoad}
/>
<section
ref={containerRef}
onScroll={onScroll}
className={styles.container}
>
<div className={styles.hero}>
<div
style={{
backgroundColor: gameColor,
flex: 1,
opacity: Math.min(1, 1 - backdropOpactiy),
}}
/>
<div
className={styles.heroLogoBackdrop}
style={{ opacity: backdropOpactiy }}
>
<div className={styles.heroContent}>
<img
src={steamUrlBuilder.logo(objectID!)}
style={{ width: 300, alignSelf: "flex-end" }}
alt={game?.title}
/>
</div>
</div>
</div>
<HeroPanel isHeaderStuck={isHeaderStuck} />
<div className={styles.descriptionContainer}>
<div className={styles.descriptionContent}>
<DescriptionHeader />
<GallerySlider />
<div
dangerouslySetInnerHTML={{
__html: shopDetails?.about_the_game ?? t("no_shop_details"),
}}
className={styles.description}
/>
</div>
<Sidebar />
</div>
</section>
</div>
);
}

View File

@ -4,6 +4,7 @@ import { Button } from "@renderer/components";
import * as styles from "./game-details.css";
import * as sidebarStyles from "./sidebar/sidebar.css";
import * as descriptionHeaderStyles from "./description-header/description-header.css";
import { useTranslation } from "react-i18next";
@ -16,15 +17,15 @@ export function GameDetailsSkeleton() {
<Skeleton className={styles.heroImageSkeleton} />
</div>
<div className={styles.heroPanelSkeleton}>
<section className={styles.descriptionHeaderInfo}>
<section className={descriptionHeaderStyles.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}>
<div className={descriptionHeaderStyles.descriptionHeader}>
<section className={descriptionHeaderStyles.descriptionHeaderInfo}>
<Skeleton width={145} />
<Skeleton width={150} />
</section>

View File

@ -1,15 +1,26 @@
import { globalStyle, keyframes, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const HERO_HEIGHT = 300;
export const slideIn = keyframes({
"0%": { transform: `translateY(${40 + SPACING_UNIT * 2}px)` },
"100%": { transform: "translateY(0)" },
});
export const wrapper = style({
display: "flex",
flexDirection: "column",
overflow: "hidden",
width: "100%",
height: "100%",
});
export const hero = style({
width: "100%",
height: "300px",
minHeight: "300px",
height: `${HERO_HEIGHT}px`,
minHeight: `${HERO_HEIGHT}px`,
display: "flex",
flexDirection: "column",
position: "relative",
@ -29,7 +40,7 @@ export const heroContent = style({
display: "flex",
});
export const heroBackdrop = style({
export const heroLogoBackdrop = style({
width: "100%",
height: "100%",
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.3) 60%, transparent 100%)",
@ -41,13 +52,18 @@ export const heroBackdrop = style({
export const heroImage = style({
width: "100%",
height: "100%",
height: `${HERO_HEIGHT}px`,
minHeight: `${HERO_HEIGHT}px`,
objectFit: "cover",
objectPosition: "top",
transition: "all ease 0.2s",
position: "absolute",
zIndex: "0",
"@media": {
"(min-width: 1250px)": {
objectPosition: "center",
height: "350px",
minHeight: "350px",
},
},
});
@ -66,12 +82,15 @@ export const container = style({
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "auto",
zIndex: "1",
});
export const descriptionContainer = style({
display: "flex",
width: "100%",
flex: "1",
background: `linear-gradient(0deg, ${vars.color.background} 50%, ${vars.color.darkBackground} 100%)`,
});
export const descriptionContent = style({
@ -111,22 +130,6 @@ export const descriptionSkeleton = style({
marginRight: "auto",
});
export const descriptionHeader = style({
width: "100%",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
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",
});
export const randomizerButton = style({
animationName: slideIn,
animationDuration: "0.2s",

View File

@ -1,30 +1,29 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { average } from "color.js";
import { Steam250Game } from "@types";
import { GameRepack, GameShop, Steam250Game } from "@types";
import { Button } from "@renderer/components";
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
import { buildGameDetailsPath } from "@renderer/helpers";
import starsAnimation from "@renderer/assets/lottie/stars.json";
import Lottie from "lottie-react";
import { useTranslation } from "react-i18next";
import { SkeletonTheme } from "react-loading-skeleton";
import { DescriptionHeader } from "./description-header";
import { GameDetailsSkeleton } from "./game-details-skeleton";
import * as styles from "./game-details.css";
import { HeroPanel } from "./hero";
import { vars } from "../../theme.css";
import { vars } from "@renderer/theme.css";
import { GallerySlider } from "./gallery-slider";
import { Sidebar } from "./sidebar/sidebar";
import { GameDetailsContent } from "./game-details-content";
import {
GameDetailsContextConsumer,
GameDetailsContextProvider,
} from "./game-details.context";
} from "@renderer/context";
import { useDownload } from "@renderer/hooks";
import { GameOptionsModal, RepacksModal } from "./modals";
import { Downloader } from "@shared";
export function GameDetails() {
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
@ -34,6 +33,8 @@ export function GameDetails() {
const fromRandomizer = searchParams.get("fromRandomizer");
const { startDownload } = useDownload();
const { t } = useTranslation("game_details");
const navigate = useNavigate();
@ -59,17 +60,34 @@ export function GameDetails() {
return (
<GameDetailsContextProvider>
<GameDetailsContextConsumer>
{({ game, shopDetails, isLoading, setGameColor }) => {
const handleHeroLoad = async () => {
const output = await average(
steamUrlBuilder.libraryHero(objectID!),
{
amount: 1,
format: "hex",
}
);
{({
isLoading,
game,
gameTitle,
shop,
showRepacksModal,
showGameOptionsModal,
updateGame,
setShowRepacksModal,
setShowGameOptionsModal,
}) => {
const handleStartDownload = async (
repack: GameRepack,
downloader: Downloader,
downloadPath: string
) => {
await startDownload({
repackId: repack.id,
objectID: objectID!,
title: gameTitle,
downloader,
shop: shop as GameShop,
downloadPath,
});
setGameColor(output as string);
await updateGame();
setShowRepacksModal(false);
setShowGameOptionsModal(false);
};
return (
@ -77,47 +95,22 @@ export function GameDetails() {
baseColor={vars.color.background}
highlightColor="#444"
>
{isLoading ? (
<GameDetailsSkeleton />
) : (
<section className={styles.container}>
<div className={styles.hero}>
<img
src={steamUrlBuilder.libraryHero(objectID!)}
className={styles.heroImage}
alt={game?.title}
onLoad={handleHeroLoad}
/>
<div className={styles.heroBackdrop}>
<div className={styles.heroContent}>
<img
src={steamUrlBuilder.logo(objectID!)}
style={{ width: 300, alignSelf: "flex-end" }}
alt={game?.title}
/>
</div>
</div>
</div>
{isLoading ? <GameDetailsSkeleton /> : <GameDetailsContent />}
<HeroPanel />
<RepacksModal
visible={showRepacksModal}
startDownload={handleStartDownload}
onClose={() => setShowRepacksModal(false)}
/>
<div className={styles.descriptionContainer}>
<div className={styles.descriptionContent}>
<DescriptionHeader />
<GallerySlider />
<div
dangerouslySetInnerHTML={{
__html:
shopDetails?.about_the_game ?? t("no_shop_details"),
}}
className={styles.description}
/>
</div>
<Sidebar />
</div>
</section>
{game && (
<GameOptionsModal
visible={showGameOptionsModal}
game={game}
onClose={() => {
setShowGameOptionsModal(false);
}}
/>
)}
{fromRandomizer && (

View File

@ -4,7 +4,8 @@ import { useDownload, useLibrary } from "@renderer/hooks";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel-actions.css";
import { gameDetailsContext } from "../game-details.context";
import { gameDetailsContext } from "@renderer/context";
export function HeroPanelActions() {
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
@ -18,8 +19,8 @@ export function HeroPanelActions() {
isGameRunning,
objectID,
gameTitle,
openRepacksModal,
openGameOptionsModal,
setShowGameOptionsModal,
setShowRepacksModal,
updateGame,
selectGameExecutable,
} = useContext(gameDetailsContext);
@ -74,7 +75,7 @@ export function HeroPanelActions() {
const showDownloadOptionsButton = (
<Button
onClick={openRepacksModal}
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}
@ -119,7 +120,7 @@ export function HeroPanelActions() {
<div className={styles.separator} />
<Button
onClick={openGameOptionsModal}
onClick={() => setShowGameOptionsModal(true)}
theme="outline"
disabled={deleting || isGameRunning}
className={styles.heroPanelAction}

View File

@ -3,9 +3,10 @@ import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel.css";
import { formatDownloadProgress } from "@renderer/helpers";
import { useDate, useDownload } from "@renderer/hooks";
import { gameDetailsContext } from "../game-details.context";
import { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export function HeroPanelPlaytime() {

View File

@ -1,19 +1,31 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css";
import { recipe } from "@vanilla-extract/recipes";
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}`,
position: "relative",
overflow: "hidden",
import { SPACING_UNIT, vars } from "../../../theme.css";
export const panel = recipe({
base: {
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}`,
position: "sticky",
overflow: "hidden",
top: "0",
zIndex: "1",
},
variants: {
stuck: {
true: {
boxShadow: "0px 0px 15px 0px #000000",
},
},
},
});
export const content = style({

View File

@ -1,14 +1,20 @@
import { format } from "date-fns";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import Color from "color";
import { useDownload } from "@renderer/hooks";
import { HeroPanelActions } from "./hero-panel-actions";
import * as styles from "./hero-panel.css";
import { HeroPanelPlaytime } from "./hero-panel-playtime";
import { gameDetailsContext } from "../game-details.context";
export function HeroPanel() {
import { gameDetailsContext } from "@renderer/context";
export interface HeroPanelProps {
isHeaderStuck: boolean;
}
export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
const { t } = useTranslation("game_details");
const { game, repacks, gameColor } = useContext(gameDetailsContext);
@ -40,17 +46,16 @@ export function HeroPanel() {
return <HeroPanelPlaytime />;
};
const backgroundColor = gameColor
? (new Color(gameColor).darken(0.6).toString() as string)
: "";
const showProgressBar =
(game?.status === "active" && game?.progress < 1) ||
game?.status === "paused";
return (
<>
<div style={{ backgroundColor }} className={styles.panel}>
<div
style={{ backgroundColor: gameColor }}
className={styles.panel({ stuck: isHeaderStuck })}
>
<div className={styles.content}>{getInfo()}</div>
<div className={styles.actions}>
<HeroPanelActions />

View File

@ -1,4 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const container = style({

View File

@ -1,4 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../../theme.css";
export const optionsContainer = style({

View File

@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import type { Game } from "@types";
import * as styles from "./game-options-modal.css";
import { gameDetailsContext } from "../game-details.context";
import { gameDetailsContext } from "@renderer/context";
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
import { useDownload } from "@renderer/hooks";
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
@ -21,7 +21,7 @@ export function GameOptionsModal({
}: GameOptionsModalProps) {
const { t } = useTranslation("game_details");
const { updateGame, openRepacksModal, selectGameExecutable } =
const { updateGame, setShowRepacksModal, selectGameExecutable } =
useContext(gameDetailsContext);
const [showDeleteModal, setShowDeleteModal] = useState(false);
@ -145,7 +145,7 @@ export function GameOptionsModal({
</div>
<div className={styles.gameOptionRow}>
<Button
onClick={openRepacksModal}
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={deleting || isGameDownloading}
>

View File

@ -1,3 +1,3 @@
export * from "./installation-guides";
export * from "./repacks-modal";
export * from "./download-settings-modal";
export * from "./game-options-modal";

View File

@ -1,4 +0,0 @@
export const DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY =
"dontShowOnlineFixInstructions";
export const DONT_SHOW_DODI_INSTRUCTIONS_KEY = "dontShowDodiInstructions";

View File

@ -1,31 +0,0 @@
import { vars } from "../../../../theme.css";
import { keyframes, style } from "@vanilla-extract/css";
export const slideIn = keyframes({
"0%": { transform: "translateY(0)" },
"40%": { transform: "translateY(0)" },
"70%": { transform: "translateY(-100%)" },
"100%": { transform: "translateY(-100%)" },
});
export const windowContainer = style({
width: "250px",
height: "150px",
alignSelf: "center",
borderRadius: "2px",
overflow: "hidden",
border: `solid 1px ${vars.color.border}`,
});
export const windowContent = style({
backgroundColor: vars.color.muted,
height: "90%",
animationName: slideIn,
animationDuration: "3s",
animationIterationCount: "infinite",
animationTimingFunction: "ease-out",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#1c1c1c",
});

View File

@ -1,78 +0,0 @@
import { useContext, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button, CheckboxField, Modal } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./dodi-installation-guide.css";
import { ArrowUpIcon } from "@primer/octicons-react";
import { DONT_SHOW_DODI_INSTRUCTIONS_KEY } from "./constants";
import { gameDetailsContext } from "../../game-details.context";
export interface DODIInstallationGuideProps {
visible: boolean;
onClose: () => void;
}
export function DODIInstallationGuide({
visible,
onClose,
}: DODIInstallationGuideProps) {
const { gameColor } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
const [dontShowAgain, setDontShowAgain] = useState(false);
const handleClose = () => {
if (dontShowAgain) {
window.localStorage.setItem(DONT_SHOW_DODI_INSTRUCTIONS_KEY, "1");
}
onClose();
};
return (
<Modal
title={t("installation_instructions")}
description={t("installation_instructions_description")}
onClose={handleClose}
visible={visible}
>
<div
style={{
display: "flex",
gap: SPACING_UNIT * 2,
flexDirection: "column",
}}
>
<p
style={{ fontFamily: "Fira Sans", marginBottom: `${SPACING_UNIT}px` }}
>
<Trans i18nKey="dodi_installation_instruction" ns="game_details">
<ArrowUpIcon size={16} />
</Trans>
</p>
<div
className={styles.windowContainer}
style={{ backgroundColor: gameColor }}
>
<div className={styles.windowContent}>
<ArrowUpIcon size={24} />
</div>
</div>
<CheckboxField
label={t("dont_show_it_again")}
onChange={() => setDontShowAgain(!dontShowAgain)}
checked={dontShowAgain}
/>
<Button style={{ alignSelf: "flex-end" }} onClick={handleClose}>
{t("got_it")}
</Button>
</div>
</Modal>
);
}

View File

@ -1,3 +0,0 @@
export * from "./online-fix-installation-guide";
export * from "./dodi-installation-guide";
export * from "./constants";

View File

@ -1,7 +0,0 @@
import { SPACING_UNIT } from "../../../../theme.css";
import { style } from "@vanilla-extract/css";
export const passwordField = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});

View File

@ -1,104 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, CheckboxField, Modal, TextField } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./online-fix-installation-guide.css";
import { CopyIcon } from "@primer/octicons-react";
import { DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY } from "./constants";
const ONLINE_FIX_PASSWORD = "online-fix.me";
export interface OnlineFixInstallationGuideProps {
visible: boolean;
onClose: () => void;
}
export function OnlineFixInstallationGuide({
visible,
onClose,
}: OnlineFixInstallationGuideProps) {
const [clipboardLocked, setClipboardLocked] = useState(false);
const { t } = useTranslation("game_details");
const [dontShowAgain, setDontShowAgain] = useState(false);
const handleCopyToClipboard = () => {
setClipboardLocked(true);
navigator.clipboard.writeText(ONLINE_FIX_PASSWORD);
const zero = performance.now();
requestAnimationFrame(function holdLock(time) {
if (time - zero <= 3000) {
requestAnimationFrame(holdLock);
} else {
setClipboardLocked(false);
}
});
};
const handleClose = () => {
if (dontShowAgain) {
window.localStorage.setItem(DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY, "1");
}
onClose();
};
return (
<Modal
title={t("installation_instructions")}
description={t("installation_instructions_description")}
onClose={handleClose}
visible={visible}
>
<div
style={{
display: "flex",
gap: SPACING_UNIT * 2,
flexDirection: "column",
}}
>
<p style={{ fontFamily: "Fira Sans" }}>{t("online_fix_instruction")}</p>
<div className={styles.passwordField}>
<TextField
value={ONLINE_FIX_PASSWORD}
readOnly
disabled
style={{ fontSize: 16 }}
textFieldProps={{ style: { height: 45 } }}
/>
<Button
style={{ alignSelf: "flex-end", height: 45 }}
theme="outline"
onClick={handleCopyToClipboard}
disabled={clipboardLocked}
>
{clipboardLocked ? (
t("copied_to_clipboard")
) : (
<>
<CopyIcon />
{t("copy_to_clipboard")}
</>
)}
</Button>
</div>
<CheckboxField
label={t("dont_show_it_again")}
onChange={() => setDontShowAgain(!dontShowAgain)}
checked={dontShowAgain}
/>
<Button style={{ alignSelf: "flex-end" }} onClick={handleClose}>
{t("got_it")}
</Button>
</div>
</Modal>
);
}

View File

@ -1,6 +1,7 @@
import { SPACING_UNIT } from "../../../theme.css";
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../../theme.css";
export const deleteActionsButtonsCtn = style({
display: "flex",
width: "100%",

View File

@ -1,4 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const repacks = style({

View File

@ -7,10 +7,10 @@ import type { GameRepack } from "@types";
import * as styles from "./repacks-modal.css";
import { SPACING_UNIT } from "../../../theme.css";
import { SPACING_UNIT } from "@renderer/theme.css";
import { format } from "date-fns";
import { DownloadSettingsModal } from "./download-settings-modal";
import { gameDetailsContext } from "../game-details.context";
import { gameDetailsContext } from "@renderer/context";
import { Downloader } from "@shared";
export interface RepacksModalProps {
@ -32,7 +32,7 @@ export function RepacksModal({
const [repack, setRepack] = useState<GameRepack | null>(null);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
const [infoHash, setInfoHash] = useState("");
const [infoHash, setInfoHash] = useState<string | null>(null);
const { repacks, game } = useContext(gameDetailsContext);
@ -40,7 +40,7 @@ export function RepacksModal({
const getInfoHash = useCallback(async () => {
const torrent = await parseTorrent(game?.uri ?? "");
setInfoHash(torrent.infoHash ?? "");
if (torrent.infoHash) setInfoHash(torrent.infoHash);
}, [game]);
useEffect(() => {
@ -89,29 +89,35 @@ export function RepacksModal({
</div>
<div className={styles.repacks}>
{filteredRepacks.map((repack) => (
<Button
key={repack.id}
theme="dark"
onClick={() => handleRepackClick(repack)}
className={styles.repackButton}
>
<p style={{ color: "#DADBE1", wordBreak: "break-word" }}>
{repack.title}
</p>
{filteredRepacks.map((repack) => {
const isLastDownloadedOption =
infoHash !== null &&
repack.magnet.toLowerCase().includes(infoHash);
{repack.magnet.toLowerCase().includes(infoHash) && (
<Badge>{t("last_downloaded_option")}</Badge>
)}
return (
<Button
key={repack.id}
theme="dark"
onClick={() => handleRepackClick(repack)}
className={styles.repackButton}
>
<p style={{ color: "#DADBE1", wordBreak: "break-word" }}>
{repack.title}
</p>
<p style={{ fontSize: "12px" }}>
{repack.fileSize} - {repack.repacker} -{" "}
{repack.uploadDate
? format(repack.uploadDate, "dd/MM/yyyy")
: ""}
</p>
</Button>
))}
{isLastDownloadedOption && (
<Badge>{t("last_downloaded_option")}</Badge>
)}
<p style={{ fontSize: "12px" }}>
{repack.fileSize} - {repack.repacker} -{" "}
{repack.uploadDate
? format(repack.uploadDate, "dd/MM/yyyy")
: ""}
</p>
</Button>
);
})}
</div>
</Modal>
</>

View File

@ -1,7 +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 { vars } from "@renderer/theme.css";
import * as styles from "./sidebar.css";
const durationTranslation: Record<string, string> = {

View File

@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components";
import * as styles from "./sidebar.css";
import { gameDetailsContext } from "../game-details.context";
import { gameDetailsContext } from "@renderer/context";
export function Sidebar() {
const [howLongToBeat, setHowLongToBeat] = useState<{

View File

@ -1,5 +1,6 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT } from "../../theme.css";
export const catalogueCategories = style({

View File

@ -1,4 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const homeHeader = style({

View File

@ -10,7 +10,7 @@ import type { Steam250Game, CatalogueEntry } from "@types";
import starsAnimation from "@renderer/assets/lottie/stars.json";
import * as styles from "./home.css";
import { vars } from "../../theme.css";
import { vars } from "@renderer/theme.css";
import Lottie from "lottie-react";
import { buildGameDetailsPath } from "@renderer/helpers";

View File

@ -9,13 +9,14 @@ import { debounce } from "lodash";
import { InboxIcon } from "@primer/octicons-react";
import { clearSearch } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { vars } from "../../theme.css";
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";
import { vars } from "@renderer/theme.css";
export function SearchResults() {
const dispatch = useAppDispatch();

View File

@ -4,9 +4,10 @@ import { Trans, useTranslation } from "react-i18next";
import { Button, CheckboxField, Link, TextField } from "@renderer/components";
import * as styles from "./settings-real-debrid.css";
import type { UserPreferences } from "@types";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useAppSelector, useToast } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css";
const REAL_DEBRID_API_TOKEN_URL = "https://real-debrid.com/apitoken";
export interface SettingsRealDebridProps {

View File

@ -1,6 +1,7 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const container = style({
padding: "24px",
width: "100%",

View File

@ -1,6 +1,7 @@
import { Modal } from "@renderer/components";
import { useTranslation } from "react-i18next";
import { Modal } from "@renderer/components";
interface BinaryNotFoundModalProps {
visible: boolean;
onClose: () => void;

View File

@ -1,8 +1,8 @@
import { createTheme } from "@vanilla-extract/css";
import { createGlobalTheme } from "@vanilla-extract/css";
export const SPACING_UNIT = 8;
export const [themeClass, vars] = createTheme({
export const vars = createGlobalTheme(":root", {
color: {
background: "#1c1c1c",
darkBackground: "#151515",