first commit

This commit is contained in:
Hydra 2024-04-18 08:46:06 +01:00
commit 91b1341271
No known key found for this signature in database
165 changed files with 20993 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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