mirror of
				https://github.com/hydralauncher/hydra.git
				synced 2025-03-09 15:40:26 +00:00 
			
		
		
		
	fix: fixing multiple state update when scrolling
This commit is contained in:
		
							parent
							
								
									d2aef7ca98
								
							
						
					
					
						commit
						c68cb3211d
					
				
					 57 changed files with 536 additions and 568 deletions
				
			
		| 
						 | 
				
			
			@ -7,6 +7,7 @@ globalStyle("*", {
 | 
			
		|||
 | 
			
		||||
globalStyle("::-webkit-scrollbar", {
 | 
			
		||||
  width: "9px",
 | 
			
		||||
  backgroundColor: vars.color.darkBackground,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
globalStyle("::-webkit-scrollbar-track", {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
import { SPACING_UNIT } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const badge = style({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const bottomPanel = style({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const card = style({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const hero = style({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { AppsIcon, GearIcon, HomeIcon } from "@primer/octicons-react";
 | 
			
		||||
 | 
			
		||||
import { DownloadIcon } from "./download-icon";
 | 
			
		||||
 | 
			
		||||
export const routes = [
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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`,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>>;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/renderer/src/context/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/renderer/src/context/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from "./game-details/game-details.context";
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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%",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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`,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			@ -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%",
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
							
								
								
									
										116
									
								
								src/renderer/src/pages/game-details/game-details-content.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								src/renderer/src/pages/game-details/game-details-content.tsx
									
										
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 && (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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() {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const container = style({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
import { SPACING_UNIT } from "../../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const optionsContainer = style({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
            >
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,3 @@
 | 
			
		|||
export * from "./installation-guides";
 | 
			
		||||
export * from "./repacks-modal";
 | 
			
		||||
export * from "./download-settings-modal";
 | 
			
		||||
export * from "./game-options-modal";
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +0,0 @@
 | 
			
		|||
export const DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY =
 | 
			
		||||
  "dontShowOnlineFixInstructions";
 | 
			
		||||
 | 
			
		||||
export const DONT_SHOW_DODI_INSTRUCTIONS_KEY = "dontShowDodiInstructions";
 | 
			
		||||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,3 +0,0 @@
 | 
			
		|||
export * from "./online-fix-installation-guide";
 | 
			
		||||
export * from "./dodi-installation-guide";
 | 
			
		||||
export * from "./constants";
 | 
			
		||||
| 
						 | 
				
			
			@ -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`,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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%",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const repacks = style({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
    </>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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> = {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<{
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const homeHeader = style({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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%",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue