Merge pull request #1441 from hydralauncher/feat/migration-to-scss-3-remake
Some checks are pending
Release / build (ubuntu-latest) (push) Waiting to run
Release / build (windows-latest) (push) Waiting to run

feat: migration to scss
This commit is contained in:
Zamitto 2025-02-02 22:49:30 -03:00 committed by GitHub
commit 70fcc6e2a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
191 changed files with 4882 additions and 4650 deletions

View File

@ -6,7 +6,6 @@ import {
externalizeDepsPlugin, externalizeDepsPlugin,
} from "electron-vite"; } from "electron-vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import svgr from "vite-plugin-svgr"; import svgr from "vite-plugin-svgr";
import { sentryVitePlugin } from "@sentry/vite-plugin"; import { sentryVitePlugin } from "@sentry/vite-plugin";
@ -55,7 +54,6 @@ export default defineConfig(({ mode }) => {
plugins: [ plugins: [
svgr(), svgr(),
react(), react(),
vanillaExtractPlugin(),
sentryVitePlugin({ sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN, authToken: process.env.SENTRY_AUTH_TOKEN,
org: "hydra-launcher", org: "hydra-launcher",

View File

@ -41,9 +41,6 @@
"@reduxjs/toolkit": "^2.2.3", "@reduxjs/toolkit": "^2.2.3",
"@sentry/react": "^8.47.0", "@sentry/react": "^8.47.0",
"@sentry/vite-plugin": "^2.22.7", "@sentry/vite-plugin": "^2.22.7",
"@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/dynamic": "^2.1.2",
"@vanilla-extract/recipes": "^0.5.2",
"auto-launch": "^5.0.6", "auto-launch": "^5.0.6",
"axios": "^1.7.9", "axios": "^1.7.9",
"better-sqlite3": "^11.7.0", "better-sqlite3": "^11.7.0",
@ -90,9 +87,8 @@
"@swc/core": "^1.4.16", "@swc/core": "^1.4.16",
"@types/auto-launch": "^5.0.5", "@types/auto-launch": "^5.0.5",
"@types/color": "^3.0.6", "@types/color": "^3.0.6",
"@types/folder-hash": "^4.0.4",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.8",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/parse-torrent": "^5.8.7", "@types/parse-torrent": "^5.8.7",
@ -100,14 +96,13 @@
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@types/sound-play": "^1.1.3", "@types/sound-play": "^1.1.3",
"@types/user-agents": "^1.0.4", "@types/user-agents": "^1.0.4",
"@vanilla-extract/vite-plugin": "^4.0.7",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"electron": "^31.7.6", "electron": "^31.7.7",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"electron-vite": "^2.0.0", "electron-vite": "^2.3.0",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"prettier": "^3.4.2", "prettier": "^3.4.2",

File diff suppressed because one or more lines are too long

View File

@ -46,9 +46,9 @@ const addGameToLibrary = async (
await gamesSublevel.put(levelKeys.game(shop, objectId), game); await gamesSublevel.put(levelKeys.game(shop, objectId), game);
updateLocalUnlockedAchivements(game!); updateLocalUnlockedAchivements(game);
createGame(game!).catch(() => {}); createGame(game).catch(() => {});
} }
}; };

View File

@ -1,3 +1,2 @@
export { db } from "./level"; export { db } from "./level";
export * from "./sublevels"; export * from "./sublevels";

View File

@ -2,5 +2,4 @@ export * from "./downloads";
export * from "./games"; export * from "./games";
export * from "./game-shop-cache"; export * from "./game-shop-cache";
export * from "./game-achievements"; export * from "./game-achievements";
export * from "./keys"; export * from "./keys";

View File

@ -234,7 +234,9 @@ export class DownloadManager {
}); });
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
if (downloadKey === this.downloadingGameId) { if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.webContents.send("on-download-progress", null);
this.downloadingGameId = null; this.downloadingGameId = null;
} }
} }

View File

@ -35,7 +35,6 @@ export const mergeWithRemoteGames = async () => {
name: "getById", name: "getById",
}); });
if (steamGame) {
const iconUrl = steamGame?.clientIcon const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon) ? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
: null; : null;
@ -52,7 +51,6 @@ export const mergeWithRemoteGames = async () => {
}); });
} }
} }
}
}) })
.catch(() => {}); .catch(() => {});
}; };

View File

@ -21,11 +21,18 @@ export const getSteamAppDetails = async (
}); });
return axios return axios
.get( .get<SteamAppDetailsResponse>(
`http://store.steampowered.com/api/appdetails?${searchParams.toString()}` `http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
) )
.then((response) => { .then((response) => {
if (response.data[objectId].success) return response.data[objectId].data; if (response.data[objectId].success) {
const data = response.data[objectId].data;
return {
...data,
objectId,
};
}
return null; return null;
}) })
.catch((err) => { .catch((err) => {

View File

@ -32,10 +32,10 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("pauseGameSeed", shop, objectId), ipcRenderer.invoke("pauseGameSeed", shop, objectId),
resumeGameSeed: (shop: GameShop, objectId: string) => resumeGameSeed: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("resumeGameSeed", shop, objectId), ipcRenderer.invoke("resumeGameSeed", shop, objectId),
onDownloadProgress: (cb: (value: DownloadProgress) => void) => { onDownloadProgress: (cb: (value: DownloadProgress | null) => void) => {
const listener = ( const listener = (
_event: Electron.IpcRendererEvent, _event: Electron.IpcRendererEvent,
value: DownloadProgress value: DownloadProgress | null
) => cb(value); ) => cb(value);
ipcRenderer.on("on-download-progress", listener); ipcRenderer.on("on-download-progress", listener);
return () => ipcRenderer.removeListener("on-download-progress", listener); return () => ipcRenderer.removeListener("on-download-progress", listener);

View File

@ -1,134 +0,0 @@
import {
ComplexStyleRule,
createContainer,
globalStyle,
style,
} from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "./theme.css";
export const appContainer = createContainer();
globalStyle("*", {
boxSizing: "border-box",
});
globalStyle("::-webkit-scrollbar", {
width: "9px",
backgroundColor: vars.color.darkBackground,
});
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("::-webkit-scrollbar-thumb:hover", {
backgroundColor: "rgba(255, 255, 255, 0.16)",
});
globalStyle("html, body, #root, main", {
height: "100%",
});
globalStyle("body", {
overflow: "hidden",
userSelect: "none",
fontFamily: "Noto Sans, sans-serif",
fontSize: vars.size.body,
color: vars.color.body,
margin: "0",
});
globalStyle("button", {
padding: "0",
backgroundColor: "transparent",
border: "none",
fontFamily: "inherit",
});
globalStyle("h1, h2, h3, h4, h5, h6, p", {
margin: 0,
});
globalStyle("p", {
lineHeight: "20px",
});
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.body,
});
globalStyle("input[type=number]", {
MozAppearance: "textfield",
});
globalStyle("img", {
WebkitUserDrag: "none",
} as Record<string, string>);
globalStyle("progress[value]", {
WebkitAppearance: "none",
});
export const container = style({
width: "100%",
height: "100%",
overflow: "hidden",
display: "flex",
flexDirection: "column",
containerName: appContainer,
containerType: "inline-size",
});
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: vars.zIndex.titleBar,
borderBottom: `1px solid ${vars.color.border}`,
} as ComplexStyleRule);
export const cloudText = style({
background: "linear-gradient(270deg, #16B195 50%, #3E62C0 100%)",
backgroundClip: "text",
color: "transparent",
});

136
src/renderer/src/app.scss Normal file
View File

@ -0,0 +1,136 @@
@use "./scss/globals.scss";
* {
box-sizing: border-box;
}
::-webkit-scrollbar {
width: 9px;
background-color: globals.$dark-background-color;
}
::-webkit-scrollbar-track {
background-color: rgba(255, 255, 255, 0.03);
}
::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.08);
border-radius: 24px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.16);
}
html,
body,
#root,
main {
height: 100%;
}
body {
overflow: hidden;
user-select: none;
font-family:
Noto Sans,
sans-serif;
font-size: globals.$body-font-size;
color: globals.$body-color;
margin: 0;
}
button {
padding: 0;
background-color: transparent;
border: none;
font-family: inherit;
}
h1,
h2,
h3,
h4,
h5,
h6,
p {
margin: 0;
}
p {
line-height: 20px;
}
#root,
main {
display: flex;
}
#root {
flex-direction: column;
}
main {
overflow: hidden;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
label {
font-size: globals.$body-font-size;
}
img {
-webkit-user-drag: none;
}
progress[value] {
-webkit-appearance: none;
}
.container {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
container-name: globals.$app-container;
container-type: inline-size;
&__content {
overflow-y: auto;
align-items: center;
display: flex;
flex-direction: column;
position: relative;
height: 100%;
background: linear-gradient(
0deg,
globals.$dark-background-color 50%,
globals.$background-color 100%
);
}
}
.title-bar {
display: flex;
width: 100%;
height: 35px;
min-height: 35px;
background-color: globals.$dark-background-color;
align-items: center;
padding: 0 calc(globals.$spacing-unit * 2);
-webkit-app-region: drag;
z-index: 4;
border-bottom: 1px solid globals.$border-color;
&__cloud-text {
background: linear-gradient(270deg, #16b195 50%, #3e62c0 100%);
background-clip: text;
color: transparent;
}
}

View File

@ -12,8 +12,6 @@ import {
useUserDetails, useUserDetails,
} from "@renderer/hooks"; } from "@renderer/hooks";
import * as styles from "./app.css";
import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { import {
setUserPreferences, setUserPreferences,
@ -29,7 +27,8 @@ import { downloadSourcesWorker } from "./workers";
import { downloadSourcesTable } from "./dexie"; import { downloadSourcesTable } from "./dexie";
import { useSubscription } from "./hooks/use-subscription"; import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { SPACING_UNIT } from "./theme.css";
import "./app.scss";
export interface AppProps { export interface AppProps {
children: React.ReactNode; children: React.ReactNode;
@ -85,7 +84,7 @@ export function App() {
useEffect(() => { useEffect(() => {
const unsubscribe = window.electron.onDownloadProgress( const unsubscribe = window.electron.onDownloadProgress(
(downloadProgress) => { (downloadProgress) => {
if (downloadProgress.progress === 1) { if (downloadProgress?.progress === 1) {
clearDownload(); clearDownload();
updateLibrary(); updateLibrary();
return; return;
@ -257,25 +256,16 @@ export function App() {
return ( return (
<> <>
{window.electron.platform === "win32" && ( {window.electron.platform === "win32" && (
<div className={styles.titleBar}> <div className="title-bar">
<h4> <h4>
Hydra Hydra
{hasActiveSubscription && ( {hasActiveSubscription && (
<span className={styles.cloudText}> Cloud</span> <span className="title-bar__cloud-text"> Cloud</span>
)} )}
</h4> </h4>
</div> </div>
)} )}
<div
style={{
position: "absolute",
bottom: `${26 + SPACING_UNIT * 2}px`,
right: "16px",
maxWidth: "420px",
width: "420px",
}}
>
<Toast <Toast
visible={toast.visible} visible={toast.visible}
title={toast.title} title={toast.title}
@ -284,7 +274,6 @@ export function App() {
onClose={handleToastClose} onClose={handleToastClose}
duration={toast.duration} duration={toast.duration}
/> />
</div>
<HydraCloudModal <HydraCloudModal
visible={isHydraCloudModalVisible} visible={isHydraCloudModalVisible}
@ -304,10 +293,10 @@ export function App() {
<main> <main>
<Sidebar /> <Sidebar />
<article className={styles.container}> <article className="container">
<Header /> <Header />
<section ref={contentRef} className={styles.content}> <section ref={contentRef} className="container__content">
<Outlet /> <Outlet />
</section> </section>
</article> </article>

View File

@ -1,54 +0,0 @@
import { keyframes } 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 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: vars.zIndex.backdrop,
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)",
},
},
windows: {
true: {
// SPACING_UNIT * 3 + title bar spacing
paddingTop: `${SPACING_UNIT * 3 + 35}px`,
},
},
},
});

View File

@ -0,0 +1,50 @@
@use "../../scss/globals.scss";
.backdrop {
animation-name: backdrop-fade-in;
animation-duration: 0.4s;
background-color: rgba(0, 0, 0, 0.7);
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: globals.$backdrop-z-index;
top: 0;
padding: calc(globals.$spacing-unit * 3);
backdrop-filter: blur(2px);
transition: all ease 0.2s;
&--closing {
animation-name: backdrop-fade-out;
backdrop-filter: blur(0px);
background-color: rgba(0, 0, 0, 0);
}
&--windows {
padding-top: calc(#{globals.$spacing-unit * 3} + 35);
}
}
@keyframes backdrop-fade-in {
0% {
backdrop-filter: blur(0px);
background-color: rgba(0, 0, 0, 0.5);
}
100% {
backdrop-filter: blur(2px);
background-color: rgba(0, 0, 0, 0.7);
}
}
@keyframes backdrop-fade-out {
0% {
backdrop-filter: blur(2px);
background-color: rgba(0, 0, 0, 0.7);
}
100% {
backdrop-filter: blur(0px);
background-color: rgba(0, 0, 0, 0);
}
}

View File

@ -1,4 +1,5 @@
import * as styles from "./backdrop.css"; import "./backdrop.scss";
import cn from "classnames";
export interface BackdropProps { export interface BackdropProps {
isClosing?: boolean; isClosing?: boolean;
@ -8,9 +9,9 @@ export interface BackdropProps {
export function Backdrop({ isClosing = false, children }: BackdropProps) { export function Backdrop({ isClosing = false, children }: BackdropProps) {
return ( return (
<div <div
className={styles.backdrop({ className={cn("backdrop", {
closing: isClosing, "backdrop--closing": isClosing,
windows: window.electron.platform === "win32", "backdrop--windows": window.electron.platform === "win32",
})} })}
> >
{children} {children}

View File

@ -7,5 +7,6 @@
border: solid 1px globals.$muted-color; border: solid 1px globals.$muted-color;
border-radius: 4px; border-radius: 4px;
display: flex; display: flex;
gap: 4px;
align-items: center; align-items: center;
} }

View File

@ -19,8 +19,6 @@ export function BottomPanel() {
const { lastPacket, progress, downloadSpeed, eta } = useDownload(); const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const isGameDownloading = !!lastPacket;
const [version, setVersion] = useState(""); const [version, setVersion] = useState("");
const [sessionHash, setSessionHash] = useState<null | string>(""); const [sessionHash, setSessionHash] = useState<null | string>("");
@ -33,9 +31,11 @@ export function BottomPanel() {
}, [userDetails?.id]); }, [userDetails?.id]);
const status = useMemo(() => { const status = useMemo(() => {
if (isGameDownloading) { const game = lastPacket
const game = library.find((game) => game.id === lastPacket?.gameId)!; ? library.find((game) => game.id === lastPacket?.gameId)
: undefined;
if (game) {
if (lastPacket?.isCheckingFiles) if (lastPacket?.isCheckingFiles)
return t("checking_files", { return t("checking_files", {
title: game.title, title: game.title,
@ -64,7 +64,7 @@ export function BottomPanel() {
} }
return t("no_downloads_in_progress"); return t("no_downloads_in_progress");
}, [t, isGameDownloading, library, lastPacket, progress, eta, downloadSpeed]); }, [t, library, lastPacket, progress, eta, downloadSpeed]);
return ( return (
<footer className="bottom-panel"> <footer className="bottom-panel">

View File

@ -1,57 +0,0 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { recipe } from "@vanilla-extract/recipes";
export const checkboxField = style({
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
cursor: "pointer",
});
export const checkbox = recipe({
base: {
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.border}`,
minWidth: "20px",
minHeight: "20px",
color: vars.color.darkBackground,
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
},
variants: {
checked: {
true: {
backgroundColor: vars.color.muted,
},
},
},
});
export const checkboxInput = style({
width: "100%",
height: "100%",
position: "absolute",
margin: "0",
padding: "0",
opacity: "0",
cursor: "pointer",
});
export const checkboxLabel = style({
cursor: "pointer",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
});

View File

@ -0,0 +1,58 @@
@use "../../scss/globals.scss";
.checkbox-field {
display: flex;
flex-direction: row;
align-items: center;
gap: globals.$spacing-unit;
cursor: pointer;
&:has(input:disabled) {
cursor: not-allowed;
opacity: globals.$disabled-opacity;
}
&__checkbox {
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
border-radius: 4px;
background-color: globals.$dark-background-color;
display: flex;
justify-content: center;
align-items: center;
position: relative;
transition: all ease 0.2s;
border: solid 1px globals.$border-color;
&:hover:not(:has(input:disabled)) {
border-color: rgba(255, 255, 255, 0.5);
}
}
&__input {
width: 100%;
height: 100%;
position: absolute;
margin: 0;
padding: 0;
opacity: 0;
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
}
&__label {
cursor: pointer;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
&:has(+ input:disabled) {
cursor: not-allowed;
}
}
}

View File

@ -1,6 +1,6 @@
import { useId } from "react"; import { useId } from "react";
import * as styles from "./checkbox-field.css";
import { CheckIcon } from "@primer/octicons-react"; import { CheckIcon } from "@primer/octicons-react";
import "./checkbox-field.scss";
export interface CheckboxFieldProps export interface CheckboxFieldProps
extends React.DetailedHTMLProps< extends React.DetailedHTMLProps<
@ -14,17 +14,19 @@ export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
const id = useId(); const id = useId();
return ( return (
<div className={styles.checkboxField}> <div className="checkbox-field">
<div className={styles.checkbox({ checked: props.checked })}> <div
className={`checkbox-field__checkbox ${props.checked ? "checked" : ""}`}
>
<input <input
id={id} id={id}
type="checkbox" type="checkbox"
className={styles.checkboxInput} className="checkbox-field__input"
{...props} {...props}
/> />
{props.checked && <CheckIcon />} {props.checked && <CheckIcon />}
</div> </div>
<label htmlFor={id} className={styles.checkboxLabel}> <label htmlFor={id} className="checkbox-field__label">
{label} {label}
</label> </label>
</div> </div>

View File

@ -1,13 +0,0 @@
import { SPACING_UNIT } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const actions = style({
display: "flex",
alignSelf: "flex-end",
gap: `${SPACING_UNIT * 2}px`,
});
export const descriptionText = style({
fontSize: "16px",
lineHeight: "24px",
});

View File

@ -0,0 +1,17 @@
@use "../../scss/globals.scss";
.confirmation-modal {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
&__actions {
display: flex;
align-self: flex-end;
gap: calc(globals.$spacing-unit * 2);
}
&__description {
font-size: 16px;
line-height: 24px;
}
}

View File

@ -1,7 +1,7 @@
import { Button } from "../button/button"; import { Button } from "../button/button";
import { Modal, type ModalProps } from "../modal/modal"; import { Modal, type ModalProps } from "../modal/modal";
import * as styles from "./confirmation-modal.css"; import "./confirmation-modal.scss";
export interface ConfirmationModalProps extends Omit<ModalProps, "children"> { export interface ConfirmationModalProps extends Omit<ModalProps, "children"> {
confirmButtonLabel: string; confirmButtonLabel: string;
@ -31,10 +31,10 @@ export function ConfirmationModal({
return ( return (
<Modal {...props}> <Modal {...props}>
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}> <div className="confirmation-modal">
<p className={styles.descriptionText}>{descriptionText}</p> <p className="confirmation-modal__description">{descriptionText}</p>
<div className={styles.actions}> <div className="confirmation-modal__actions">
<Button theme="outline" onClick={handleCancelClick}> <Button theme="outline" onClick={handleCancelClick}>
{cancelButtonLabel} {cancelButtonLabel}
</Button> </Button>

View File

@ -1,106 +0,0 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const card = style({
width: "100%",
height: "180px",
boxShadow: "0px 0px 15px 0px #000000",
overflow: "hidden",
borderRadius: "4px",
transition: "all ease 0.2s",
border: `solid 1px ${vars.color.border}`,
cursor: "pointer",
zIndex: "1",
":active": {
opacity: vars.opacity.active,
},
});
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",
listStyle: "none",
});
export const specifics = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
justifyContent: "center",
});
export const specificsItem = style({
gap: `${SPACING_UNIT}px`,
display: "flex",
color: vars.color.muted,
fontSize: "12px",
alignItems: "flex-end",
});
export const titleContainer = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
color: vars.color.muted,
});
export const shopIcon = style({
width: "20px",
height: "20px",
minWidth: "20px",
});
export const noDownloadsLabel = style({
color: vars.color.body,
fontWeight: "bold",
});

View File

@ -0,0 +1,102 @@
@use "../../scss/globals.scss";
.game-card {
width: 100%;
height: 180px;
box-shadow: 0px 0px 15px 0px #000000;
overflow: hidden;
border-radius: 4px;
transition: all ease 0.2s;
border: solid 1px globals.$border-color;
cursor: pointer;
z-index: 1;
&:active {
opacity: globals.$active-opacity;
}
&__backdrop {
background: linear-gradient(0deg, rgba(0, 0, 0, 0.7) 50%, transparent 100%);
width: 100%;
height: 100%;
display: flex;
justify-content: flex-end;
flex-direction: column;
position: relative;
}
&__cover {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
position: absolute;
z-index: -1;
transition: all ease 0.2s;
}
&__content {
color: #dadbe1;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
display: flex;
align-items: flex-start;
gap: globals.$spacing-unit;
flex-direction: column;
transition: all ease 0.2s;
transform: translateY(24px);
}
&__title {
font-size: 16px;
font-weight: bold;
text-align: left;
}
&__download-options {
display: flex;
margin: 0;
padding: 0;
gap: globals.$spacing-unit;
flex-wrap: wrap;
list-style: none;
}
&__specifics {
display: flex;
gap: calc(globals.$spacing-unit * 2);
justify-content: center;
}
&__specifics-item {
gap: globals.$spacing-unit;
display: flex;
color: globals.$muted-color;
font-size: 12px;
align-items: flex-end;
}
&__title-container {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
color: globals.$muted-color;
}
&__shop-icon {
width: 20px;
height: 20px;
min-width: 20px;
}
&__no-download-label {
color: globals.$body-color;
font-weight: bold;
}
&:hover &__cover {
transform: scale(1.05);
}
&:hover &__content {
transform: translateY(0px);
}
}

View File

@ -3,7 +3,8 @@ import type { GameStats } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./game-card.css"; import "./game-card.scss";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge"; import { Badge } from "../badge/badge";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
@ -19,7 +20,7 @@ export interface GameCardProps
} }
const shopIcon = { const shopIcon = {
steam: <SteamLogo className={styles.shopIcon} />, steam: <SteamLogo className="game-card__shop-icon" />,
}; };
export function GameCard({ game, ...props }: GameCardProps) { export function GameCard({ game, ...props }: GameCardProps) {
@ -48,25 +49,25 @@ export function GameCard({ game, ...props }: GameCardProps) {
<button <button
{...props} {...props}
type="button" type="button"
className={styles.card} className="game-card"
onMouseEnter={handleHover} onMouseEnter={handleHover}
> >
<div className={styles.backdrop}> <div className="game-card__backdrop">
<img <img
src={steamUrlBuilder.library(game.objectId)} src={steamUrlBuilder.library(game.objectId)}
alt={game.title} alt={game.title}
className={styles.cover} className="game-card__cover"
loading="lazy" loading="lazy"
/> />
<div className={styles.content}> <div className="game-card__content">
<div className={styles.titleContainer}> <div className="game-card__title-container">
{shopIcon[game.shop]} {shopIcon[game.shop]}
<p className={styles.title}>{game.title}</p> <p className="game-card__title">{game.title}</p>
</div> </div>
{uniqueRepackers.length > 0 ? ( {uniqueRepackers.length > 0 ? (
<ul className={styles.downloadOptions}> <ul className="game-card__download-options">
{uniqueRepackers.map((repacker) => ( {uniqueRepackers.map((repacker) => (
<li key={repacker}> <li key={repacker}>
<Badge>{repacker}</Badge> <Badge>{repacker}</Badge>
@ -74,17 +75,17 @@ export function GameCard({ game, ...props }: GameCardProps) {
))} ))}
</ul> </ul>
) : ( ) : (
<p className={styles.noDownloadsLabel}>{t("no_downloads")}</p> <p className="game-card__no-download-label">{t("no_downloads")}</p>
)} )}
<div className={styles.specifics}> <div className="game-card__specifics">
<div className={styles.specificsItem}> <div className="game-card__specifics-item">
<DownloadIcon /> <DownloadIcon />
<span> <span>
{stats ? numberFormatter.format(stats.downloadCount) : "…"} {stats ? numberFormatter.format(stats.downloadCount) : "…"}
</span> </span>
</div> </div>
<div className={styles.specificsItem}> <div className="game-card__specifics-item">
<PeopleIcon /> <PeopleIcon />
<span> <span>
{stats ? numberFormatter.format(stats?.playerCount) : "…"} {stats ? numberFormatter.format(stats?.playerCount) : "…"}

View File

@ -0,0 +1,32 @@
@use "../../scss/globals.scss";
.auto-update-sub-header {
border-bottom: solid 1px globals.$body-color;
padding: calc(globals.$spacing-unit / 2) calc(globals.$spacing-unit * 3);
&__new-version-link {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
color: #8e919b;
font-size: 12px;
}
&__new-version-icon {
color: globals.$success-color;
}
&__new-version-button {
display: flex;
align-items: center;
justify-content: center;
gap: globals.$spacing-unit;
color: globals.$body-color;
font-size: 12px;
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
}

View File

@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { SyncIcon } from "@primer/octicons-react"; import { SyncIcon } from "@primer/octicons-react";
import { Link } from "../link/link"; import { Link } from "../link/link";
import * as styles from "./header.css"; import "./auto-update-header.scss";
import type { AppUpdaterEvent } from "@types"; import type { AppUpdaterEvent } from "@types";
export const releasesPageUrl = export const releasesPageUrl =
@ -45,9 +45,15 @@ export function AutoUpdateSubHeader() {
if (!isAutoInstallAvailable) { if (!isAutoInstallAvailable) {
return ( return (
<header className={styles.subheader}> <header className="auto-update-sub-header">
<Link to={releasesPageUrl} className={styles.newVersionLink}> <Link
<SyncIcon className={styles.newVersionIcon} size={12} /> to={releasesPageUrl}
className="auto-update-sub-header__new-version-link"
>
<SyncIcon
className="auto-update-sub-header__new-version-icon"
size={12}
/>
{t("version_available_download", { version: newVersion })} {t("version_available_download", { version: newVersion })}
</Link> </Link>
</header> </header>
@ -56,13 +62,16 @@ export function AutoUpdateSubHeader() {
if (isReadyToInstall) { if (isReadyToInstall) {
return ( return (
<header className={styles.subheader}> <header className="auto-update-sub-header">
<button <button
type="button" type="button"
className={styles.newVersionButton} className="auto-update-sub-header__new-version-button"
onClick={handleClickInstallUpdate} onClick={handleClickInstallUpdate}
> >
<SyncIcon className={styles.newVersionIcon} size={12} /> <SyncIcon
className="auto-update-sub-header__new-version-icon"
size={12}
/>
{t("version_available_install", { version: newVersion })} {t("version_available_install", { version: newVersion })}
</button> </button>
</header> </header>

View File

@ -1,182 +0,0 @@
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 "../../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: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`,
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.border}`,
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",
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%",
overflow: "hidden",
});
export const backButton = recipe({
base: {
color: vars.color.body,
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",
overflow: "hidden",
textOverflow: "ellipsis",
width: "100%",
},
variants: {
hasBackButton: {
true: {
transform: "translateX(28px)",
width: "calc(100% - 28px)",
},
},
},
});
export const subheader = style({
borderBottom: `solid 1px ${vars.color.border}`,
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT * 3}px`,
});
export const newVersionButton = style({
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: `${SPACING_UNIT}px`,
color: vars.color.body,
fontSize: "12px",
":hover": {
textDecoration: "underline",
cursor: "pointer",
},
});
export const newVersionLink = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
color: "#8e919b",
fontSize: "12px",
});
export const newVersionIcon = style({
color: vars.color.success,
});

View File

@ -0,0 +1,137 @@
@use "../../scss/globals.scss";
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
-webkit-app-region: drag;
width: 100%;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
color: globals.$muted-color;
border-bottom: solid 1px globals.$border-color;
background-color: globals.$dark-background-color;
&--dragging-disabled {
-webkit-app-region: no-drag;
}
&--is-windows {
-webkit-app-region: no-drag;
}
&__search {
background-color: globals.$background-color;
display: inline-flex;
transition: all ease 0.2s;
width: 200px;
align-items: center;
border-radius: 8px;
border: solid 1px globals.$border-color;
height: 40px;
-webkit-app-region: no-drag;
&:hover {
border-color: rgba(255, 255, 255, 0.5);
}
&--focused {
width: 250px;
border-color: #dadbe1;
}
}
&__search-input {
background-color: transparent;
border: none;
width: 100%;
height: 100%;
outline: none;
color: #dadbe1;
cursor: default;
font-family: inherit;
text-overflow: ellipsis;
&:focus {
cursor: text;
}
}
&__action-button {
color: inherit;
cursor: pointer;
transition: all ease 0.2s;
padding: globals.$spacing-unit;
&:hover {
color: #dadbe1;
}
}
&__section {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
height: 100%;
overflow: hidden;
&--left {
flex: 1;
}
}
&__back-button {
color: globals.$body-color;
cursor: pointer;
-webkit-app-region: no-drag;
position: absolute;
transition: transform ease 0.2s;
animation-duration: 0.2s;
width: 16px;
height: 16px;
display: flex;
align-items: center;
opacity: 0;
pointer-events: none;
animation-name: slide-out;
&--enabled {
animation: slide-in;
opacity: 1;
pointer-events: all;
}
}
&__title {
transition: all ease 0.2s;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
&--has-back-button {
transform: translateX(28px);
width: calc(100% - 28px);
}
}
}
@keyframes slide-in {
0% {
transform: translateX(20px);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slide-out {
0% {
transform: translateX(0px);
opacity: 1;
}
100% {
transform: translateX(20px);
opacity: 0;
}
}

View File

@ -5,9 +5,10 @@ import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import { useAppDispatch, useAppSelector } from "@renderer/hooks"; import { useAppDispatch, useAppSelector } from "@renderer/hooks";
import * as styles from "./header.css"; import "./header.scss";
import { AutoUpdateSubHeader } from "./auto-update-sub-header"; import { AutoUpdateSubHeader } from "./auto-update-sub-header";
import { setFilters } from "@renderer/features"; import { setFilters } from "@renderer/features";
import cn from "classnames";
const pathTitle: Record<string, string> = { const pathTitle: Record<string, string> = {
"/": "home", "/": "home",
@ -75,16 +76,16 @@ export function Header() {
return ( return (
<> <>
<header <header
className={styles.header({ className={cn("header", {
draggingDisabled, "header--dragging-disabled": draggingDisabled,
isWindows: window.electron.platform === "win32", "header--is-windows": window.electron.platform === "win32",
})} })}
> >
<section className={styles.section} style={{ flex: 1 }}> <section className="header__section header__section--left">
<button <button
type="button" type="button"
className={styles.backButton({ className={cn("header__back-button", {
enabled: location.key !== "default", "header__back-button--enabled": location.key !== "default",
})} })}
onClick={handleBackButtonClick} onClick={handleBackButtonClick}
disabled={location.key === "default"} disabled={location.key === "default"}
@ -93,19 +94,23 @@ export function Header() {
</button> </button>
<h3 <h3
className={styles.title({ className={cn("header__title", {
hasBackButton: location.key !== "default", "header__title--has-back-button": location.key !== "default",
})} })}
> >
{title} {title}
</h3> </h3>
</section> </section>
<section className={styles.section}> <section className="header__section">
<div className={styles.search({ focused: isFocused })}> <div
className={cn("header__search", {
"header__search--focused": isFocused,
})}
>
<button <button
type="button" type="button"
className={styles.actionButton} className="header__action-button"
onClick={focusInput} onClick={focusInput}
> >
<SearchIcon /> <SearchIcon />
@ -117,7 +122,7 @@ export function Header() {
name="search" name="search"
placeholder={t("search")} placeholder={t("search")}
value={searchValue} value={searchValue}
className={styles.searchInput} className="header__search-input"
onChange={(event) => handleSearch(event.target.value)} onChange={(event) => handleSearch(event.target.value)}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={handleBlur} onBlur={handleBlur}
@ -127,7 +132,7 @@ export function Header() {
<button <button
type="button" type="button"
onClick={() => dispatch(setFilters({ title: "" }))} onClick={() => dispatch(setFilters({ title: "" }))}
className={styles.actionButton} className="header__action-button"
> >
<XIcon /> <XIcon />
</button> </button>

View File

@ -1,60 +0,0 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const hero = style({
width: "100%",
height: "280px",
minHeight: "280px",
maxHeight: "280px",
borderRadius: "4px",
color: "#DADBE1",
overflow: "hidden",
boxShadow: "0px 0px 15px 0px #000000",
cursor: "pointer",
border: `solid 1px ${vars.color.border}`,
zIndex: "1",
});
export const heroMedia = style({
objectFit: "cover",
objectPosition: "center",
position: "absolute",
zIndex: "-1",
width: "100%",
height: "100%",
transition: "all ease 0.2s",
imageRendering: "revert",
selectors: {
[`${hero}:hover &`]: {
transform: "scale(1.02)",
},
},
});
export const backdrop = style({
width: "100%",
height: "100%",
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.8) 25%, transparent 100%)",
position: "relative",
display: "flex",
overflow: "hidden",
});
export const description = style({
maxWidth: "700px",
color: vars.color.muted,
textAlign: "left",
lineHeight: "20px",
marginTop: `${SPACING_UNIT * 2}px`,
});
export const content = style({
width: "100%",
height: "100%",
padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 3}px`,
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
justifyContent: "flex-end",
});

View File

@ -0,0 +1,56 @@
@use "../../scss/globals.scss";
.hero {
width: 100%;
height: 280px;
min-height: 280px;
max-height: 280px;
border-radius: 4px;
color: #dadbe1;
overflow: hidden;
box-shadow: 0px 0px 15px 0px #000000;
cursor: pointer;
border: solid 1px globals.$border-color;
z-index: 1;
&__media {
object-fit: cover;
object-position: center;
position: absolute;
z-index: -1;
width: 100%;
height: 100%;
transition: all ease 0.2s;
image-rendering: revert;
}
&:hover &__media {
transform: scale(1.02);
}
&__backdrop {
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 25%, transparent 100%);
position: relative;
display: flex;
overflow: hidden;
}
&__description {
max-width: 700px;
color: globals.$muted-color;
text-align: left;
line-height: 20px;
margin-top: calc(globals.$spacing-unit * 2);
}
&__content {
width: 100%;
height: 100%;
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 3);
gap: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
justify-content: flex-end;
}
}

View File

@ -1,9 +1,9 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import * as styles from "./hero.css";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { TrendingGame } from "@types"; import type { TrendingGame } from "@types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Skeleton from "react-loading-skeleton"; import Skeleton from "react-loading-skeleton";
import "./hero.scss";
export function Hero() { export function Hero() {
const [featuredGameDetails, setFeaturedGameDetails] = useState< const [featuredGameDetails, setFeaturedGameDetails] = useState<
@ -29,7 +29,7 @@ export function Hero() {
}, [i18n.language]); }, [i18n.language]);
if (isLoading) { if (isLoading) {
return <Skeleton className={styles.hero} />; return <Skeleton className="hero" />;
} }
if (featuredGameDetails?.length) { if (featuredGameDetails?.length) {
@ -37,17 +37,17 @@ export function Hero() {
<button <button
type="button" type="button"
onClick={() => navigate(game.uri)} onClick={() => navigate(game.uri)}
className={styles.hero} className="hero"
key={index} key={index}
> >
<div className={styles.backdrop}> <div className="hero__backdrop">
<img <img
src={game.background} src={game.background}
alt={game.description} alt={game.description}
className={styles.heroMedia} className="hero__media"
/> />
<div className={styles.content}> <div className="hero__content">
{game.logo && ( {game.logo && (
<img <img
src={game.logo} src={game.logo}
@ -56,7 +56,7 @@ export function Hero() {
loading="eager" loading="eager"
/> />
)} )}
<p className={styles.description}>{game.description}</p> <p className="hero__description">{game.description}</p>
</div> </div>
</div> </div>
</button> </button>

View File

@ -1,9 +0,0 @@
import { style } from "@vanilla-extract/css";
export const link = style({
textDecoration: "none",
color: "#C0C1C7",
":hover": {
textDecoration: "underline",
},
});

View File

@ -0,0 +1,7 @@
.link {
text-decoration: none;
color: #c0c1c7;
&:hover {
text-decoration: underline;
}
}

View File

@ -1,6 +1,6 @@
import { Link as ReactRouterDomLink, LinkProps } from "react-router-dom"; import { Link as ReactRouterDomLink, LinkProps } from "react-router-dom";
import cn from "classnames"; import cn from "classnames";
import * as styles from "./link.css"; import "./link.scss";
export function Link({ children, to, className, ...props }: LinkProps) { export function Link({ children, to, className, ...props }: LinkProps) {
const openExternal = (event: React.MouseEvent) => { const openExternal = (event: React.MouseEvent) => {
@ -12,7 +12,7 @@ export function Link({ children, to, className, ...props }: LinkProps) {
return ( return (
<a <a
href={to} href={to}
className={cn(styles.link, className)} className={cn("link", className)}
onClick={openExternal} onClick={openExternal}
{...props} {...props}
> >
@ -22,11 +22,7 @@ export function Link({ children, to, className, ...props }: LinkProps) {
} }
return ( return (
<ReactRouterDomLink <ReactRouterDomLink className={cn("link", className)} to={to} {...props}>
className={cn(styles.link, className)}
to={to}
{...props}
>
{children} {children}
</ReactRouterDomLink> </ReactRouterDomLink>
); );

View File

@ -1,78 +0,0 @@
import { keyframes, style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const scaleFadeIn = keyframes({
"0%": { opacity: "0", scale: "0.5" },
"100%": {
opacity: "1",
scale: "1",
},
});
export const scaleFadeOut = keyframes({
"0%": { opacity: "1", scale: "1" },
"100%": {
opacity: "0",
scale: "0.5",
},
});
export const modal = recipe({
base: {
animation: `${scaleFadeIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`,
backgroundColor: vars.color.background,
borderRadius: "4px",
minWidth: "400px",
maxWidth: "600px",
color: vars.color.body,
maxHeight: "100%",
border: `solid 1px ${vars.color.border}`,
overflow: "hidden",
display: "flex",
flexDirection: "column",
},
variants: {
closing: {
true: {
animationName: scaleFadeOut,
opacity: "0",
},
},
large: {
true: {
width: "800px",
maxWidth: "800px",
},
},
},
});
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.border}`,
justifyContent: "space-between",
alignItems: "center",
});
export const closeModalButton = style({
cursor: "pointer",
transition: "all ease 0.2s",
alignSelf: "flex-start",
":hover": {
opacity: "0.75",
},
});
export const closeModalButtonIcon = style({
color: vars.color.body,
});

View File

@ -0,0 +1,83 @@
@use "../../scss/globals.scss";
.modal {
animation: scale-fade-in 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none
running;
background-color: globals.$background-color;
border-radius: 4px;
min-width: 400px;
max-width: 600px;
color: globals.$body-color;
max-height: 100%;
border: solid 1px globals.$border-color;
overflow: hidden;
display: flex;
flex-direction: column;
&--closing {
animation-name: scale-fade-out;
opacity: 0;
}
&--large {
width: 800px;
max-width: 800px;
}
&__content {
height: 100%;
overflow: auto;
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
}
&__header {
display: flex;
gap: globals.$spacing-unit;
padding: calc(globals.$spacing-unit * 2);
border-bottom: solid 1px globals.$border-color;
justify-content: space-between;
align-items: center;
&-title {
display: flex;
gap: 4px;
flex-direction: column;
}
}
&__close-button {
cursor: pointer;
transition: all ease 0.2s;
align-self: flex-start;
&:hover {
opacity: 0.75;
}
}
&__close-button-icon {
color: globals.$body-color;
}
}
@keyframes scale-fade-in {
0% {
opacity: 0;
scale: 0.5;
}
100% {
opacity: 1;
scale: 1;
}
}
@keyframes scale-fade-out {
0% {
opacity: 1;
scale: 1;
}
100% {
opacity: 0;
scale: 0.5;
}
}

View File

@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { XIcon } from "@primer/octicons-react"; import { XIcon } from "@primer/octicons-react";
import * as styles from "./modal.css"; import "./modal.scss";
import { Backdrop } from "../backdrop/backdrop"; import { Backdrop } from "../backdrop/backdrop";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import cn from "classnames";
export interface ModalProps { export interface ModalProps {
visible: boolean; visible: boolean;
@ -109,15 +110,18 @@ export function Modal({
return createPortal( return createPortal(
<Backdrop isClosing={isClosing}> <Backdrop isClosing={isClosing}>
<div <div
className={styles.modal({ closing: isClosing, large })} className={cn("modal", {
"modal--closing": isClosing,
"modal--large": large,
})}
role="dialog" role="dialog"
aria-labelledby={title} aria-labelledby={title}
aria-describedby={description} aria-describedby={description}
ref={modalContentRef} ref={modalContentRef}
data-hydra-dialog data-hydra-dialog
> >
<div className={styles.modalHeader}> <div className="modal__header">
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}> <div className="modal__header-title">
<h3>{title}</h3> <h3>{title}</h3>
{description && <p>{description}</p>} {description && <p>{description}</p>}
</div> </div>
@ -125,13 +129,13 @@ export function Modal({
<button <button
type="button" type="button"
onClick={handleCloseClick} onClick={handleCloseClick}
className={styles.closeModalButton} className="modal__close-button"
aria-label={t("close")} aria-label={t("close")}
> >
<XIcon className={styles.closeModalButtonIcon} size={24} /> <XIcon className="modal__close-button-icon" size={24} />
</button> </button>
</div> </div>
<div className={styles.modalContent}>{children}</div> <div className="modal__content">{children}</div>
</div> </div>
</Backdrop>, </Backdrop>,
document.body document.body

View File

@ -1,59 +0,0 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const select = recipe({
base: {
display: "inline-flex",
transition: "all ease 0.2s",
width: "fit-content",
alignItems: "center",
borderRadius: "8px",
border: `1px solid ${vars.color.border}`,
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 option = style({
backgroundColor: vars.color.darkBackground,
borderRight: "4px solid",
borderColor: "transparent",
borderRadius: "8px",
width: "fit-content",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
fontSize: vars.size.body,
textOverflow: "ellipsis",
padding: `${SPACING_UNIT}px`,
});
export const label = style({
marginBottom: `${SPACING_UNIT}px`,
display: "block",
color: vars.color.body,
});

View File

@ -0,0 +1,54 @@
@use "../../scss/globals.scss";
.select-field {
display: inline-flex;
transition: all ease 0.2s;
width: fit-content;
align-items: center;
border-radius: 8px;
border: 1px solid globals.$border-color;
height: 40px;
min-height: 40px;
&__container {
flex: 1;
}
&:hover {
border-color: rgba(255, 255, 255, 0.5);
}
&--focused {
border-color: #dadbe1;
}
&--primary {
background-color: globals.$dark-background-color;
}
&--dark {
background-color: globals.$background-color;
}
&__option {
background-color: globals.$dark-background-color;
border-right: 4px solid;
border-color: transparent;
border-radius: 8px;
width: fit-content;
height: 100%;
outline: none;
color: #dadbe1;
cursor: default;
font-family: inherit;
font-size: globals.$body-font-size;
text-overflow: ellipsis;
padding: globals.$spacing-unit;
}
&__label {
margin-bottom: globals.$spacing-unit;
display: block;
color: globals.$body-color;
}
}

View File

@ -1,13 +1,13 @@
import { useId, useState } from "react"; import { useId, useState } from "react";
import type { RecipeVariants } from "@vanilla-extract/recipes"; import "./select-field.scss";
import * as styles from "./select-field.css"; import cn from "classnames";
export interface SelectProps export interface SelectProps
extends React.DetailedHTMLProps< extends React.DetailedHTMLProps<
React.SelectHTMLAttributes<HTMLSelectElement>, React.SelectHTMLAttributes<HTMLSelectElement>,
HTMLSelectElement HTMLSelectElement
> { > {
theme?: NonNullable<RecipeVariants<typeof styles.select>>["theme"]; theme?: "primary" | "dark";
label?: string; label?: string;
options?: { key: string; value: string; label: string }[]; options?: { key: string; value: string; label: string }[];
} }
@ -23,18 +23,22 @@ export function SelectField({
const id = useId(); const id = useId();
return ( return (
<div style={{ flex: 1 }}> <div className="select-field__container">
{label && ( {label && (
<label htmlFor={id} className={styles.label}> <label htmlFor={id} className="select-field__label">
{label} {label}
</label> </label>
)} )}
<div className={styles.select({ focused: isFocused, theme })}> <div
className={cn("select-field", `select-field--${theme}`, {
"select-field--focused": isFocused,
})}
>
<select <select
id={id} id={id}
value={value} value={value}
className={styles.option} className="select-field__option"
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
onChange={onChange} onChange={onChange}

View File

@ -1,79 +0,0 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const profileContainer = style({
position: "relative",
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
});
export const profileButton = style({
display: "flex",
cursor: "pointer",
transition: "all ease 0.1s",
color: vars.color.muted,
width: "100%",
overflow: "hidden",
borderRadius: "4px",
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const profileButtonContent = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
width: "100%",
});
export const profileButtonInformation = style({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
flex: "1",
minWidth: 0,
});
export const profileButtonTitle = style({
fontWeight: "bold",
fontSize: vars.size.body,
width: "100%",
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
export const friendsButton = style({
color: vars.color.muted,
cursor: "pointer",
borderRadius: "50%",
width: "40px",
minWidth: "40px",
minHeight: "40px",
height: "40px",
backgroundColor: vars.color.background,
position: "relative",
transition: "all ease 0.3s",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const friendsButtonBadge = style({
backgroundColor: vars.color.success,
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
height: "20px",
borderRadius: "50%",
position: "absolute",
top: "-5px",
right: "-5px",
});

View File

@ -0,0 +1,89 @@
@use "../../scss/globals.scss";
.sidebar-profile {
position: relative;
display: flex;
align-items: center;
gap: globals.$spacing-unit;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
&__button {
display: flex;
cursor: pointer;
transition: all ease 0.1s;
color: globals.$muted-color;
width: 100%;
overflow: hidden;
border-radius: 4px;
padding: globals.$spacing-unit globals.$spacing-unit;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
}
&__button-content {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit + globals.$spacing-unit / 2);
width: 100%;
}
&__button-information {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
min-width: 0;
}
&__button-title {
font-weight: bold;
font-size: globals.$body-font-size;
width: 100%;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__friends-button {
color: globals.$muted-color;
cursor: pointer;
border-radius: 50%;
width: 40px;
min-width: 40px;
min-height: 40px;
height: 40px;
background-color: globals.$background-color;
position: relative;
transition: all ease 0.3s;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
}
&__friends-button-badge {
background-color: globals.$success-color;
display: flex;
justify-content: center;
align-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
position: absolute;
top: -5px;
right: -5px;
}
&__game-running-icon {
border-radius: 4px;
}
&__button-game-running-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
text-align: left;
}
}

View File

@ -1,6 +1,5 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { PeopleIcon } from "@primer/octicons-react"; import { PeopleIcon } from "@primer/octicons-react";
import * as styles from "./sidebar-profile.css";
import { useAppSelector, useUserDetails } from "@renderer/hooks"; import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -8,6 +7,7 @@ import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-mo
import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar } from "../avatar/avatar"; import { Avatar } from "../avatar/avatar";
import { AuthPage } from "@shared"; import { AuthPage } from "@shared";
import "./sidebar-profile.scss";
const LONG_POLLING_INTERVAL = 120_000; const LONG_POLLING_INTERVAL = 120_000;
@ -50,14 +50,14 @@ export function SidebarProfile() {
return ( return (
<button <button
type="button" type="button"
className={styles.friendsButton} className="sidebar-profile__friends-button"
onClick={() => onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id) showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
} }
title={t("friends")} title={t("friends")}
> >
{friendRequestCount > 0 && ( {friendRequestCount > 0 && (
<small className={styles.friendsButtonBadge}> <small className="sidebar-profile__friends-button-badge">
{friendRequestCount > 99 ? "99+" : friendRequestCount} {friendRequestCount > 99 ? "99+" : friendRequestCount}
</small> </small>
)} )}
@ -73,9 +73,9 @@ export function SidebarProfile() {
if (gameRunning.iconUrl) { if (gameRunning.iconUrl) {
return ( return (
<img <img
className="sidebar-profile__game-running-icon"
alt={gameRunning.title} alt={gameRunning.title}
width={24} width={24}
style={{ borderRadius: 4 }}
src={gameRunning.iconUrl} src={gameRunning.iconUrl}
/> />
); );
@ -85,34 +85,26 @@ export function SidebarProfile() {
}; };
return ( return (
<div className={styles.profileContainer}> <div className="sidebar-profile">
<button <button
type="button" type="button"
className={styles.profileButton} className="sidebar-profile__button"
onClick={handleProfileClick} onClick={handleProfileClick}
> >
<div className={styles.profileButtonContent}> <div className="sidebar-profile__button-content">
<Avatar <Avatar
size={35} size={35}
src={userDetails?.profileImageUrl} src={userDetails?.profileImageUrl}
alt={userDetails?.displayName} alt={userDetails?.displayName}
/> />
<div className={styles.profileButtonInformation}> <div className="sidebar-profile__button-information">
<p className={styles.profileButtonTitle}> <p className="sidebar-profile__button-title">
{userDetails ? userDetails.displayName : t("sign_in")} {userDetails ? userDetails.displayName : t("sign_in")}
</p> </p>
{userDetails && gameRunning && ( {userDetails && gameRunning && (
<div <div className="sidebar-profile__button-game-running-title">
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
width: "100%",
textAlign: "left",
}}
>
<small>{gameRunning.title}</small> <small>{gameRunning.title}</small>
</div> </div>
)} )}

View File

@ -101,6 +101,12 @@
font-weight: bold; font-weight: bold;
} }
&__container {
display: flex;
flex-direction: column;
overflow: hidden;
}
&__section { &__section {
gap: calc(globals.$spacing-unit * 2); gap: calc(globals.$spacing-unit * 2);
display: flex; display: flex;

View File

@ -180,14 +180,7 @@ export function Sidebar() {
maxWidth: sidebarWidth, maxWidth: sidebarWidth,
}} }}
> >
<div <div className="sidebar__container">
style={{
display: "flex",
flexDirection: "column",
overflow: "hidden",
flex: 1,
}}
>
<SidebarProfile /> <SidebarProfile />
<div className="sidebar__content"> <div className="sidebar__content">

View File

@ -1,89 +0,0 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const textFieldContainer = style({
flex: "1",
gap: `${SPACING_UNIT}px`,
display: "flex",
flexDirection: "column",
});
export const textField = recipe({
base: {
display: "inline-flex",
transition: "all ease 0.2s",
flex: 1,
alignItems: "center",
borderRadius: "8px",
border: `solid 1px ${vars.color.border}`,
height: "40px",
minHeight: "40px",
},
variants: {
theme: {
primary: {
backgroundColor: vars.color.darkBackground,
},
dark: {
backgroundColor: vars.color.background,
},
},
hasError: {
true: {
borderColor: vars.color.danger,
},
},
focused: {
true: {
borderColor: "#DADBE1",
},
false: {
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
},
},
},
});
export const textFieldInput = recipe({
base: {
backgroundColor: "transparent",
border: "none",
width: "100%",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
textOverflow: "ellipsis",
padding: `${SPACING_UNIT}px`,
":focus": {
cursor: "text",
},
},
variants: {
readOnly: {
true: {
textOverflow: "inherit",
},
},
},
});
export const togglePasswordButton = style({
cursor: "pointer",
color: vars.color.muted,
padding: `${SPACING_UNIT}px`,
});
export const textFieldWrapper = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const errorLabel = style({
color: vars.color.danger,
});

View File

@ -0,0 +1,79 @@
@use "../../scss/globals.scss";
.text-field-container {
flex: 1;
gap: globals.$spacing-unit;
display: flex;
flex-direction: column;
&__text-field {
display: inline-flex;
transition: all ease 0.2s;
width: 100%;
align-items: center;
border-radius: 8px;
border: solid 1px globals.$border-color;
height: 40px;
min-height: 40px;
flex: 1;
min-width: 0;
&:hover {
border-color: rgba(255, 255, 255, 0.5);
}
&--primary {
background-color: globals.$dark-background-color;
}
&--dark {
background-color: globals.$background-color;
}
&--has-error {
border-color: globals.$danger-color;
}
&--focused {
border-color: #dadbe1;
}
}
&__text-field-input {
background-color: transparent;
border: none;
width: 100%;
height: 100%;
outline: none;
color: #dadbe1;
cursor: default;
font-family: inherit;
text-overflow: ellipsis;
padding: globals.$spacing-unit;
&:focus {
cursor: text;
}
&--read-only {
text-overflow: inherit;
}
}
&__toggle-password-button {
cursor: pointer;
color: globals.$muted-color;
padding: globals.$spacing-unit;
}
&__text-field-wrapper {
display: flex;
gap: globals.$spacing-unit;
width: 100%;
align-items: center;
}
&__error-label {
color: globals.$danger-color;
}
}

View File

@ -1,16 +1,17 @@
import React, { useId, useMemo, useState } from "react"; import React, { useId, useMemo, useState } from "react";
import type { RecipeVariants } from "@vanilla-extract/recipes";
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react"; import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./text-field.css"; import cn from "classnames";
import "./text-field.scss";
export interface TextFieldProps export interface TextFieldProps
extends React.DetailedHTMLProps< extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement HTMLInputElement
> { > {
theme?: NonNullable<RecipeVariants<typeof styles.textField>>["theme"]; theme?: "primary" | "dark";
label?: string | React.ReactNode; label?: string | React.ReactNode;
hint?: string | React.ReactNode; hint?: string | React.ReactNode;
textFieldProps?: React.DetailedHTMLProps< textFieldProps?: React.DetailedHTMLProps<
@ -54,7 +55,10 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
}, [props.type, isPasswordVisible]); }, [props.type, isPasswordVisible]);
const hintContent = useMemo(() => { const hintContent = useMemo(() => {
if (error) return <small className={styles.errorLabel}>{error}</small>; if (error)
return (
<small className="text-field-container__error-label">{error}</small>
);
if (hint) return <small>{hint}</small>; if (hint) return <small>{hint}</small>;
return null; return null;
@ -73,22 +77,28 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
const hasError = !!error; const hasError = !!error;
return ( return (
<div className={styles.textFieldContainer} {...containerProps}> <div className="text-field-container" {...containerProps}>
{label && <label htmlFor={id}>{label}</label>} {label && <label htmlFor={id}>{label}</label>}
<div className={styles.textFieldWrapper}> <div className="text-field-container__text-field-wrapper">
<div <div
className={styles.textField({ className={cn(
theme, "text-field-container__text-field",
hasError, `text-field-container__text-field--${theme}`,
focused: isFocused, {
})} "text-field-container__text-field--has-error": hasError,
"text-field-container__text-field--focused": isFocused,
}
)}
{...textFieldProps} {...textFieldProps}
> >
<input <input
ref={ref} ref={ref}
id={id} id={id}
className={styles.textFieldInput({ readOnly: props.readOnly })} className={cn("text-field-container__text-field-input", {
"text-field-container__text-field-input--read-only":
props.readOnly,
})}
{...props} {...props}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
@ -98,7 +108,7 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
{showPasswordToggleButton && ( {showPasswordToggleButton && (
<button <button
type="button" type="button"
className={styles.togglePasswordButton} className="text-field-container__toggle-password-button"
onClick={() => setIsPasswordVisible(!isPasswordVisible)} onClick={() => setIsPasswordVisible(!isPasswordVisible)}
aria-label={t("toggle_password_visibility")} aria-label={t("toggle_password_visibility")}
> >

View File

@ -7,8 +7,8 @@
background-color: globals.$dark-background-color; background-color: globals.$dark-background-color;
border-radius: 4px; border-radius: 4px;
border: solid 1px globals.$border-color; border: solid 1px globals.$border-color;
right: 0; right: 16px;
bottom: 0; bottom: 26px + globals.$spacing-unit;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -31,6 +31,16 @@
align-items: center; align-items: center;
} }
&__message-container {
display: flex;
gap: globals.$spacing-unit;
flex-direction: column;
}
&__message {
font-weight: bold;
}
&__progress { &__progress {
width: 100%; width: 100%;
height: 5px; height: 5px;
@ -38,6 +48,7 @@
&::-webkit-progress-bar { &::-webkit-progress-bar {
background-color: globals.$dark-background-color; background-color: globals.$dark-background-color;
} }
&::-webkit-progress-value { &::-webkit-progress-value {
background-color: globals.$muted-color; background-color: globals.$muted-color;
} }

View File

@ -87,13 +87,7 @@ export function Toast({
})} })}
> >
<div className="toast__content"> <div className="toast__content">
<div <div className="toast__message-container">
style={{
display: "flex",
gap: `8px`,
flexDirection: "column",
}}
>
<div <div
style={{ style={{
display: "flex", display: "flex",

View File

@ -50,7 +50,7 @@ declare global {
pauseGameSeed: (shop: GameShop, objectId: string) => Promise<void>; pauseGameSeed: (shop: GameShop, objectId: string) => Promise<void>;
resumeGameSeed: (shop: GameShop, objectId: string) => Promise<void>; resumeGameSeed: (shop: GameShop, objectId: string) => Promise<void>;
onDownloadProgress: ( onDownloadProgress: (
cb: (value: DownloadProgress) => void cb: (value: DownloadProgress | null) => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
onSeedingStatus: ( onSeedingStatus: (
cb: (value: SeedingStatus[]) => void cb: (value: SeedingStatus[]) => void

View File

@ -18,9 +18,9 @@ export const downloadSlice = createSlice({
name: "download", name: "download",
initialState, initialState,
reducers: { reducers: {
setLastPacket: (state, action: PayloadAction<DownloadProgress>) => { setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
state.lastPacket = action.payload; state.lastPacket = action.payload;
if (!state.gameId) state.gameId = action.payload.gameId; if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
}, },
clearDownload: (state) => { clearDownload: (state) => {
state.lastPacket = null; state.lastPacket = null;

View File

@ -114,7 +114,7 @@ export function useDownload() {
pauseSeeding, pauseSeeding,
resumeSeeding, resumeSeeding,
clearDownload: () => dispatch(clearDownload()), clearDownload: () => dispatch(clearDownload()),
setLastPacket: (packet: DownloadProgress) => setLastPacket: (packet: DownloadProgress | null) =>
dispatch(setLastPacket(packet)), dispatch(setLastPacket(packet)),
}; };
} }

View File

@ -1,71 +0,0 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const panel = style({
width: "100%",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
backgroundColor: vars.color.background,
display: "flex",
flexDirection: "column",
alignItems: "start",
justifyContent: "space-between",
borderBottom: `solid 1px ${vars.color.border}`,
});
export const content = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
});
export const actions = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const downloadDetailsRow = style({
gap: `${SPACING_UNIT}px`,
display: "flex",
color: vars.color.body,
alignItems: "center",
});
export const downloadsLink = style({
color: vars.color.body,
textDecoration: "underline",
});
export const progressBar = recipe({
base: {
position: "absolute",
bottom: "0",
left: "0",
width: "100%",
height: "3px",
transition: "all ease 0.2s",
"::-webkit-progress-bar": {
backgroundColor: "transparent",
},
"::-webkit-progress-value": {
backgroundColor: vars.color.muted,
},
},
variants: {
disabled: {
true: {
opacity: vars.opacity.disabled,
},
},
},
});
export const link = style({
textAlign: "start",
color: vars.color.body,
":hover": {
textDecoration: "underline",
cursor: "pointer",
},
});

View File

@ -0,0 +1,97 @@
@use "../../scss/globals.scss";
.achievement-panel {
width: 100%;
padding: globals.$spacing-unit * 2 globals.$spacing-unit * 3;
background-color: globals.$background-color;
display: flex;
flex-direction: column;
align-items: start;
justify-content: space-between;
border-bottom: solid 1px globals.$border-color;
&__content {
display: flex;
gap: globals.$spacing-unit;
justify-content: center;
align-items: center;
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
}
&__download-details-row {
gap: globals.$spacing-unit;
display: flex;
color: globals.$body-color;
align-items: center;
}
&__downloads-link {
color: globals.$body-color;
text-decoration: underline;
}
&__progress-bar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
transition: all ease 0.2s;
&::-webkit-progress-bar {
background-color: transparent;
}
&::-webkit-progress-value {
background-color: globals.$muted-color;
}
&--disabled {
opacity: globals.$disabled-opacity;
}
}
&__link {
text-align: start;
color: globals.$body-color;
background: none;
border: none;
padding: 0;
&:hover {
text-decoration: underline;
cursor: pointer;
}
&--warning {
color: globals.$warning-color;
}
}
&__grid {
display: grid;
gap: globals.$spacing-unit * 2;
}
&__grid--with-subscription {
grid-template-columns: 3fr 1fr 1fr;
}
&__grid--without-subscription {
grid-template-columns: 3fr 2fr;
}
&__points-container {
display: flex;
gap: globals.$spacing-unit;
}
&__content-icon {
width: 18px;
height: 18px;
}
}

View File

@ -3,8 +3,7 @@ import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { UserAchievement } from "@types"; import { UserAchievement } from "@types";
import { useSubscription } from "@renderer/hooks/use-subscription"; import { useSubscription } from "@renderer/hooks/use-subscription";
import { useUserDetails } from "@renderer/hooks"; import { useUserDetails } from "@renderer/hooks";
import { vars } from "@renderer/theme.css"; import "./achievement-panel.scss";
import * as styles from "./achievement-panel.css";
export interface AchievementPanelProps { export interface AchievementPanelProps {
achievements: UserAchievement[]; achievements: UserAchievement[];
@ -28,17 +27,18 @@ export function AchievementPanel({ achievements }: AchievementPanelProps) {
if (!hasActiveSubscription) { if (!hasActiveSubscription) {
return ( return (
<div className={styles.panel}> <div className="achievement-panel">
<div className={styles.content}> <div className="achievement-panel__content">
{t("earned_points")} <HydraIcon width={20} height={20} /> {t("earned_points")}{" "}
<HydraIcon className="achievement-panel__content-icon" />
??? / ??? ??? / ???
</div> </div>
<button <button
type="button" type="button"
onClick={() => showHydraCloudModal("achievements-points")} onClick={() => showHydraCloudModal("achievements-points")}
className={styles.link} className="achievement-panel__link"
> >
<small style={{ color: vars.color.warning }}> <small className="achievement-panel__link--warning">
{t("how_to_earn_achievements_points")} {t("how_to_earn_achievements_points")}
</small> </small>
</button> </button>
@ -47,9 +47,10 @@ export function AchievementPanel({ achievements }: AchievementPanelProps) {
} }
return ( return (
<div className={styles.panel}> <div className="achievement-panel">
<div className={styles.content}> <div className="achievement-panel__content">
{t("earned_points")} <HydraIcon width={20} height={20} /> {t("earned_points")}{" "}
<HydraIcon className="achievement-panel__content-icon" />
{achievementsPointsEarnedSum} / {achievementsPointsTotal} {achievementsPointsEarnedSum} / {achievementsPointsTotal}
</div> </div>
</div> </div>

View File

@ -0,0 +1,248 @@
@use "../../scss/globals.scss";
$hero-height: 150px;
$logo-height: 100px;
$logo-max-width: 200px;
.achievements-content {
&__comparison {
display: flex;
gap: globals.$spacing-unit * 2;
align-items: center;
position: relative;
padding: globals.$spacing-unit;
&__container {
position: absolute;
z-index: 2;
inset: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
flex-direction: row;
gap: globals.$spacing-unit;
border-radius: 4px;
justify-content: center;
&__subscription-required-button {
text-decoration: none;
display: flex;
justify-content: center;
width: 100%;
gap: globals.$spacing-unit / 2;
color: globals.$body-color;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
&__blured-avatar {
display: flex;
gap: globals.$spacing-unit * 2;
align-items: center;
height: 62px;
position: relative;
filter: blur(4px);
h1 {
margin-bottom: 8px;
}
}
&__small-avatar {
height: 32px;
width: 32px;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
object-fit: cover;
}
}
&__subscription-required-button {
text-decoration: none;
display: flex;
justify-content: center;
width: 100%;
gap: 8px;
color: globals.$body-color;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&__user-summary {
display: flex;
gap: globals.$spacing-unit * 2;
align-items: center;
padding: globals.$spacing-unit globals.$spacing-unit * 2;
&__container {
display: flex;
flex-direction: column;
width: 100%;
h1 {
margin-bottom: 8px;
}
&__stats {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
color: globals.$muted-color;
&__trophy-count {
display: flex;
align-items: center;
gap: 8px;
}
&__progress-bar {
width: 100%;
height: 8px;
transition: all ease 0.2s;
&::-webkit-progress-bar {
background-color: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
&::-webkit-progress-value {
background-color: globals.$muted-color;
border-radius: 4px;
}
}
}
}
}
&__achievements-list {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
transition: all ease 0.3s;
&__image {
display: none;
}
&__section {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
z-index: 1;
&__container {
display: flex;
flex-direction: column;
background: linear-gradient(
0deg,
globals.$background-color 0%,
globals.$background-color 100%
);
&__hero {
width: 100%;
height: $hero-height;
min-height: $hero-height;
display: flex;
flex-direction: column;
position: relative;
transition: all ease 0.2s;
&__content {
padding: globals.$spacing-unit * 2;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
&__game-logo {
width: $logo-max-width;
height: $logo-height;
object-fit: contain;
transition: all ease 0.2s;
&:hover {
transform: scale(1.05);
}
}
}
}
&__achievements-summary-wrapper {
display: flex;
flex-direction: column;
width: 100%;
gap: globals.$spacing-unit;
padding: globals.$spacing-unit;
}
}
&__table-header {
width: 100%;
background-color: globals.$background-color;
transition: all ease 0.2s;
border-bottom: 1px solid globals.$border-color;
position: sticky;
top: 0;
z-index: 1;
&--stuck {
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
}
&__container {
display: grid;
gap: globals.$spacing-unit * 2;
padding: globals.$spacing-unit * 3;
&--has-no-active-subscription {
grid-template-columns: 3fr 2fr;
}
&--has-active-subscription {
grid-template-columns: 3fr 1fr 1fr;
}
&__user-avatar {
display: flex;
justify-content: center;
}
&__other-user-avatar {
display: flex;
justify-content: center;
}
}
}
}
}
&__profile-avatar {
height: 54px;
width: 54px;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
background-color: globals.$background-color;
position: relative;
object-fit: cover;
}
}

View File

@ -8,18 +8,17 @@ import {
formatDownloadProgress, formatDownloadProgress,
} from "@renderer/helpers"; } from "@renderer/helpers";
import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react"; import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import type { ComparedAchievements } from "@types"; import type { ComparedAchievements } from "@types";
import { average } from "color.js"; import { average } from "color.js";
import Color from "color"; import Color from "color";
import { Link } from "@renderer/components"; import { Link } from "@renderer/components";
import { ComparedAchievementList } from "./compared-achievement-list"; import { ComparedAchievementList } from "./compared-achievement-list";
import * as styles from "./achievements.css";
import { AchievementList } from "./achievement-list"; import { AchievementList } from "./achievement-list";
import { AchievementPanel } from "./achievement-panel"; import { AchievementPanel } from "./achievement-panel";
import { ComparedAchievementPanel } from "./compared-achievement-panel"; import { ComparedAchievementPanel } from "./compared-achievement-panel";
import { useSubscription } from "@renderer/hooks/use-subscription"; import { useSubscription } from "@renderer/hooks/use-subscription";
import "./achievements-content.scss";
interface UserInfo { interface UserInfo {
id: string; id: string;
@ -48,10 +47,10 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
user: Pick<UserInfo, "profileImageUrl" | "displayName"> user: Pick<UserInfo, "profileImageUrl" | "displayName">
) => { ) => {
return ( return (
<div className={styles.profileAvatar}> <div className="achievements-content__profile-avatar">
{user.profileImageUrl ? ( {user.profileImageUrl ? (
<img <img
className={styles.profileAvatar} className="achievements-content__profile-avatar"
src={user.profileImageUrl} src={user.profileImageUrl}
alt={user.displayName} alt={user.displayName}
/> />
@ -64,91 +63,33 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
if (isComparison && userDetails?.id == user.id && !hasActiveSubscription) { if (isComparison && userDetails?.id == user.id && !hasActiveSubscription) {
return ( return (
<div <div className="achievements-content__comparison">
style={{ <div className="achievements-content__comparison__container">
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
alignItems: "center",
position: "relative",
padding: `${SPACING_UNIT}px`,
}}
>
<div
style={{
position: "absolute",
zIndex: 2,
inset: 0,
width: "100%",
height: "100%",
background: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
borderRadius: "4px",
justifyContent: "center",
}}
>
<LockIcon size={24} /> <LockIcon size={24} />
<h3> <h3>
<button <button
className={styles.subscriptionRequiredButton} className="achievements-content__comparison__container__subscription-required-button"
onClick={() => showHydraCloudModal("achievements")} onClick={() => showHydraCloudModal("achievements")}
> >
{t("subscription_needed")} {t("subscription_needed")}
</button> </button>
</h3> </h3>
</div> </div>
<div <div className="achievements-content__comparison__blured-avatar">
style={{
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
alignItems: "center",
height: "62px",
position: "relative",
filter: "blur(4px)",
}}
>
{getProfileImage(user)} {getProfileImage(user)}
<h1 style={{ marginBottom: "8px" }}>{user.displayName}</h1> <h1>{user.displayName}</h1>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div <div className="achievements-content__user-summary">
style={{
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
alignItems: "center",
padding: `${SPACING_UNIT}px`,
}}
>
{getProfileImage(user)} {getProfileImage(user)}
<div <div className="achievements-content__user-summary__container">
style={{ <h1>{user.displayName}</h1>
display: "flex", <div className="achievements-content__user-summary__container__stats">
flexDirection: "column", <div className="achievements-content__user-summary__container__stats__trophy-count">
width: "100%",
}}
>
<h1 style={{ marginBottom: "8px" }}>{user.displayName}</h1>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 8,
color: vars.color.muted,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<TrophyIcon size={13} /> <TrophyIcon size={13} />
<span> <span>
{user.unlockedAchievementCount} / {user.totalAchievementCount} {user.unlockedAchievementCount} / {user.totalAchievementCount}
@ -164,7 +105,7 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
<progress <progress
max={1} max={1}
value={user.unlockedAchievementCount / user.totalAchievementCount} value={user.unlockedAchievementCount / user.totalAchievementCount}
className={styles.achievementsProgressBar} className="achievements-content__user-summary__container__stats__progress-bar"
/> />
</div> </div>
</div> </div>
@ -203,7 +144,7 @@ export function AchievementsContent({
}; };
const onScroll: React.UIEventHandler<HTMLElement> = (event) => { const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
const heroHeight = heroRef.current?.clientHeight ?? styles.HERO_HEIGHT; const heroHeight = heroRef.current?.clientHeight ?? 150;
const scrollY = (event.target as HTMLDivElement).scrollTop; const scrollY = (event.target as HTMLDivElement).scrollTop;
if (scrollY >= heroHeight && !isHeaderStuck) { if (scrollY >= heroHeight && !isHeaderStuck) {
@ -219,10 +160,10 @@ export function AchievementsContent({
user: Pick<UserInfo, "profileImageUrl" | "displayName"> user: Pick<UserInfo, "profileImageUrl" | "displayName">
) => { ) => {
return ( return (
<div className={styles.profileAvatarSmall}> <div className="achievements-content__comparison__small-avatar">
{user.profileImageUrl ? ( {user.profileImageUrl ? (
<img <img
className={styles.profileAvatarSmall} className="achievements-content__comparison__small-avatar"
src={user.profileImageUrl} src={user.profileImageUrl}
alt={user.displayName} alt={user.displayName}
/> />
@ -236,10 +177,10 @@ export function AchievementsContent({
if (!objectId || !shop || !gameTitle || !userDetails) return null; if (!objectId || !shop || !gameTitle || !userDetails) return null;
return ( return (
<div className={styles.wrapper}> <div className="achievements-content__achievements-list">
<img <img
src={steamUrlBuilder.libraryHero(objectId)} src={steamUrlBuilder.libraryHero(objectId)}
style={{ display: "none" }} className="achievements-content__achievements-list__image"
alt={gameTitle} alt={gameTitle}
onLoad={handleHeroLoad} onLoad={handleHeroLoad}
/> />
@ -247,38 +188,32 @@ export function AchievementsContent({
<section <section
ref={containerRef} ref={containerRef}
onScroll={onScroll} onScroll={onScroll}
className={styles.container} className="achievements-content__achievements-list__section"
> >
<div <div
className="achievements-content__achievements-list__section__container"
style={{ style={{
display: "flex", background: `linear-gradient(0deg, #151515 0%, ${gameColor} 100%)`,
flexDirection: "column",
background: `linear-gradient(0deg, ${vars.color.darkBackground} 0%, ${gameColor} 100%)`,
}} }}
> >
<div ref={heroRef} className={styles.hero}> <div
<div className={styles.heroContent}> ref={heroRef}
className="achievements-content__achievements-list__section__container__hero"
>
<div className="achievements-content__achievements-list__section__container__hero__content">
<Link <Link
to={buildGameDetailsPath({ shop, objectId, title: gameTitle })} to={buildGameDetailsPath({ shop, objectId, title: gameTitle })}
> >
<img <img
src={steamUrlBuilder.logo(objectId)} src={steamUrlBuilder.logo(objectId)}
className={styles.gameLogo} className="achievements-content__achievements-list__section__container__hero__content__game-logo"
alt={gameTitle} alt={gameTitle}
/> />
</Link> </Link>
</div> </div>
</div> </div>
<div <div className="achievements-content__achievements-list__section__container__achievements-summary-wrapper">
style={{
display: "flex",
flexDirection: "column",
width: "100%",
gap: `${SPACING_UNIT}px`,
padding: `${SPACING_UNIT}px`,
}}
>
<AchievementSummary <AchievementSummary
user={{ user={{
...userDetails, ...userDetails,
@ -298,24 +233,19 @@ export function AchievementsContent({
</div> </div>
{otherUser && ( {otherUser && (
<div className={styles.tableHeader({ stuck: isHeaderStuck })}>
<div <div
style={{ className={`achievements-content__achievements-list__section__table-header ${isHeaderStuck ? "achievements-content__achievements-list__section__table-header--stuck" : ""}`}
display: "grid", >
gridTemplateColumns: hasActiveSubscription <div
? "3fr 1fr 1fr" className={`achievements-content__achievements-list__section__table-header__container ${hasActiveSubscription ? "achievements-content__achievements-list__section__table-header__container--has-active-subscription" : "achievements-content__achievements-list__section__table-header__container--has-no-active-subscription"}`}
: "3fr 2fr",
gap: `${SPACING_UNIT * 2}px`,
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 3}px`,
}}
> >
<div></div> <div></div>
{hasActiveSubscription && ( {hasActiveSubscription && (
<div style={{ display: "flex", justifyContent: "center" }}> <div className="achievements-content__achievements-list__section__table-header__container__user-avatar">
{getProfileImage({ ...userDetails })} {getProfileImage({ ...userDetails })}
</div> </div>
)} )}
<div style={{ display: "flex", justifyContent: "center" }}> <div className="achievements-content__achievements-list__section__table-header__container__other-user-avatar">
{getProfileImage(otherUser)} {getProfileImage(otherUser)}
</div> </div>
</div> </div>

View File

@ -1,13 +1,13 @@
import Skeleton from "react-loading-skeleton"; import Skeleton from "react-loading-skeleton";
import * as styles from "./achievements.css"; import "./achievements.scss";
export function AchievementsSkeleton() { export function AchievementsSkeleton() {
return ( return (
<div className={styles.container}> <div className="achievements__container">
<div className={styles.hero}> <div className="achievements__hero">
<Skeleton className={styles.heroImageSkeleton} /> <Skeleton className="achievements__hero-image-skeleton" />
</div> </div>
<div className={styles.heroPanelSkeleton}></div> <div className="achievements__hero-panel-skeleton"></div>
</div> </div>
); );
} }

View File

@ -1,197 +0,0 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
export const HERO_HEIGHT = 150;
const LOGO_HEIGHT = 100;
const LOGO_MAX_WIDTH = 200;
export const wrapper = style({
display: "flex",
flexDirection: "column",
overflow: "hidden",
width: "100%",
height: "100%",
transition: "all ease 0.3s",
});
export const hero = style({
width: "100%",
height: `${HERO_HEIGHT}px`,
minHeight: `${HERO_HEIGHT}px`,
display: "flex",
flexDirection: "column",
position: "relative",
transition: "all ease 0.2s",
});
export const heroContent = style({
padding: `${SPACING_UNIT * 2}px`,
width: "100%",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
});
export const gameLogo = style({
width: LOGO_MAX_WIDTH,
height: LOGO_HEIGHT,
objectFit: "contain",
transition: "all ease 0.2s",
":hover": {
transform: "scale(1.05)",
},
});
export const container = style({
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "auto",
zIndex: "1",
});
export const tableHeader = recipe({
base: {
width: "100%",
backgroundColor: vars.color.darkBackground,
transition: "all ease 0.2s",
borderBottom: `solid 1px ${vars.color.border}`,
position: "sticky",
top: "0",
zIndex: "1",
},
variants: {
stuck: {
true: {
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.8)",
},
},
},
});
export const list = style({
listStyle: "none",
margin: "0",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
padding: `${SPACING_UNIT * 2}px`,
width: "100%",
backgroundColor: vars.color.background,
});
export const listItem = style({
transition: "all ease 0.1s",
color: vars.color.muted,
width: "100%",
overflow: "hidden",
borderRadius: "4px",
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
gap: `${SPACING_UNIT * 2}px`,
alignItems: "center",
textAlign: "left",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
textDecoration: "none",
},
});
export const listItemImage = recipe({
base: {
width: "54px",
height: "54px",
borderRadius: "4px",
objectFit: "cover",
},
variants: {
unlocked: {
false: {
filter: "grayscale(100%)",
},
},
},
});
export const achievementsProgressBar = style({
width: "100%",
height: "8px",
transition: "all ease 0.2s",
"::-webkit-progress-bar": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
borderRadius: "4px",
},
"::-webkit-progress-value": {
backgroundColor: vars.color.muted,
borderRadius: "4px",
},
});
export const heroLogoBackdrop = style({
width: "100%",
height: "100%",
position: "absolute",
display: "flex",
flexDirection: "column",
justifyContent: "flex-end",
});
export const heroImageSkeleton = style({
height: "150px",
});
export const heroPanelSkeleton = style({
width: "100%",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
display: "flex",
alignItems: "center",
backgroundColor: vars.color.background,
height: "72px",
borderBottom: `solid 1px ${vars.color.border}`,
});
export const listItemSkeleton = style({
width: "100%",
overflow: "hidden",
borderRadius: "4px",
padding: `${SPACING_UNIT}px ${SPACING_UNIT}px`,
gap: `${SPACING_UNIT * 2}px`,
});
export const profileAvatar = style({
height: "54px",
width: "54px",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
position: "relative",
objectFit: "cover",
});
export const profileAvatarSmall = style({
height: "32px",
width: "32px",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
position: "relative",
objectFit: "cover",
});
export const subscriptionRequiredButton = style({
textDecoration: "none",
display: "flex",
justifyContent: "center",
width: "100%",
gap: `${SPACING_UNIT / 2}px`,
color: vars.color.body,
cursor: "pointer",
":hover": {
textDecoration: "underline",
},
});

View File

@ -66,9 +66,9 @@ $logo-max-width: 200px;
&__table-header { &__table-header {
width: 100%; width: 100%;
background-color: var(--color-dark-background); background-color: globals.$dark-background-color;
transition: all ease 0.2s; transition: all ease 0.2s;
border-bottom: solid 1px var(--color-border); border-bottom: solid 1px globals.$border-color;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
@ -86,13 +86,13 @@ $logo-max-width: 200px;
gap: globals.$spacing-unit * 2; gap: globals.$spacing-unit * 2;
padding: globals.$spacing-unit * 2; padding: globals.$spacing-unit * 2;
width: 100%; width: 100%;
background-color: var(--color-background); background-color: globals.$background-color;
} }
&__item { &__item {
display: flex; display: flex;
transition: all ease 0.1s; transition: all ease 0.1s;
color: var(--color-muted); color: globals.$muted-color;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
border-radius: 4px; border-radius: 4px;
@ -102,7 +102,7 @@ $logo-max-width: 200px;
text-align: left; text-align: left;
&:hover { &:hover {
background-color: rgba(255, 255, 255, 0.15); background-color: globals.$border-color;
text-decoration: none; text-decoration: none;
} }
@ -129,7 +129,7 @@ $logo-max-width: 200px;
&-hidden-icon { &-hidden-icon {
display: flex; display: flex;
color: var(--color-warning); color: globals.$warning-color;
opacity: 0.8; opacity: 0.8;
&:hover { &:hover {
@ -164,7 +164,7 @@ $logo-max-width: 200px;
&--locked { &--locked {
cursor: pointer; cursor: pointer;
color: var(--color-warning); color: globals.$warning-color;
} }
&-icon { &-icon {
@ -219,12 +219,12 @@ $logo-max-width: 200px;
transition: all ease 0.2s; transition: all ease 0.2s;
&::-webkit-progress-bar { &::-webkit-progress-bar {
background-color: rgba(255, 255, 255, 0.15); background-color: globals.$border-color;
border-radius: 4px; border-radius: 4px;
} }
&::-webkit-progress-value { &::-webkit-progress-value {
background-color: var(--color-muted); background-color: globals.$muted-color;
border-radius: 4px; border-radius: 4px;
} }
} }
@ -236,7 +236,7 @@ $logo-max-width: 200px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background-color: var(--color-background); background-color: globals.$background-color;
position: relative; position: relative;
object-fit: cover; object-fit: cover;
@ -252,7 +252,7 @@ $logo-max-width: 200px;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
gap: math.div(globals.$spacing-unit, 2); gap: math.div(globals.$spacing-unit, 2);
color: var(--color-body); color: globals.$muted-color;
cursor: pointer; cursor: pointer;
&:hover { &:hover {

View File

@ -3,7 +3,6 @@ import { useAppDispatch, useUserDetails } from "@renderer/hooks";
import type { ComparedAchievements, GameShop } from "@types"; import type { ComparedAchievements, GameShop } from "@types";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { vars } from "@renderer/theme.css";
import { import {
GameDetailsContextConsumer, GameDetailsContextConsumer,
GameDetailsContextProvider, GameDetailsContextProvider,
@ -75,10 +74,7 @@ export default function Achievements() {
(otherUserId && comparedAchievements === null); (otherUserId && comparedAchievements === null);
return ( return (
<SkeletonTheme <SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
baseColor={vars.color.background}
highlightColor="#444"
>
{showSkeleton ? ( {showSkeleton ? (
<AchievementsSkeleton /> <AchievementsSkeleton />
) : ( ) : (

View File

@ -1,12 +1,11 @@
import type { ComparedAchievements } from "@types"; import type { ComparedAchievements } from "@types";
import * as styles from "./achievements.css"; import "./achievements.scss";
import { import {
CheckCircleIcon, CheckCircleIcon,
EyeClosedIcon, EyeClosedIcon,
LockIcon, LockIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { useDate } from "@renderer/hooks"; import { useDate } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface ComparedAchievementListProps { export interface ComparedAchievementListProps {
@ -20,39 +19,26 @@ export function ComparedAchievementList({
const { formatDateTime } = useDate(); const { formatDateTime } = useDate();
return ( return (
<ul className={styles.list}> <ul className="achievements__list">
{achievements.achievements.map((achievement, index) => ( {achievements.achievements.map((achievement, index) => (
<li <li
key={index} key={index}
className={styles.listItem} className={`achievements__item achievements__item-compared ${
style={{ !achievement.ownerStat && "achievements__item-compared--no-owner"
display: "grid", }`}
gridTemplateColumns: achievement.ownerStat
? "3fr 1fr 1fr"
: "3fr 2fr",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
}}
> >
<div className="achievements__item-main">
<img <img
className={styles.listItemImage({ className="achievements__item-image"
unlocked: true,
})}
src={achievement.icon} src={achievement.icon}
alt={achievement.displayName} alt={achievement.displayName}
loading="lazy" loading="lazy"
/> />
<div> <div className="achievements__item-content">
<h4 style={{ display: "flex", alignItems: "center", gap: "4px" }}> <h4 className="achievements__item-title">
{achievement.hidden && ( {achievement.hidden && (
<span <span
style={{ display: "flex" }} className="achievements__item-hidden-icon"
title={t("hidden_achievement_tooltip")} title={t("hidden_achievement_tooltip")}
> >
<EyeClosedIcon size={12} /> <EyeClosedIcon size={12} />
@ -67,25 +53,13 @@ export function ComparedAchievementList({
{achievement.ownerStat ? ( {achievement.ownerStat ? (
achievement.ownerStat.unlocked ? ( achievement.ownerStat.unlocked ? (
<div <div
style={{ className="achievements__item-status achievements__item-status--unlocked"
whiteSpace: "nowrap",
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
title={formatDateTime(achievement.ownerStat.unlockTime!)} title={formatDateTime(achievement.ownerStat.unlockTime!)}
> >
<CheckCircleIcon /> <CheckCircleIcon />
</div> </div>
) : ( ) : (
<div <div className="achievements__item-status">
style={{
display: "flex",
padding: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<LockIcon /> <LockIcon />
</div> </div>
) )
@ -93,25 +67,13 @@ export function ComparedAchievementList({
{achievement.targetStat.unlocked ? ( {achievement.targetStat.unlocked ? (
<div <div
style={{ className="achievements__item-status achievements__item-status--unlocked"
whiteSpace: "nowrap",
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
title={formatDateTime(achievement.targetStat.unlockTime!)} title={formatDateTime(achievement.targetStat.unlockTime!)}
> >
<CheckCircleIcon /> <CheckCircleIcon />
</div> </div>
) : ( ) : (
<div <div className="achievements__item-status">
style={{
display: "flex",
padding: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<LockIcon /> <LockIcon />
</div> </div>
)} )}

View File

@ -1,10 +1,8 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./achievement-panel.css";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { ComparedAchievements } from "@types"; import { ComparedAchievements } from "@types";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useUserDetails } from "@renderer/hooks"; import { useUserDetails } from "@renderer/hooks";
import "./achievement-panel.scss";
export interface ComparedAchievementPanelProps { export interface ComparedAchievementPanelProps {
achievements: ComparedAchievements; achievements: ComparedAchievements;
@ -18,25 +16,25 @@ export function ComparedAchievementPanel({
return ( return (
<div <div
className={styles.panel} className={`achievement-panel achievement-panel__grid ${
style={{ hasActiveSubscription
display: "grid", ? "achievement-panel__grid--with-subscription"
gridTemplateColumns: hasActiveSubscription ? "3fr 1fr 1fr" : "3fr 2fr", : "achievement-panel__grid--without-subscription"
gap: `${SPACING_UNIT * 2}px`, }`}
}}
> >
<div style={{ display: "flex", gap: `${SPACING_UNIT}px` }}> <div className="achievement-panel__points-container">
{t("available_points")} <HydraIcon width={20} height={20} />{" "} {t("available_points")}{" "}
<HydraIcon className="achievement-panel__content-icon" />{" "}
{achievements.achievementsPointsTotal} {achievements.achievementsPointsTotal}
</div> </div>
{hasActiveSubscription && ( {hasActiveSubscription && (
<div className={styles.content}> <div className="achievement-panel__content">
<HydraIcon width={20} height={20} /> <HydraIcon className="achievement-panel__content-icon" />
{achievements.owner.achievementsPointsEarnedSum ?? 0} {achievements.owner.achievementsPointsEarnedSum ?? 0}
</div> </div>
)} )}
<div className={styles.content}> <div className="achievement-panel__content">
<HydraIcon width={20} height={20} /> <HydraIcon className="achievement-panel__content-icon" />
{achievements.target.achievementsPointsEarnedSum} {achievements.target.achievementsPointsEarnedSum}
</div> </div>
</div> </div>

View File

@ -19,4 +19,62 @@
border: 1px solid globals.$border-color; border: 1px solid globals.$border-color;
align-self: flex-start; align-self: flex-start;
} }
&__header {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
&__filters-wrapper {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
&__filters-list {
display: flex;
gap: 8px;
flex-wrap: wrap;
list-style: none;
margin: 0;
padding: 0;
}
&__content {
display: flex;
gap: calc(globals.$spacing-unit * 2);
justify-content: space-between;
}
&__games-container {
display: flex;
flex-direction: column;
width: 100%;
gap: 8px;
}
&__pagination-container {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
}
&__result-count {
font-size: 12px;
}
&__filters-sections {
display: flex;
flex-direction: column;
gap: 16px;
}
&__skeleton {
height: 105px;
border-radius: 4px;
border: solid 1px rgba(255, 255, 255, 0.15);
}
} }

View File

@ -10,7 +10,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
import "./catalogue.scss"; import "./catalogue.scss";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { downloadSourcesTable } from "@renderer/dexie"; import { downloadSourcesTable } from "@renderer/dexie";
import { FilterSection } from "./filter-section"; import { FilterSection } from "./filter-section";
import { setFilters, setPage } from "@renderer/features"; import { setFilters, setPage } from "@renderer/features";
@ -230,25 +229,9 @@ export default function Catalogue() {
return ( return (
<div className="catalogue" ref={cataloguePageRef}> <div className="catalogue" ref={cataloguePageRef}>
<div <div className="catalogue__header">
style={{ <div className="catalogue__filters-wrapper">
display: "flex", <ul className="catalogue__filters-list">
gap: 8,
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<ul
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
listStyle: "none",
margin: 0,
padding: 0,
}}
>
{groupedFilters.map((filter) => ( {groupedFilters.map((filter) => (
<li key={`${filter.key}-${filter.value}`}> <li key={`${filter.key}-${filter.value}`}>
<FilterItem <FilterItem
@ -270,50 +253,20 @@ export default function Catalogue() {
</div> </div>
</div> </div>
<div <div className="catalogue__content">
style={{ <div className="catalogue__games-container">
display: "flex",
gap: SPACING_UNIT * 2,
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
gap: 8,
}}
>
{isLoading ? ( {isLoading ? (
<SkeletonTheme <SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
baseColor={vars.color.darkBackground}
highlightColor={vars.color.background}
>
{Array.from({ length: PAGE_SIZE }).map((_, i) => ( {Array.from({ length: PAGE_SIZE }).map((_, i) => (
<Skeleton <Skeleton key={i} className="catalogue__skeleton" />
key={i}
style={{
height: 105,
borderRadius: 4,
border: `solid 1px ${vars.color.border}`,
}}
/>
))} ))}
</SkeletonTheme> </SkeletonTheme>
) : ( ) : (
results.map((game) => <GameItem key={game.id} game={game} />) results.map((game) => <GameItem key={game.id} game={game} />)
)} )}
<div <div className="catalogue__pagination-container">
style={{ <span className="catalogue__result-count">
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 16,
}}
>
<span style={{ fontSize: 12 }}>
{t("result_count", { {t("result_count", {
resultCount: formatNumber(itemsCount), resultCount: formatNumber(itemsCount),
})} })}
@ -333,7 +286,7 @@ export default function Catalogue() {
</div> </div>
<div className="catalogue__filters-container"> <div className="catalogue__filters-container">
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> <div className="catalogue__filters-sections">
{filterSections.map((section) => ( {filterSections.map((section) => (
<FilterSection <FilterSection
key={section.key} key={section.key}

View File

@ -1,5 +1,5 @@
import { vars } from "@renderer/theme.css";
import { XIcon } from "@primer/octicons-react"; import { XIcon } from "@primer/octicons-react";
import "./filter.scss";
interface FilterItemProps { interface FilterItemProps {
filter: string; filter: string;
@ -9,39 +9,13 @@ interface FilterItemProps {
export function FilterItem({ filter, orbColor, onRemove }: FilterItemProps) { export function FilterItem({ filter, orbColor, onRemove }: FilterItemProps) {
return ( return (
<div <div className="filter-item">
style={{ <div className="filter-item__orb" style={{ backgroundColor: orbColor }} />
display: "flex",
alignItems: "center",
color: vars.color.body,
backgroundColor: vars.color.darkBackground,
padding: "6px 12px",
borderRadius: 4,
border: `solid 1px ${vars.color.border}`,
fontSize: 12,
}}
>
<div
style={{
width: 10,
height: 10,
backgroundColor: orbColor,
borderRadius: "50%",
marginRight: 8,
}}
/>
{filter} {filter}
<button <button
type="button" type="button"
onClick={onRemove} onClick={onRemove}
style={{ className="filter-item__remove-button"
color: vars.color.body,
marginLeft: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
}}
> >
<XIcon size={13} /> <XIcon size={13} />
</button> </button>

View File

@ -1,9 +1,8 @@
import { CheckboxField, TextField } from "@renderer/components"; import { CheckboxField, TextField } from "@renderer/components";
import { useFormat } from "@renderer/hooks"; import { useFormat } from "@renderer/hooks";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import "./filter.scss";
import List from "rc-virtual-list"; import List from "rc-virtual-list";
import { vars } from "@renderer/theme.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface FilterSectionProps { export interface FilterSectionProps {
@ -54,36 +53,18 @@ export function FilterSection({
return ( return (
<div> <div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}> <div className="filter-section__header">
<div <div
style={{ className="filter-section__orb"
width: 10, style={{ backgroundColor: color }}
height: 10,
backgroundColor: color,
borderRadius: "50%",
}}
/> />
<h3 <h3 className="filter-section__title">{title}</h3>
style={{
fontSize: 16,
fontWeight: 500,
}}
>
{title}
</h3>
</div> </div>
{selectedItemsCount > 0 ? ( {selectedItemsCount > 0 ? (
<button <button
type="button" type="button"
style={{ className="filter-section__clear-button"
fontSize: 12,
marginBottom: 12,
display: "block",
color: vars.color.body,
cursor: "pointer",
textDecoration: "underline",
}}
onClick={onClear} onClick={onClear}
> >
{t("clear_filters", { {t("clear_filters", {
@ -91,7 +72,7 @@ export function FilterSection({
})} })}
</button> </button>
) : ( ) : (
<span style={{ fontSize: 12, marginBottom: 12, display: "block" }}> <span className="filter-section__count">
{t("filter_count", { {t("filter_count", {
filterCount: formatNumber(items.length), filterCount: formatNumber(items.length),
})} })}
@ -102,7 +83,7 @@ export function FilterSection({
placeholder={t("search")} placeholder={t("search")}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
value={search} value={search}
containerProps={{ style: { marginBottom: 16 } }} containerProps={{ className: "filter-section__search" }}
theme="dark" theme="dark"
/> />
@ -122,7 +103,7 @@ export function FilterSection({
}} }}
> >
{(item) => ( {(item) => (
<div key={item.value} style={{ height: 28, maxHeight: 28 }}> <div key={item.value} className="filter-section__item">
<CheckboxField <CheckboxField
label={item.label} label={item.label}
checked={item.checked} checked={item.checked}

View File

@ -0,0 +1,71 @@
@use "../../scss/globals.scss";
.filter-item {
display: flex;
align-items: center;
color: globals.$body-color;
background-color: globals.$dark-background-color;
padding: 6px 12px;
border-radius: 4px;
border: solid 1px globals.$border-color;
font-size: 12px;
&__orb {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
}
&__remove-button {
color: globals.$body-color;
margin-left: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
}
.filter-section {
&__header {
display: flex;
gap: 8px;
align-items: center;
}
&__orb {
width: 10px;
height: 10px;
border-radius: 50%;
}
&__title {
font-size: 16px;
font-weight: 500;
}
&__count {
font-size: 12px;
margin-bottom: 12px;
display: block;
}
&__clear-button {
font-size: 12px;
margin-bottom: 12px;
display: block;
color: globals.$body-color;
cursor: pointer;
text-decoration: underline;
}
&__search {
margin-bottom: 16px;
}
&__item {
height: 28px;
max-height: 28px;
}
}

View File

@ -0,0 +1,21 @@
.pagination {
display: flex;
gap: 4px;
&__button {
width: 40px;
max-width: 40px;
max-height: 40px;
}
&__ellipsis {
width: 40px;
display: flex;
justify-content: center;
align-items: center;
&-text {
font-size: 16px;
}
}
}

View File

@ -1,6 +1,7 @@
import { Button } from "@renderer/components/button/button"; import { Button } from "@renderer/components/button/button";
import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react"; import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react";
import { useFormat } from "@renderer/hooks/use-format"; import { useFormat } from "@renderer/hooks/use-format";
import "./pagination.scss";
interface PaginationProps { interface PaginationProps {
page: number; page: number;
@ -31,17 +32,12 @@ export function Pagination({
} }
return ( return (
<div <div className="pagination">
style={{
display: "flex",
gap: 4,
}}
>
{/* Previous Button */} {/* Previous Button */}
<Button <Button
theme="outline" theme="outline"
onClick={() => onPageChange(page - 1)} onClick={() => onPageChange(page - 1)}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }} className="pagination__button"
disabled={page === 1} disabled={page === 1}
> >
<ChevronLeftIcon /> <ChevronLeftIcon />
@ -53,22 +49,15 @@ export function Pagination({
<Button <Button
theme="outline" theme="outline"
onClick={() => onPageChange(1)} onClick={() => onPageChange(1)}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }} className="pagination__button"
disabled={page === 1} disabled={page === 1}
> >
{1} {1}
</Button> </Button>
{/* ellipsis */} {/* ellipsis */}
<div <div className="pagination__ellipsis">
style={{ <span className="pagination__ellipsis-text">...</span>
width: 40,
justifyContent: "center",
display: "flex",
alignItems: "center",
}}
>
<span style={{ fontSize: 16 }}>...</span>
</div> </div>
</> </>
)} )}
@ -81,7 +70,7 @@ export function Pagination({
<Button <Button
theme={page === pageNumber ? "primary" : "outline"} theme={page === pageNumber ? "primary" : "outline"}
key={pageNumber} key={pageNumber}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }} className="pagination__button"
onClick={() => onPageChange(pageNumber)} onClick={() => onPageChange(pageNumber)}
> >
{formatNumber(pageNumber)} {formatNumber(pageNumber)}
@ -91,22 +80,15 @@ export function Pagination({
{page < totalPages - 1 && ( {page < totalPages - 1 && (
<> <>
{/* ellipsis */} {/* ellipsis */}
<div <div className="pagination__ellipsis">
style={{ <span className="pagination__ellipsis-text">...</span>
width: 40,
justifyContent: "center",
display: "flex",
alignItems: "center",
}}
>
<span style={{ fontSize: 16 }}>...</span>
</div> </div>
{/* last page */} {/* last page */}
<Button <Button
theme="outline" theme="outline"
onClick={() => onPageChange(totalPages)} onClick={() => onPageChange(totalPages)}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }} className="pagination__button"
disabled={page === totalPages} disabled={page === totalPages}
> >
{formatNumber(totalPages)} {formatNumber(totalPages)}
@ -118,7 +100,7 @@ export function Pagination({
<Button <Button
theme="outline" theme="outline"
onClick={() => onPageChange(page + 1)} onClick={() => onPageChange(page + 1)}
style={{ width: 40, maxWidth: 40, maxHeight: 40 }} className="pagination__button"
disabled={page === totalPages} disabled={page === totalPages}
> >
<ChevronRightIcon /> <ChevronRightIcon />

View File

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

View File

@ -0,0 +1,11 @@
@use "../../scss/globals.scss";
.delete-game-modal {
&__actions {
display: flex;
width: 100%;
justify-content: flex-end;
align-items: center;
gap: globals.$spacing-unit;
}
}

View File

@ -1,8 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, Modal } from "@renderer/components"; import { Button, Modal } from "@renderer/components";
import "./delete-game-modal.scss";
import * as styles from "./delete-game-modal.css";
interface DeleteGameModalProps { interface DeleteGameModalProps {
visible: boolean; visible: boolean;
@ -29,7 +27,7 @@ export function DeleteGameModal({
description={t("delete_modal_description")} description={t("delete_modal_description")}
onClose={onClose} onClose={onClose}
> >
<div className={styles.deleteActionsButtonsCtn}> <div className="delete-game-modal__actions">
<Button onClick={handleDeleteGame} theme="outline"> <Button onClick={handleDeleteGame} theme="outline">
{t("delete")} {t("delete")}
</Button> </Button>

View File

@ -31,8 +31,6 @@ import {
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import torBoxLogo from "@renderer/assets/icons/torbox.webp"; import torBoxLogo from "@renderer/assets/icons/torbox.webp";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
export interface DownloadGroupProps { export interface DownloadGroupProps {
library: LibraryGame[]; library: LibraryGame[];
title: string; title: string;
@ -287,24 +285,14 @@ export function DownloadGroup({
<div className="download-group__cover-content"> <div className="download-group__cover-content">
{game.download?.downloader === Downloader.TorBox ? ( {game.download?.downloader === Downloader.TorBox ? (
<div <Badge>
style={{
display: "flex",
alignItems: "center",
background: "#11141b",
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
borderRadius: "4px",
gap: 4,
border: `1px solid ${vars.color.border}`,
}}
>
<img <img
src={torBoxLogo} src={torBoxLogo}
alt="TorBox" alt="TorBox"
style={{ width: 13 }} style={{ width: 13 }}
/> />
<span style={{ fontSize: 10 }}>TorBox</span> <span>TorBox</span>
</div> </Badge>
) : ( ) : (
<Badge> <Badge>
{DOWNLOADER_NAME[game.download!.downloader]} {DOWNLOADER_NAME[game.download!.downloader]}

View File

@ -1,37 +0,0 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../theme.css";
export const downloadsContainer = style({
display: "flex",
padding: `${SPACING_UNIT * 3}px`,
flexDirection: "column",
width: "100%",
});
export const downloadGroups = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
flexDirection: "column",
});
export const arrowIcon = style({
width: "60px",
height: "60px",
borderRadius: "50%",
backgroundColor: "rgba(255, 255, 255, 0.06)",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: `${SPACING_UNIT * 2}px`,
});
export const noDownloads = style({
display: "flex",
width: "100%",
height: "100%",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});

View File

@ -0,0 +1,37 @@
@use "../../scss/globals.scss";
.downloads {
&__container {
display: flex;
padding: calc(globals.$spacing-unit * 3);
flex-direction: column;
width: 100%;
}
&__groups {
display: flex;
gap: calc(globals.$spacing-unit * 3);
flex-direction: column;
}
&__arrow-icon {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__no-downloads {
display: flex;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
flex-direction: column;
gap: globals.$spacing-unit;
}
}

View File

@ -4,7 +4,7 @@ import { useDownload, useLibrary } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal"; import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
import * as styles from "./downloads.css"; import "./downloads.scss";
import { DeleteGameModal } from "./delete-game-modal"; import { DeleteGameModal } from "./delete-game-modal";
import { DownloadGroup } from "./download-group"; import { DownloadGroup } from "./download-group";
import type { GameShop, LibraryGame, SeedingStatus } from "@types"; import type { GameShop, LibraryGame, SeedingStatus } from "@types";
@ -125,8 +125,8 @@ export default function Downloads() {
/> />
{hasItemsInLibrary ? ( {hasItemsInLibrary ? (
<section className={styles.downloadsContainer}> <section className="downloads__container">
<div className={styles.downloadGroups}> <div className="downloads__groups">
{downloadGroups.map((group) => ( {downloadGroups.map((group) => (
<DownloadGroup <DownloadGroup
key={group.title} key={group.title}
@ -140,8 +140,8 @@ export default function Downloads() {
</div> </div>
</section> </section>
) : ( ) : (
<div className={styles.noDownloads}> <div className="downloads__no-downloads">
<div className={styles.arrowIcon}> <div className="downloads__arrow-icon">
<ArrowDownIcon size={24} /> <ArrowDownIcon size={24} />
</div> </div>
<h2>{t("no_downloads_title")}</h2> <h2>{t("no_downloads_title")}</h2>

View File

@ -1,27 +0,0 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const mappingMethods = style({
display: "grid",
gap: `${SPACING_UNIT}px`,
gridTemplateColumns: "repeat(2, 1fr)",
});
export const fileList = style({
listStyle: "none",
margin: "0",
padding: "0",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
marginTop: `${SPACING_UNIT * 2}px`,
});
export const fileItem = style({
flex: 1,
color: vars.color.muted,
textDecoration: "underline",
display: "flex",
cursor: "pointer",
});

View File

@ -0,0 +1,41 @@
@use "../../../scss/globals.scss";
.cloud-sync-files-modal {
&__mapping-methods {
display: grid;
gap: globals.$spacing-unit;
grid-template-columns: repeat(2, 1fr);
}
&__file-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
margin-top: calc(globals.$spacing-unit * 2);
}
&__file-item {
flex: 1;
color: globals.$muted-color;
text-decoration: underline;
display: flex;
cursor: pointer;
}
&__container {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__mapping-label {
margin-bottom: globals.$spacing-unit;
}
&__custom-path {
margin-top: calc(globals.$spacing-unit * 2);
}
}

View File

@ -4,7 +4,7 @@ import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CheckCircleFillIcon, FileDirectoryIcon } from "@primer/octicons-react"; import { CheckCircleFillIcon, FileDirectoryIcon } from "@primer/octicons-react";
import * as styles from "./cloud-sync-files-modal.css"; import "./cloud-sync-files-modal.scss";
import { formatBytes } from "@shared"; import { formatBytes } from "@shared";
import { useToast } from "@renderer/hooks"; import { useToast } from "@renderer/hooks";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -96,10 +96,12 @@ export function CloudSyncFilesModal({
description={t("manage_files_description")} description={t("manage_files_description")}
onClose={onClose} onClose={onClose}
> >
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div className="cloud-sync-files-modal__container">
<span style={{ marginBottom: 8 }}>{t("mapping_method_label")}</span> <span className="cloud-sync-files-modal__mapping-label">
{t("mapping_method_label")}
</span>
<div className={styles.mappingMethods}> <div className="cloud-sync-files-modal__mapping-methods">
{Object.values(FileMappingMethod).map((mappingMethod) => ( {Object.values(FileMappingMethod).map((mappingMethod) => (
<Button <Button
key={mappingMethod} key={mappingMethod}
@ -119,7 +121,7 @@ export function CloudSyncFilesModal({
</div> </div>
</div> </div>
<div style={{ marginTop: 16 }}> <div className="cloud-sync-files-modal__custom-path">
{selectedFileMappingMethod === FileMappingMethod.Automatic ? ( {selectedFileMappingMethod === FileMappingMethod.Automatic ? (
<p>{t("files_automatically_mapped")}</p> <p>{t("files_automatically_mapped")}</p>
) : ( ) : (
@ -142,11 +144,11 @@ export function CloudSyncFilesModal({
/> />
)} )}
<ul className={styles.fileList}> <ul className="cloud-sync-files-modal__file-list">
{files.map((file) => ( {files.map((file) => (
<li key={file.path} style={{ display: "flex" }}> <li key={file.path} className="cloud-sync-files-modal__file-item">
<button <button
className={styles.fileItem} className="cloud-sync-files-modal__file-item"
onClick={() => window.electron.showItemInFolder(file.path)} onClick={() => window.electron.showItemInFolder(file.path)}
> >
{file.path.split("/").at(-1)} {file.path.split("/").at(-1)}

View File

@ -1,65 +0,0 @@
import { keyframes, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const rotate = keyframes({
"0%": { transform: "rotate(0deg)" },
"100%": {
transform: "rotate(360deg)",
},
});
export const artifacts = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
flexDirection: "column",
listStyle: "none",
margin: "0",
padding: "0",
});
export const artifactButton = style({
display: "flex",
textAlign: "left",
flexDirection: "row",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
color: vars.color.body,
padding: `${SPACING_UNIT * 2}px`,
backgroundColor: vars.color.darkBackground,
border: `1px solid ${vars.color.border}`,
borderRadius: "4px",
justifyContent: "space-between",
});
export const syncIcon = style({
animationName: rotate,
animationDuration: "1s",
animationIterationCount: "infinite",
animationTimingFunction: "linear",
});
export const progress = style({
width: "100%",
height: "5px",
"::-webkit-progress-bar": {
backgroundColor: vars.color.darkBackground,
},
"::-webkit-progress-value": {
backgroundColor: vars.color.muted,
},
});
export const manageFilesButton = style({
margin: "0",
padding: "0",
alignSelf: "flex-start",
fontSize: 14,
cursor: "pointer",
textDecoration: "underline",
color: vars.color.body,
":disabled": {
cursor: "not-allowed",
opacity: vars.opacity.disabled,
},
});

View File

@ -0,0 +1,119 @@
@use "../../../scss/globals.scss";
@keyframes rotate {
0% {
transform: rotate(360deg);
}
100% {
transform: rotate(0deg);
}
}
.cloud-sync-modal {
&__header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
&__title-container {
display: flex;
gap: 4px;
flex-direction: column;
}
&__backups-header {
margin-bottom: 16px;
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__artifacts {
display: flex;
gap: globals.$spacing-unit;
flex-direction: column;
list-style: none;
margin: 0;
padding: 0;
}
&__artifact {
display: flex;
text-align: left;
flex-direction: row;
align-items: center;
gap: globals.$spacing-unit;
color: globals.$body-color;
padding: calc(globals.$spacing-unit * 2);
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 4px;
justify-content: space-between;
&-info {
display: flex;
flex-direction: column;
gap: 4px;
}
&-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
&-meta {
display: flex;
align-items: center;
gap: 8px;
}
&-actions {
display: flex;
gap: 8px;
align-items: center;
}
}
&__sync-icon {
animation: rotate 1s linear infinite;
}
&__progress {
width: 100%;
height: 5px;
&::-webkit-progress-bar {
background-color: globals.$dark-background-color;
}
&::-webkit-progress-value {
background-color: globals.$muted-color;
}
}
&__manage-files-button {
margin: 0;
padding: 0;
align-self: flex-start;
font-size: 14px;
cursor: pointer;
text-decoration: underline;
color: globals.$body-color;
&:disabled {
cursor: not-allowed;
opacity: globals.$disabled-opacity;
}
}
&__backup-state-label {
display: flex;
align-items: center;
gap: 8px;
}
}

View File

@ -2,7 +2,7 @@ import { Button, Modal, ModalProps } from "@renderer/components";
import { useContext, useEffect, useMemo, useState } from "react"; import { useContext, useEffect, useMemo, useState } from "react";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import * as styles from "./cloud-sync-modal.css"; import "./cloud-sync-modal.scss";
import { formatBytes } from "@shared"; import { formatBytes } from "@shared";
import { format } from "date-fns"; import { format } from "date-fns";
import { import {
@ -18,7 +18,6 @@ import { useAppSelector, useToast } from "@renderer/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AxiosProgressEvent } from "axios"; import { AxiosProgressEvent } from "axios";
import { formatDownloadProgress } from "@renderer/helpers"; import { formatDownloadProgress } from "@renderer/helpers";
import { SPACING_UNIT } from "@renderer/theme.css";
export interface CloudSyncModalProps export interface CloudSyncModalProps
extends Omit<ModalProps, "children" | "title"> {} extends Omit<ModalProps, "children" | "title"> {}
@ -94,8 +93,8 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
const backupStateLabel = useMemo(() => { const backupStateLabel = useMemo(() => {
if (uploadingBackup) { if (uploadingBackup) {
return ( return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <span className="cloud-sync-modal__backup-state-label">
<SyncIcon className={styles.syncIcon} /> <SyncIcon className="cloud-sync-modal__sync-icon" />
{t("uploading_backup")} {t("uploading_backup")}
</span> </span>
); );
@ -103,8 +102,8 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
if (restoringBackup) { if (restoringBackup) {
return ( return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <span className="cloud-sync-modal__backup-state-label">
<SyncIcon className={styles.syncIcon} /> <SyncIcon className="cloud-sync-modal__sync-icon" />
{t("restoring_backup", { {t("restoring_backup", {
progress: formatDownloadProgress( progress: formatDownloadProgress(
backupDownloadProgress?.progress ?? 0 backupDownloadProgress?.progress ?? 0
@ -116,8 +115,8 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
if (loadingPreview) { if (loadingPreview) {
return ( return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <span className="cloud-sync-modal__backup-state-label">
<SyncIcon className={styles.syncIcon} /> <SyncIcon className="cloud-sync-modal__sync-icon" />
{t("loading_save_preview")} {t("loading_save_preview")}
</span> </span>
); );
@ -157,21 +156,14 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
onClose={onClose} onClose={onClose}
large large
> >
<div <div className="cloud-sync-modal__header">
style={{ <div className="cloud-sync-modal__title-container">
marginBottom: 24,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
<h2>{gameTitle}</h2> <h2>{gameTitle}</h2>
<p>{backupStateLabel}</p> <p>{backupStateLabel}</p>
<button <button
type="button" type="button"
className={styles.manageFilesButton} className="cloud-sync-modal__manage-files-button"
onClick={() => setShowCloudSyncFilesModal(true)} onClick={() => setShowCloudSyncFilesModal(true)}
disabled={disableActions} disabled={disableActions}
> >
@ -188,40 +180,28 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
artifacts.length >= backupsPerGameLimit artifacts.length >= backupsPerGameLimit
} }
> >
{uploadingBackup ? (
<SyncIcon className="cloud-sync-modal__sync-icon" />
) : (
<UploadIcon /> <UploadIcon />
)}
{t("create_backup")} {t("create_backup")}
</Button> </Button>
</div> </div>
<div style={{ display: "flex", justifyContent: "space-between" }}> <div className="cloud-sync-modal__backups-header">
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
gap: SPACING_UNIT,
}}
>
<h2>{t("backups")}</h2> <h2>{t("backups")}</h2>
<small> <small>
{artifacts.length} / {backupsPerGameLimit} {artifacts.length} / {backupsPerGameLimit}
</small> </small>
</div> </div>
</div>
{artifacts.length > 0 ? ( {artifacts.length > 0 ? (
<ul className={styles.artifacts}> <ul className="cloud-sync-modal__artifacts">
{artifacts.map((artifact) => ( {artifacts.map((artifact) => (
<li key={artifact.id} className={styles.artifactButton}> <li key={artifact.id} className="cloud-sync-modal__artifact">
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}> <div className="cloud-sync-modal__artifact-info">
<div <div className="cloud-sync-modal__artifact-header">
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 4,
}}
>
<h3> <h3>
{t("backup_from", { {t("backup_from", {
date: format(artifact.createdAt, "dd/MM/yyyy"), date: format(artifact.createdAt, "dd/MM/yyyy"),
@ -230,29 +210,33 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
<small>{formatBytes(artifact.artifactLengthInBytes)}</small> <small>{formatBytes(artifact.artifactLengthInBytes)}</small>
</div> </div>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <span className="cloud-sync-modal__artifact-meta">
<DeviceDesktopIcon size={14} /> <DeviceDesktopIcon size={14} />
{artifact.hostname} {artifact.hostname}
</span> </span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <span className="cloud-sync-modal__artifact-meta">
<InfoIcon size={14} /> <InfoIcon size={14} />
{artifact.downloadOptionTitle ?? t("no_download_option_info")} {artifact.downloadOptionTitle ?? t("no_download_option_info")}
</span> </span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <span className="cloud-sync-modal__artifact-meta">
<ClockIcon size={14} /> <ClockIcon size={14} />
{format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")} {format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")}
</span> </span>
</div> </div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}> <div className="cloud-sync-modal__artifact-actions">
<Button <Button
type="button" type="button"
onClick={() => handleBackupInstallClick(artifact.id)} onClick={() => handleBackupInstallClick(artifact.id)}
disabled={disableActions} disabled={disableActions}
> >
{restoringBackup ? (
<SyncIcon className="cloud-sync-modal__sync-icon" />
) : (
<HistoryIcon /> <HistoryIcon />
)}
{t("install_backup")} {t("install_backup")}
</Button> </Button>
<Button <Button

View File

@ -1,19 +0,0 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const descriptionHeader = style({
width: "100%",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
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",
});

View File

@ -0,0 +1,17 @@
@use "../../../scss/globals.scss";
.description-header {
width: 100%;
padding: calc(globals.$spacing-unit * 2);
display: flex;
justify-content: space-between;
align-items: center;
background-color: globals.$background-color;
height: 72px;
&__info {
display: flex;
gap: globals.$spacing-unit;
flex-direction: column;
}
}

View File

@ -1,19 +1,17 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./description-header.css";
import { useContext } from "react"; import { useContext } from "react";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import "./description-header.scss";
export function DescriptionHeader() { export function DescriptionHeader() {
const { shopDetails } = useContext(gameDetailsContext); const { shopDetails } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
if (!shopDetails) return null; if (!shopDetails) return null;
return ( return (
<div className={styles.descriptionHeader}> <div className="description-header">
<section className={styles.descriptionHeaderInfo}> <section className="description-header__info">
<p> <p>
{t("release_date", { {t("release_date", {
date: shopDetails?.release_date.date, date: shopDetails?.release_date.date,

View File

@ -1,131 +0,0 @@
import { recipe } from "@vanilla-extract/recipes";
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const gallerySliderContainer = style({
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
});
export const gallerySliderMedia = style({
width: "100%",
height: "100%",
display: "block",
flexShrink: "0",
flexGrow: "0",
transition: "translate 0.3s ease-in-out",
borderRadius: "4px",
alignSelf: "center",
});
export const gallerySliderAnimationContainer = style({
width: "100%",
height: "100%",
display: "flex",
position: "relative",
overflow: "hidden",
"@media": {
"(min-width: 1280px)": {
width: "60%",
},
},
});
export const gallerySliderPreview = style({
width: "100%",
padding: `${SPACING_UNIT}px 0`,
height: "100%",
display: "flex",
position: "relative",
overflowX: "auto",
overflowY: "hidden",
gap: `${SPACING_UNIT / 2}px`,
"@media": {
"(min-width: 1280px)": {
width: "60%",
},
},
"::-webkit-scrollbar-thumb": {
width: "20%",
},
"::-webkit-scrollbar": {
height: "10px",
},
});
export const mediaPreviewButton = recipe({
base: {
cursor: "pointer",
width: "20%",
display: "block",
flexShrink: "0",
flexGrow: "0",
opacity: "0.3",
transition: "translate 0.3s ease-in-out, opacity 0.2s ease",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
overflow: "hidden",
":hover": {
opacity: "0.8",
},
},
variants: {
active: {
true: {
opacity: "1",
},
},
},
});
export const mediaPreview = style({
width: "100%",
display: "flex",
});
export const gallerySliderButton = recipe({
base: {
position: "absolute",
alignSelf: "center",
cursor: "pointer",
backgroundColor: "rgba(0, 0, 0, 0.4)",
transition: "all 0.2s ease-in-out",
borderRadius: "50%",
color: vars.color.muted,
width: "48px",
height: "48px",
":hover": {
backgroundColor: "rgba(0, 0, 0, 0.6)",
},
":active": {
transform: "scale(0.95)",
},
},
variants: {
direction: {
left: {
left: "0",
marginLeft: `${SPACING_UNIT}px`,
transform: `translateX(${-(48 + SPACING_UNIT)}px)`,
},
right: {
right: "0",
marginRight: `${SPACING_UNIT}px`,
transform: `translateX(${48 + SPACING_UNIT}px)`,
},
},
visible: {
true: {
transform: "translateX(0)",
opacity: "1",
},
false: {
opacity: "0",
},
},
},
});

View File

@ -0,0 +1,131 @@
@use "../../../scss/globals.scss";
.gallery-slider {
&__container {
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
&__media {
width: 100%;
height: 100%;
display: block;
flex-shrink: 0;
flex-grow: 0;
transition: translate 0.3s ease-in-out;
border-radius: 4px;
align-self: center;
}
&__animation-container {
width: 100%;
height: 100%;
display: flex;
position: relative;
overflow: hidden;
@media (min-width: 1280px) {
width: 60%;
}
}
&__preview {
width: 100%;
padding: globals.$spacing-unit 0;
height: 100%;
display: flex;
position: relative;
overflow-x: auto;
overflow-y: hidden;
gap: calc(globals.$spacing-unit / 2);
@media (min-width: 1280px) {
width: 60%;
}
&::-webkit-scrollbar-thumb {
width: 20%;
}
&::-webkit-scrollbar {
height: 10px;
}
}
&__preview-button {
cursor: pointer;
width: 20%;
display: block;
flex-shrink: 0;
flex-grow: 0;
opacity: 0.3;
transition:
translate 0.3s ease-in-out,
opacity 0.2s ease;
border-radius: 4px;
border: solid 1px globals.$border-color;
overflow: hidden;
&:hover {
opacity: 0.8;
}
&--active {
opacity: 1;
}
}
&__preview-image {
width: 100%;
display: flex;
}
&__button {
position: absolute;
align-self: center;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.4);
transition: all 0.2s ease-in-out;
border-radius: 50%;
color: globals.$muted-color;
width: 48px;
height: 48px;
&:hover {
background-color: rgba(0, 0, 0, 0.6);
}
&:active {
transform: scale(0.95);
}
&--left {
left: 0;
margin-left: globals.$spacing-unit;
transform: translateX(calc(-1 * (48px + globals.$spacing-unit)));
&.gallery-slider__button--visible {
transform: translateX(0);
opacity: 1;
}
}
&--right {
right: 0;
margin-right: globals.$spacing-unit;
transform: translateX(calc(48px + globals.$spacing-unit));
&.gallery-slider__button--visible {
transform: translateX(0);
opacity: 1;
}
}
&--hidden {
opacity: 0;
}
}
}

View File

@ -1,9 +1,8 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react"; import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
import * as styles from "./gallery-slider.css";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import "./gallery-slider.scss";
export function GallerySlider() { export function GallerySlider() {
const { shopDetails } = useContext(gameDetailsContext); const { shopDetails } = useContext(gameDetailsContext);
@ -97,11 +96,11 @@ export function GallerySlider() {
return ( return (
<> <>
{hasScreenshots && ( {hasScreenshots && (
<div className={styles.gallerySliderContainer}> <div className="gallery-slider__container">
<div <div
onMouseEnter={() => setShowArrows(true)} onMouseEnter={() => setShowArrows(true)}
onMouseLeave={() => setShowArrows(false)} onMouseLeave={() => setShowArrows(false)}
className={styles.gallerySliderAnimationContainer} className="gallery-slider__animation-container"
ref={mediaContainerRef} ref={mediaContainerRef}
> >
{shopDetails.movies && {shopDetails.movies &&
@ -109,7 +108,7 @@ export function GallerySlider() {
<video <video
key={video.id} key={video.id}
controls controls
className={styles.gallerySliderMedia} className="gallery-slider__media"
poster={video.thumbnail} poster={video.thumbnail}
style={{ translate: `${-100 * mediaIndex}%` }} style={{ translate: `${-100 * mediaIndex}%` }}
loop loop
@ -124,7 +123,7 @@ export function GallerySlider() {
shopDetails.screenshots?.map((image, i) => ( shopDetails.screenshots?.map((image, i) => (
<img <img
key={image.id} key={image.id}
className={styles.gallerySliderMedia} className="gallery-slider__media"
src={image.path_full} src={image.path_full}
style={{ translate: `${-100 * mediaIndex}%` }} style={{ translate: `${-100 * mediaIndex}%` }}
alt={t("screenshot", { number: i + 1 })} alt={t("screenshot", { number: i + 1 })}
@ -135,10 +134,11 @@ export function GallerySlider() {
<button <button
onClick={showPrevImage} onClick={showPrevImage}
type="button" type="button"
className={styles.gallerySliderButton({ className={`gallery-slider__button gallery-slider__button--left ${
visible: showArrows, showArrows
direction: "left", ? "gallery-slider__button--visible"
})} : "gallery-slider__button--hidden"
}`}
aria-label={t("previous_screenshot")} aria-label={t("previous_screenshot")}
tabIndex={0} tabIndex={0}
> >
@ -148,10 +148,11 @@ export function GallerySlider() {
<button <button
onClick={showNextImage} onClick={showNextImage}
type="button" type="button"
className={styles.gallerySliderButton({ className={`gallery-slider__button gallery-slider__button--right ${
visible: showArrows, showArrows
direction: "right", ? "gallery-slider__button--visible"
})} : "gallery-slider__button--hidden"
}`}
aria-label={t("next_screenshot")} aria-label={t("next_screenshot")}
tabIndex={0} tabIndex={0}
> >
@ -159,20 +160,22 @@ export function GallerySlider() {
</button> </button>
</div> </div>
<div className={styles.gallerySliderPreview} ref={scrollContainerRef}> <div className="gallery-slider__preview" ref={scrollContainerRef}>
{previews.map((media, i) => ( {previews.map((media, i) => (
<button <button
key={media.id} key={media.id}
type="button" type="button"
className={styles.mediaPreviewButton({ className={`gallery-slider__preview-button ${
active: mediaIndex === i, mediaIndex === i
})} ? "gallery-slider__preview-button--active"
: ""
}`}
onClick={() => setMediaIndex(i)} onClick={() => setMediaIndex(i)}
aria-label={t("open_screenshot", { number: i + 1 })} aria-label={t("open_screenshot", { number: i + 1 })}
> >
<img <img
src={media.thumbnail} src={media.thumbnail}
className={styles.mediaPreview} className="gallery-slider__preview-image"
alt={t("screenshot", { number: i + 1 })} alt={t("screenshot", { number: i + 1 })}
/> />
</button> </button>

View File

@ -7,7 +7,6 @@ import { DescriptionHeader } from "./description-header/description-header";
import { GallerySlider } from "./gallery-slider/gallery-slider"; import { GallerySlider } from "./gallery-slider/gallery-slider";
import { Sidebar } from "./sidebar/sidebar"; import { Sidebar } from "./sidebar/sidebar";
import * as styles from "./game-details.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { AuthPage, steamUrlBuilder } from "@shared"; import { AuthPage, steamUrlBuilder } from "@shared";
@ -15,7 +14,9 @@ import { AuthPage, steamUrlBuilder } from "@shared";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif"; import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails } from "@renderer/hooks"; import { useUserDetails } from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription"; import { useSubscription } from "@renderer/hooks/use-subscription";
import "./game-details.scss";
const HERO_HEIGHT = 300;
const HERO_ANIMATION_THRESHOLD = 25; const HERO_ANIMATION_THRESHOLD = 25;
export function GameDetailsContent() { export function GameDetailsContent() {
@ -80,7 +81,7 @@ export function GameDetailsContent() {
}, [objectId]); }, [objectId]);
const onScroll: React.UIEventHandler<HTMLElement> = (event) => { const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
const heroHeight = heroRef.current?.clientHeight ?? styles.HERO_HEIGHT; const heroHeight = heroRef.current?.clientHeight ?? HERO_HEIGHT;
const scrollY = (event.target as HTMLDivElement).scrollTop; const scrollY = (event.target as HTMLDivElement).scrollTop;
const opacity = Math.max( const opacity = Math.max(
@ -118,10 +119,12 @@ export function GameDetailsContent() {
}, [getGameArtifacts]); }, [getGameArtifacts]);
return ( return (
<div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}> <div
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
>
<img <img
src={steamUrlBuilder.libraryHero(objectId!)} src={steamUrlBuilder.libraryHero(objectId!)}
className={styles.heroImage} className="game-details__hero-image"
alt={game?.title} alt={game?.title}
onLoad={handleHeroLoad} onLoad={handleHeroLoad}
/> />
@ -129,10 +132,11 @@ export function GameDetailsContent() {
<section <section
ref={containerRef} ref={containerRef}
onScroll={onScroll} onScroll={onScroll}
className={styles.container} className="game-details__container"
> >
<div ref={heroRef} className={styles.hero}> <div ref={heroRef} className="game-details__hero">
<div <div
className="game-details__hero-backdrop"
style={{ style={{
backgroundColor: gameColor, backgroundColor: gameColor,
flex: 1, flex: 1,
@ -141,35 +145,26 @@ export function GameDetailsContent() {
/> />
<div <div
className={styles.heroLogoBackdrop} className="game-details__hero-logo-backdrop"
style={{ opacity: backdropOpactiy }} style={{ opacity: backdropOpactiy }}
> >
<div className={styles.heroContent}> <div className="game-details__hero-content">
<img <img
src={steamUrlBuilder.logo(objectId!)} src={steamUrlBuilder.logo(objectId!)}
className={styles.gameLogo} className="game-details__game-logo"
alt={game?.title} alt={game?.title}
/> />
<button <button
type="button" type="button"
className={styles.cloudSyncButton} className="game-details__cloud-sync-button"
onClick={handleCloudSaveButtonClick} onClick={handleCloudSaveButtonClick}
> >
<div <div className="game-details__cloud-icon-container">
style={{
width: 16 + 4,
height: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
}}
>
<img <img
src={cloudIconAnimated} src={cloudIconAnimated}
alt="Cloud icon" alt="Cloud icon"
style={{ width: 26, position: "absolute", top: -3 }} className="game-details__cloud-icon"
/> />
</div> </div>
{t("cloud_save")} {t("cloud_save")}
@ -180,8 +175,8 @@ export function GameDetailsContent() {
<HeroPanel isHeaderStuck={isHeaderStuck} /> <HeroPanel isHeaderStuck={isHeaderStuck} />
<div className={styles.descriptionContainer}> <div className="game-details__description-container">
<div className={styles.descriptionContent}> <div className="game-details__description-content">
<DescriptionHeader /> <DescriptionHeader />
<GallerySlider /> <GallerySlider />
@ -189,7 +184,7 @@ export function GameDetailsContent() {
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: aboutTheGame, __html: aboutTheGame,
}} }}
className={styles.description} className="game-details__description"
/> />
</div> </div>

View File

@ -1,65 +1,52 @@
import Skeleton from "react-loading-skeleton"; import Skeleton from "react-loading-skeleton";
import { Button } from "@renderer/components"; import { Button } from "@renderer/components";
import * as styles from "./game-details.css";
import * as sidebarStyles from "./sidebar/sidebar.css";
import * as descriptionHeaderStyles from "./description-header/description-header.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import "./game-details.scss";
export function GameDetailsSkeleton() { export function GameDetailsSkeleton() {
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
return ( return (
<div className={styles.container}> <div className="game-details__container">
<div className={styles.hero}> <div className="game-details__hero">
<Skeleton className={styles.heroImageSkeleton} /> <Skeleton className="game-details__hero-image-skeleton" />
</div> </div>
<div className={styles.heroPanelSkeleton}> <div className="game-details__hero-panel-skeleton">
<section className={descriptionHeaderStyles.descriptionHeaderInfo}> <section className="description-header__info">
<Skeleton width={155} /> <Skeleton width={155} />
<Skeleton width={135} /> <Skeleton width={135} />
</section> </section>
</div> </div>
<div className={styles.descriptionContainer}> <div className="game-details__description-container">
<div className={styles.descriptionContent}> <div className="game-details__description-content">
<div className={descriptionHeaderStyles.descriptionHeader}> <div className="description-header">
<section className={descriptionHeaderStyles.descriptionHeaderInfo}> <section className="description-header__info">
<Skeleton width={145} /> <Skeleton width={145} />
<Skeleton width={150} /> <Skeleton width={150} />
</section> </section>
</div> </div>
<div className={styles.descriptionSkeleton}> <div className="game-details__description-skeleton">
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} /> <Skeleton key={index} />
))} ))}
<Skeleton className={styles.heroImageSkeleton} /> <Skeleton className="game-details__hero-image-skeleton" />
{Array.from({ length: 2 }).map((_, index) => ( {Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} /> <Skeleton key={index} />
))} ))}
<Skeleton className={styles.heroImageSkeleton} /> <Skeleton className="game-details__hero-image-skeleton" />
<Skeleton /> <Skeleton />
</div> </div>
</div> </div>
<div className={sidebarStyles.contentSidebar}> <div className="content-sidebar">
<div className={sidebarStyles.requirementButtonContainer}> <div className="requirement__button-container">
<Button <Button className="requirement__button" theme="primary" disabled>
className={sidebarStyles.requirementButton}
theme="primary"
disabled
>
{t("minimum")} {t("minimum")}
</Button> </Button>
<Button <Button className="requirement__button" theme="outline" disabled>
className={sidebarStyles.requirementButton}
theme="outline"
disabled
>
{t("recommended")} {t("recommended")}
</Button> </Button>
</div> </div>
<div className={sidebarStyles.requirementsDetailsSkeleton}> <div className="requirement__details-skeleton">
{Array.from({ length: 6 }).map((_, index) => ( {Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} height={20} /> <Skeleton key={index} height={20} />
))} ))}

View File

@ -1,234 +0,0 @@
import { globalStyle, keyframes, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { recipe } from "@vanilla-extract/recipes";
export const HERO_HEIGHT = 300;
export const slideIn = keyframes({
"0%": { transform: `translateY(${40 + SPACING_UNIT * 2}px)`, opacity: "0px" },
"100%": { transform: "translateY(0)", opacity: "1" },
});
export const wrapper = recipe({
base: {
display: "flex",
flexDirection: "column",
overflow: "hidden",
width: "100%",
height: "100%",
transition: "all ease 0.3s",
},
variants: {
blurredContent: {
true: {
filter: "blur(20px)",
},
},
},
});
export const hero = style({
width: "100%",
height: `${HERO_HEIGHT}px`,
minHeight: `${HERO_HEIGHT}px`,
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",
justifyContent: "space-between",
alignItems: "flex-end",
});
export const heroLogoBackdrop = 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: `${HERO_HEIGHT}px`,
minHeight: `${HERO_HEIGHT}px`,
objectFit: "cover",
objectPosition: "top",
transition: "all ease 0.2s",
position: "absolute",
zIndex: "0",
"@media": {
"(min-width: 1250px)": {
objectPosition: "center",
height: "350px",
minHeight: "350px",
},
},
});
export const gameLogo = style({
width: 300,
alignSelf: "flex-end",
});
export const heroImageSkeleton = style({
height: "300px",
"@media": {
"(min-width: 1250px)": {
height: "350px",
},
},
});
export const container = style({
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "auto",
zIndex: "1",
});
export const descriptionContainer = style({
display: "flex",
width: "100%",
flex: "1",
background: `linear-gradient(0deg, ${vars.color.background} 50%, ${vars.color.darkBackground} 100%)`,
});
export const descriptionContent = style({
width: "100%",
height: "100%",
});
export const description = style({
userSelect: "text",
lineHeight: "22px",
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 randomizerButton = style({
animationName: slideIn,
animationDuration: "0.2s",
position: "fixed",
bottom: `${SPACING_UNIT * 3}px`,
/* Scroll bar + spacing */
right: `${9 + SPACING_UNIT * 2}px`,
boxShadow: "rgba(255, 255, 255, 0.1) 0px 0px 10px 1px",
border: `solid 2px ${vars.color.border}`,
zIndex: "1",
backgroundColor: vars.color.background,
":hover": {
backgroundColor: vars.color.background,
boxShadow: "rgba(255, 255, 255, 0.1) 0px 0px 15px 5px",
opacity: "1",
},
":active": {
transform: "scale(0.98)",
},
":disabled": {
boxShadow: "none",
transform: "none",
opacity: "0.8",
backgroundColor: vars.color.background,
},
});
export const heroPanelSkeleton = style({
width: "100%",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
display: "flex",
alignItems: "center",
backgroundColor: vars.color.background,
height: "72px",
borderBottom: `solid 1px ${vars.color.border}`,
});
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",
width: "100%",
height: "auto",
objectFit: "cover",
});
globalStyle(`${description} a`, {
color: vars.color.body,
});
export const cloudSyncButton = style({
padding: `${SPACING_UNIT * 1.5}px ${SPACING_UNIT * 2}px`,
backgroundColor: "rgba(0, 0, 0, 0.6)",
backdropFilter: "blur(20px)",
borderRadius: "8px",
transition: "all ease 0.2s",
cursor: "pointer",
minHeight: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: `${SPACING_UNIT}px`,
color: vars.color.muted,
fontSize: "14px",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 10px 0px rgba(0, 0, 0, 0.8)",
animation: `${slideIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`,
animationDuration: "0.3s",
":active": {
opacity: "0.9",
},
":disabled": {
opacity: vars.opacity.disabled,
cursor: "not-allowed",
},
":hover": {
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
});

View File

@ -0,0 +1,270 @@
@use "../../scss/globals.scss";
$hero-height: 300px;
@keyframes slide-in {
0% {
transform: translateY(calc(40px + globals.$spacing-unit * 2));
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
.game-details {
&__wrapper {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
transition: all ease 0.3s;
&--blurred {
filter: blur(20px);
}
}
&__hero {
width: 100%;
height: $hero-height;
min-height: $hero-height;
display: flex;
flex-direction: column;
position: relative;
transition: all ease 0.2s;
@media (min-width: 1250px) {
height: 350px;
min-height: 350px;
}
}
&__hero-content {
padding: calc(globals.$spacing-unit * 2);
height: 100%;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
&__hero-logo-backdrop {
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.3) 60%, transparent 100%);
position: absolute;
display: flex;
flex-direction: column;
justify-content: space-between;
}
&__hero-image {
width: 100%;
height: $hero-height;
min-height: $hero-height;
object-fit: cover;
object-position: top;
transition: all ease 0.2s;
position: absolute;
z-index: 0;
@media (min-width: 1250px) {
object-position: center;
height: 350px;
min-height: 350px;
}
}
&__game-logo {
width: 300px;
align-self: flex-end;
}
&__hero-image-skeleton {
height: 300px;
@media (min-width: 1250px) {
height: 350px;
}
}
&__container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
z-index: 1;
}
&__description-container {
display: flex;
width: 100%;
flex: 1;
background: linear-gradient(
0deg,
globals.$background-color 50%,
globals.$dark-background-color 100%
);
}
&__description-content {
width: 100%;
height: 100%;
}
&__description {
user-select: text;
line-height: 22px;
font-size: globals.$body-font-size;
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
width: 100%;
margin-left: auto;
margin-right: auto;
@media (min-width: 1280px) {
width: 60%;
}
img {
border-radius: 5px;
margin-top: globals.$spacing-unit;
margin-bottom: calc(globals.$spacing-unit * 3);
display: block;
width: 100%;
height: auto;
object-fit: cover;
}
a {
color: globals.$body-color;
}
.bb_tag {
margin-top: calc(globals.$spacing-unit * 2);
margin-bottom: calc(globals.$spacing-unit * 2);
}
}
&__description-skeleton {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
width: 100%;
margin-left: auto;
margin-right: auto;
@media (min-width: 1280px) {
width: 60%;
line-height: 22px;
}
}
&__randomizer-button {
animation: slide-in 0.2s;
position: fixed;
bottom: calc(globals.$spacing-unit * 3);
right: calc(9px + globals.$spacing-unit * 2);
box-shadow: rgba(255, 255, 255, 0.1) 0px 0px 10px 1px;
border: solid 2px globals.$border-color;
z-index: 1;
background-color: globals.$background-color;
&:hover {
background-color: globals.$background-color;
box-shadow: rgba(255, 255, 255, 0.1) 0px 0px 15px 5px;
opacity: 1;
}
&:active {
transform: scale(0.98);
}
&:disabled {
box-shadow: none;
transform: none;
opacity: 0.8;
background-color: globals.$background-color;
}
}
&__hero-panel-skeleton {
width: 100%;
padding: calc(globals.$spacing-unit * 2);
display: flex;
align-items: center;
background-color: globals.$background-color;
height: 72px;
border-bottom: solid 1px globals.$border-color;
}
&__cloud-sync-button {
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: globals.$spacing-unit;
color: globals.$muted-color;
font-size: globals.$small-font-size;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
&:active {
opacity: 0.9;
}
&:disabled {
opacity: globals.$disabled-opacity;
cursor: not-allowed;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
}
}
&__stars-icon-container {
width: 16px;
height: 16px;
position: relative;
}
&__stars-icon {
width: 70px;
position: absolute;
top: -28px;
left: -27px;
}
&__cloud-icon-container {
width: 20px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
&__cloud-icon {
width: 26px;
position: absolute;
top: -3px;
}
&__hero-backdrop {
flex: 1;
transition: opacity 0.2s ease;
}
}

View File

@ -11,9 +11,6 @@ import starsIconAnimated from "@renderer/assets/icons/stars-animated.gif";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SkeletonTheme } from "react-loading-skeleton"; import { SkeletonTheme } from "react-loading-skeleton";
import { GameDetailsSkeleton } from "./game-details-skeleton"; import { GameDetailsSkeleton } from "./game-details-skeleton";
import * as styles from "./game-details.css";
import { vars } from "@renderer/theme.css";
import { GameDetailsContent } from "./game-details-content"; import { GameDetailsContent } from "./game-details-content";
import { import {
@ -27,6 +24,7 @@ import { GameOptionsModal, RepacksModal } from "./modals";
import { Downloader, getDownloadersForUri } from "@shared"; import { Downloader, getDownloadersForUri } from "@shared";
import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal"; import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal";
import { CloudSyncFilesModal } from "./cloud-sync-files-modal/cloud-sync-files-modal"; import { CloudSyncFilesModal } from "./cloud-sync-files-modal/cloud-sync-files-modal";
import "./game-details.scss";
export default function GameDetails() { export default function GameDetails() {
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null); const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
@ -149,10 +147,7 @@ export default function GameDetails() {
)} )}
</CloudSyncContextConsumer> </CloudSyncContextConsumer>
<SkeletonTheme <SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
baseColor={vars.color.background}
highlightColor="#444"
>
{isLoading ? <GameDetailsSkeleton /> : <GameDetailsContent />} {isLoading ? <GameDetailsSkeleton /> : <GameDetailsContent />}
<RepacksModal <RepacksModal
@ -186,23 +181,16 @@ export default function GameDetails() {
{fromRandomizer && ( {fromRandomizer && (
<Button <Button
className={styles.randomizerButton} className="game-details__randomizer-button"
onClick={handleRandomizerClick} onClick={handleRandomizerClick}
theme="outline" theme="outline"
disabled={!randomGame || randomizerLocked} disabled={!randomGame || randomizerLocked}
> >
<div <div className="game-details__stars-icon-container">
style={{ width: 16, height: 16, position: "relative" }}
>
<img <img
src={starsIconAnimated} src={starsIconAnimated}
alt="Stars animation" alt="Stars animation"
style={{ className="game-details__stars-icon"
width: 70,
position: "absolute",
top: -28,
left: -27,
}}
/> />
</div> </div>
{t("next_suggestion")} {t("next_suggestion")}

View File

@ -1,18 +0,0 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const heroPanelAction = style({
border: `solid 1px ${vars.color.muted}`,
});
export const actions = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
});
export const separator = style({
width: "1px",
backgroundColor: vars.color.muted,
opacity: "0.2",
});

Some files were not shown because too many files have changed in this diff Show More