refactor: migrate game details styles from VE to SCSS + BEM

This commit is contained in:
Hachi-R 2025-01-19 19:33:37 -03:00
parent 296524f894
commit a52979d912
31 changed files with 1197 additions and 264 deletions

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

@ -0,0 +1,113 @@
@use "../../../scss/globals.scss";
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.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;
}
}
}

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"> {}
@ -95,7 +94,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
if (uploadingBackup) { if (uploadingBackup) {
return ( return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<SyncIcon className={styles.syncIcon} /> <SyncIcon className="cloud-sync-modal__sync-icon" />
{t("uploading_backup")} {t("uploading_backup")}
</span> </span>
); );
@ -104,7 +103,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
if (restoringBackup) { if (restoringBackup) {
return ( return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<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
@ -117,7 +116,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
if (loadingPreview) { if (loadingPreview) {
return ( return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<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,36 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
artifacts.length >= backupsPerGameLimit artifacts.length >= backupsPerGameLimit
} }
> >
<UploadIcon /> {uploadingBackup ? (
<SyncIcon className="cloud-sync-modal__sync-icon" />
) : (
<UploadIcon />
)}
{t("create_backup")} {t("create_backup")}
</Button> </Button>
</div> </div>
<div style={{ display: "flex", justifyContent: "space-between" }}> {uploadingBackup && (
<div <progress
style={{ className="cloud-sync-modal__progress"
marginBottom: 16, value={backupDownloadProgress?.progress ?? 0}
display: "flex", max={100}
alignItems: "center", />
gap: SPACING_UNIT, )}
}}
> <div className="cloud-sync-modal__backups-header">
<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 +218,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}
> >
<HistoryIcon /> {restoringBackup ? (
<SyncIcon className="cloud-sync-modal__sync-icon" />
) : (
<HistoryIcon />
)}
{t("install_backup")} {t("install_backup")}
</Button> </Button>
<Button <Button

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

@ -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,47 +132,38 @@ 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,
opacity: Math.min(1, 1 - backdropOpactiy), opacity: Math.min(1, 1 - backdropOpactiy),
}} }}
/> />
<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 +174,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 +183,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

@ -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,7 +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 { vars } from "@renderer/theme.css";
@ -27,6 +26,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);
@ -185,23 +185,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

@ -0,0 +1,18 @@
@use "../../../scss/globals.scss";
.hero-panel-actions {
&__action {
border: solid 1px globals.$muted-color;
}
&__container {
display: flex;
gap: calc(globals.$spacing-unit * 2);
}
&__separator {
width: 1px;
background-color: globals.$muted-color;
opacity: 0.2;
}
}

View file

@ -8,9 +8,8 @@ import { Button } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks"; import { useDownload, useLibrary } from "@renderer/hooks";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel-actions.css";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import "./hero-panel-actions.scss";
export function HeroPanelActions() { export function HeroPanelActions() {
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] = const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
@ -84,7 +83,7 @@ export function HeroPanelActions() {
theme="outline" theme="outline"
disabled={toggleLibraryGameDisabled} disabled={toggleLibraryGameDisabled}
onClick={addGameToLibrary} onClick={addGameToLibrary}
className={styles.heroPanelAction} className="hero-panel-actions__action"
> >
<PlusCircleIcon /> <PlusCircleIcon />
{t("add_to_library")} {t("add_to_library")}
@ -96,7 +95,7 @@ export function HeroPanelActions() {
onClick={() => setShowRepacksModal(true)} onClick={() => setShowRepacksModal(true)}
theme="outline" theme="outline"
disabled={deleting} disabled={deleting}
className={styles.heroPanelAction} className="hero-panel-actions__action"
> >
{t("open_download_options")} {t("open_download_options")}
</Button> </Button>
@ -109,7 +108,7 @@ export function HeroPanelActions() {
onClick={closeGame} onClick={closeGame}
theme="outline" theme="outline"
disabled={deleting} disabled={deleting}
className={styles.heroPanelAction} className="hero-panel-actions__action"
> >
{t("close")} {t("close")}
</Button> </Button>
@ -122,7 +121,7 @@ export function HeroPanelActions() {
onClick={openGame} onClick={openGame}
theme="outline" theme="outline"
disabled={deleting || isGameRunning} disabled={deleting || isGameRunning}
className={styles.heroPanelAction} className="hero-panel-actions__action"
> >
<PlayIcon /> <PlayIcon />
{t("play")} {t("play")}
@ -135,7 +134,7 @@ export function HeroPanelActions() {
onClick={() => setShowRepacksModal(true)} onClick={() => setShowRepacksModal(true)}
theme="outline" theme="outline"
disabled={isGameDownloading || !repacks.length} disabled={isGameDownloading || !repacks.length}
className={styles.heroPanelAction} className="hero-panel-actions__action"
> >
<DownloadIcon /> <DownloadIcon />
{t("download")} {t("download")}
@ -154,16 +153,14 @@ export function HeroPanelActions() {
if (game) { if (game) {
return ( return (
<div className={styles.actions}> <div className="hero-panel-actions__container">
{gameActionButton()} {gameActionButton()}
<div className="hero-panel-actions__separator" />
<div className={styles.separator} />
<Button <Button
onClick={() => setShowGameOptionsModal(true)} onClick={() => setShowGameOptionsModal(true)}
theme="outline" theme="outline"
disabled={deleting} disabled={deleting}
className={styles.heroPanelAction} className="hero-panel-actions__action"
> >
<GearIcon /> <GearIcon />
{t("options")} {t("options")}

View file

@ -0,0 +1,66 @@
@use "../../../scss/globals.scss";
.hero-panel {
width: 100%;
height: 72px;
min-height: 72px;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
background-color: globals.$dark-background-color;
display: flex;
align-items: center;
justify-content: space-between;
transition: all ease 0.2s;
border-bottom: solid 1px globals.$border-color;
position: sticky;
overflow: hidden;
top: 0;
z-index: 2;
&--stuck {
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
}
&__content {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
}
&__download-details {
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;
}
}
}

View file

@ -4,10 +4,10 @@ import { useTranslation } from "react-i18next";
import { useDate, useDownload } from "@renderer/hooks"; import { useDate, useDownload } from "@renderer/hooks";
import { HeroPanelActions } from "./hero-panel-actions"; import { HeroPanelActions } from "./hero-panel-actions";
import * as styles from "./hero-panel.css";
import { HeroPanelPlaytime } from "./hero-panel-playtime"; import { HeroPanelPlaytime } from "./hero-panel-playtime";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import "./hero-panel.scss";
export interface HeroPanelProps { export interface HeroPanelProps {
isHeaderStuck: boolean; isHeaderStuck: boolean;
@ -54,28 +54,26 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
game?.status === "paused"; game?.status === "paused";
return ( return (
<> <div
<div style={{ backgroundColor: gameColor }}
style={{ backgroundColor: gameColor }} className={`hero-panel ${isHeaderStuck ? "hero-panel--stuck" : ""}`}
className={styles.panel({ stuck: isHeaderStuck })} >
> <div className="hero-panel__content">{getInfo()}</div>
<div className={styles.content}>{getInfo()}</div> <div className="hero-panel__actions">
<div className={styles.actions}> <HeroPanelActions />
<HeroPanelActions />
</div>
{showProgressBar && (
<progress
max={1}
value={
isGameDownloading ? lastPacket?.game.progress : game?.progress
}
className={styles.progressBar({
disabled: game?.status === "paused",
})}
/>
)}
</div> </div>
</>
{showProgressBar && (
<progress
max={1}
value={isGameDownloading ? lastPacket?.game.progress : game?.progress}
className={`hero-panel__progress-bar ${
game?.status === "paused"
? "hero-panel__progress-bar--disabled"
: ""
}`}
/>
)}
</div>
); );
} }

View file

@ -0,0 +1,47 @@
@use "../../../scss/globals.scss";
.download-settings-modal {
&__container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 3);
width: 100%;
}
&__downloads-path-field {
display: flex;
gap: globals.$spacing-unit;
}
&__hint-text {
font-size: globals.$small-font-size;
color: globals.$body-color;
}
&__downloaders {
display: grid;
gap: globals.$spacing-unit;
grid-template-columns: repeat(2, 1fr);
}
&__downloader-option {
position: relative;
&:only-child {
grid-column: 1 / -1;
}
}
&__downloader-icon {
position: absolute;
left: calc(globals.$spacing-unit * 2);
}
&__path-error {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}

View file

@ -1,15 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import * as styles from "./download-settings-modal.css";
import { Button, Link, Modal, TextField } from "@renderer/components"; import { Button, Link, Modal, TextField } from "@renderer/components";
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react"; import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUris } from "@shared"; import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
import type { GameRepack } from "@types"; import type { GameRepack } from "@types";
import { SPACING_UNIT } from "@renderer/theme.css";
import { DOWNLOADER_NAME } from "@renderer/constants"; import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useFeature, useToast } from "@renderer/hooks"; import { useAppSelector, useFeature, useToast } from "@renderer/hooks";
import "./download-settings-modal.scss";
export interface DownloadSettingsModalProps { export interface DownloadSettingsModalProps {
visible: boolean; visible: boolean;
@ -145,21 +142,15 @@ export function DownloadSettingsModal({
})} })}
onClose={onClose} onClose={onClose}
> >
<div className={styles.container}> <div className="download-settings-modal__container">
<div <div className="download-settings-modal__downloads-path-field">
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
<span>{t("downloader")}</span> <span>{t("downloader")}</span>
<div className={styles.downloaders}> <div className="download-settings-modal__downloaders">
{downloaders.map((downloader) => ( {downloaders.map((downloader) => (
<Button <Button
key={downloader} key={downloader}
className={styles.downloaderOption} className="download-settings-modal__downloader-option"
theme={ theme={
selectedDownloader === downloader ? "primary" : "outline" selectedDownloader === downloader ? "primary" : "outline"
} }
@ -170,7 +161,7 @@ export function DownloadSettingsModal({
onClick={() => setSelectedDownloader(downloader)} onClick={() => setSelectedDownloader(downloader)}
> >
{selectedDownloader === downloader && ( {selectedDownloader === downloader && (
<CheckCircleFillIcon className={styles.downloaderIcon} /> <CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
)} )}
{DOWNLOADER_NAME[downloader]} {DOWNLOADER_NAME[downloader]}
</Button> </Button>
@ -178,13 +169,7 @@ export function DownloadSettingsModal({
</div> </div>
</div> </div>
<div <div className="download-settings-modal__downloads-path-field">
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
<TextField <TextField
value={selectedPath} value={selectedPath}
readOnly readOnly
@ -193,7 +178,7 @@ export function DownloadSettingsModal({
error={ error={
hasWritePermission === false ? ( hasWritePermission === false ? (
<span <span
className={styles.pathError} className="download-settings-modal__path-error"
data-open-article="cannot-write-directory" data-open-article="cannot-write-directory"
> >
{t("no_write_permission")} {t("no_write_permission")}
@ -212,7 +197,7 @@ export function DownloadSettingsModal({
} }
/> />
<p className={styles.hintText}> <p className="download-settings-modal__hint-text">
<Trans i18nKey="select_folder_hint" ns="game_details"> <Trans i18nKey="select_folder_hint" ns="game_details">
<Link to="/settings" /> <Link to="/settings" />
</Trans> </Trans>

View file

@ -0,0 +1,24 @@
@use "../../../scss/globals.scss";
.game-options-modal {
&__container {
display: flex;
gap: calc(globals.$spacing-unit * 2);
flex-direction: column;
}
&__header {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__header-description {
font-weight: 400;
}
&__row {
display: flex;
gap: globals.$spacing-unit;
}
}

View file

@ -10,6 +10,7 @@ import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
import { ResetAchievementsModal } from "./reset-achievements-modal"; import { ResetAchievementsModal } from "./reset-achievements-modal";
import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react"; import { FileDirectoryIcon, FileIcon } from "@primer/octicons-react";
import { debounce } from "lodash-es"; import { debounce } from "lodash-es";
import "./game-options-modal.scss";
export interface GameOptionsModalProps { export interface GameOptionsModalProps {
visible: boolean; visible: boolean;
@ -199,10 +200,10 @@ export function GameOptionsModal({
onClose={onClose} onClose={onClose}
large={true} large={true}
> >
<div className={styles.optionsContainer}> <div className="game-options-modal__container">
<div className={styles.gameOptionHeader}> <div className="game-options-modal__header">
<h2>{t("executable_section_title")}</h2> <h2>{t("executable_section_title")}</h2>
<h4 className={styles.gameOptionHeaderDescription}> <h4 className="game-options-modal__header-description">
{t("executable_section_description")} {t("executable_section_description")}
</h4> </h4>
</div> </div>

View file

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

View file

@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, Modal } from "@renderer/components"; import { Button, Modal } from "@renderer/components";
import * as styles from "./remove-from-library-modal.css";
import type { Game } from "@types"; import type { Game } from "@types";
import "./remove-from-library-modal.scss";
interface RemoveGameFromLibraryModalProps { interface RemoveGameFromLibraryModalProps {
visible: boolean; visible: boolean;
@ -30,7 +30,7 @@ export function RemoveGameFromLibraryModal({
description={t("remove_from_library_description", { game: game.title })} description={t("remove_from_library_description", { game: game.title })}
onClose={onClose} onClose={onClose}
> >
<div className={styles.deleteActionsButtonsCtn}> <div className="remove-from-library-modal__actions">
<Button onClick={handleRemoveGame} theme="outline"> <Button onClick={handleRemoveGame} theme="outline">
{t("remove")} {t("remove")}
</Button> </Button>

View file

@ -0,0 +1,28 @@
@use "../../../scss/globals.scss";
.repacks-modal {
&__repacks {
display: flex;
gap: globals.$spacing-unit;
flex-direction: column;
}
&__repack-button {
display: flex;
text-align: left;
flex-direction: column;
align-items: flex-start;
gap: globals.$spacing-unit;
color: globals.$body-color;
padding: calc(globals.$spacing-unit * 2);
}
&__repack-title {
color: globals.$muted-color;
word-break: break-word;
}
&__repack-info {
font-size: globals.$small-font-size;
}
}

View file

@ -4,14 +4,13 @@ import { useTranslation } from "react-i18next";
import { Badge, Button, Modal, TextField } from "@renderer/components"; import { Badge, Button, Modal, TextField } from "@renderer/components";
import type { GameRepack } from "@types"; import type { GameRepack } from "@types";
import * as styles from "./repacks-modal.css";
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
import { DownloadSettingsModal } from "./download-settings-modal"; import { DownloadSettingsModal } from "./download-settings-modal";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { orderBy } from "lodash-es"; import { orderBy } from "lodash-es";
import { useDate } from "@renderer/hooks"; import { useDate } from "@renderer/hooks";
import "./repacks-modal.scss";
export interface RepacksModalProps { export interface RepacksModalProps {
visible: boolean; visible: boolean;
@ -90,7 +89,7 @@ export function RepacksModal({
<TextField placeholder={t("filter")} onChange={handleFilter} /> <TextField placeholder={t("filter")} onChange={handleFilter} />
</div> </div>
<div className={styles.repacks}> <div className="repacks-modal__repacks">
{filteredRepacks.map((repack) => { {filteredRepacks.map((repack) => {
const isLastDownloadedOption = checkIfLastDownloadedOption(repack); const isLastDownloadedOption = checkIfLastDownloadedOption(repack);
@ -99,17 +98,15 @@ export function RepacksModal({
key={repack.id} key={repack.id}
theme="dark" theme="dark"
onClick={() => handleRepackClick(repack)} onClick={() => handleRepackClick(repack)}
className={styles.repackButton} className="repacks-modal__repack-button"
> >
<p style={{ color: "#DADBE1", wordBreak: "break-word" }}> <p className="repacks-modal__repack-title">{repack.title}</p>
{repack.title}
</p>
{isLastDownloadedOption && ( {isLastDownloadedOption && (
<Badge>{t("last_downloaded_option")}</Badge> <Badge>{t("last_downloaded_option")}</Badge>
)} )}
<p style={{ fontSize: "12px" }}> <p className="repacks-modal__repack-info">
{repack.fileSize} - {repack.repacker} -{" "} {repack.fileSize} - {repack.repacker} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate!) : ""} {repack.uploadDate ? formatDate(repack.uploadDate!) : ""}
</p> </p>

View file

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

View file

@ -1,7 +1,8 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, Modal } from "@renderer/components"; import { Button, Modal } from "@renderer/components";
import * as styles from "./remove-from-library-modal.css";
import type { Game } from "@types"; import type { Game } from "@types";
import "./reset-achievements-modal.scss";
type ResetAchievementsModalProps = Readonly<{ type ResetAchievementsModalProps = Readonly<{
visible: boolean; visible: boolean;
game: Game; game: Game;
@ -34,7 +35,7 @@ export function ResetAchievementsModal({
game: game.title, game: game.title,
})} })}
> >
<div className={styles.deleteActionsButtonsCtn}> <div className="reset-achievements-modal__actions">
<Button onClick={handleResetAchievements} theme="outline"> <Button onClick={handleResetAchievements} theme="outline">
{t("reset_achievements")} {t("reset_achievements")}
</Button> </Button>

View file

@ -0,0 +1,40 @@
@use "../../../scss/globals.scss";
.sidebar-section {
&__button {
height: 72px;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2);
display: flex;
align-items: center;
background-color: globals.$background-color;
color: globals.$muted-color;
width: 100%;
cursor: pointer;
transition: all ease 0.2s;
gap: globals.$spacing-unit;
font-size: globals.$body-font-size;
font-weight: bold;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
&:active {
opacity: globals.$active-opacity;
}
}
&__chevron {
transition: transform ease 0.2s;
&--open {
transform: rotate(180deg);
}
}
&__content {
overflow: hidden;
transition: max-height 0.4s cubic-bezier(0, 1, 0, 1);
position: relative;
}
}

View file

@ -1,7 +1,6 @@
import { ChevronDownIcon } from "@primer/octicons-react"; import { ChevronDownIcon } from "@primer/octicons-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import "./sidebar-section.scss";
import * as styles from "./sidebar-section.css";
export interface SidebarSectionProps { export interface SidebarSectionProps {
title: string; title: string;
@ -22,23 +21,25 @@ export function SidebarSection({ title, children }: SidebarSectionProps) {
}, [isOpen, children, height]); }, [isOpen, children, height]);
return ( return (
<div> <div className="sidebar-section">
<button <button
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={styles.sidebarSectionButton} className="sidebar-section__button"
> >
<ChevronDownIcon className={styles.chevron({ open: isOpen })} /> <ChevronDownIcon
className={`sidebar-section__chevron ${
isOpen ? "sidebar-section__chevron--open" : ""
}`}
/>
<span>{title}</span> <span>{title}</span>
</button> </button>
<div <div
ref={content} ref={content}
className="sidebar-section__content"
style={{ style={{
maxHeight: `${height}px`, maxHeight: `${height}px`,
overflow: "hidden",
transition: "max-height 0.4s cubic-bezier(0, 1, 0, 1)",
position: "relative",
}} }}
> >
{children} {children}

View file

@ -2,9 +2,8 @@ import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { HowLongToBeatCategory } from "@types"; import type { HowLongToBeatCategory } from "@types";
import { vars } from "@renderer/theme.css"; import { vars } from "@renderer/theme.css";
import * as styles from "./sidebar.css";
import { SidebarSection } from "../sidebar-section/sidebar-section"; import { SidebarSection } from "../sidebar-section/sidebar-section";
import "./sidebar.scss";
const durationTranslation: Record<string, string> = { const durationTranslation: Record<string, string> = {
Hours: "hours", Hours: "hours",
@ -32,15 +31,12 @@ export function HowLongToBeatSection({
return ( return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444"> <SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<SidebarSection title="HowLongToBeat"> <SidebarSection title="HowLongToBeat">
<ul className={styles.howLongToBeatCategoriesList}> <ul className="how-long-to-beat__categories-list">
{howLongToBeatData {howLongToBeatData
? howLongToBeatData.map((category) => ( ? howLongToBeatData.map((category) => (
<li <li key={category.title} className="how-long-to-beat__category">
key={category.title}
className={styles.howLongToBeatCategory}
>
<p <p
className={styles.howLongToBeatCategoryLabel} className="how-long-to-beat__category-label"
style={{ style={{
fontWeight: "bold", fontWeight: "bold",
}} }}
@ -48,7 +44,7 @@ export function HowLongToBeatSection({
{category.title} {category.title}
</p> </p>
<p className={styles.howLongToBeatCategoryLabel}> <p className="how-long-to-beat__category-label">
{getDuration(category.duration)} {getDuration(category.duration)}
</p> </p>
@ -62,7 +58,7 @@ export function HowLongToBeatSection({
: Array.from({ length: 4 }).map((_, index) => ( : Array.from({ length: 4 }).map((_, index) => (
<Skeleton <Skeleton
key={index} key={index}
className={styles.howLongToBeatCategorySkeleton} className="how-long-to-beat__category-skeleton"
/> />
))} ))}
</ul> </ul>

View file

@ -0,0 +1,174 @@
@use "../../../scss/globals.scss";
.content-sidebar {
border-left: solid 1px globals.$border-color;
background-color: globals.$dark-background-color;
width: 100%;
height: 100%;
@media (min-width: 1024px) {
max-width: 300px;
width: 100%;
}
@media (min-width: 1280px) {
width: 100%;
max-width: 400px;
}
}
.requirement {
&__button-container {
width: 100%;
display: flex;
}
&__button {
border: solid 1px globals.$border-color;
border-left: none;
border-right: none;
border-radius: 0;
width: 100%;
}
&__details {
padding: calc(globals.$spacing-unit * 2);
line-height: 22px;
font-size: globals.$body-font-size;
a {
display: flex;
color: globals.$body-color;
}
}
&__details-skeleton {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
padding: calc(globals.$spacing-unit * 2);
font-size: globals.$body-font-size;
}
}
.how-long-to-beat {
&__categories-list {
margin: 0;
padding: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__category {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
background: linear-gradient(
90deg,
transparent 20%,
rgb(255 255 255 / 2%) 100%
);
border-radius: 4px;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
border: solid 1px globals.$border-color;
}
&__category-label {
color: globals.$muted-color;
}
&__category-skeleton {
border: solid 1px globals.$border-color;
border-radius: 4px;
height: 76px;
}
}
.stats {
&__section {
display: flex;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
justify-content: space-between;
transition: max-height ease 0.5s;
overflow: hidden;
@media (min-width: 1024px) {
flex-direction: column;
}
@media (min-width: 1280px) {
flex-direction: row;
}
}
&__category-title {
font-size: globals.$small-font-size;
font-weight: bold;
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__category {
display: flex;
flex-direction: row;
gap: calc(globals.$spacing-unit / 2);
justify-content: space-between;
align-items: center;
}
}
.list {
list-style: none;
margin: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
&__item {
display: flex;
cursor: pointer;
transition: all ease 0.1s;
color: globals.$muted-color;
width: 100%;
overflow: hidden;
border-radius: 4px;
padding: globals.$spacing-unit;
gap: calc(globals.$spacing-unit * 2);
align-items: center;
text-align: left;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
text-decoration: none;
}
}
&__item-image {
width: 54px;
height: 54px;
border-radius: 4px;
object-fit: cover;
&--locked {
filter: grayscale(100%);
}
}
}
.subscription-required-button {
text-decoration: none;
display: flex;
justify-content: center;
width: 100%;
gap: calc(globals.$spacing-unit / 2);
color: globals.$warning-color;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}

View file

@ -7,7 +7,6 @@ import type {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, Link } from "@renderer/components"; import { Button, Link } from "@renderer/components";
import * as styles from "./sidebar.css";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { useDate, useFormat, useUserDetails } from "@renderer/hooks"; import { useDate, useFormat, useUserDetails } from "@renderer/hooks";
import { import {
@ -20,8 +19,8 @@ import { HowLongToBeatSection } from "./how-long-to-beat-section";
import { howLongToBeatEntriesTable } from "@renderer/dexie"; import { howLongToBeatEntriesTable } from "@renderer/dexie";
import { SidebarSection } from "../sidebar-section/sidebar-section"; import { SidebarSection } from "../sidebar-section/sidebar-section";
import { buildGameAchievementPath } from "@renderer/helpers"; import { buildGameAchievementPath } from "@renderer/helpers";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useSubscription } from "@renderer/hooks/use-subscription"; import { useSubscription } from "@renderer/hooks/use-subscription";
import "./sidebar.scss";
const fakeAchievements: UserAchievement[] = [ const fakeAchievements: UserAchievement[] = [
{ {
@ -64,7 +63,6 @@ export function Sidebar() {
}>({ isLoading: true, data: null }); }>({ isLoading: true, data: null });
const { userDetails, hasActiveSubscription } = useUserDetails(); const { userDetails, hasActiveSubscription } = useUserDetails();
const [activeRequirement, setActiveRequirement] = const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum"); useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
@ -72,10 +70,8 @@ export function Sidebar() {
useContext(gameDetailsContext); useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription(); const { showHydraCloudModal } = useSubscription();
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const { formatDateTime } = useDate(); const { formatDateTime } = useDate();
const { numberFormatter } = useFormat(); const { numberFormatter } = useFormat();
useEffect(() => { useEffect(() => {
@ -118,7 +114,7 @@ export function Sidebar() {
}, [objectId, shop, gameTitle]); }, [objectId, shop, gameTitle]);
return ( return (
<aside className={styles.contentSidebar}> <aside className="content-sidebar">
{userDetails === null && ( {userDetails === null && (
<SidebarSection title={t("achievements")}> <SidebarSection title={t("achievements")}>
<div <div
@ -133,21 +129,21 @@ export function Sidebar() {
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
flexDirection: "column", flexDirection: "column",
gap: `${SPACING_UNIT}px`, gap: "8px",
}} }}
> >
<LockIcon size={36} /> <LockIcon size={36} />
<h3>{t("sign_in_to_see_achievements")}</h3> <h3>{t("sign_in_to_see_achievements")}</h3>
</div> </div>
<ul className={styles.list} style={{ filter: "blur(4px)" }}> <ul className="list" style={{ filter: "blur(4px)" }}>
{fakeAchievements.map((achievement, index) => ( {fakeAchievements.map((achievement, index) => (
<li key={index}> <li key={index}>
<div className={styles.listItem}> <div className="list__item">
<img <img
style={{ filter: "blur(8px)" }} style={{ filter: "blur(8px)" }}
className={styles.listItemImage({ className={`list__item-image ${
unlocked: achievement.unlocked, achievement.unlocked ? "" : "list__item-image--locked"
})} }`}
src={achievement.icon} src={achievement.icon}
alt={achievement.displayName} alt={achievement.displayName}
/> />
@ -164,6 +160,7 @@ export function Sidebar() {
</ul> </ul>
</SidebarSection> </SidebarSection>
)} )}
{userDetails && achievements && achievements.length > 0 && ( {userDetails && achievements && achievements.length > 0 && (
<SidebarSection <SidebarSection
title={t("achievements_count", { title={t("achievements_count", {
@ -171,10 +168,10 @@ export function Sidebar() {
achievementsCount: achievements.length, achievementsCount: achievements.length,
})} })}
> >
<ul className={styles.list}> <ul className="list">
{!hasActiveSubscription && ( {!hasActiveSubscription && (
<button <button
className={styles.subscriptionRequiredButton} className="subscription-required-button"
onClick={() => showHydraCloudModal("achievements")} onClick={() => showHydraCloudModal("achievements")}
> >
<CloudOfflineIcon size={16} /> <CloudOfflineIcon size={16} />
@ -190,13 +187,13 @@ export function Sidebar() {
objectId: objectId!, objectId: objectId!,
title: gameTitle, title: gameTitle,
})} })}
className={styles.listItem} className="list__item"
title={achievement.description} title={achievement.description}
> >
<img <img
className={styles.listItemImage({ className={`list__item-image ${
unlocked: achievement.unlocked, achievement.unlocked ? "" : "list__item-image--locked"
})} }`}
src={achievement.icon} src={achievement.icon}
alt={achievement.displayName} alt={achievement.displayName}
/> />
@ -227,17 +224,17 @@ export function Sidebar() {
{stats && ( {stats && (
<SidebarSection title={t("stats")}> <SidebarSection title={t("stats")}>
<div className={styles.statsSection}> <div className="stats__section">
<div className={styles.statsCategory}> <div className="stats__category">
<p className={styles.statsCategoryTitle}> <p className="stats__category-title">
<DownloadIcon size={18} /> <DownloadIcon size={18} />
{t("download_count")} {t("download_count")}
</p> </p>
<p>{numberFormatter.format(stats?.downloadCount)}</p> <p>{numberFormatter.format(stats?.downloadCount)}</p>
</div> </div>
<div className={styles.statsCategory}> <div className="stats__category">
<p className={styles.statsCategoryTitle}> <p className="stats__category-title">
<PeopleIcon size={18} /> <PeopleIcon size={18} />
{t("player_count")} {t("player_count")}
</p> </p>
@ -253,9 +250,9 @@ export function Sidebar() {
/> />
<SidebarSection title={t("requirements")}> <SidebarSection title={t("requirements")}>
<div className={styles.requirementButtonContainer}> <div className="requirement__button-container">
<Button <Button
className={styles.requirementButton} className="requirement__button"
onClick={() => setActiveRequirement("minimum")} onClick={() => setActiveRequirement("minimum")}
theme={activeRequirement === "minimum" ? "primary" : "outline"} theme={activeRequirement === "minimum" ? "primary" : "outline"}
> >
@ -263,7 +260,7 @@ export function Sidebar() {
</Button> </Button>
<Button <Button
className={styles.requirementButton} className="requirement__button"
onClick={() => setActiveRequirement("recommended")} onClick={() => setActiveRequirement("recommended")}
theme={activeRequirement === "recommended" ? "primary" : "outline"} theme={activeRequirement === "recommended" ? "primary" : "outline"}
> >
@ -272,7 +269,7 @@ export function Sidebar() {
</div> </div>
<div <div
className={styles.requirementsDetails} className="requirement__details"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: __html:
shopDetails?.pc_requirements?.[activeRequirement] ?? shopDetails?.pc_requirements?.[activeRequirement] ??