mirror of
				https://github.com/hydralauncher/hydra.git
				synced 2025-03-09 15:40:26 +00:00 
			
		
		
		
	first commit
This commit is contained in:
		
						commit
						91b1341271
					
				
					 165 changed files with 20993 additions and 0 deletions
				
			
		
							
								
								
									
										27
									
								
								src/renderer/components/async-image/async-image.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/renderer/components/async-image/async-image.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,27 @@
 | 
			
		|||
import { forwardRef, useEffect, useState } from "react";
 | 
			
		||||
 | 
			
		||||
export interface AsyncImageProps
 | 
			
		||||
  extends React.DetailedHTMLProps<
 | 
			
		||||
    React.ImgHTMLAttributes<HTMLImageElement>,
 | 
			
		||||
    HTMLImageElement
 | 
			
		||||
  > {
 | 
			
		||||
  onSettled?: (url: string) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const AsyncImage = forwardRef<HTMLImageElement, AsyncImageProps>(
 | 
			
		||||
  ({ onSettled, ...props }, ref) => {
 | 
			
		||||
    const [source, setSource] = useState<string | null>(null);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
      if (props.src && props.src.startsWith("http")) {
 | 
			
		||||
        window.electron.getOrCacheImage(props.src).then((url) => {
 | 
			
		||||
          setSource(url);
 | 
			
		||||
 | 
			
		||||
          if (onSettled) onSettled(url);
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }, [props.src, onSettled]);
 | 
			
		||||
 | 
			
		||||
    return <img ref={ref} {...props} src={source ?? props.src} />;
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										22
									
								
								src/renderer/components/bottom-panel/bottom-panel.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/renderer/components/bottom-panel/bottom-panel.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,22 @@
 | 
			
		|||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
export const bottomPanel = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  borderTop: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
  padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT * 2}px`,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  justifyContent: "space-between",
 | 
			
		||||
  fontSize: vars.size.bodyFontSize,
 | 
			
		||||
  zIndex: "1",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const downloadsButton = style({
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
  color: vars.color.bodyText,
 | 
			
		||||
  ":hover": {
 | 
			
		||||
    textDecoration: "underline",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										68
									
								
								src/renderer/components/bottom-panel/bottom-panel.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/renderer/components/bottom-panel/bottom-panel.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,68 @@
 | 
			
		|||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
import { useDownload } from "@renderer/hooks";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./bottom-panel.css";
 | 
			
		||||
import { vars } from "@renderer/theme.css";
 | 
			
		||||
import { useEffect, useMemo, useState } from "react";
 | 
			
		||||
import { useNavigate } from "react-router-dom";
 | 
			
		||||
import { VERSION_CODENAME } from "@renderer/constants";
 | 
			
		||||
 | 
			
		||||
export function BottomPanel() {
 | 
			
		||||
  const { t } = useTranslation("bottom_panel");
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
  const { game, progress, downloadSpeed, eta, isDownloading } = useDownload();
 | 
			
		||||
 | 
			
		||||
  const [version, setVersion] = useState("");
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    window.electron.getVersion().then((result) => setVersion(result));
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const status = useMemo(() => {
 | 
			
		||||
    if (isDownloading) {
 | 
			
		||||
      if (game.status === "downloading_metadata")
 | 
			
		||||
        return t("downloading_metadata", { title: game.title });
 | 
			
		||||
 | 
			
		||||
      if (game.status === "checking_files")
 | 
			
		||||
        return t("checking_files", {
 | 
			
		||||
          title: game.title,
 | 
			
		||||
          percentage: progress,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
      return t("downloading", {
 | 
			
		||||
        title: game?.title,
 | 
			
		||||
        percentage: progress,
 | 
			
		||||
        eta,
 | 
			
		||||
        speed: downloadSpeed,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return t("no_downloads_in_progress");
 | 
			
		||||
  }, [t, game, progress, eta, isDownloading, downloadSpeed]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <footer
 | 
			
		||||
      className={styles.bottomPanel}
 | 
			
		||||
      style={{
 | 
			
		||||
        background: isDownloading
 | 
			
		||||
          ? `linear-gradient(90deg, ${vars.color.background} ${progress}, ${vars.color.darkBackground} ${progress})`
 | 
			
		||||
          : vars.color.darkBackground,
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <button
 | 
			
		||||
        type="button"
 | 
			
		||||
        className={styles.downloadsButton}
 | 
			
		||||
        onClick={() => navigate("/downloads")}
 | 
			
		||||
      >
 | 
			
		||||
        <small>{status}</small>
 | 
			
		||||
      </button>
 | 
			
		||||
 | 
			
		||||
      <small>
 | 
			
		||||
        v{version} "{VERSION_CODENAME}"
 | 
			
		||||
      </small>
 | 
			
		||||
    </footer>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										52
									
								
								src/renderer/components/button/button.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/renderer/components/button/button.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,52 @@
 | 
			
		|||
import { style, styleVariants } from "@vanilla-extract/css";
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
const base = style({
 | 
			
		||||
  padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
 | 
			
		||||
  backgroundColor: "#c0c1c7",
 | 
			
		||||
  borderRadius: "8px",
 | 
			
		||||
  border: "solid 1px transparent",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
  minHeight: "40px",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  justifyContent: "center",
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  ":active": {
 | 
			
		||||
    opacity: vars.opacity.active,
 | 
			
		||||
  },
 | 
			
		||||
  ":disabled": {
 | 
			
		||||
    opacity: vars.opacity.disabled,
 | 
			
		||||
    pointerEvents: "none",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const button = styleVariants({
 | 
			
		||||
  primary: [
 | 
			
		||||
    base,
 | 
			
		||||
    {
 | 
			
		||||
      ":hover": {
 | 
			
		||||
        backgroundColor: "#DADBE1",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  outline: [
 | 
			
		||||
    base,
 | 
			
		||||
    {
 | 
			
		||||
      backgroundColor: "transparent",
 | 
			
		||||
      border: "solid 1px #c0c1c7",
 | 
			
		||||
      color: "#c0c1c7",
 | 
			
		||||
      ":hover": {
 | 
			
		||||
        backgroundColor: "rgba(255, 255, 255, 0.1)",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  dark: [
 | 
			
		||||
    base,
 | 
			
		||||
    {
 | 
			
		||||
      backgroundColor: vars.color.darkBackground,
 | 
			
		||||
      color: "#c0c1c7",
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										27
									
								
								src/renderer/components/button/button.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/renderer/components/button/button.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,27 @@
 | 
			
		|||
import cn from "classnames";
 | 
			
		||||
import * as styles from "./button.css";
 | 
			
		||||
 | 
			
		||||
export interface ButtonProps
 | 
			
		||||
  extends React.DetailedHTMLProps<
 | 
			
		||||
    React.ButtonHTMLAttributes<HTMLButtonElement>,
 | 
			
		||||
    HTMLButtonElement
 | 
			
		||||
  > {
 | 
			
		||||
  theme?: keyof typeof styles.button;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Button({
 | 
			
		||||
  children,
 | 
			
		||||
  theme = "primary",
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: ButtonProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <button
 | 
			
		||||
      {...props}
 | 
			
		||||
      type="button"
 | 
			
		||||
      className={cn(styles.button[theme], className)}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </button>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								src/renderer/components/checkbox-field/checkbox-field.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/renderer/components/checkbox-field/checkbox-field.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,40 @@
 | 
			
		|||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
 | 
			
		||||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
export const checkboxField = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  flexDirection: "row",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const checkbox = style({
 | 
			
		||||
  width: "20px",
 | 
			
		||||
  height: "20px",
 | 
			
		||||
  borderRadius: "4px",
 | 
			
		||||
  backgroundColor: vars.color.darkBackground,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  justifyContent: "center",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  position: "relative",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  border: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
  ":hover": {
 | 
			
		||||
    borderColor: "rgba(255, 255, 255, 0.5)",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const checkboxInput = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  position: "absolute",
 | 
			
		||||
  margin: "0",
 | 
			
		||||
  padding: "0",
 | 
			
		||||
  opacity: "0",
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const checkboxLabel = style({
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										32
									
								
								src/renderer/components/checkbox-field/checkbox-field.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/renderer/components/checkbox-field/checkbox-field.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
import { useId } from "react";
 | 
			
		||||
import * as styles from "./checkbox-field.css";
 | 
			
		||||
import { CheckIcon } from "@primer/octicons-react";
 | 
			
		||||
 | 
			
		||||
export interface CheckboxFieldProps
 | 
			
		||||
  extends React.DetailedHTMLProps<
 | 
			
		||||
    React.InputHTMLAttributes<HTMLInputElement>,
 | 
			
		||||
    HTMLInputElement
 | 
			
		||||
  > {
 | 
			
		||||
  label: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
 | 
			
		||||
  const id = useId();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={styles.checkboxField}>
 | 
			
		||||
      <div className={styles.checkbox}>
 | 
			
		||||
        <input
 | 
			
		||||
          id={id}
 | 
			
		||||
          type="checkbox"
 | 
			
		||||
          className={styles.checkboxInput}
 | 
			
		||||
          {...props}
 | 
			
		||||
        />
 | 
			
		||||
        {props.checked && <CheckIcon />}
 | 
			
		||||
      </div>
 | 
			
		||||
      <label htmlFor={id} className={styles.checkboxLabel}>
 | 
			
		||||
        {label}
 | 
			
		||||
      </label>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										127
									
								
								src/renderer/components/game-card/game-card.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								src/renderer/components/game-card/game-card.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,127 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
import { recipe } from "@vanilla-extract/recipes";
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const card = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    width: "100%",
 | 
			
		||||
    height: "180px",
 | 
			
		||||
    boxShadow: "0px 0px 15px 0px #000000",
 | 
			
		||||
    overflow: "hidden",
 | 
			
		||||
    borderRadius: "4px",
 | 
			
		||||
    transition: "all ease 0.2s",
 | 
			
		||||
    border: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
    cursor: "pointer",
 | 
			
		||||
    zIndex: "1",
 | 
			
		||||
    ":active": {
 | 
			
		||||
      opacity: vars.opacity.active,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    disabled: {
 | 
			
		||||
      true: {
 | 
			
		||||
        pointerEvents: "none",
 | 
			
		||||
        boxShadow: "none",
 | 
			
		||||
        opacity: vars.opacity.disabled,
 | 
			
		||||
        filter: "grayscale(50%)",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const backdrop = style({
 | 
			
		||||
  background: "linear-gradient(0deg, rgba(0, 0, 0, 0.7) 50%, transparent 100%)",
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  justifyContent: "flex-end",
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  position: "relative",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const cover = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  objectFit: "cover",
 | 
			
		||||
  objectPosition: "center",
 | 
			
		||||
  position: "absolute",
 | 
			
		||||
  zIndex: "-1",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  selectors: {
 | 
			
		||||
    [`${card({})}:hover &`]: {
 | 
			
		||||
      transform: "scale(1.05)",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const content = style({
 | 
			
		||||
  color: "#DADBE1",
 | 
			
		||||
  padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "flex-start",
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  transform: "translateY(24px)",
 | 
			
		||||
  selectors: {
 | 
			
		||||
    [`${card({})}:hover &`]: {
 | 
			
		||||
      transform: "translateY(0px)",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const title = style({
 | 
			
		||||
  fontSize: "16px",
 | 
			
		||||
  fontWeight: "bold",
 | 
			
		||||
  textAlign: "left",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const downloadOptions = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  margin: "0",
 | 
			
		||||
  padding: "0",
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  flexWrap: "wrap",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const downloadOption = style({
 | 
			
		||||
  color: "#c0c1c7",
 | 
			
		||||
  fontSize: "10px",
 | 
			
		||||
  padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
 | 
			
		||||
  border: "solid 1px #c0c1c7",
 | 
			
		||||
  borderRadius: "4px",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const specifics = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  gap: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
  justifyContent: "center",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const specificsItem = style({
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  color: "#c0c1c7",
 | 
			
		||||
  fontSize: "12px",
 | 
			
		||||
  alignItems: "flex-end",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const titleContainer = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  color: "#c0c1c7",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const shopIcon = style({
 | 
			
		||||
  width: "20px",
 | 
			
		||||
  height: "20px",
 | 
			
		||||
  minWidth: "20px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const noDownloadsLabel = style({
 | 
			
		||||
  color: vars.color.bodyText,
 | 
			
		||||
  fontWeight: "bold",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										87
									
								
								src/renderer/components/game-card/game-card.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/renderer/components/game-card/game-card.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,87 @@
 | 
			
		|||
import { DownloadIcon, FileDirectoryIcon } from "@primer/octicons-react";
 | 
			
		||||
import type { CatalogueEntry } from "@types";
 | 
			
		||||
 | 
			
		||||
import SteamLogo from "@renderer/assets/steam-logo.svg";
 | 
			
		||||
import EpicGamesLogo from "@renderer/assets/epic-games-logo.svg";
 | 
			
		||||
 | 
			
		||||
import { AsyncImage } from "../async-image/async-image";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./game-card.css";
 | 
			
		||||
import { useAppSelector } from "@renderer/hooks";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
export interface GameCardProps
 | 
			
		||||
  extends React.DetailedHTMLProps<
 | 
			
		||||
    React.ButtonHTMLAttributes<HTMLButtonElement>,
 | 
			
		||||
    HTMLButtonElement
 | 
			
		||||
  > {
 | 
			
		||||
  game: CatalogueEntry;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const shopIcon = {
 | 
			
		||||
  epic: <EpicGamesLogo className={styles.shopIcon} />,
 | 
			
		||||
  steam: <SteamLogo className={styles.shopIcon} />,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function GameCard({ game, disabled, ...props }: GameCardProps) {
 | 
			
		||||
  const { t } = useTranslation("game_card");
 | 
			
		||||
 | 
			
		||||
  const repackersFriendlyNames = useAppSelector(
 | 
			
		||||
    (state) => state.repackersFriendlyNames.value
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const uniqueRepackers = Array.from(
 | 
			
		||||
    new Set(game.repacks.map(({ repacker }) => repacker))
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <button
 | 
			
		||||
      {...props}
 | 
			
		||||
      type="button"
 | 
			
		||||
      className={styles.card({ disabled })}
 | 
			
		||||
      disabled={disabled}
 | 
			
		||||
    >
 | 
			
		||||
      <div className={styles.backdrop}>
 | 
			
		||||
        <AsyncImage
 | 
			
		||||
          src={game.cover}
 | 
			
		||||
          alt={game.title}
 | 
			
		||||
          className={styles.cover}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <div className={styles.content}>
 | 
			
		||||
          <div className={styles.titleContainer}>
 | 
			
		||||
            {shopIcon[game.shop]}
 | 
			
		||||
            <p className={styles.title}>{game.title}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          {uniqueRepackers.length > 0 ? (
 | 
			
		||||
            <ul className={styles.downloadOptions}>
 | 
			
		||||
              {uniqueRepackers.map((repacker) => (
 | 
			
		||||
                <li key={repacker} className={styles.downloadOption}>
 | 
			
		||||
                  <span>{repackersFriendlyNames[repacker]}</span>
 | 
			
		||||
                </li>
 | 
			
		||||
              ))}
 | 
			
		||||
            </ul>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <p className={styles.noDownloadsLabel}>{t("no_downloads")}</p>
 | 
			
		||||
          )}
 | 
			
		||||
 | 
			
		||||
          <div className={styles.specifics}>
 | 
			
		||||
            <div className={styles.specificsItem}>
 | 
			
		||||
              <DownloadIcon />
 | 
			
		||||
              <span>{game.repacks.length}</span>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            {game.repacks.length > 0 && (
 | 
			
		||||
              <div className={styles.specificsItem}>
 | 
			
		||||
                <FileDirectoryIcon />
 | 
			
		||||
                <span>{game.repacks.at(0)?.fileSize}</span>
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </button>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										148
									
								
								src/renderer/components/header/header.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								src/renderer/components/header/header.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,148 @@
 | 
			
		|||
import type { ComplexStyleRule } from "@vanilla-extract/css";
 | 
			
		||||
import { keyframes, style } from "@vanilla-extract/css";
 | 
			
		||||
import { recipe } from "@vanilla-extract/recipes";
 | 
			
		||||
 | 
			
		||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
 | 
			
		||||
 | 
			
		||||
export const slideIn = keyframes({
 | 
			
		||||
  "0%": { transform: "translateX(20px)", opacity: "0" },
 | 
			
		||||
  "100%": {
 | 
			
		||||
    transform: "translateX(0)",
 | 
			
		||||
    opacity: "1",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const slideOut = keyframes({
 | 
			
		||||
  "0%": { transform: "translateX(0px)", opacity: "1" },
 | 
			
		||||
  "100%": {
 | 
			
		||||
    transform: "translateX(20px)",
 | 
			
		||||
    opacity: "0",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const header = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    display: "flex",
 | 
			
		||||
    justifyContent: "space-between",
 | 
			
		||||
    alignItems: "center",
 | 
			
		||||
    gap: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
    WebkitAppRegion: "drag",
 | 
			
		||||
    width: "100%",
 | 
			
		||||
    padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
 | 
			
		||||
    color: "#c0c1c7",
 | 
			
		||||
    borderBottom: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
    backgroundColor: vars.color.darkBackground,
 | 
			
		||||
  } as ComplexStyleRule,
 | 
			
		||||
  variants: {
 | 
			
		||||
    draggingDisabled: {
 | 
			
		||||
      true: {
 | 
			
		||||
        WebkitAppRegion: "no-drag",
 | 
			
		||||
      } as ComplexStyleRule,
 | 
			
		||||
    },
 | 
			
		||||
    isWindows: {
 | 
			
		||||
      true: {
 | 
			
		||||
        WebkitAppRegion: "no-drag",
 | 
			
		||||
      } as ComplexStyleRule,
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const search = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    backgroundColor: vars.color.background,
 | 
			
		||||
    display: "inline-flex",
 | 
			
		||||
    transition: "all ease 0.2s",
 | 
			
		||||
    width: "200px",
 | 
			
		||||
    alignItems: "center",
 | 
			
		||||
    borderRadius: "8px",
 | 
			
		||||
    border: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
    height: "40px",
 | 
			
		||||
    WebkitAppRegion: "no-drag",
 | 
			
		||||
  } as ComplexStyleRule,
 | 
			
		||||
  variants: {
 | 
			
		||||
    focused: {
 | 
			
		||||
      true: {
 | 
			
		||||
        width: "250px",
 | 
			
		||||
        borderColor: "#DADBE1",
 | 
			
		||||
      },
 | 
			
		||||
      false: {
 | 
			
		||||
        ":hover": {
 | 
			
		||||
          borderColor: "rgba(255, 255, 255, 0.5)",
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const searchInput = style({
 | 
			
		||||
  backgroundColor: "transparent",
 | 
			
		||||
  border: "none",
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  outline: "none",
 | 
			
		||||
  color: "#DADBE1",
 | 
			
		||||
  cursor: "default",
 | 
			
		||||
  fontFamily: "inherit",
 | 
			
		||||
  fontSize: vars.size.bodyFontSize,
 | 
			
		||||
  textOverflow: "ellipsis",
 | 
			
		||||
  ":focus": {
 | 
			
		||||
    cursor: "text",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionButton = style({
 | 
			
		||||
  color: "inherit",
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  padding: `${SPACING_UNIT}px`,
 | 
			
		||||
  ":hover": {
 | 
			
		||||
    color: "#DADBE1",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const section = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  gap: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
  height: "100%",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const backButton = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    color: vars.color.bodyText,
 | 
			
		||||
    cursor: "pointer",
 | 
			
		||||
    WebkitAppRegion: "no-drag",
 | 
			
		||||
    position: "absolute",
 | 
			
		||||
    transition: "transform ease 0.2s",
 | 
			
		||||
    animationDuration: "0.2s",
 | 
			
		||||
    width: "16px",
 | 
			
		||||
    height: "16px",
 | 
			
		||||
    display: "flex",
 | 
			
		||||
    alignItems: "center",
 | 
			
		||||
  } as ComplexStyleRule,
 | 
			
		||||
  variants: {
 | 
			
		||||
    enabled: {
 | 
			
		||||
      true: {
 | 
			
		||||
        animationName: slideIn,
 | 
			
		||||
      },
 | 
			
		||||
      false: {
 | 
			
		||||
        opacity: "0",
 | 
			
		||||
        pointerEvents: "none",
 | 
			
		||||
        animationName: slideOut,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const title = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    transition: "all ease 0.2s",
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    hasBackButton: {
 | 
			
		||||
      true: {
 | 
			
		||||
        transform: "translateX(28px)",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										125
									
								
								src/renderer/components/header/header.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								src/renderer/components/header/header.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,125 @@
 | 
			
		|||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { useEffect, useMemo, useRef, useState } from "react";
 | 
			
		||||
import { useLocation, useNavigate } from "react-router-dom";
 | 
			
		||||
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
 | 
			
		||||
 | 
			
		||||
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./header.css";
 | 
			
		||||
import { clearSearch } from "@renderer/features";
 | 
			
		||||
 | 
			
		||||
export interface HeaderProps {
 | 
			
		||||
  onSearch: (query: string) => void;
 | 
			
		||||
  onClear: () => void;
 | 
			
		||||
  search?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const pathTitle: Record<string, string> = {
 | 
			
		||||
  "/": "catalogue",
 | 
			
		||||
  "/downloads": "downloads",
 | 
			
		||||
  "/settings": "settings",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function Header({ onSearch, onClear, search }: HeaderProps) {
 | 
			
		||||
  const inputRef = useRef<HTMLInputElement>(null);
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
  const location = useLocation();
 | 
			
		||||
 | 
			
		||||
  const { headerTitle, draggingDisabled } = useAppSelector(
 | 
			
		||||
    (state) => state.window
 | 
			
		||||
  );
 | 
			
		||||
  const dispatch = useAppDispatch();
 | 
			
		||||
 | 
			
		||||
  const [isFocused, setIsFocused] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const { t } = useTranslation("header");
 | 
			
		||||
 | 
			
		||||
  const title = useMemo(() => {
 | 
			
		||||
    if (location.pathname.startsWith("/game")) return headerTitle;
 | 
			
		||||
    if (location.pathname.startsWith("/search")) return t("search_results");
 | 
			
		||||
 | 
			
		||||
    return t(pathTitle[location.pathname]);
 | 
			
		||||
  }, [location.pathname, headerTitle, t]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (search && !location.pathname.startsWith("/search")) {
 | 
			
		||||
      dispatch(clearSearch());
 | 
			
		||||
    }
 | 
			
		||||
  }, [location.pathname, search, dispatch]);
 | 
			
		||||
 | 
			
		||||
  const focusInput = () => {
 | 
			
		||||
    setIsFocused(true);
 | 
			
		||||
    inputRef.current?.focus();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleBlur = () => {
 | 
			
		||||
    setIsFocused(false);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleBackButtonClick = () => {
 | 
			
		||||
    navigate(-1);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <header
 | 
			
		||||
      className={styles.header({
 | 
			
		||||
        draggingDisabled,
 | 
			
		||||
        isWindows: window.electron.platform === "win32",
 | 
			
		||||
      })}
 | 
			
		||||
    >
 | 
			
		||||
      <div className={styles.section}>
 | 
			
		||||
        <button
 | 
			
		||||
          type="button"
 | 
			
		||||
          className={styles.backButton({ enabled: location.key !== "default" })}
 | 
			
		||||
          onClick={handleBackButtonClick}
 | 
			
		||||
          disabled={location.key === "default"}
 | 
			
		||||
        >
 | 
			
		||||
          <ArrowLeftIcon />
 | 
			
		||||
        </button>
 | 
			
		||||
 | 
			
		||||
        <h3
 | 
			
		||||
          className={styles.title({
 | 
			
		||||
            hasBackButton: location.key !== "default",
 | 
			
		||||
          })}
 | 
			
		||||
        >
 | 
			
		||||
          {title}
 | 
			
		||||
        </h3>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <section className={styles.section}>
 | 
			
		||||
        <div className={styles.search({ focused: isFocused })}>
 | 
			
		||||
          <button
 | 
			
		||||
            type="button"
 | 
			
		||||
            className={styles.actionButton}
 | 
			
		||||
            onClick={focusInput}
 | 
			
		||||
          >
 | 
			
		||||
            <SearchIcon />
 | 
			
		||||
          </button>
 | 
			
		||||
 | 
			
		||||
          <input
 | 
			
		||||
            ref={inputRef}
 | 
			
		||||
            type="text"
 | 
			
		||||
            name="search"
 | 
			
		||||
            placeholder={t("search")}
 | 
			
		||||
            value={search}
 | 
			
		||||
            className={styles.searchInput}
 | 
			
		||||
            onChange={(event) => onSearch(event.target.value)}
 | 
			
		||||
            onFocus={() => setIsFocused(true)}
 | 
			
		||||
            onBlur={handleBlur}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          {search && (
 | 
			
		||||
            <button
 | 
			
		||||
              type="button"
 | 
			
		||||
              onClick={onClear}
 | 
			
		||||
              className={styles.actionButton}
 | 
			
		||||
            >
 | 
			
		||||
              <XIcon />
 | 
			
		||||
            </button>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </section>
 | 
			
		||||
    </header>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										64
									
								
								src/renderer/components/hero/hero.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								src/renderer/components/hero/hero.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,64 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
 | 
			
		||||
 | 
			
		||||
export const hero = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "280px",
 | 
			
		||||
  minHeight: "280px",
 | 
			
		||||
  maxHeight: "280px",
 | 
			
		||||
  borderRadius: "8px",
 | 
			
		||||
  color: "#DADBE1",
 | 
			
		||||
  overflow: "hidden",
 | 
			
		||||
  boxShadow: "0px 0px 15px 0px #000000",
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
  border: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
  zIndex: "1",
 | 
			
		||||
  "@media": {
 | 
			
		||||
    "(min-width: 1250px)": {
 | 
			
		||||
      backgroundPosition: "center",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const heroMedia = style({
 | 
			
		||||
  objectFit: "cover",
 | 
			
		||||
  objectPosition: "center",
 | 
			
		||||
  position: "absolute",
 | 
			
		||||
  zIndex: "-1",
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  transition: "all ease 0.2s",
 | 
			
		||||
  selectors: {
 | 
			
		||||
    [`${hero}:hover &`]: {
 | 
			
		||||
      transform: "scale(1.05)",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const backdrop = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  background: "linear-gradient(0deg, rgba(0, 0, 0, 0.6) 25%, transparent 100%)",
 | 
			
		||||
  position: "relative",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  overflow: "hidden",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const description = style({
 | 
			
		||||
  maxWidth: "700px",
 | 
			
		||||
  fontSize: vars.size.bodyFontSize,
 | 
			
		||||
  color: "#c0c1c7",
 | 
			
		||||
  textAlign: "left",
 | 
			
		||||
  fontFamily: "'Fira Sans', sans-serif",
 | 
			
		||||
  lineHeight: "20px",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const content = style({
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 3}px`,
 | 
			
		||||
  gap: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  justifyContent: "flex-end",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										59
									
								
								src/renderer/components/hero/hero.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								src/renderer/components/hero/hero.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,59 @@
 | 
			
		|||
import { useNavigate } from "react-router-dom";
 | 
			
		||||
import { AsyncImage } from "@renderer/components";
 | 
			
		||||
import * as styles from "./hero.css";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { ShopDetails } from "@types";
 | 
			
		||||
import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
 | 
			
		||||
const FEATURED_GAME_ID = "377160";
 | 
			
		||||
 | 
			
		||||
export function Hero() {
 | 
			
		||||
  const [featuredGameDetails, setFeaturedGameDetails] =
 | 
			
		||||
    useState<ShopDetails | null>(null);
 | 
			
		||||
 | 
			
		||||
  const { i18n } = useTranslation();
 | 
			
		||||
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    window.electron
 | 
			
		||||
      .getGameShopDetails(
 | 
			
		||||
        FEATURED_GAME_ID,
 | 
			
		||||
        "steam",
 | 
			
		||||
        getSteamLanguage(i18n.language)
 | 
			
		||||
      )
 | 
			
		||||
      .then((result) => {
 | 
			
		||||
        setFeaturedGameDetails(result);
 | 
			
		||||
      });
 | 
			
		||||
  }, [i18n.language]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <button
 | 
			
		||||
      type="button"
 | 
			
		||||
      onClick={() => navigate(`/game/steam/${FEATURED_GAME_ID}`)}
 | 
			
		||||
      className={styles.hero}
 | 
			
		||||
    >
 | 
			
		||||
      <div className={styles.backdrop}>
 | 
			
		||||
        <AsyncImage
 | 
			
		||||
          src="https://cdn2.steamgriddb.com/hero/e7a7ba56b1be30e178cd52820e063396.png"
 | 
			
		||||
          alt={featuredGameDetails?.name}
 | 
			
		||||
          className={styles.heroMedia}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <div className={styles.content}>
 | 
			
		||||
          <AsyncImage
 | 
			
		||||
            src={steamUrlBuilder.logo(FEATURED_GAME_ID)}
 | 
			
		||||
            width="250px"
 | 
			
		||||
            alt={featuredGameDetails?.name}
 | 
			
		||||
            style={{ marginBottom: 16 }}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <p className={styles.description}>
 | 
			
		||||
            {featuredGameDetails?.short_description}
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </button>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								src/renderer/components/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/renderer/components/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
export * from "./bottom-panel/bottom-panel";
 | 
			
		||||
export * from "./button/button";
 | 
			
		||||
export * from "./game-card/game-card";
 | 
			
		||||
export * from "./header/header";
 | 
			
		||||
export * from "./hero/hero";
 | 
			
		||||
export * from "./modal/modal";
 | 
			
		||||
export * from "./sidebar/sidebar";
 | 
			
		||||
export * from "./async-image/async-image";
 | 
			
		||||
export * from "./text-field/text-field";
 | 
			
		||||
export * from "./checkbox-field/checkbox-field";
 | 
			
		||||
							
								
								
									
										108
									
								
								src/renderer/components/modal/modal.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/renderer/components/modal/modal.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,108 @@
 | 
			
		|||
import { keyframes, style } from "@vanilla-extract/css";
 | 
			
		||||
import { recipe } from "@vanilla-extract/recipes";
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const backdropFadeIn = keyframes({
 | 
			
		||||
  "0%": { backdropFilter: "blur(0px)", backgroundColor: "rgba(0, 0, 0, 0.5)" },
 | 
			
		||||
  "100%": {
 | 
			
		||||
    backdropFilter: "blur(2px)",
 | 
			
		||||
    backgroundColor: "rgba(0, 0, 0, 0.7)",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const backdropFadeOut = keyframes({
 | 
			
		||||
  "0%": { backdropFilter: "blur(2px)", backgroundColor: "rgba(0, 0, 0, 0.7)" },
 | 
			
		||||
  "100%": {
 | 
			
		||||
    backdropFilter: "blur(0px)",
 | 
			
		||||
    backgroundColor: "rgba(0, 0, 0, 0)",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const modalSlideIn = keyframes({
 | 
			
		||||
  "0%": { opacity: 0 },
 | 
			
		||||
  "100%": {
 | 
			
		||||
    opacity: 1,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const modalSlideOut = keyframes({
 | 
			
		||||
  "0%": { opacity: 1 },
 | 
			
		||||
  "100%": {
 | 
			
		||||
    opacity: 0,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const backdrop = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    animationName: backdropFadeIn,
 | 
			
		||||
    animationDuration: "0.4s",
 | 
			
		||||
    backgroundColor: "rgba(0, 0, 0, 0.7)",
 | 
			
		||||
    position: "absolute",
 | 
			
		||||
    width: "100%",
 | 
			
		||||
    height: "100%",
 | 
			
		||||
    display: "flex",
 | 
			
		||||
    justifyContent: "center",
 | 
			
		||||
    alignItems: "center",
 | 
			
		||||
    zIndex: 1,
 | 
			
		||||
    top: 0,
 | 
			
		||||
    padding: `${SPACING_UNIT * 3}px`,
 | 
			
		||||
    backdropFilter: "blur(2px)",
 | 
			
		||||
    transition: "all ease 0.2s",
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    closing: {
 | 
			
		||||
      true: {
 | 
			
		||||
        animationName: backdropFadeOut,
 | 
			
		||||
        backdropFilter: "blur(0px)",
 | 
			
		||||
        backgroundColor: "rgba(0, 0, 0, 0)",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const modal = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    animationName: modalSlideIn,
 | 
			
		||||
    animationDuration: "0.3s",
 | 
			
		||||
    backgroundColor: vars.color.background,
 | 
			
		||||
    borderRadius: "5px",
 | 
			
		||||
    maxWidth: "600px",
 | 
			
		||||
    color: vars.color.bodyText,
 | 
			
		||||
    maxHeight: "100%",
 | 
			
		||||
    border: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
    overflow: "hidden",
 | 
			
		||||
    display: "flex",
 | 
			
		||||
    flexDirection: "column",
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    closing: {
 | 
			
		||||
      true: {
 | 
			
		||||
        animationName: modalSlideOut,
 | 
			
		||||
        opacity: 0,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const modalContent = style({
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  overflow: "auto",
 | 
			
		||||
  padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const modalHeader = style({
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  padding: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
  borderBottom: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
  justifyContent: "space-between",
 | 
			
		||||
  alignItems: "flex-start",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const closeModalButton = style({
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const closeModalButtonIcon = style({
 | 
			
		||||
  color: vars.color.bodyText,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										69
									
								
								src/renderer/components/modal/modal.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/renderer/components/modal/modal.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,69 @@
 | 
			
		|||
import { useEffect, useState } from "react";
 | 
			
		||||
import { createPortal } from "react-dom";
 | 
			
		||||
import { XIcon } from "@primer/octicons-react";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./modal.css";
 | 
			
		||||
import { useAppDispatch } from "@renderer/hooks";
 | 
			
		||||
import { toggleDragging } from "@renderer/features";
 | 
			
		||||
 | 
			
		||||
export interface ModalProps {
 | 
			
		||||
  visible: boolean;
 | 
			
		||||
  title: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Modal({
 | 
			
		||||
  visible,
 | 
			
		||||
  title,
 | 
			
		||||
  description,
 | 
			
		||||
  onClose,
 | 
			
		||||
  children,
 | 
			
		||||
}: ModalProps) {
 | 
			
		||||
  const [isClosing, setIsClosing] = useState(false);
 | 
			
		||||
  const dispatch = useAppDispatch();
 | 
			
		||||
 | 
			
		||||
  const handleCloseClick = () => {
 | 
			
		||||
    setIsClosing(true);
 | 
			
		||||
    const zero = performance.now();
 | 
			
		||||
 | 
			
		||||
    requestAnimationFrame(function animateClosing(time) {
 | 
			
		||||
      if (time - zero <= 400) {
 | 
			
		||||
        requestAnimationFrame(animateClosing);
 | 
			
		||||
      } else {
 | 
			
		||||
        onClose();
 | 
			
		||||
        setIsClosing(false);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    dispatch(toggleDragging(visible));
 | 
			
		||||
  }, [dispatch, visible]);
 | 
			
		||||
 | 
			
		||||
  if (!visible) return null;
 | 
			
		||||
 | 
			
		||||
  return createPortal(
 | 
			
		||||
    <div className={styles.backdrop({ closing: isClosing })}>
 | 
			
		||||
      <div className={styles.modal({ closing: isClosing })}>
 | 
			
		||||
        <div className={styles.modalHeader}>
 | 
			
		||||
          <div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
 | 
			
		||||
            <h3>{title}</h3>
 | 
			
		||||
            <p style={{ fontSize: 14 }}>{description}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <button
 | 
			
		||||
            type="button"
 | 
			
		||||
            onClick={handleCloseClick}
 | 
			
		||||
            className={styles.closeModalButton}
 | 
			
		||||
          >
 | 
			
		||||
            <XIcon className={styles.closeModalButtonIcon} size={24} />
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className={styles.modalContent}>{children}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>,
 | 
			
		||||
    document.body
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								src/renderer/components/sidebar/download-icon.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/renderer/components/sidebar/download-icon.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
 | 
			
		||||
export const downloadIconWrapper = style({
 | 
			
		||||
  width: "16px",
 | 
			
		||||
  height: "12px",
 | 
			
		||||
  position: "relative",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const downloadIcon = style({
 | 
			
		||||
  width: "24px",
 | 
			
		||||
  position: "absolute",
 | 
			
		||||
  left: "-4px",
 | 
			
		||||
  top: "-9px",
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										26
									
								
								src/renderer/components/sidebar/download-icon.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/renderer/components/sidebar/download-icon.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,26 @@
 | 
			
		|||
import { useRef } from "react";
 | 
			
		||||
import Lottie from "lottie-react";
 | 
			
		||||
 | 
			
		||||
import downloadingAnimation from "@renderer/assets/lottie/downloading.json";
 | 
			
		||||
import * as styles from "./download-icon.css";
 | 
			
		||||
 | 
			
		||||
export interface DownloadIconProps {
 | 
			
		||||
  isDownloading: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function DownloadIcon({ isDownloading }: DownloadIconProps) {
 | 
			
		||||
  const lottieRef = useRef(null);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={styles.downloadIconWrapper}>
 | 
			
		||||
      <Lottie
 | 
			
		||||
        lottieRef={lottieRef}
 | 
			
		||||
        animationData={downloadingAnimation}
 | 
			
		||||
        loop={isDownloading}
 | 
			
		||||
        autoplay={isDownloading}
 | 
			
		||||
        className={styles.downloadIcon}
 | 
			
		||||
        onDOMLoaded={() => lottieRef.current?.setSpeed(1.7)}
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								src/renderer/components/sidebar/routes.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/renderer/components/sidebar/routes.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,22 @@
 | 
			
		|||
import { GearIcon, ListUnorderedIcon } from "@primer/octicons-react";
 | 
			
		||||
import { DownloadIcon } from "./download-icon";
 | 
			
		||||
 | 
			
		||||
export const routes = [
 | 
			
		||||
  {
 | 
			
		||||
    path: "/",
 | 
			
		||||
    nameKey: "catalogue",
 | 
			
		||||
    render: () => <ListUnorderedIcon />,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: "/downloads",
 | 
			
		||||
    nameKey: "downloads",
 | 
			
		||||
    render: (isDownloading: boolean) => (
 | 
			
		||||
      <DownloadIcon isDownloading={isDownloading} />
 | 
			
		||||
    ),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    path: "/settings",
 | 
			
		||||
    nameKey: "settings",
 | 
			
		||||
    render: () => <GearIcon />,
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
							
								
								
									
										136
									
								
								src/renderer/components/sidebar/sidebar.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								src/renderer/components/sidebar/sidebar.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,136 @@
 | 
			
		|||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
import { recipe } from "@vanilla-extract/recipes";
 | 
			
		||||
import { SPACING_UNIT, vars } from "../../theme.css";
 | 
			
		||||
 | 
			
		||||
export const sidebar = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    backgroundColor: vars.color.darkBackground,
 | 
			
		||||
    color: "#c0c1c7",
 | 
			
		||||
    display: "flex",
 | 
			
		||||
    transition: "opacity ease 0.2s",
 | 
			
		||||
    borderRight: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
    position: "relative",
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    resizing: {
 | 
			
		||||
      true: {
 | 
			
		||||
        opacity: vars.opacity.active,
 | 
			
		||||
        pointerEvents: "none",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const content = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    display: "flex",
 | 
			
		||||
    flexDirection: "column",
 | 
			
		||||
    padding: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
    paddingBottom: "0",
 | 
			
		||||
    width: "100%",
 | 
			
		||||
    overflow: "auto",
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    macos: {
 | 
			
		||||
      true: {
 | 
			
		||||
        paddingTop: `${SPACING_UNIT * 6}px`,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const handle = style({
 | 
			
		||||
  width: "5px",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  cursor: "col-resize",
 | 
			
		||||
  position: "absolute",
 | 
			
		||||
  right: "0",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const menu = style({
 | 
			
		||||
  listStyle: "none",
 | 
			
		||||
  padding: "0",
 | 
			
		||||
  margin: "0",
 | 
			
		||||
  gap: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  flexDirection: "column",
 | 
			
		||||
  overflow: "hidden",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const menuItem = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    transition: "all ease 0.1s",
 | 
			
		||||
    cursor: "pointer",
 | 
			
		||||
    textWrap: "nowrap",
 | 
			
		||||
    display: "flex",
 | 
			
		||||
    opacity: "0.9",
 | 
			
		||||
    color: "#DADBE1",
 | 
			
		||||
    ":hover": {
 | 
			
		||||
      opacity: "1",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    active: {
 | 
			
		||||
      true: {
 | 
			
		||||
        opacity: "1",
 | 
			
		||||
        fontWeight: "bold",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    muted: {
 | 
			
		||||
      true: {
 | 
			
		||||
        opacity: vars.opacity.disabled,
 | 
			
		||||
        ":hover": {
 | 
			
		||||
          opacity: "1",
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const menuItemButton = style({
 | 
			
		||||
  color: "inherit",
 | 
			
		||||
  display: "flex",
 | 
			
		||||
  alignItems: "center",
 | 
			
		||||
  gap: `${SPACING_UNIT}px`,
 | 
			
		||||
  cursor: "pointer",
 | 
			
		||||
  overflow: "hidden",
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  selectors: {
 | 
			
		||||
    [`${menuItem({ active: true }).split(" ")[1]} &`]: {
 | 
			
		||||
      fontWeight: "bold",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const menuItemButtonLabel = style({
 | 
			
		||||
  textOverflow: "ellipsis",
 | 
			
		||||
  overflow: "hidden",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const gameIcon = style({
 | 
			
		||||
  width: "20px",
 | 
			
		||||
  height: "20px",
 | 
			
		||||
  borderRadius: "4px",
 | 
			
		||||
  backgroundSize: "cover",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const sectionTitle = style({
 | 
			
		||||
  textTransform: "uppercase",
 | 
			
		||||
  fontWeight: "bold",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const section = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    padding: `${SPACING_UNIT * 2}px 0`,
 | 
			
		||||
    gap: `${SPACING_UNIT * 2}px`,
 | 
			
		||||
    display: "flex",
 | 
			
		||||
    flexDirection: "column",
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    hasBorder: {
 | 
			
		||||
      true: {
 | 
			
		||||
        borderBottom: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										217
									
								
								src/renderer/components/sidebar/sidebar.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								src/renderer/components/sidebar/sidebar.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,217 @@
 | 
			
		|||
import { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { useLocation, useNavigate } from "react-router-dom";
 | 
			
		||||
 | 
			
		||||
import type { Game } from "@types";
 | 
			
		||||
 | 
			
		||||
import { useDownload, useLibrary } from "@renderer/hooks";
 | 
			
		||||
import { AsyncImage, TextField } from "@renderer/components";
 | 
			
		||||
import { SPACING_UNIT } from "@renderer/theme.css";
 | 
			
		||||
 | 
			
		||||
import * as styles from "./sidebar.css";
 | 
			
		||||
import { routes } from "./routes";
 | 
			
		||||
 | 
			
		||||
const SIDEBAR_MIN_WIDTH = 200;
 | 
			
		||||
const SIDEBAR_INITIAL_WIDTH = 250;
 | 
			
		||||
const SIDEBAR_MAX_WIDTH = 450;
 | 
			
		||||
 | 
			
		||||
const initialSidebarWidth = window.localStorage.getItem("sidebarWidth");
 | 
			
		||||
 | 
			
		||||
export function Sidebar() {
 | 
			
		||||
  const { t } = useTranslation("sidebar");
 | 
			
		||||
  const { library, updateLibrary } = useLibrary();
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
  const [filteredLibrary, setFilteredLibrary] = useState<Game[]>([]);
 | 
			
		||||
 | 
			
		||||
  const [isResizing, setIsResizing] = useState(false);
 | 
			
		||||
  const [sidebarWidth, setSidebarWidth] = useState(
 | 
			
		||||
    initialSidebarWidth ? Number(initialSidebarWidth) : SIDEBAR_INITIAL_WIDTH
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const location = useLocation();
 | 
			
		||||
 | 
			
		||||
  const { game: gameDownloading, progress } = useDownload();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    updateLibrary();
 | 
			
		||||
  }, [gameDownloading?.id, updateLibrary]);
 | 
			
		||||
 | 
			
		||||
  const isDownloading = library.some((game) =>
 | 
			
		||||
    ["downloading", "checking_files", "downloading_metadata"].includes(
 | 
			
		||||
      game.status
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const sidebarRef = useRef<HTMLElement>(null);
 | 
			
		||||
 | 
			
		||||
  const cursorPos = useRef({ x: 0 });
 | 
			
		||||
  const sidebarInitialWidth = useRef(0);
 | 
			
		||||
 | 
			
		||||
  const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (
 | 
			
		||||
    event
 | 
			
		||||
  ) => {
 | 
			
		||||
    setIsResizing(true);
 | 
			
		||||
    cursorPos.current.x = event.screenX;
 | 
			
		||||
    sidebarInitialWidth.current =
 | 
			
		||||
      sidebarRef.current?.clientWidth || SIDEBAR_INITIAL_WIDTH;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
 | 
			
		||||
    setFilteredLibrary(
 | 
			
		||||
      library.filter((game) =>
 | 
			
		||||
        game.title
 | 
			
		||||
          .toLowerCase()
 | 
			
		||||
          .includes(event.target.value.toLocaleLowerCase())
 | 
			
		||||
      )
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setFilteredLibrary(library);
 | 
			
		||||
  }, [library]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    window.onmousemove = (event) => {
 | 
			
		||||
      if (isResizing) {
 | 
			
		||||
        const cursorXDelta = event.screenX - cursorPos.current.x;
 | 
			
		||||
        const newWidth = Math.max(
 | 
			
		||||
          SIDEBAR_MIN_WIDTH,
 | 
			
		||||
          Math.min(
 | 
			
		||||
            sidebarInitialWidth.current + cursorXDelta,
 | 
			
		||||
            SIDEBAR_MAX_WIDTH
 | 
			
		||||
          )
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        setSidebarWidth(newWidth);
 | 
			
		||||
        window.localStorage.setItem("sidebarWidth", String(newWidth));
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    window.onmouseup = () => {
 | 
			
		||||
      if (isResizing) setIsResizing(false);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      window.onmouseup = null;
 | 
			
		||||
      window.onmousemove = null;
 | 
			
		||||
    };
 | 
			
		||||
  }, [isResizing]);
 | 
			
		||||
 | 
			
		||||
  const getGameTitle = (game: Game) => {
 | 
			
		||||
    if (game.status === "paused") return t("paused", { title: game.title });
 | 
			
		||||
 | 
			
		||||
    if (gameDownloading?.id === game.id) {
 | 
			
		||||
      const isVerifying = ["downloading_metadata", "checking_files"].includes(
 | 
			
		||||
        gameDownloading?.status
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (isVerifying)
 | 
			
		||||
        return t(gameDownloading.status, {
 | 
			
		||||
          title: game.title,
 | 
			
		||||
          percentage: progress,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
      return t("downloading", {
 | 
			
		||||
        title: game.title,
 | 
			
		||||
        percentage: progress,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return game.title;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSidebarItemClick = (path: string) => {
 | 
			
		||||
    if (path !== location.pathname) {
 | 
			
		||||
      navigate(path);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  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",
 | 
			
		||||
        })}
 | 
			
		||||
      >
 | 
			
		||||
        {window.electron.platform === "darwin" && (
 | 
			
		||||
          <h2 style={{ marginBottom: SPACING_UNIT }}>Hydra</h2>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <section className={styles.section({ hasBorder: false })}>
 | 
			
		||||
          <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)}
 | 
			
		||||
                >
 | 
			
		||||
                  {render(isDownloading)}
 | 
			
		||||
                  <span>{t(nameKey)}</span>
 | 
			
		||||
                </button>
 | 
			
		||||
              </li>
 | 
			
		||||
            ))}
 | 
			
		||||
          </ul>
 | 
			
		||||
        </section>
 | 
			
		||||
 | 
			
		||||
        <section className={styles.section({ hasBorder: false })}>
 | 
			
		||||
          <small className={styles.sectionTitle}>{t("my_library")}</small>
 | 
			
		||||
 | 
			
		||||
          <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 === null || game.status === "cancelled",
 | 
			
		||||
                })}
 | 
			
		||||
              >
 | 
			
		||||
                <button
 | 
			
		||||
                  type="button"
 | 
			
		||||
                  className={styles.menuItemButton}
 | 
			
		||||
                  onClick={() =>
 | 
			
		||||
                    handleSidebarItemClick(
 | 
			
		||||
                      `/game/${game.shop}/${game.objectID}`
 | 
			
		||||
                    )
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  <AsyncImage className={styles.gameIcon} src={game.iconUrl} />
 | 
			
		||||
                  <span className={styles.menuItemButtonLabel}>
 | 
			
		||||
                    {getGameTitle(game)}
 | 
			
		||||
                  </span>
 | 
			
		||||
                </button>
 | 
			
		||||
              </li>
 | 
			
		||||
            ))}
 | 
			
		||||
          </ul>
 | 
			
		||||
        </section>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <button
 | 
			
		||||
        type="button"
 | 
			
		||||
        className={styles.handle}
 | 
			
		||||
        onMouseDown={handleMouseDown}
 | 
			
		||||
      />
 | 
			
		||||
    </aside>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										59
									
								
								src/renderer/components/text-field/text-field.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								src/renderer/components/text-field/text-field.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,59 @@
 | 
			
		|||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
 | 
			
		||||
import { style } from "@vanilla-extract/css";
 | 
			
		||||
import { recipe } from "@vanilla-extract/recipes";
 | 
			
		||||
 | 
			
		||||
export const textField = recipe({
 | 
			
		||||
  base: {
 | 
			
		||||
    display: "inline-flex",
 | 
			
		||||
    transition: "all ease 0.2s",
 | 
			
		||||
    width: "100%",
 | 
			
		||||
    alignItems: "center",
 | 
			
		||||
    borderRadius: "8px",
 | 
			
		||||
    border: `solid 1px ${vars.color.borderColor}`,
 | 
			
		||||
    height: "40px",
 | 
			
		||||
    minHeight: "40px",
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    focused: {
 | 
			
		||||
      true: {
 | 
			
		||||
        borderColor: "#DADBE1",
 | 
			
		||||
      },
 | 
			
		||||
      false: {
 | 
			
		||||
        ":hover": {
 | 
			
		||||
          borderColor: "rgba(255, 255, 255, 0.5)",
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    theme: {
 | 
			
		||||
      primary: {
 | 
			
		||||
        backgroundColor: vars.color.darkBackground,
 | 
			
		||||
      },
 | 
			
		||||
      dark: {
 | 
			
		||||
        backgroundColor: vars.color.background,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const textFieldInput = style({
 | 
			
		||||
  backgroundColor: "transparent",
 | 
			
		||||
  border: "none",
 | 
			
		||||
  width: "100%",
 | 
			
		||||
  height: "100%",
 | 
			
		||||
  outline: "none",
 | 
			
		||||
  color: "#DADBE1",
 | 
			
		||||
  cursor: "default",
 | 
			
		||||
  fontFamily: "inherit",
 | 
			
		||||
  fontSize: vars.size.bodyFontSize,
 | 
			
		||||
  textOverflow: "ellipsis",
 | 
			
		||||
  padding: `${SPACING_UNIT}px`,
 | 
			
		||||
  ":focus": {
 | 
			
		||||
    cursor: "text",
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const label = style({
 | 
			
		||||
  marginBottom: `${SPACING_UNIT}px`,
 | 
			
		||||
  display: "block",
 | 
			
		||||
  color: vars.color.bodyText,
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										42
									
								
								src/renderer/components/text-field/text-field.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/renderer/components/text-field/text-field.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,42 @@
 | 
			
		|||
import { useId, useState } from "react";
 | 
			
		||||
import type { RecipeVariants } from "@vanilla-extract/recipes";
 | 
			
		||||
import * as styles from "./text-field.css";
 | 
			
		||||
 | 
			
		||||
export interface TextFieldProps
 | 
			
		||||
  extends React.DetailedHTMLProps<
 | 
			
		||||
    React.InputHTMLAttributes<HTMLInputElement>,
 | 
			
		||||
    HTMLInputElement
 | 
			
		||||
  > {
 | 
			
		||||
  theme?: RecipeVariants<typeof styles.textField>["theme"];
 | 
			
		||||
  label?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function TextField({
 | 
			
		||||
  theme = "primary",
 | 
			
		||||
  label,
 | 
			
		||||
  ...props
 | 
			
		||||
}: TextFieldProps) {
 | 
			
		||||
  const [isFocused, setIsFocused] = useState(false);
 | 
			
		||||
  const id = useId();
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div style={{ flex: 1 }}>
 | 
			
		||||
      {label && (
 | 
			
		||||
        <label htmlFor={id} className={styles.label}>
 | 
			
		||||
          {label}
 | 
			
		||||
        </label>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <div className={styles.textField({ focused: isFocused, theme })}>
 | 
			
		||||
        <input
 | 
			
		||||
          id={id}
 | 
			
		||||
          type="text"
 | 
			
		||||
          className={styles.textFieldInput}
 | 
			
		||||
          onFocus={() => setIsFocused(true)}
 | 
			
		||||
          onBlur={() => setIsFocused(false)}
 | 
			
		||||
          {...props}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue