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