first commit

This commit is contained in:
Hydra 2024-04-18 08:46:06 +01:00
commit f1bdec484e
165 changed files with 20993 additions and 0 deletions

107
src/renderer/app.css.ts Normal file
View file

@ -0,0 +1,107 @@
import { ComplexStyleRule, globalStyle, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "./theme.css";
globalStyle("*", {
boxSizing: "border-box",
});
globalStyle("::-webkit-scrollbar", {
width: "9px",
});
globalStyle("::-webkit-scrollbar-track", {
backgroundColor: "rgba(255, 255, 255, 0.03)",
});
globalStyle("::-webkit-scrollbar-thumb", {
backgroundColor: "rgba(255, 255, 255, 0.08)",
borderRadius: "24px",
});
globalStyle("html, body, #root, main", {
height: "100%",
});
globalStyle("body", {
overflow: "hidden",
userSelect: "none",
fontFamily: "'Fira Mono', monospace",
background: vars.color.background,
color: vars.color.bodyText,
margin: "0",
});
globalStyle("button", {
padding: "0",
backgroundColor: "transparent",
border: "none",
fontFamily: "inherit",
fontSize: vars.size.bodyFontSize,
});
globalStyle("h1, h2, h3, h4, h5, h6, p", {
margin: 0,
});
globalStyle("#root, main", {
display: "flex",
});
globalStyle("#root", {
flexDirection: "column",
});
globalStyle("main", {
overflow: "hidden",
});
globalStyle(
"input::-webkit-outer-spin-button, input::-webkit-inner-spin-button",
{
WebkitAppearance: "none",
margin: "0",
}
);
globalStyle("label", {
fontSize: vars.size.bodyFontSize,
});
globalStyle("input[type=number]", {
MozAppearance: "textfield",
});
globalStyle("img", {
WebkitUserDrag: "none",
} as Record<string, string>);
export const container = style({
width: "100%",
height: "100%",
overflow: "hidden",
display: "flex",
flexDirection: "column",
});
export const content = style({
overflowY: "auto",
alignItems: "center",
display: "flex",
flexDirection: "column",
position: "relative",
height: "100%",
background: `linear-gradient(0deg, ${vars.color.darkBackground} 50%, ${vars.color.background} 100%)`,
});
export const titleBar = style({
display: "flex",
width: "100%",
height: "35px",
minHeight: "35px",
backgroundColor: vars.color.darkBackground,
alignItems: "center",
padding: `0 ${SPACING_UNIT * 2}px`,
WebkitAppRegion: "drag",
zIndex: "2",
borderBottom: `1px solid ${vars.color.borderColor}`,
} as ComplexStyleRule);

122
src/renderer/app.tsx Normal file
View file

@ -0,0 +1,122 @@
import { useCallback, useEffect, useRef } from "react";
import { Sidebar, BottomPanel, Header } from "@renderer/components";
import {
useAppDispatch,
useAppSelector,
useDownload,
useLibrary,
} from "@renderer/hooks";
import * as styles from "./app.css";
import { themeClass } from "./theme.css";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import {
setSearch,
clearSearch,
setUserPreferences,
setRepackersFriendlyNames,
} from "@renderer/features";
document.body.classList.add(themeClass);
export function App() {
const contentRef = useRef<HTMLDivElement>(null);
const { updateLibrary } = useLibrary();
const { clearDownload, addPacket } = useDownload();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const search = useAppSelector((state) => state.search.value);
useEffect(() => {
Promise.all([
window.electron.getUserPreferences(),
window.electron.getRepackersFriendlyNames(),
updateLibrary(),
]).then(([preferences, repackersFriendlyNames]) => {
dispatch(setUserPreferences(preferences));
dispatch(setRepackersFriendlyNames(repackersFriendlyNames));
});
}, [navigate, location.pathname, dispatch, updateLibrary]);
useEffect(() => {
const unsubscribe = window.electron.onDownloadProgress(
(downloadProgress) => {
if (downloadProgress.game.progress === 1) {
clearDownload();
updateLibrary();
return;
}
addPacket(downloadProgress);
}
);
return () => {
unsubscribe();
};
}, [clearDownload, addPacket, updateLibrary]);
const handleSearch = useCallback(
(query: string) => {
dispatch(setSearch(query));
if (query === "") {
navigate(-1);
return;
}
const searchParams = new URLSearchParams({
query,
});
navigate(`/search?${searchParams.toString()}`, {
replace: location.pathname.startsWith("/search"),
});
},
[dispatch, location.pathname, navigate]
);
const handleClear = useCallback(() => {
dispatch(clearSearch());
navigate(-1);
}, [dispatch, navigate]);
useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0;
}, [location.pathname, location.search]);
return (
<>
{window.electron.platform === "win32" && (
<div className={styles.titleBar}>
<h4>Hydra</h4>
</div>
)}
<main>
<Sidebar />
<article className={styles.container}>
<Header
onSearch={handleSearch}
search={search}
onClear={handleClear}
/>
<section ref={contentRef} className={styles.content}>
<Outlet />
</section>
</article>
</main>
<BottomPanel />
</>
);
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 0 0 .02.433c.031.3.037.59.316.92c.027.033.311.245.311.245c.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2c0 0 .284-.211.311-.243c.28-.33.285-.621.316-.92a4.261 4.261 0 0 0 .02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 0 1 .591.108a1.745 1.745 0 0 1 .49.299l-.452.546a1.247 1.247 0 0 0-.308-.195a.91.91 0 0 0-.363-.068a.658.658 0 0 0-.28.06a.703.703 0 0 0-.224.163a.783.783 0 0 0-.151.243a.799.799 0 0 0-.056.299v.008a.852.852 0 0 0 .056.31a.7.7 0 0 0 .157.245a.736.736 0 0 0 .238.16a.774.774 0 0 0 .303.058a.79.79 0 0 0 .445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 0 1-.524.307a1.789 1.789 0 0 1-.683.123a1.642 1.642 0 0 1-.602-.107a1.46 1.46 0 0 1-.478-.3a1.371 1.371 0 0 1-.318-.455a1.438 1.438 0 0 1-.115-.58v-.008a1.426 1.426 0 0 1 .113-.57a1.449 1.449 0 0 1 .312-.46a1.418 1.418 0 0 1 .474-.309a1.58 1.58 0 0 1 .598-.111a1.708 1.708 0 0 1 .045 0zm11.963.008a2.006 2.006 0 0 1 .612.094a1.61 1.61 0 0 1 .507.277l-.386.546a1.562 1.562 0 0 0-.39-.205a1.178 1.178 0 0 0-.388-.07a.347.347 0 0 0-.208.052a.154.154 0 0 0-.07.127v.008a.158.158 0 0 0 .022.084a.198.198 0 0 0 .076.066a.831.831 0 0 0 .147.06c.062.02.14.04.236.061a3.389 3.389 0 0 1 .43.122a1.292 1.292 0 0 1 .328.17a.678.678 0 0 1 .207.24a.739.739 0 0 1 .071.337v.008a.865.865 0 0 1-.081.382a.82.82 0 0 1-.229.285a1.032 1.032 0 0 1-.353.18a1.606 1.606 0 0 1-.46.061a2.16 2.16 0 0 1-.71-.116a1.718 1.718 0 0 1-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 0 0 .236-.05a.157.157 0 0 0 .082-.142v-.008a.15.15 0 0 0-.02-.077a.204.204 0 0 0-.073-.066a.753.753 0 0 0-.143-.062a2.45 2.45 0 0 0-.233-.062a5.036 5.036 0 0 1-.413-.113a1.26 1.26 0 0 1-.331-.16a.72.72 0 0 1-.222-.243a.73.73 0 0 1-.082-.36v-.008a.863.863 0 0 1 .074-.359a.794.794 0 0 1 .214-.283a1.007 1.007 0 0 1 .34-.185a1.423 1.423 0 0 1 .448-.066a2.006 2.006 0 0 1 .025 0m-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08l.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,928 @@
{
"v": "4.8.0",
"meta": { "g": "LottieFiles AE 3.5.6", "a": "", "k": "", "d": "", "tc": "" },
"fr": 60,
"ip": 0,
"op": 120,
"w": 300,
"h": 300,
"nm": "Comp 1",
"ddd": 0,
"assets": [
{
"id": "comp_0",
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 5,
"nm": "3",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [0]
},
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 30,
"s": [8]
},
{ "t": 60, "s": [0] }
],
"ix": 10
},
"p": { "a": 0, "k": [930, 525, 0], "ix": 2 },
"a": { "a": 0, "k": [16.605, -23.904, 0], "ix": 1 },
"s": { "a": 0, "k": [170, 170, 100], "ix": 6 }
},
"ao": 0,
"hasMask": true,
"masksProperties": [
{
"inv": false,
"mode": "a",
"pt": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"v": [
[14.987, -34.426],
[9.105, -30.309],
[9.987, -22.073],
[17.487, -16.779],
[24.105, -23.544],
[22.193, -30.603]
],
"c": true
},
"ix": 1
},
"o": { "a": 0, "k": 100, "ix": 3 },
"x": { "a": 0, "k": 0, "ix": 4 },
"nm": "Mask 1"
}
],
"ef": [
{
"ty": 21,
"nm": "Fill",
"np": 9,
"mn": "ADBE Fill",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 10,
"nm": "Fill Mask",
"mn": "ADBE Fill-0001",
"ix": 1,
"v": { "a": 0, "k": 0, "ix": 1 }
},
{
"ty": 7,
"nm": "All Masks",
"mn": "ADBE Fill-0007",
"ix": 2,
"v": { "a": 0, "k": 0, "ix": 2 }
},
{
"ty": 2,
"nm": "Color",
"mn": "ADBE Fill-0002",
"ix": 3,
"v": {
"a": 0,
"k": [0.992156863213, 0.880375564098, 0.128396704793, 1],
"ix": 3
}
},
{
"ty": 7,
"nm": "Invert",
"mn": "ADBE Fill-0006",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Horizontal Feather",
"mn": "ADBE Fill-0003",
"ix": 5,
"v": { "a": 0, "k": 0, "ix": 5 }
},
{
"ty": 0,
"nm": "Vertical Feather",
"mn": "ADBE Fill-0004",
"ix": 6,
"v": { "a": 0, "k": 0, "ix": 6 }
},
{
"ty": 0,
"nm": "Opacity",
"mn": "ADBE Fill-0005",
"ix": 7,
"v": { "a": 0, "k": 1, "ix": 7 }
}
]
}
],
"t": {
"d": {
"k": [
{
"s": {
"s": 40,
"f": "SegoeUIEmoji",
"t": "✨",
"j": 0,
"tr": 0,
"lh": 48,
"ls": 0,
"fc": [1, 1, 1]
},
"t": 0
}
]
},
"p": {},
"m": { "g": 1, "a": { "a": 0, "k": [0, 0], "ix": 2 } },
"a": []
},
"ip": 0,
"op": 123,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 2,
"ty": 5,
"nm": "2",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [0]
},
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 30,
"s": [-8]
},
{ "t": 60, "s": [0] }
],
"ix": 10
},
"p": { "a": 0, "k": [960, 540, 0], "ix": 2 },
"a": { "a": 0, "k": [31.912, -13.397, 0], "ix": 1 },
"s": { "a": 0, "k": [170, 170, 100], "ix": 6 }
},
"ao": 0,
"hasMask": true,
"masksProperties": [
{
"inv": false,
"mode": "a",
"pt": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"v": [
[31.31, -34.72],
[24.546, -22.514],
[16.605, -16.485],
[17.046, -11.338],
[21.163, -7.073],
[27.487, -0.309],
[33.663, 10.133],
[47.634, -1.926],
[51.31, -12.073]
],
"c": true
},
"ix": 1
},
"o": { "a": 0, "k": 100, "ix": 3 },
"x": { "a": 0, "k": 0, "ix": 4 },
"nm": "Mask 1"
}
],
"ef": [
{
"ty": 21,
"nm": "Fill",
"np": 9,
"mn": "ADBE Fill",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 10,
"nm": "Fill Mask",
"mn": "ADBE Fill-0001",
"ix": 1,
"v": { "a": 0, "k": 0, "ix": 1 }
},
{
"ty": 7,
"nm": "All Masks",
"mn": "ADBE Fill-0007",
"ix": 2,
"v": { "a": 0, "k": 0, "ix": 2 }
},
{
"ty": 2,
"nm": "Color",
"mn": "ADBE Fill-0002",
"ix": 3,
"v": {
"a": 0,
"k": [0.992156863213, 0.880375564098, 0.128396704793, 1],
"ix": 3
}
},
{
"ty": 7,
"nm": "Invert",
"mn": "ADBE Fill-0006",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Horizontal Feather",
"mn": "ADBE Fill-0003",
"ix": 5,
"v": { "a": 0, "k": 0, "ix": 5 }
},
{
"ty": 0,
"nm": "Vertical Feather",
"mn": "ADBE Fill-0004",
"ix": 6,
"v": { "a": 0, "k": 0, "ix": 6 }
},
{
"ty": 0,
"nm": "Opacity",
"mn": "ADBE Fill-0005",
"ix": 7,
"v": { "a": 0, "k": 1, "ix": 7 }
}
]
}
],
"t": {
"d": {
"k": [
{
"s": {
"s": 40,
"f": "SegoeUIEmoji",
"t": "✨",
"j": 0,
"tr": 0,
"lh": 48,
"ls": 0,
"fc": [1, 1, 1]
},
"t": 0
}
]
},
"p": {},
"m": { "g": 1, "a": { "a": 0, "k": [0, 0], "ix": 2 } },
"a": []
},
"ip": 0,
"op": 123,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 3,
"ty": 5,
"nm": "✨",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [0]
},
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 30,
"s": [8]
},
{ "t": 60, "s": [0] }
],
"ix": 10
},
"p": { "a": 0, "k": [935, 560, 0], "ix": 2 },
"a": { "a": 0, "k": [14.973, -6.64, 0], "ix": 1 },
"s": { "a": 0, "k": [170, 170, 100], "ix": 6 }
},
"ao": 0,
"hasMask": true,
"masksProperties": [
{
"inv": false,
"mode": "a",
"pt": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"v": [
[13.957, -17.514],
[2.928, -9.132],
[2.487, 1.603],
[14.105, 7.339],
[21.605, -0.161],
[22.193, -5.161],
[17.34, -10.014]
],
"c": true
},
"ix": 1
},
"o": { "a": 0, "k": 100, "ix": 3 },
"x": { "a": 0, "k": 0, "ix": 4 },
"nm": "Mask 1"
}
],
"ef": [
{
"ty": 21,
"nm": "Fill",
"np": 9,
"mn": "ADBE Fill",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 10,
"nm": "Fill Mask",
"mn": "ADBE Fill-0001",
"ix": 1,
"v": { "a": 0, "k": 0, "ix": 1 }
},
{
"ty": 7,
"nm": "All Masks",
"mn": "ADBE Fill-0007",
"ix": 2,
"v": { "a": 0, "k": 0, "ix": 2 }
},
{
"ty": 2,
"nm": "Color",
"mn": "ADBE Fill-0002",
"ix": 3,
"v": {
"a": 0,
"k": [0.992156863213, 0.880375564098, 0.128396704793, 1],
"ix": 3
}
},
{
"ty": 7,
"nm": "Invert",
"mn": "ADBE Fill-0006",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Horizontal Feather",
"mn": "ADBE Fill-0003",
"ix": 5,
"v": { "a": 0, "k": 0, "ix": 5 }
},
{
"ty": 0,
"nm": "Vertical Feather",
"mn": "ADBE Fill-0004",
"ix": 6,
"v": { "a": 0, "k": 0, "ix": 6 }
},
{
"ty": 0,
"nm": "Opacity",
"mn": "ADBE Fill-0005",
"ix": 7,
"v": { "a": 0, "k": 1, "ix": 7 }
}
]
}
],
"t": {
"d": {
"k": [
{
"s": {
"s": 40,
"f": "SegoeUIEmoji",
"t": "✨",
"j": 0,
"tr": 0,
"lh": 48,
"ls": 0,
"fc": [1, 1, 1]
},
"t": 0
}
]
},
"p": {},
"m": { "g": 1, "a": { "a": 0, "k": [0, 0], "ix": 2 } },
"a": []
},
"ip": 0,
"op": 123,
"st": 0,
"bm": 0
}
]
}
],
"fonts": {
"list": [
{
"fName": "SegoeUIEmoji",
"fFamily": "Segoe UI Emoji",
"fStyle": "Regular",
"ascent": 74.0234375
}
]
},
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 0,
"nm": "botão",
"refId": "comp_0",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [155, 154, 0], "ix": 2 },
"a": { "a": 0, "k": [960, 540, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
},
"ao": 0,
"ef": [
{
"ty": 25,
"nm": "Drop Shadow",
"np": 8,
"mn": "ADBE Drop Shadow",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 2,
"nm": "Shadow Color",
"mn": "ADBE Drop Shadow-0001",
"ix": 1,
"v": {
"a": 0,
"k": [1, 0.829733371735, 0.414901971817, 1],
"ix": 1
}
},
{
"ty": 0,
"nm": "Opacity",
"mn": "ADBE Drop Shadow-0002",
"ix": 2,
"v": {
"a": 1,
"k": [
{
"i": { "x": [0], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [127.5]
},
{
"i": { "x": [0], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 15,
"s": [204]
},
{
"i": { "x": [0], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 30,
"s": [127.5]
},
{
"i": { "x": [0], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 45,
"s": [204]
},
{ "t": 70, "s": [76.5] }
],
"ix": 2
}
},
{
"ty": 0,
"nm": "Direction",
"mn": "ADBE Drop Shadow-0003",
"ix": 3,
"v": { "a": 0, "k": 135, "ix": 3 }
},
{
"ty": 0,
"nm": "Distance",
"mn": "ADBE Drop Shadow-0004",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Softness",
"mn": "ADBE Drop Shadow-0005",
"ix": 5,
"v": { "a": 0, "k": 40, "ix": 5 }
},
{
"ty": 7,
"nm": "Shadow Only",
"mn": "ADBE Drop Shadow-0006",
"ix": 6,
"v": { "a": 0, "k": 0, "ix": 6 }
}
]
}
],
"w": 1920,
"h": 1080,
"ip": 0,
"op": 120,
"st": 0,
"bm": 0
}
],
"markers": [],
"chars": [
{
"ch": "✨",
"size": 40,
"style": "Regular",
"w": 137.3,
"data": {
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0.423, 1.042],
[0, 0],
[0.7, 0],
[0.293, -0.618],
[0, 0],
[1.041, -0.488],
[0, 0],
[0, -0.684],
[-0.652, -0.293],
[0, 0],
[-0.423, -1.041],
[0, 0],
[-0.716, 0],
[-0.293, 0.619],
[0, 0],
[-1.042, 0.488],
[0, 0],
[0, 0.684],
[0.618, 0.293],
[0, 0]
],
"o": [
[0, 0],
[-0.326, -0.618],
[-0.7, 0],
[0, 0],
[-0.456, 1.009],
[0, 0],
[-0.652, 0.293],
[0, 0.684],
[0, 0],
[1.074, 0.456],
[0, 0],
[0.293, 0.619],
[0.716, 0],
[0, 0],
[0.455, -1.009],
[0, 0],
[0.618, -0.293],
[0, -0.684],
[0, 0],
[-1.074, -0.455]
],
"v": [
[47.119, -68.994],
[43.799, -76.562],
[42.261, -77.49],
[40.771, -76.562],
[37.402, -68.994],
[35.156, -66.748],
[30.908, -64.893],
[29.932, -63.428],
[30.908, -61.963],
[35.156, -60.107],
[37.402, -57.861],
[40.771, -50.244],
[42.285, -49.316],
[43.799, -50.244],
[47.119, -57.861],
[49.365, -60.107],
[53.662, -61.963],
[54.59, -63.428],
[53.662, -64.893],
[49.365, -66.748]
],
"c": true
},
"ix": 2
},
"nm": "✨",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ind": 1,
"ty": "sh",
"ix": 2,
"ks": {
"a": 0,
"k": {
"i": [
[1.334, 3.223],
[0, 0],
[1.204, 0.423],
[1.204, -0.423],
[0.618, -1.237],
[0, 0],
[3.125, -1.432],
[0, 0],
[0.423, -1.221],
[-0.423, -1.221],
[-1.27, -0.618],
[0, 0],
[-1.335, -3.223],
[0, 0],
[-1.205, -0.407],
[-1.205, 0.407],
[-0.619, 1.27],
[0, 0],
[-3.125, 1.433],
[0, 0],
[-0.423, 1.221],
[0.423, 1.221],
[1.27, 0.619],
[0, 0]
],
"o": [
[0, 0],
[-0.619, -1.237],
[-1.205, -0.423],
[-1.205, 0.423],
[0, 0],
[-1.367, 3.223],
[0, 0],
[-1.27, 0.619],
[-0.423, 1.221],
[0.423, 1.221],
[0, 0],
[3.157, 1.433],
[0, 0],
[0.618, 1.27],
[1.204, 0.407],
[1.204, -0.407],
[0, 0],
[1.367, -3.223],
[0, 0],
[1.27, -0.618],
[0.423, -1.221],
[-0.423, -1.221],
[0, 0],
[-3.158, -1.432]
],
"v": [
[95.605, -50.83],
[85.498, -74.658],
[82.764, -77.148],
[79.15, -77.148],
[76.416, -74.658],
[66.357, -50.83],
[59.619, -43.848],
[46.875, -38.086],
[44.336, -35.327],
[44.336, -31.665],
[46.875, -28.906],
[59.619, -23.145],
[66.357, -16.162],
[76.416, 7.666],
[79.15, 10.181],
[82.764, 10.181],
[85.498, 7.666],
[95.605, -16.162],
[102.344, -23.145],
[115.088, -28.906],
[117.627, -31.665],
[117.627, -35.327],
[115.088, -38.086],
[102.344, -43.848]
],
"c": true
},
"ix": 2
},
"nm": "✨",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ind": 2,
"ty": "sh",
"ix": 3,
"ks": {
"a": 0,
"k": {
"i": [
[-1.367, -0.651],
[0, 0],
[0, -0.928],
[0.813, -0.423],
[0, 0],
[0.586, -1.399],
[0, 0],
[0.895, 0],
[0.391, 0.846],
[0, 0],
[1.334, 0.652],
[0, 0],
[0, 0.928],
[-0.814, 0.423],
[0, 0],
[-0.586, 1.4],
[0, 0],
[-0.896, 0],
[-0.391, -0.846],
[0, 0]
],
"o": [
[0, 0],
[0.813, 0.423],
[0, 0.928],
[0, 0],
[-1.335, 0.652],
[0, 0],
[-0.391, 0.846],
[-0.896, 0],
[0, 0],
[-0.586, -1.399],
[0, 0],
[-0.814, -0.423],
[0, -0.928],
[0, 0],
[1.334, -0.651],
[0, 0],
[0.391, -0.846],
[0.895, 0],
[0, 0],
[0.553, 1.4]
],
"v": [
[44.385, -16.943],
[49.854, -14.404],
[51.074, -12.378],
[49.854, -10.352],
[44.385, -7.812],
[41.504, -4.736],
[37.158, 5.713],
[35.229, 6.982],
[33.301, 5.713],
[28.955, -4.736],
[26.074, -7.812],
[20.605, -10.352],
[19.385, -12.378],
[20.605, -14.404],
[26.074, -16.943],
[28.955, -20.02],
[33.301, -30.469],
[35.229, -31.738],
[37.158, -30.469],
[41.504, -20.02]
],
"c": true
},
"ix": 2
},
"nm": "✨",
"mn": "ADBE Vector Shape - Group",
"hd": false
}
],
"nm": "✨",
"np": 6,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
]
},
"fFamily": "Segoe UI Emoji"
}
]
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 0 1 10 10a10 10 0 0 1-10 10c-4.6 0-8.45-3.08-9.64-7.27l3.83 1.58a2.843 2.843 0 0 0 2.78 2.27c1.56 0 2.83-1.27 2.83-2.83v-.13l3.4-2.43h.08c2.08 0 3.77-1.69 3.77-3.77s-1.69-3.77-3.77-3.77s-3.78 1.69-3.78 3.77v.05l-2.37 3.46l-.16-.01c-.59 0-1.14.18-1.59.49L2 11.2C2.43 6.05 6.73 2 12 2M8.28 17.17c.8.33 1.72-.04 2.05-.84c.33-.8-.05-1.71-.83-2.04l-1.28-.53c.49-.18 1.04-.19 1.56.03c.53.21.94.62 1.15 1.15c.22.52.22 1.1 0 1.62c-.43 1.08-1.7 1.6-2.78 1.15c-.5-.21-.88-.59-1.09-1.04zm9.52-7.75c0 1.39-1.13 2.52-2.52 2.52a2.52 2.52 0 0 1-2.51-2.52a2.5 2.5 0 0 1 2.51-2.51a2.52 2.52 0 0 1 2.52 2.51m-4.4 0c0 1.04.84 1.89 1.89 1.89c1.04 0 1.88-.85 1.88-1.89s-.84-1.89-1.88-1.89c-1.05 0-1.89.85-1.89 1.89"/></svg>

After

Width:  |  Height:  |  Size: 828 B

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

View file

@ -0,0 +1 @@
export const VERSION_CODENAME = "Exodus";

89
src/renderer/declaration.d.ts vendored Normal file
View file

@ -0,0 +1,89 @@
import type {
CatalogueEntry,
GameShop,
Game,
CatalogueCategory,
TorrentProgress,
ShopDetails,
UserPreferences,
HowLongToBeatCategory,
} from "@types";
import type { DiskSpace } from "check-disk-space";
declare global {
declare module "*.svg" {
const content: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
export default content;
}
interface Electron {
/* Torrenting */
startGameDownload: (
repackId: number,
objectID: string,
title: string,
shop: GameShop
) => Promise<Game>;
cancelGameDownload: (gameId: number) => Promise<void>;
pauseGameDownload: (gameId: number) => Promise<void>;
resumeGameDownload: (gameId: number) => Promise<void>;
onDownloadProgress: (
cb: (value: TorrentProgress) => void
) => () => Electron.IpcRenderer;
/* Catalogue */
searchGames: (query: string) => Promise<CatalogueEntry[]>;
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
getGameShopDetails: (
objectID: string,
shop: GameShop,
language: string
) => Promise<ShopDetails | null>;
getRandomGame: () => Promise<string>;
getHowLongToBeat: (
objectID: string,
shop: GameShop,
title: string
) => Promise<HowLongToBeatCategory[] | null>;
/* Library */
addGameToLibrary: (
objectID: string,
title: string,
shop: GameShop
) => Promise<void>;
getLibrary: () => Promise<Game[]>;
getRepackersFriendlyNames: () => Promise<Record<string, string>>;
openGameInstaller: (gameId: number) => Promise<boolean>;
openGame: (gameId: number, path: string) => Promise<void>;
closeGame: (gameId: number) => Promise<boolean>;
removeGame: (gameId: number) => Promise<void>;
deleteGameFolder: (gameId: number) => Promise<unknown>;
getGameByObjectID: (objectID: string) => Promise<Game | null>;
onPlaytime: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;
onGameClose: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;
/* User preferences */
getUserPreferences: () => Promise<UserPreferences | null>;
updateUserPreferences: (
preferences: Partial<UserPreferences>
) => Promise<void>;
/* Hardware */
getDiskFreeSpace: () => Promise<DiskSpace>;
/* Misc */
getOrCacheImage: (url: string) => Promise<string>;
getVersion: () => Promise<string>;
ping: () => string;
getDefaultDownloadsPath: () => Promise<string>;
showOpenDialog: (
options: Electron.OpenDialogOptions
) => Promise<Electron.OpenDialogReturnValue>;
platform: NodeJS.Platform;
}
interface Window {
electron: Electron;
}
}

View file

@ -0,0 +1,49 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { TorrentProgress } from "@types";
interface DownloadState {
packets: TorrentProgress[];
gameId: number | null;
gamesWithDeletionInProgress: number[];
}
const initialState: DownloadState = {
packets: [],
gameId: null,
gamesWithDeletionInProgress: [],
};
export const downloadSlice = createSlice({
name: "download",
initialState,
reducers: {
addPacket: (state, action: PayloadAction<TorrentProgress>) => {
state.packets = [...state.packets, action.payload];
if (!state.gameId) state.gameId = action.payload.game.id;
},
clearDownload: (state) => {
state.packets = [];
state.gameId = null;
},
setGameDeleting: (state, action: PayloadAction<number>) => {
if (
!state.gamesWithDeletionInProgress.includes(action.payload) &&
action.payload
) {
state.gamesWithDeletionInProgress.push(action.payload);
}
},
removeGameFromDeleting: (state, action: PayloadAction<number>) => {
const index = state.gamesWithDeletionInProgress.indexOf(action.payload);
if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1);
},
},
});
export const {
addPacket,
clearDownload,
setGameDeleting,
removeGameFromDeleting,
} = downloadSlice.actions;

View file

@ -0,0 +1,6 @@
export * from "./search-slice";
export * from "./repackers-friendly-names-slice";
export * from "./library-slice";
export * from "./use-preferences-slice";
export * from "./download-slice";
export * from "./window-slice";

View file

@ -0,0 +1,24 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { Game } from "@types";
interface LibraryState {
value: Game[];
}
const initialState: LibraryState = {
value: [],
};
export const librarySlice = createSlice({
name: "library",
initialState,
reducers: {
setLibrary: (state, action: PayloadAction<LibraryState["value"]>) => {
state.value = action.payload;
},
},
});
export const { setLibrary } = librarySlice.actions;

View file

@ -0,0 +1,26 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
interface RepackersFriendlyNamesState {
value: Record<string, string>;
}
const initialState: RepackersFriendlyNamesState = {
value: {},
};
export const repackersFriendlyNamesSlice = createSlice({
name: "repackersFriendlyNames",
initialState,
reducers: {
setRepackersFriendlyNames: (
state,
action: PayloadAction<RepackersFriendlyNamesState["value"]>
) => {
state.value = action.payload;
},
},
});
export const { setRepackersFriendlyNames } =
repackersFriendlyNamesSlice.actions;

View file

@ -0,0 +1,25 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
interface SearchState {
value: string;
}
const initialState: SearchState = {
value: "",
};
export const searchSlice = createSlice({
name: "search",
initialState,
reducers: {
setSearch: (state, action: PayloadAction<string>) => {
state.value = action.payload;
},
clearSearch: (state) => {
state.value = "";
},
},
});
export const { setSearch, clearSearch } = searchSlice.actions;

View file

@ -0,0 +1,23 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { UserPreferences } from "@types";
interface UserPreferencesState {
value: UserPreferences | null;
}
const initialState: UserPreferencesState = {
value: null,
};
export const userPreferencesSlice = createSlice({
name: "userPreferences",
initialState,
reducers: {
setUserPreferences: (state, action: PayloadAction<UserPreferences>) => {
state.value = action.payload;
},
},
});
export const { setUserPreferences } = userPreferencesSlice.actions;

View file

@ -0,0 +1,33 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
interface WindowState {
draggingDisabled: boolean;
scrollingDisabled: boolean;
headerTitle: string;
}
const initialState: WindowState = {
draggingDisabled: false,
scrollingDisabled: false,
headerTitle: "",
};
export const windowSlice = createSlice({
name: "window",
initialState,
reducers: {
toggleDragging: (state, action: PayloadAction<boolean>) => {
state.draggingDisabled = action.payload;
},
toggleScrolling: (state, action: PayloadAction<boolean>) => {
state.scrollingDisabled = action.payload;
},
setHeaderTitle: (state, action: PayloadAction<string>) => {
state.headerTitle = action.payload;
},
},
});
export const { toggleDragging, toggleScrolling, setHeaderTitle } =
windowSlice.actions;

25
src/renderer/helpers.ts Normal file
View file

@ -0,0 +1,25 @@
export const steamUrlBuilder = {
library: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`,
libraryHero: (objectID: string) =>
`https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`,
logo: (objectID: string) =>
`https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`,
};
export const formatDownloadProgress = (progress?: number) => {
if (!progress) return "0%";
const progressPercentage = progress * 100;
if (Number(progressPercentage.toFixed(2)) % 1 === 0)
return `${Math.floor(progressPercentage)}%`;
return `${progressPercentage.toFixed(2)}%`;
};
export const getSteamLanguage = (language: string) => {
if (language.startsWith("pt")) return "brazilian";
if (language.startsWith("es")) return "spanish";
if (language.startsWith("fr")) return "french";
return "english";
};

View file

@ -0,0 +1,3 @@
export * from "./use-download";
export * from "./use-library";
export * from "./redux";

View file

@ -0,0 +1,7 @@
import { useDispatch, useSelector } from "react-redux";
import type { TypedUseSelectorHook } from "react-redux";
import type { AppDispatch, RootState } from "@renderer/store";
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View file

@ -0,0 +1,33 @@
import { formatDistance } from "date-fns";
import type { FormatDistanceOptions } from "date-fns";
import { ptBR, enUS, es, fr } from "date-fns/locale";
import { useTranslation } from "react-i18next";
export function useDate() {
const { i18n } = useTranslation();
const getDateLocale = () => {
if (i18n.language.startsWith("pt")) return ptBR;
if (i18n.language.startsWith("es")) return es;
if (i18n.language.startsWith("fr")) return fr;
return enUS;
};
return {
formatDistance: (
date: string | number | Date,
baseDate: string | number | Date,
options?: FormatDistanceOptions
) => {
try {
return formatDistance(date, baseDate, {
...options,
locale: getDateLocale(),
});
} catch (err) {
return "";
}
},
};
}

View file

@ -0,0 +1,134 @@
import { addMilliseconds } from "date-fns";
import prettyBytes from "pretty-bytes";
import { formatDownloadProgress } from "@renderer/helpers";
import { useLibrary } from "./use-library";
import { useAppDispatch, useAppSelector } from "./redux";
import {
addPacket,
clearDownload,
setGameDeleting,
removeGameFromDeleting,
} from "@renderer/features";
import type { GameShop, TorrentProgress } from "@types";
import { useDate } from "./use-date";
export function useDownload() {
const { updateLibrary } = useLibrary();
const { formatDistance } = useDate();
const { packets, gamesWithDeletionInProgress } = useAppSelector(
(state) => state.download
);
const dispatch = useAppDispatch();
const lastPacket = packets.at(-1);
const startDownload = (
repackId: number,
objectID: string,
title: string,
shop: GameShop
) =>
window.electron
.startGameDownload(repackId, objectID, title, shop)
.then((game) => {
dispatch(clearDownload());
updateLibrary();
return game;
});
const pauseDownload = (gameId: number) =>
window.electron.pauseGameDownload(gameId).then(() => {
dispatch(clearDownload());
updateLibrary();
});
const resumeDownload = (gameId: number) =>
window.electron.resumeGameDownload(gameId).then(() => {
updateLibrary();
});
const cancelDownload = (gameId: number) =>
window.electron.cancelGameDownload(gameId).then(() => {
dispatch(clearDownload());
updateLibrary();
deleteGame(gameId);
});
const removeGame = (gameId: number) =>
window.electron.removeGame(gameId).then(() => {
updateLibrary();
});
const isVerifying = ["downloading_metadata", "checking_files"].includes(
lastPacket?.game.status
);
const getETA = () => {
if (isVerifying || !isFinite(lastPacket?.timeRemaining)) {
return "";
}
try {
return formatDistance(
addMilliseconds(new Date(), lastPacket?.timeRemaining ?? 1),
new Date(),
{ addSuffix: true }
);
} catch (err) {
return "";
}
};
const getProgress = () => {
if (lastPacket?.game.status === "checking_files") {
return formatDownloadProgress(lastPacket?.game.fileVerificationProgress);
}
return formatDownloadProgress(lastPacket?.game.progress);
};
const deleteGame = (gameId: number) =>
window.electron
.cancelGameDownload(gameId)
.then(() => {
dispatch(setGameDeleting(gameId));
return window.electron.deleteGameFolder(gameId);
})
.catch(() => {})
.finally(() => {
updateLibrary();
dispatch(removeGameFromDeleting(gameId));
});
const isGameDeleting = (gameId: number) => {
return gamesWithDeletionInProgress.includes(gameId);
};
return {
game: lastPacket?.game,
bytesDownloaded: lastPacket?.game.bytesDownloaded,
fileSize: lastPacket?.game.fileSize,
isVerifying,
gameId: lastPacket?.game.id,
downloadSpeed: `${prettyBytes(lastPacket?.downloadSpeed ?? 0, {
bits: true,
})}/s`,
isDownloading: Boolean(lastPacket),
progress: getProgress(),
numPeers: lastPacket?.numPeers,
numSeeds: lastPacket?.numSeeds,
eta: getETA(),
startDownload,
pauseDownload,
resumeDownload,
cancelDownload,
removeGame,
deleteGame,
isGameDeleting,
clearDownload: () => dispatch(clearDownload()),
addPacket: (packet: TorrentProgress) => dispatch(addPacket(packet)),
};
}

View file

@ -0,0 +1,16 @@
import { useCallback } from "react";
import { useAppDispatch, useAppSelector } from "./redux";
import { setLibrary } from "@renderer/features";
export function useLibrary() {
const dispatch = useAppDispatch();
const library = useAppSelector((state) => state.library.value);
const updateLibrary = useCallback(async () => {
return window.electron
.getLibrary()
.then((updatedLibrary) => dispatch(setLibrary(updatedLibrary)));
}, [dispatch]);
return { library, updateLibrary };
}

86
src/renderer/main.tsx Normal file
View file

@ -0,0 +1,86 @@
import React from "react";
import ReactDOM from "react-dom/client";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { Provider } from "react-redux";
import LanguageDetector from "i18next-browser-languagedetector";
import { createHashRouter, RouterProvider } from "react-router-dom";
import { init } from "@sentry/electron/renderer";
import { init as reactInit } from "@sentry/react";
import "@fontsource/fira-mono/400.css";
import "@fontsource/fira-mono/500.css";
import "@fontsource/fira-mono/700.css";
import "@fontsource/fira-sans/400.css";
import "@fontsource/fira-sans/500.css";
import "@fontsource/fira-sans/700.css";
import "react-loading-skeleton/dist/skeleton.css";
import { App } from "./app";
import {
Catalogue,
Downloads,
GameDetails,
SearchResults,
Settings,
} from "@renderer/pages";
import { store } from "./store";
import * as resources from "@locales";
if (process.env.SENTRY_DSN) {
init({ dsn: process.env.SENTRY_DSN }, reactInit);
}
const router = createHashRouter([
{
path: "/",
Component: App,
children: [
{
path: "/",
Component: Catalogue,
},
{
path: "/downloads",
Component: Downloads,
},
{
path: "/game/:shop/:objectID",
Component: GameDetails,
},
{
path: "/search",
Component: SearchResults,
},
{
path: "/settings",
Component: Settings,
},
],
},
]);
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
})
.then(() => {
window.electron.updateUserPreferences({ language: i18n.language });
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<RouterProvider router={router} />
</Provider>
</React.StrictMode>
);

View file

@ -0,0 +1,34 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const catalogueCategories = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const content = style({
width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
padding: `${SPACING_UNIT * 3}px`,
flex: "1",
});
export const cards = recipe({
base: {
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: `${SPACING_UNIT * 2}px`,
transition: "all ease 0.2s",
},
variants: {
searching: {
true: {
pointerEvents: "none",
opacity: vars.opacity.disabled,
},
},
},
});

View file

@ -0,0 +1,76 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const catalogueCategories = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const catalogueHeader = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "space-between",
});
export const content = style({
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
padding: `${SPACING_UNIT * 3}px`,
flex: "1",
overflowY: "auto",
});
export const cards = recipe({
base: {
display: "grid",
gridTemplateColumns: "repeat(1, 1fr)",
gap: `${SPACING_UNIT * 2}px`,
transition: "all ease 0.2s",
"@media": {
"(min-width: 768px)": {
gridTemplateColumns: "repeat(2, 1fr)",
},
"(min-width: 1250px)": {
gridTemplateColumns: "repeat(3, 1fr)",
},
"(min-width: 1600px)": {
gridTemplateColumns: "repeat(4, 1fr)",
},
},
},
variants: {
searching: {
true: {
pointerEvents: "none",
opacity: vars.opacity.disabled,
},
},
},
});
export const cardSkeleton = style({
width: "100%",
height: "180px",
boxShadow: "0px 0px 15px 0px #000000",
overflow: "hidden",
borderRadius: "4px",
transition: "all ease 0.2s",
zIndex: "1",
":active": {
opacity: vars.opacity.active,
},
});
export const noResults = style({
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
gap: "16px",
gridColumn: "1 / -1",
});

View file

@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { Button, GameCard, Hero } from "@renderer/components";
import type { CatalogueCategory, CatalogueEntry } from "@types";
import starsAnimation from "@renderer/assets/lottie/stars.json";
import * as styles from "./catalogue.css";
import { vars } from "@renderer/theme.css";
import Lottie from "lottie-react";
const categories: CatalogueCategory[] = ["trending", "recently_added"];
export function Catalogue() {
const { t } = useTranslation("catalogue");
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingRandomGame, setIsLoadingRandomGame] = useState(false);
const randomGameObjectID = useRef<string | null>(null);
const [searchParams] = useSearchParams();
const [catalogue, setCatalogue] = useState<
Record<CatalogueCategory, CatalogueEntry[]>
>({
trending: [],
recently_added: [],
});
const getCatalogue = useCallback((category: CatalogueCategory) => {
setIsLoading(true);
window.electron
.getCatalogue(category)
.then((catalogue) => {
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
})
.catch(() => {})
.finally(() => {
setIsLoading(false);
});
}, []);
const currentCategory = searchParams.get("category") || categories[0];
const handleSelectCategory = (category: CatalogueCategory) => {
if (category !== currentCategory) {
getCatalogue(category);
navigate(`/?category=${category}`, { replace: true });
}
};
const getRandomGame = useCallback(() => {
setIsLoadingRandomGame(true);
window.electron
.getRandomGame()
.then((objectID) => {
randomGameObjectID.current = objectID;
})
.finally(() => {
setIsLoadingRandomGame(false);
});
}, []);
const handleRandomizerClick = () => {
const searchParams = new URLSearchParams({
fromRandomizer: "1",
});
navigate(
`/game/steam/${randomGameObjectID.current}?${searchParams.toString()}`
);
};
useEffect(() => {
setIsLoading(true);
getCatalogue(currentCategory as CatalogueCategory);
getRandomGame();
}, [getCatalogue, currentCategory, getRandomGame]);
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<section className={styles.content}>
<h2>{t("featured")}</h2>
<Hero />
<section className={styles.catalogueHeader}>
<div className={styles.catalogueCategories}>
{categories.map((category) => (
<Button
key={category}
theme={currentCategory === category ? "primary" : "outline"}
onClick={() => handleSelectCategory(category)}
>
{t(category)}
</Button>
))}
</div>
<Button
onClick={handleRandomizerClick}
theme="outline"
disabled={isLoadingRandomGame}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie
animationData={starsAnimation}
style={{ width: 70, position: "absolute", top: -28, left: -27 }}
loop
/>
</div>
{t("surprise_me")}
</Button>
</section>
<h2>{t(currentCategory)}</h2>
<section className={styles.cards({})}>
{isLoading
? Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} />
))
: catalogue[currentCategory as CatalogueCategory].map((result) => (
<GameCard
key={result.objectID}
game={result}
onClick={() =>
navigate(`/game/${result.shop}/${result.objectID}`)
}
/>
))}
</section>
</section>
</SkeletonTheme>
);
}

View file

@ -0,0 +1,87 @@
import { GameCard } from "@renderer/components";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import type { CatalogueEntry } from "@types";
import type { DebouncedFunc } from "lodash";
import debounce from "lodash/debounce";
import { InboxIcon } from "@primer/octicons-react";
import { clearSearch } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { vars } from "@renderer/theme.css";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import * as styles from "./catalogue.css";
export function SearchResults() {
const dispatch = useAppDispatch();
const { t } = useTranslation("catalogue");
const [searchParams] = useSearchParams();
const [searchResults, setSearchResults] = useState<CatalogueEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const debouncedFunc = useRef<DebouncedFunc<() => void | null>>(null);
const navigate = useNavigate();
const handleGameClick = (game: CatalogueEntry) => {
dispatch(clearSearch());
navigate(`/game/${game.shop}/${game.objectID}`);
};
useEffect(() => {
setIsLoading(true);
if (debouncedFunc.current) debouncedFunc.current.cancel();
debouncedFunc.current = debounce(() => {
window.electron
.searchGames(searchParams.get("query"))
.then((results) => {
setSearchResults(results);
})
.finally(() => {
setIsLoading(false);
});
}, 300);
debouncedFunc.current();
}, [searchParams, dispatch]);
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<section className={styles.content}>
<section className={styles.cards({ searching: false })}>
{isLoading &&
Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} />
))}
{!isLoading && searchResults.length > 0 && (
<>
{searchResults.map((game) => (
<GameCard
key={game.objectID}
game={game}
onClick={() => handleGameClick(game)}
disabled={!game.repacks.length}
/>
))}
</>
)}
</section>
{!isLoading && searchResults.length === 0 && (
<div className={styles.noResults}>
<InboxIcon size={56} />
<p>{t("no_results")}</p>
</div>
)}
</section>
</SkeletonTheme>
);
}

View file

@ -0,0 +1,10 @@
import { SPACING_UNIT } from "@renderer/theme.css";
import { style } from "@vanilla-extract/css";
export const deleteActionsButtonsCtn = style({
display: "flex",
width: "100%",
justifyContent: "end",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
});

View file

@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import { Button, Modal } from "@renderer/components";
import * as styles from "./delete-modal.css";
interface DeleteModalProps {
visible: boolean;
onClose: () => void;
deleteGame: () => void;
}
export function DeleteModal({
onClose,
visible,
deleteGame,
}: DeleteModalProps) {
const { t } = useTranslation("downloads");
const handleDeleteGame = () => {
deleteGame();
onClose();
};
return (
<Modal
visible={visible}
title={t("delete_modal_title")}
description={t("delete_modal_description")}
onClose={onClose}
>
<div className={styles.deleteActionsButtonsCtn}>
<Button onClick={handleDeleteGame} theme="outline">
{t("delete")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
</Button>
</div>
</Modal>
);
}

View file

@ -0,0 +1,90 @@
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
export const downloadTitle = style({
fontWeight: "bold",
cursor: "pointer",
color: vars.color.bodyText,
textAlign: "left",
marginBottom: `${SPACING_UNIT}px`,
fontSize: "16px",
display: "block",
":hover": {
textDecoration: "underline",
},
});
export const downloads = style({
width: "100%",
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
margin: "0",
padding: "0",
marginTop: `${SPACING_UNIT * 3}px`,
});
export const downloadCover = style({
width: "280px",
minWidth: "280px",
height: "auto",
objectFit: "cover",
objectPosition: "center",
borderRight: `solid 1px ${vars.color.borderColor}`,
});
export const download = recipe({
base: {
width: "100%",
backgroundColor: vars.color.background,
display: "flex",
borderRadius: "8px",
border: `solid 1px ${vars.color.borderColor}`,
overflow: "hidden",
boxShadow: "0px 0px 15px 0px #000000",
transition: "all ease 0.2s",
height: "140px",
minHeight: "140px",
maxHeight: "140px",
},
variants: {
cancelled: {
true: {
opacity: vars.opacity.disabled,
":hover": {
opacity: "1",
},
},
},
},
});
export const downloadDetails = style({
display: "flex",
flexDirection: "column",
flex: "1",
justifyContent: "center",
gap: `${SPACING_UNIT / 2}px`,
fontSize: "14px",
});
export const downloadRightContent = style({
display: "flex",
padding: `${SPACING_UNIT * 2}px`,
flex: "1",
gap: `${SPACING_UNIT}px`,
});
export const downloadActions = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
});
export const downloadsContainer = style({
display: "flex",
padding: `${SPACING_UNIT * 3}px`,
flexDirection: "column",
width: "100%",
});

View file

@ -0,0 +1,272 @@
import prettyBytes from "pretty-bytes";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { AsyncImage, Button, TextField } from "@renderer/components";
import { formatDownloadProgress, steamUrlBuilder } from "@renderer/helpers";
import { useDownload, useLibrary } from "@renderer/hooks";
import type { Game } from "@types";
import { useEffect, useMemo, useRef, useState } from "react";
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
import * as styles from "./downloads.css";
import { DeleteModal } from "./delete-modal";
export function Downloads() {
const { library, updateLibrary } = useLibrary();
const { t } = useTranslation("downloads");
const navigate = useNavigate();
const gameToBeDeleted = useRef<number | null>(null);
const [filteredLibrary, setFilteredLibrary] = useState<Game[]>([]);
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const {
game: gameDownloading,
progress,
isDownloading,
numPeers,
numSeeds,
pauseDownload,
resumeDownload,
cancelDownload,
deleteGame,
isGameDeleting,
} = useDownload();
const libraryWithDownloadedGamesOnly = useMemo(() => {
return library.filter((game) => game.status);
}, [library]);
useEffect(() => {
setFilteredLibrary(libraryWithDownloadedGamesOnly);
}, [libraryWithDownloadedGamesOnly]);
const openGameInstaller = (gameId: number) =>
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
updateLibrary();
});
const removeGame = (gameId: number) =>
window.electron.removeGame(gameId).then(() => {
updateLibrary();
});
const getFinalDownloadSize = (game: Game) => {
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
if (!game) return "N/A";
if (game.fileSize) return prettyBytes(game.fileSize);
if (gameDownloading?.fileSize && isGameDownloading)
return prettyBytes(gameDownloading.fileSize);
return game.repack?.fileSize ?? "N/A";
};
const getGameInfo = (game: Game) => {
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
const finalDownloadSize = getFinalDownloadSize(game);
if (isGameDeleting(game?.id)) {
return <p>{t("deleting")}</p>;
}
if (isGameDownloading) {
return (
<>
<p>{progress}</p>
{gameDownloading?.status !== "downloading" ? (
<p>{t(gameDownloading?.status)}</p>
) : (
<>
<p>
{prettyBytes(gameDownloading?.bytesDownloaded)} /{" "}
{finalDownloadSize}
</p>
<p>
{numPeers} peers / {numSeeds} seeds
</p>
</>
)}
</>
);
}
if (game?.status === "seeding") {
return (
<>
<p>{game?.repack.title}</p>
<p>{t("completed")}</p>
</>
);
}
if (game?.status === "cancelled") return <p>{t("cancelled")}</p>;
if (game?.status === "downloading_metadata")
return <p>{t("starting_download")}</p>;
if (game?.status === "paused") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>{t("paused")}</p>
</>
);
}
};
const openDeleteModal = (gameId: number) => {
gameToBeDeleted.current = gameId;
setShowDeleteModal(true);
};
const getGameActions = (game: Game) => {
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
const deleting = isGameDeleting(game.id);
if (isGameDownloading) {
return (
<>
<Button onClick={() => pauseDownload(game.id)} theme="outline">
{t("pause")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "paused") {
return (
<>
<Button onClick={() => resumeDownload(game.id)} theme="outline">
{t("resume")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "seeding") {
return (
<>
<Button
onClick={() => openGameInstaller(game.id)}
theme="outline"
disabled={deleting}
>
{t("install")}
</Button>
<Button onClick={() => openDeleteModal(game.id)} theme="outline">
{t("delete")}
</Button>
</>
);
}
if (game?.status === "downloading_metadata") {
return (
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
);
}
return (
<>
<Button
onClick={() => navigate(`/game/${game.shop}/${game.objectID}`)}
theme="outline"
disabled={deleting}
>
{t("download_again")}
</Button>
<Button
onClick={() => removeGame(game.id)}
theme="outline"
disabled={deleting}
>
{t("remove_from_list")}
</Button>
</>
);
};
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setFilteredLibrary(
libraryWithDownloadedGamesOnly.filter((game) =>
game.title
.toLowerCase()
.includes(event.target.value.toLocaleLowerCase())
)
);
};
return (
<section className={styles.downloadsContainer}>
<BinaryNotFoundModal
visible={showBinaryNotFoundModal}
onClose={() => setShowBinaryNotFoundModal(false)}
/>
<DeleteModal
visible={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
deleteGame={() =>
deleteGame(gameToBeDeleted.current).then(updateLibrary)
}
/>
<TextField placeholder={t("filter")} onChange={handleFilter} />
<ul className={styles.downloads}>
{filteredLibrary.map((game) => {
return (
<li
key={game.id}
className={styles.download({
cancelled: game.status === "cancelled",
})}
>
<AsyncImage
src={steamUrlBuilder.library(game.objectID)}
className={styles.downloadCover}
alt={game.title}
/>
<div className={styles.downloadRightContent}>
<div className={styles.downloadDetails}>
<button
type="button"
className={styles.downloadTitle}
onClick={() =>
navigate(`/game/${game.shop}/${game.objectID}`)
}
>
{game.title}
</button>
{getGameInfo(game)}
</div>
<div className={styles.downloadActions}>
{getGameActions(game)}
</div>
</div>
</li>
);
})}
</ul>
</section>
);
}

View file

@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { ShareAndroidIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
import type { ShopDetails } from "@types";
import * as styles from "./game-details.css";
const OPEN_HYDRA_URL = "https://open.hydralauncher.site";
export interface DescriptionHeaderProps {
gameDetails: ShopDetails | null;
}
export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
const [clipboardLock, setClipboardLock] = useState(false);
const { t, i18n } = useTranslation("game_details");
const { objectID, shop } = useParams();
useEffect(() => {
if (!gameDetails) return setClipboardLock(true);
setClipboardLock(false);
}, [gameDetails]);
const handleCopyToClipboard = () => {
setClipboardLock(true);
const searchParams = new URLSearchParams({
p: btoa(
JSON.stringify([
objectID,
shop,
encodeURIComponent(gameDetails?.name),
i18n.language,
])
),
});
navigator.clipboard.writeText(
OPEN_HYDRA_URL + `/?${searchParams.toString()}`
);
const zero = performance.now();
requestAnimationFrame(function holdLock(time) {
if (time - zero <= 3000) {
requestAnimationFrame(holdLock);
} else {
setClipboardLock(false);
}
});
};
return (
<div className={styles.descriptionHeader}>
<section className={styles.descriptionHeaderInfo}>
<p>
{t("release_date", {
date: gameDetails?.release_date.date,
})}
</p>
<p>{t("publisher", { publisher: gameDetails?.publishers[0] })}</p>
</section>
<Button
theme="outline"
onClick={handleCopyToClipboard}
disabled={clipboardLock || !gameDetails}
>
{clipboardLock ? (
t("copied_link_to_clipboard")
) : (
<>
<ShareAndroidIcon />
{t("copy_link_to_clipboard")}
</>
)}
</Button>
</div>
);
}

View file

@ -0,0 +1,89 @@
import Skeleton from "react-loading-skeleton";
import { Button } from "@renderer/components";
import * as styles from "./game-details.css";
import { useTranslation } from "react-i18next";
import { ShareAndroidIcon } from "@primer/octicons-react";
export function GameDetailsSkeleton() {
const { t } = useTranslation("game_details");
return (
<div className={styles.container}>
<div className={styles.hero}>
<Skeleton className={styles.heroImageSkeleton} />
</div>
<div className={styles.descriptionHeader}>
<section className={styles.descriptionHeaderInfo}>
<Skeleton width={155} />
<Skeleton width={135} />
</section>
</div>
<div className={styles.descriptionContainer}>
<div className={styles.descriptionContent}>
<div className={styles.descriptionHeader}>
<section className={styles.descriptionHeaderInfo}>
<Skeleton width={145} />
<Skeleton width={150} />
</section>
<Button theme="outline" disabled>
<ShareAndroidIcon />
{t("copy_link_to_clipboard")}
</Button>
</div>
<div className={styles.descriptionSkeleton}>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} />
))}
<Skeleton className={styles.heroImageSkeleton} />
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} />
))}
<Skeleton className={styles.heroImageSkeleton} />
<Skeleton />
</div>
</div>
<div className={styles.contentSidebar}>
<div className={styles.contentSidebarTitle}>
<h3>HowLongToBeat</h3>
</div>
<ul className={styles.howLongToBeatCategoriesList}>
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton
key={index}
className={styles.howLongToBeatCategorySkeleton}
/>
))}
</ul>
<div
className={styles.contentSidebarTitle}
style={{ border: "none" }}
>
<h3>{t("requirements")}</h3>
</div>
<div className={styles.requirementButtonContainer}>
<Button
className={styles.requirementButton}
theme="primary"
disabled
>
{t("minimum")}
</Button>
<Button
className={styles.requirementButton}
theme="outline"
disabled
>
{t("recommended")}
</Button>
</div>
<div className={styles.requirementsDetailsSkeleton}>
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} height={20} />
))}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,256 @@
import { globalStyle, keyframes, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
export const slideIn = keyframes({
"0%": { transform: `translateY(${40 + 16}px)` },
"100%": { transform: "translateY(0)" },
});
export const hero = style({
width: "100%",
height: "300px",
minHeight: "300px",
display: "flex",
flexDirection: "column",
position: "relative",
transition: "all ease 0.2s",
"@media": {
"(min-width: 1250px)": {
height: "350px",
minHeight: "350px",
},
},
});
export const heroContent = style({
padding: `${SPACING_UNIT * 2}px`,
height: "100%",
width: "100%",
display: "flex",
});
export const heroBackdrop = style({
width: "100%",
height: "100%",
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.3) 60%, transparent 100%)",
position: "absolute",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
});
export const heroImage = style({
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "top",
transition: "all ease 0.2s",
"@media": {
"(min-width: 1250px)": {
objectPosition: "center",
},
},
});
export const heroImageSkeleton = style({
height: "300px",
"@media": {
"(min-width: 1250px)": {
height: "350px",
},
},
});
export const container = style({
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
});
export const descriptionContainer = style({
display: "flex",
width: "100%",
flex: "1",
});
export const descriptionContent = style({
width: "100%",
height: "100%",
});
export const contentSidebar = style({
borderLeft: `solid 1px ${vars.color.borderColor};`,
width: "100%",
height: "100%",
"@media": {
"(min-width: 768px)": {
width: "100%",
maxWidth: "200px",
},
"(min-width: 1024px)": {
maxWidth: "300px",
width: "100%",
},
"(min-width: 1280px)": {
width: "100%",
maxWidth: "400px",
},
},
});
export const contentSidebarTitle = style({
height: "72px",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
display: "flex",
alignItems: "center",
backgroundColor: vars.color.background,
borderBottom: `solid 1px ${vars.color.borderColor}`,
});
export const requirementButtonContainer = style({
width: "100%",
display: "flex",
});
export const requirementButton = style({
border: `solid 1px ${vars.color.borderColor};`,
borderLeft: "none",
borderRight: "none",
borderRadius: "0",
width: "100%",
});
export const requirementsDetails = style({
padding: `${SPACING_UNIT * 2}px`,
lineHeight: "22px",
fontFamily: "'Fira Sans', sans-serif",
fontSize: "16px",
});
export const requirementsDetailsSkeleton = style({
display: "flex",
flexDirection: "column",
gap: "8px",
padding: `${SPACING_UNIT * 2}px`,
fontSize: "16px",
});
export const description = style({
userSelect: "text",
lineHeight: "22px",
fontFamily: "'Fira Sans', sans-serif",
fontSize: "16px",
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
"@media": {
"(min-width: 1280px)": {
width: "60%",
},
},
width: "100%",
marginLeft: "auto",
marginRight: "auto",
});
export const descriptionSkeleton = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
width: "100%",
"@media": {
"(min-width: 1280px)": {
width: "60%",
lineHeight: "22px",
},
},
marginLeft: "auto",
marginRight: "auto",
});
export const descriptionHeader = style({
width: "100%",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
borderBottom: `solid 1px ${vars.color.borderColor}`,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: vars.color.background,
height: "72px",
});
export const descriptionHeaderInfo = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
flexDirection: "column",
fontSize: vars.size.bodyFontSize,
});
export const howLongToBeatCategoriesList = style({
margin: "0",
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "16px",
});
export const howLongToBeatCategory = style({
display: "flex",
flexDirection: "column",
gap: "4px",
backgroundColor: vars.color.background,
borderRadius: "8px",
padding: `8px 16px`,
border: `solid 1px ${vars.color.borderColor}`,
});
export const howLongToBeatCategoryLabel = style({
fontSize: vars.size.bodyFontSize,
color: "#DADBE1",
});
export const howLongToBeatCategorySkeleton = style({
border: `solid 1px ${vars.color.borderColor}`,
borderRadius: "8px",
height: "76px",
});
export const randomizerButton = style({
animationName: slideIn,
animationDuration: "0.4s",
position: "fixed",
bottom: 26 + 16,
boxShadow: "rgba(255, 255, 255, 0.1) 0px 0px 10px 3px",
border: `solid 1px ${vars.color.borderColor}`,
backgroundColor: vars.color.darkBackground,
":hover": {
backgroundColor: vars.color.background,
boxShadow: "rgba(255, 255, 255, 0.1) 0px 0px 15px 5px",
opacity: 1,
},
":active": {
transform: "scale(0.98)",
},
});
globalStyle(".bb_tag", {
marginTop: `${SPACING_UNIT * 2}px`,
marginBottom: `${SPACING_UNIT * 2}px`,
});
globalStyle(`${description} img`, {
borderRadius: "5px",
marginTop: `${SPACING_UNIT}px`,
marginBottom: `${SPACING_UNIT * 3}px`,
display: "block",
maxWidth: "100%",
});
globalStyle(`${description} a`, {
color: vars.color.bodyText,
});
globalStyle(`${requirementsDetails} a`, {
display: "flex",
color: vars.color.bodyText,
});

View file

@ -0,0 +1,287 @@
import Color from "color";
import { average } from "color.js";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import type {
Game,
GameShop,
HowLongToBeatCategory,
ShopDetails,
SteamAppDetails,
} from "@types";
import { AsyncImage, Button } from "@renderer/components";
import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers";
import { useAppDispatch, useDownload } from "@renderer/hooks";
import starsAnimation from "@renderer/assets/lottie/stars.json";
import { vars } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
import { SkeletonTheme } from "react-loading-skeleton";
import { GameDetailsSkeleton } from "./game-details-skeleton";
import * as styles from "./game-details.css";
import { HeroPanel } from "./hero-panel";
import { HowLongToBeatSection } from "./how-long-to-beat-section";
import { RepacksModal } from "./repacks-modal";
import Lottie from "lottie-react";
import { DescriptionHeader } from "./description-header";
export function GameDetails() {
const { objectID, shop } = useParams();
const [isLoading, setIsLoading] = useState(false);
const [color, setColor] = useState("");
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
const [howLongToBeat, setHowLongToBeat] = useState<{
isLoading: boolean;
data: HowLongToBeatCategory[] | null;
}>({ isLoading: true, data: null });
const [game, setGame] = useState<Game | null>(null);
const [isGamePlaying, setIsGamePlaying] = useState(false);
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { t, i18n } = useTranslation("game_details");
const [showRepacksModal, setShowRepacksModal] = useState(false);
const randomGameObjectID = useRef<string | null>(null);
const dispatch = useAppDispatch();
const { game: gameDownloading, startDownload, isDownloading } = useDownload();
const getRandomGame = useCallback(() => {
window.electron.getRandomGame().then((objectID) => {
randomGameObjectID.current = objectID;
});
}, []);
const handleImageSettled = useCallback((url: string) => {
average(url, { amount: 1, format: "hex" })
.then((color) => {
setColor(new Color(color).darken(0.6).toString() as string);
})
.catch(() => {});
}, []);
const getGame = useCallback(() => {
window.electron
.getGameByObjectID(objectID)
.then((result) => setGame(result));
}, [setGame, objectID]);
useEffect(() => {
getGame();
}, [getGame, gameDownloading?.id]);
useEffect(() => {
setIsLoading(true);
dispatch(setHeaderTitle(""));
getRandomGame();
window.electron
.getGameShopDetails(objectID, "steam", getSteamLanguage(i18n.language))
.then((result) => {
if (!result) {
navigate(-1);
return;
}
window.electron
.getHowLongToBeat(objectID, "steam", result.name)
.then((data) => {
setHowLongToBeat({ isLoading: false, data });
});
setGameDetails(result);
dispatch(setHeaderTitle(result.name));
})
.finally(() => {
setIsLoading(false);
});
getGame();
setHowLongToBeat({ isLoading: true, data: null });
}, [getGame, getRandomGame, dispatch, navigate, objectID, i18n.language]);
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
useEffect(() => {
if (isGameDownloading)
setGame((prev) => ({ ...prev, status: gameDownloading?.status }));
}, [isGameDownloading, gameDownloading?.status]);
useEffect(() => {
const listeners = [
window.electron.onGameClose(() => {
if (isGamePlaying) setIsGamePlaying(false);
}),
window.electron.onPlaytime((gameId) => {
if (gameId === game?.id) {
if (!isGamePlaying) setIsGamePlaying(true);
getGame();
}
}),
];
return () => {
listeners.forEach((unsubscribe) => unsubscribe());
};
}, [game?.id, isGamePlaying, getGame]);
const handleStartDownload = async (repackId: number) => {
return startDownload(
repackId,
gameDetails.objectID,
gameDetails.name,
shop as GameShop
).then(() => {
getGame();
setShowRepacksModal(false);
});
};
const handleRandomizerClick = () => {
if (!randomGameObjectID.current) return;
const searchParams = new URLSearchParams({
fromRandomizer: "1",
});
navigate(
`/game/steam/${randomGameObjectID.current}?${searchParams.toString()}`
);
};
const fromRandomizer = searchParams.get("fromRandomizer");
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
{gameDetails && (
<RepacksModal
visible={showRepacksModal}
gameDetails={gameDetails}
startDownload={handleStartDownload}
onClose={() => setShowRepacksModal(false)}
/>
)}
{isLoading ? (
<GameDetailsSkeleton />
) : (
<section className={styles.container}>
<div className={styles.hero}>
<AsyncImage
src={steamUrlBuilder.libraryHero(objectID)}
className={styles.heroImage}
alt={game?.title}
onSettled={handleImageSettled}
/>
<div className={styles.heroBackdrop}>
<div className={styles.heroContent}>
<AsyncImage
src={steamUrlBuilder.logo(objectID)}
style={{ width: 300, alignSelf: "flex-end" }}
/>
</div>
</div>
</div>
<HeroPanel
game={game}
color={color}
gameDetails={gameDetails}
openRepacksModal={() => setShowRepacksModal(true)}
getGame={getGame}
isGamePlaying={isGamePlaying}
/>
<div className={styles.descriptionContainer}>
<div className={styles.descriptionContent}>
<DescriptionHeader gameDetails={gameDetails} />
<div
dangerouslySetInnerHTML={{
__html: gameDetails?.about_the_game ?? "",
}}
className={styles.description}
/>
</div>
<div className={styles.contentSidebar}>
<HowLongToBeatSection
howLongToBeatData={howLongToBeat.data}
isLoading={howLongToBeat.isLoading}
/>
<div
className={styles.contentSidebarTitle}
style={{ border: "none" }}
>
<h3>{t("requirements")}</h3>
</div>
<div className={styles.requirementButtonContainer}>
<Button
className={styles.requirementButton}
onClick={() => setActiveRequirement("minimum")}
theme={
activeRequirement === "minimum" ? "primary" : "outline"
}
>
{t("minimum")}
</Button>
<Button
className={styles.requirementButton}
onClick={() => setActiveRequirement("recommended")}
theme={
activeRequirement === "recommended" ? "primary" : "outline"
}
>
{t("recommended")}
</Button>
</div>
<div
className={styles.requirementsDetails}
dangerouslySetInnerHTML={{
__html:
gameDetails?.pc_requirements?.[activeRequirement] ??
t(`no_${activeRequirement}_requirements`, {
title: gameDetails?.name,
}),
}}
></div>
</div>
</div>
</section>
)}
{fromRandomizer && (
<Button
className={styles.randomizerButton}
onClick={handleRandomizerClick}
theme="outline"
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie
animationData={starsAnimation}
style={{ width: 70, position: "absolute", top: -28, left: -27 }}
loop
/>
</div>
{t("next_suggestion")}
</Button>
)}
</SkeletonTheme>
);
}

View file

@ -0,0 +1,32 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
export const panel = style({
width: "100%",
height: "72px",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
transition: "all ease 0.2s",
borderBottom: `solid 1px ${vars.color.borderColor}`,
boxShadow: "0px 0px 15px 0px #000000",
});
export const content = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
fontSize: vars.size.bodyFontSize,
});
export const actions = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const downloadDetailsRow = style({
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
alignItems: "flex-end",
});

View file

@ -0,0 +1,356 @@
import { format } from "date-fns";
import prettyBytes from "pretty-bytes";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks";
import type { Game, ShopDetails } from "@types";
import { formatDownloadProgress } from "@renderer/helpers";
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
import * as styles from "./hero-panel.css";
import { useDate } from "@renderer/hooks/use-date";
export interface HeroPanelProps {
game: Game | null;
gameDetails: ShopDetails | null;
color: string;
isGamePlaying: boolean;
openRepacksModal: () => void;
getGame: () => void;
}
export function HeroPanel({
game,
gameDetails,
color,
openRepacksModal,
getGame,
isGamePlaying,
}: HeroPanelProps) {
const { t } = useTranslation("game_details");
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { formatDistance } = useDate();
const {
game: gameDownloading,
isDownloading,
progress,
eta,
numPeers,
numSeeds,
resumeDownload,
pauseDownload,
cancelDownload,
removeGame,
isGameDeleting,
} = useDownload();
const { updateLibrary, library } = useLibrary();
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
useState(false);
const gameOnLibrary = library.find(
({ objectID }) => objectID === gameDetails?.objectID
);
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
const updateLastTimePlayed = useCallback(() => {
setLastTimePlayed(
formatDistance(game.lastTimePlayed, new Date(), {
addSuffix: true,
})
);
}, [game?.lastTimePlayed, formatDistance]);
useEffect(() => {
if (game?.lastTimePlayed) {
updateLastTimePlayed();
const interval = setInterval(() => {
updateLastTimePlayed();
}, 1000);
return () => {
clearInterval(interval);
};
}
}, [game?.lastTimePlayed, updateLastTimePlayed]);
const openGameInstaller = () => {
window.electron.openGameInstaller(game.id).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
updateLibrary();
});
};
const openGame = () => {
if (game.executablePath) {
window.electron.openGame(game.id, game.executablePath);
return;
}
if (game?.executablePath) {
window.electron.openGame(game.id, game.executablePath);
return;
}
window.electron
.showOpenDialog({
properties: ["openFile"],
filters: [{ name: "Game executable (.exe)", extensions: ["exe"] }],
})
.then(({ filePaths }) => {
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
window.electron.openGame(game.id, path);
}
});
};
const closeGame = () => {
window.electron.closeGame(game.id);
};
const finalDownloadSize = useMemo(() => {
if (!game) return "N/A";
if (game.fileSize) return prettyBytes(game.fileSize);
if (gameDownloading?.fileSize && isGameDownloading)
return prettyBytes(gameDownloading.fileSize);
return game.repack?.fileSize ?? "N/A";
}, [game, isGameDownloading, gameDownloading]);
const toggleLibraryGame = async () => {
setToggleLibraryGameDisabled(true);
try {
if (gameOnLibrary) {
await window.electron.removeGame(gameOnLibrary.id);
} else {
await window.electron.addGameToLibrary(
gameDetails.objectID,
gameDetails.name,
"steam"
);
}
await updateLibrary();
} finally {
setToggleLibraryGameDisabled(false);
}
};
const getInfo = () => {
if (!gameDetails) return null;
if (isGameDeleting(game?.id)) {
return <p>{t("deleting")}</p>;
}
if (isGameDownloading) {
return (
<>
<p className={styles.downloadDetailsRow}>
{progress}
{eta && <small>{t("eta", { eta })}</small>}
</p>
{gameDownloading?.status !== "downloading" ? (
<>
<p>{t(gameDownloading?.status)}</p>
{eta && <small>{t("eta", { eta })}</small>}
</>
) : (
<p className={styles.downloadDetailsRow}>
{prettyBytes(gameDownloading?.bytesDownloaded)} /{" "}
{finalDownloadSize}
<small>
{numPeers} peers / {numSeeds} seeds
</small>
</p>
)}
</>
);
}
if (game?.status === "paused") {
return (
<>
<p>
{t("paused_progress", {
progress: formatDownloadProgress(game.progress),
})}
</p>
<p>
{prettyBytes(game.bytesDownloaded)} / {finalDownloadSize}
</p>
</>
);
}
if (game?.status === "seeding") {
if (!game.lastTimePlayed) {
return <p>{t("not_played_yet", { title: game.title })}</p>;
}
return (
<>
<p>
{t("play_time", {
amount: formatDistance(0, game.playTimeInMilliseconds),
})}
</p>
<p>
{t("last_time_played", {
period: lastTimePlayed,
})}
</p>
</>
);
}
const [latestRepack] = gameDetails.repacks;
if (latestRepack) {
const lastUpdate = format(latestRepack.uploadDate!, "dd/MM/yyyy");
const repacksCount = gameDetails.repacks.length;
return (
<>
<p>{t("updated_at", { updated_at: lastUpdate })}</p>
<p>{t("download_options", { count: repacksCount })}</p>
</>
);
}
return <p>{t("no_downloads")}</p>;
};
const getActions = () => {
const deleting = isGameDeleting(game?.id);
const toggleGameOnLibraryButton = (
<Button
theme="outline"
disabled={!gameDetails || toggleLibraryGameDisabled}
onClick={toggleLibraryGame}
>
{gameOnLibrary ? <NoEntryIcon /> : <PlusCircleIcon />}
{gameOnLibrary ? t("remove_from_library") : t("add_to_library")}
</Button>
);
if (isGameDownloading) {
return (
<>
<Button onClick={() => pauseDownload(game.id)} theme="outline">
{t("pause")}
</Button>
<Button onClick={() => cancelDownload(game.id)} theme="outline">
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "paused") {
return (
<>
<Button onClick={() => resumeDownload(game.id)} theme="outline">
{t("resume")}
</Button>
<Button
onClick={() => cancelDownload(game.id).then(getGame)}
theme="outline"
>
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "seeding") {
return (
<>
<Button
onClick={openGameInstaller}
theme="outline"
disabled={deleting || isGamePlaying}
>
{t("install")}
</Button>
{isGamePlaying ? (
<Button onClick={closeGame} theme="outline" disabled={deleting}>
{t("close")}
</Button>
) : (
<Button
onClick={openGame}
theme="outline"
disabled={deleting || isGamePlaying}
>
{t("play")}
</Button>
)}
</>
);
}
if (game?.status === "cancelled") {
return (
<>
<Button
onClick={openRepacksModal}
theme="outline"
disabled={deleting}
>
{t("open_download_options")}
</Button>
<Button
onClick={() => removeGame(game.id).then(getGame)}
theme="outline"
disabled={deleting}
>
{t("remove_from_list")}
</Button>
</>
);
}
if (gameDetails && gameDetails.repacks.length) {
return (
<>
{toggleGameOnLibraryButton}
<Button onClick={openRepacksModal} theme="outline">
{t("open_download_options")}
</Button>
</>
);
}
return toggleGameOnLibraryButton;
};
return (
<>
<BinaryNotFoundModal
visible={showBinaryNotFoundModal}
onClose={() => setShowBinaryNotFoundModal(false)}
/>
<div style={{ backgroundColor: color }} className={styles.panel}>
<div className={styles.content}>{getInfo()}</div>
<div className={styles.actions}>{getActions()}</div>
</div>
</>
);
}

View file

@ -0,0 +1,69 @@
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import type { HowLongToBeatCategory } from "@types";
import { useTranslation } from "react-i18next";
import { vars } from "@renderer/theme.css";
import * as styles from "./game-details.css";
const durationTranslation: Record<string, string> = {
Hours: "hours",
Mins: "minutes",
};
export interface HowLongToBeatSectionProps {
howLongToBeatData: HowLongToBeatCategory[] | null;
isLoading: boolean;
}
export function HowLongToBeatSection({
howLongToBeatData,
isLoading,
}: HowLongToBeatSectionProps) {
const { t } = useTranslation("game_details");
const getDuration = (duration: string) => {
const [value, unit] = duration.split(" ");
return `${value} ${t(durationTranslation[unit])}`;
};
if (!howLongToBeatData && !isLoading) return null;
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<div className={styles.contentSidebarTitle}>
<h3>HowLongToBeat</h3>
</div>
<ul className={styles.howLongToBeatCategoriesList}>
{howLongToBeatData
? howLongToBeatData.map((category) => (
<li key={category.title} className={styles.howLongToBeatCategory}>
<p
className={styles.howLongToBeatCategoryLabel}
style={{
fontWeight: "bold",
}}
>
{category.title}
</p>
<p className={styles.howLongToBeatCategoryLabel}>
{getDuration(category.duration)}
</p>
{category.accuracy !== "00" && (
<small>
{t("accuracy", { accuracy: category.accuracy })}
</small>
)}
</li>
))
: Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={index}
className={styles.howLongToBeatCategorySkeleton}
/>
))}
</ul>
</SkeletonTheme>
);
}

View file

@ -0,0 +1,18 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
export const repacks = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
flexDirection: "column",
});
export const repackButton = style({
display: "flex",
textAlign: "left",
flexDirection: "column",
alignItems: "flex-start",
gap: `${SPACING_UNIT}px`,
color: vars.color.bodyText,
padding: `${SPACING_UNIT * 2}px`,
});

View file

@ -0,0 +1,96 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import prettyBytes from "pretty-bytes";
import { Button, Modal, TextField } from "@renderer/components";
import type { GameRepack, ShopDetails } from "@types";
import * as styles from "./repacks-modal.css";
import type { DiskSpace } from "check-disk-space";
import { format } from "date-fns";
import { SPACING_UNIT } from "@renderer/theme.css";
export interface RepacksModalProps {
visible: boolean;
gameDetails: ShopDetails;
startDownload: (repackId: number) => Promise<void>;
onClose: () => void;
}
export function RepacksModal({
visible,
gameDetails,
startDownload,
onClose,
}: RepacksModalProps) {
const [downloadStarting, setDownloadStarting] = useState(false);
const [diskFreeSpace, setDiskFreeSpace] = useState<DiskSpace>(null);
const [filteredRepacks, setFilteredRepacks] = useState<GameRepack[]>([]);
const { t } = useTranslation("game_details");
useEffect(() => {
setFilteredRepacks(gameDetails.repacks);
}, [gameDetails.repacks]);
const getDiskFreeSpace = () => {
window.electron.getDiskFreeSpace().then((result) => {
setDiskFreeSpace(result);
});
};
useEffect(() => {
getDiskFreeSpace();
}, [visible]);
const handleRepackClick = (repack: GameRepack) => {
setDownloadStarting(true);
startDownload(repack.id).finally(() => {
setDownloadStarting(false);
});
};
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setFilteredRepacks(
gameDetails.repacks.filter((repack) =>
repack.title
.toLowerCase()
.includes(event.target.value.toLocaleLowerCase())
)
);
};
return (
<Modal
visible={visible}
title={`${gameDetails.name} Repacks`}
description={t("space_left_on_disk", {
space: prettyBytes(diskFreeSpace?.free ?? 0),
})}
onClose={onClose}
>
<div style={{ marginBottom: `${SPACING_UNIT * 2}px` }}>
<TextField placeholder={t("filter")} onChange={handleFilter} />
</div>
<div className={styles.repacks}>
{filteredRepacks.map((repack) => (
<Button
key={repack.id}
theme="dark"
onClick={() => handleRepackClick(repack)}
disabled={downloadStarting}
className={styles.repackButton}
>
<p style={{ color: "#DADBE1" }}>{repack.title}</p>
<p style={{ fontSize: "12px" }}>
{repack.fileSize} - {repack.repacker} -{" "}
{format(repack.uploadDate, "dd/MM/yyyy")}
</p>
</Button>
))}
</div>
</Modal>
);
}

View file

@ -0,0 +1,5 @@
export * from "./catalogue/catalogue";
export * from "./game-details/game-details";
export * from "./downloads/downloads";
export * from "./catalogue/search-results";
export * from "./settings/settings";

View file

@ -0,0 +1,26 @@
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { style } from "@vanilla-extract/css";
export const container = style({
padding: "24px",
width: "100%",
display: "flex",
});
export const content = style({
backgroundColor: vars.color.background,
width: "100%",
height: "100%",
padding: `${SPACING_UNIT * 3}px`,
border: `solid 1px ${vars.color.borderColor}`,
boxShadow: "0px 0px 15px 0px #000000",
borderRadius: "8px",
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
});
export const downloadsPathField = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
});

View file

@ -0,0 +1,101 @@
import { useEffect, useState } from "react";
import { Button, CheckboxField, TextField } from "@renderer/components";
import * as styles from "./settings.css";
import { useTranslation } from "react-i18next";
import { UserPreferences } from "@types";
export function Settings() {
const [form, setForm] = useState({
downloadsPath: "",
downloadNotificationsEnabled: false,
repackUpdatesNotificationsEnabled: false,
});
const { t } = useTranslation("settings");
useEffect(() => {
Promise.all([
window.electron.getDefaultDownloadsPath(),
window.electron.getUserPreferences(),
]).then(([path, userPreferences]) => {
setForm({
downloadsPath: userPreferences?.downloadsPath || path,
downloadNotificationsEnabled:
userPreferences?.downloadNotificationsEnabled,
repackUpdatesNotificationsEnabled:
userPreferences?.repackUpdatesNotificationsEnabled,
});
});
}, []);
const updateUserPreferences = <T extends keyof UserPreferences>(
field: T,
value: UserPreferences[T]
) => {
setForm((prev) => ({ ...prev, [field]: value }));
window.electron.updateUserPreferences({
[field]: value,
});
};
const handleChooseDownloadsPath = async () => {
const { filePaths } = await window.electron.showOpenDialog({
defaultPath: form.downloadsPath,
properties: ["openDirectory"],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
updateUserPreferences("downloadsPath", path);
}
};
return (
<section className={styles.container}>
<div className={styles.content}>
<div className={styles.downloadsPathField}>
<TextField
label={t("downloads_path")}
value={form.downloadsPath}
readOnly
disabled
/>
<Button
style={{ alignSelf: "flex-end" }}
theme="outline"
onClick={handleChooseDownloadsPath}
>
{t("change")}
</Button>
</div>
<h3>{t("notifications")}</h3>
<CheckboxField
label={t("enable_download_notifications")}
checked={form.downloadNotificationsEnabled}
onChange={() =>
updateUserPreferences(
"downloadNotificationsEnabled",
!form.downloadNotificationsEnabled
)
}
/>
<CheckboxField
label={t("enable_repack_list_notifications")}
checked={form.repackUpdatesNotificationsEnabled}
onChange={() =>
updateUserPreferences(
"repackUpdatesNotificationsEnabled",
!form.repackUpdatesNotificationsEnabled
)
}
/>
</div>
</section>
);
}

View file

@ -0,0 +1,25 @@
import { Modal } from "@renderer/components";
import { useTranslation } from "react-i18next";
interface BinaryNotFoundModalProps {
visible: boolean;
onClose: () => void;
}
export const BinaryNotFoundModal = ({
visible,
onClose,
}: BinaryNotFoundModalProps) => {
const { t } = useTranslation("binary_not_found_modal");
return (
<Modal
visible={visible}
title={t("title")}
description={t("description")}
onClose={onClose}
>
{t("instructions")}
</Modal>
);
};

23
src/renderer/store.ts Normal file
View file

@ -0,0 +1,23 @@
import { configureStore } from "@reduxjs/toolkit";
import {
downloadSlice,
windowSlice,
librarySlice,
repackersFriendlyNamesSlice,
searchSlice,
userPreferencesSlice,
} from "@renderer/features";
export const store = configureStore({
reducer: {
search: searchSlice.reducer,
repackersFriendlyNames: repackersFriendlyNamesSlice.reducer,
window: windowSlice.reducer,
library: librarySlice.reducer,
userPreferences: userPreferencesSlice.reducer,
download: downloadSlice.reducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

19
src/renderer/theme.css.ts Normal file
View file

@ -0,0 +1,19 @@
import { createTheme } from "@vanilla-extract/css";
export const SPACING_UNIT = 8;
export const [themeClass, vars] = createTheme({
color: {
background: "#1c1c1c",
darkBackground: "#151515",
bodyText: "#8e919b",
borderColor: "rgba(255, 255, 255, 0.1)",
},
opacity: {
disabled: "0.5",
active: "0.7",
},
size: {
bodyFontSize: "14px",
},
});