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