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 { 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 { useToast } from "@renderer/hooks";
import { useForm } from "react-hook-form";
@ -96,10 +96,12 @@ export function CloudSyncFilesModal({
description={t("manage_files_description")}
onClose={onClose}
>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span style={{ marginBottom: 8 }}>{t("mapping_method_label")}</span>
<div className="cloud-sync-files-modal__container">
<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) => (
<Button
key={mappingMethod}
@ -119,7 +121,7 @@ export function CloudSyncFilesModal({
</div>
</div>
<div style={{ marginTop: 16 }}>
<div className="cloud-sync-files-modal__custom-path">
{selectedFileMappingMethod === FileMappingMethod.Automatic ? (
<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) => (
<li key={file.path} style={{ display: "flex" }}>
<li key={file.path} className="cloud-sync-files-modal__file-item">
<button
className={styles.fileItem}
className="cloud-sync-files-modal__file-item"
onClick={() => window.electron.showItemInFolder(file.path)}
>
{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 { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import * as styles from "./cloud-sync-modal.css";
import "./cloud-sync-modal.scss";
import { formatBytes } from "@shared";
import { format } from "date-fns";
import {
@ -18,7 +18,6 @@ import { useAppSelector, useToast } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
import { AxiosProgressEvent } from "axios";
import { formatDownloadProgress } from "@renderer/helpers";
import { SPACING_UNIT } from "@renderer/theme.css";
export interface CloudSyncModalProps
extends Omit<ModalProps, "children" | "title"> {}
@ -95,7 +94,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
if (uploadingBackup) {
return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<SyncIcon className={styles.syncIcon} />
<SyncIcon className="cloud-sync-modal__sync-icon" />
{t("uploading_backup")}
</span>
);
@ -104,7 +103,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
if (restoringBackup) {
return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<SyncIcon className={styles.syncIcon} />
<SyncIcon className="cloud-sync-modal__sync-icon" />
{t("restoring_backup", {
progress: formatDownloadProgress(
backupDownloadProgress?.progress ?? 0
@ -117,7 +116,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
if (loadingPreview) {
return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<SyncIcon className={styles.syncIcon} />
<SyncIcon className="cloud-sync-modal__sync-icon" />
{t("loading_save_preview")}
</span>
);
@ -157,21 +156,14 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
onClose={onClose}
large
>
<div
style={{
marginBottom: 24,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
<div className="cloud-sync-modal__header">
<div className="cloud-sync-modal__title-container">
<h2>{gameTitle}</h2>
<p>{backupStateLabel}</p>
<button
type="button"
className={styles.manageFilesButton}
className="cloud-sync-modal__manage-files-button"
onClick={() => setShowCloudSyncFilesModal(true)}
disabled={disableActions}
>
@ -188,40 +180,36 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
artifacts.length >= backupsPerGameLimit
}
>
<UploadIcon />
{uploadingBackup ? (
<SyncIcon className="cloud-sync-modal__sync-icon" />
) : (
<UploadIcon />
)}
{t("create_backup")}
</Button>
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
gap: SPACING_UNIT,
}}
>
<h2>{t("backups")}</h2>
<small>
{artifacts.length} / {backupsPerGameLimit}
</small>
</div>
{uploadingBackup && (
<progress
className="cloud-sync-modal__progress"
value={backupDownloadProgress?.progress ?? 0}
max={100}
/>
)}
<div className="cloud-sync-modal__backups-header">
<h2>{t("backups")}</h2>
<small>
{artifacts.length} / {backupsPerGameLimit}
</small>
</div>
{artifacts.length > 0 ? (
<ul className={styles.artifacts}>
<ul className="cloud-sync-modal__artifacts">
{artifacts.map((artifact) => (
<li key={artifact.id} className={styles.artifactButton}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 4,
}}
>
<li key={artifact.id} className="cloud-sync-modal__artifact">
<div className="cloud-sync-modal__artifact-info">
<div className="cloud-sync-modal__artifact-header">
<h3>
{t("backup_from", {
date: format(artifact.createdAt, "dd/MM/yyyy"),
@ -230,29 +218,33 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
</div>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span className="cloud-sync-modal__artifact-meta">
<DeviceDesktopIcon size={14} />
{artifact.hostname}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span className="cloud-sync-modal__artifact-meta">
<InfoIcon size={14} />
{artifact.downloadOptionTitle ?? t("no_download_option_info")}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span className="cloud-sync-modal__artifact-meta">
<ClockIcon size={14} />
{format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")}
</span>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div className="cloud-sync-modal__artifact-actions">
<Button
type="button"
onClick={() => handleBackupInstallClick(artifact.id)}
disabled={disableActions}
>
<HistoryIcon />
{restoringBackup ? (
<SyncIcon className="cloud-sync-modal__sync-icon" />
) : (
<HistoryIcon />
)}
{t("install_backup")}
</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 * as styles from "./description-header.css";
import { useContext } from "react";
import { gameDetailsContext } from "@renderer/context";
import "./description-header.scss";
export function DescriptionHeader() {
const { shopDetails } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
if (!shopDetails) return null;
return (
<div className={styles.descriptionHeader}>
<section className={styles.descriptionHeaderInfo}>
<div className="description-header">
<section className="description-header__info">
<p>
{t("release_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 { useTranslation } from "react-i18next";
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
import * as styles from "./gallery-slider.css";
import { gameDetailsContext } from "@renderer/context";
import "./gallery-slider.scss";
export function GallerySlider() {
const { shopDetails } = useContext(gameDetailsContext);
@ -97,11 +96,11 @@ export function GallerySlider() {
return (
<>
{hasScreenshots && (
<div className={styles.gallerySliderContainer}>
<div className="gallery-slider__container">
<div
onMouseEnter={() => setShowArrows(true)}
onMouseLeave={() => setShowArrows(false)}
className={styles.gallerySliderAnimationContainer}
className="gallery-slider__animation-container"
ref={mediaContainerRef}
>
{shopDetails.movies &&
@ -109,7 +108,7 @@ export function GallerySlider() {
<video
key={video.id}
controls
className={styles.gallerySliderMedia}
className="gallery-slider__media"
poster={video.thumbnail}
style={{ translate: `${-100 * mediaIndex}%` }}
loop
@ -124,7 +123,7 @@ export function GallerySlider() {
shopDetails.screenshots?.map((image, i) => (
<img
key={image.id}
className={styles.gallerySliderMedia}
className="gallery-slider__media"
src={image.path_full}
style={{ translate: `${-100 * mediaIndex}%` }}
alt={t("screenshot", { number: i + 1 })}
@ -135,10 +134,11 @@ export function GallerySlider() {
<button
onClick={showPrevImage}
type="button"
className={styles.gallerySliderButton({
visible: showArrows,
direction: "left",
})}
className={`gallery-slider__button gallery-slider__button--left ${
showArrows
? "gallery-slider__button--visible"
: "gallery-slider__button--hidden"
}`}
aria-label={t("previous_screenshot")}
tabIndex={0}
>
@ -148,10 +148,11 @@ export function GallerySlider() {
<button
onClick={showNextImage}
type="button"
className={styles.gallerySliderButton({
visible: showArrows,
direction: "right",
})}
className={`gallery-slider__button gallery-slider__button--right ${
showArrows
? "gallery-slider__button--visible"
: "gallery-slider__button--hidden"
}`}
aria-label={t("next_screenshot")}
tabIndex={0}
>
@ -159,20 +160,22 @@ export function GallerySlider() {
</button>
</div>
<div className={styles.gallerySliderPreview} ref={scrollContainerRef}>
<div className="gallery-slider__preview" ref={scrollContainerRef}>
{previews.map((media, i) => (
<button
key={media.id}
type="button"
className={styles.mediaPreviewButton({
active: mediaIndex === i,
})}
className={`gallery-slider__preview-button ${
mediaIndex === i
? "gallery-slider__preview-button--active"
: ""
}`}
onClick={() => setMediaIndex(i)}
aria-label={t("open_screenshot", { number: i + 1 })}
>
<img
src={media.thumbnail}
className={styles.mediaPreview}
className="gallery-slider__preview-image"
alt={t("screenshot", { number: i + 1 })}
/>
</button>

View file

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

View file

@ -1,65 +1,52 @@
import Skeleton from "react-loading-skeleton";
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 "./game-details.scss";
export function GameDetailsSkeleton() {
const { t } = useTranslation("game_details");
return (
<div className={styles.container}>
<div className={styles.hero}>
<Skeleton className={styles.heroImageSkeleton} />
<div className="game-details__container">
<div className="game-details__hero">
<Skeleton className="game-details__hero-image-skeleton" />
</div>
<div className={styles.heroPanelSkeleton}>
<section className={descriptionHeaderStyles.descriptionHeaderInfo}>
<div className="game-details__hero-panel-skeleton">
<section className="description-header__info">
<Skeleton width={155} />
<Skeleton width={135} />
</section>
</div>
<div className={styles.descriptionContainer}>
<div className={styles.descriptionContent}>
<div className={descriptionHeaderStyles.descriptionHeader}>
<section className={descriptionHeaderStyles.descriptionHeaderInfo}>
<div className="game-details__description-container">
<div className="game-details__description-content">
<div className="description-header">
<section className="description-header__info">
<Skeleton width={145} />
<Skeleton width={150} />
</section>
</div>
<div className={styles.descriptionSkeleton}>
<div className="game-details__description-skeleton">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} />
))}
<Skeleton className={styles.heroImageSkeleton} />
<Skeleton className="game-details__hero-image-skeleton" />
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} />
))}
<Skeleton className={styles.heroImageSkeleton} />
<Skeleton className="game-details__hero-image-skeleton" />
<Skeleton />
</div>
</div>
<div className={sidebarStyles.contentSidebar}>
<div className={sidebarStyles.requirementButtonContainer}>
<Button
className={sidebarStyles.requirementButton}
theme="primary"
disabled
>
<div className="content-sidebar">
<div className="requirement__button-container">
<Button className="requirement__button" theme="primary" disabled>
{t("minimum")}
</Button>
<Button
className={sidebarStyles.requirementButton}
theme="outline"
disabled
>
<Button className="requirement__button" theme="outline" disabled>
{t("recommended")}
</Button>
</div>
<div className={sidebarStyles.requirementsDetailsSkeleton}>
<div className="requirement__details-skeleton">
{Array.from({ length: 6 }).map((_, index) => (
<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 { SkeletonTheme } from "react-loading-skeleton";
import { GameDetailsSkeleton } from "./game-details-skeleton";
import * as styles from "./game-details.css";
import { vars } from "@renderer/theme.css";
@ -27,6 +26,7 @@ import { GameOptionsModal, RepacksModal } from "./modals";
import { Downloader, getDownloadersForUri } from "@shared";
import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal";
import { CloudSyncFilesModal } from "./cloud-sync-files-modal/cloud-sync-files-modal";
import "./game-details.scss";
export default function GameDetails() {
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
@ -185,23 +185,16 @@ export default function GameDetails() {
{fromRandomizer && (
<Button
className={styles.randomizerButton}
className="game-details__randomizer-button"
onClick={handleRandomizerClick}
theme="outline"
disabled={!randomGame || randomizerLocked}
>
<div
style={{ width: 16, height: 16, position: "relative" }}
>
<div className="game-details__stars-icon-container">
<img
src={starsIconAnimated}
alt="Stars animation"
style={{
width: 70,
position: "absolute",
top: -28,
left: -27,
}}
className="game-details__stars-icon"
/>
</div>
{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 { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel-actions.css";
import { gameDetailsContext } from "@renderer/context";
import "./hero-panel-actions.scss";
export function HeroPanelActions() {
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
@ -84,7 +83,7 @@ export function HeroPanelActions() {
theme="outline"
disabled={toggleLibraryGameDisabled}
onClick={addGameToLibrary}
className={styles.heroPanelAction}
className="hero-panel-actions__action"
>
<PlusCircleIcon />
{t("add_to_library")}
@ -96,7 +95,7 @@ export function HeroPanelActions() {
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}
className="hero-panel-actions__action"
>
{t("open_download_options")}
</Button>
@ -109,7 +108,7 @@ export function HeroPanelActions() {
onClick={closeGame}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}
className="hero-panel-actions__action"
>
{t("close")}
</Button>
@ -122,7 +121,7 @@ export function HeroPanelActions() {
onClick={openGame}
theme="outline"
disabled={deleting || isGameRunning}
className={styles.heroPanelAction}
className="hero-panel-actions__action"
>
<PlayIcon />
{t("play")}
@ -135,7 +134,7 @@ export function HeroPanelActions() {
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={isGameDownloading || !repacks.length}
className={styles.heroPanelAction}
className="hero-panel-actions__action"
>
<DownloadIcon />
{t("download")}
@ -154,16 +153,14 @@ export function HeroPanelActions() {
if (game) {
return (
<div className={styles.actions}>
<div className="hero-panel-actions__container">
{gameActionButton()}
<div className={styles.separator} />
<div className="hero-panel-actions__separator" />
<Button
onClick={() => setShowGameOptionsModal(true)}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}
className="hero-panel-actions__action"
>
<GearIcon />
{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 { HeroPanelActions } from "./hero-panel-actions";
import * as styles from "./hero-panel.css";
import { HeroPanelPlaytime } from "./hero-panel-playtime";
import { gameDetailsContext } from "@renderer/context";
import "./hero-panel.scss";
export interface HeroPanelProps {
isHeaderStuck: boolean;
@ -54,28 +54,26 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
game?.status === "paused";
return (
<>
<div
style={{ backgroundColor: gameColor }}
className={styles.panel({ stuck: isHeaderStuck })}
>
<div className={styles.content}>{getInfo()}</div>
<div className={styles.actions}>
<HeroPanelActions />
</div>
{showProgressBar && (
<progress
max={1}
value={
isGameDownloading ? lastPacket?.game.progress : game?.progress
}
className={styles.progressBar({
disabled: game?.status === "paused",
})}
/>
)}
<div
style={{ backgroundColor: gameColor }}
className={`hero-panel ${isHeaderStuck ? "hero-panel--stuck" : ""}`}
>
<div className="hero-panel__content">{getInfo()}</div>
<div className="hero-panel__actions">
<HeroPanelActions />
</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 { Trans, useTranslation } from "react-i18next";
import * as styles from "./download-settings-modal.css";
import { Button, Link, Modal, TextField } from "@renderer/components";
import { CheckCircleFillIcon, DownloadIcon } from "@primer/octicons-react";
import { Downloader, formatBytes, getDownloadersForUris } from "@shared";
import type { GameRepack } from "@types";
import { SPACING_UNIT } from "@renderer/theme.css";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useFeature, useToast } from "@renderer/hooks";
import "./download-settings-modal.scss";
export interface DownloadSettingsModalProps {
visible: boolean;
@ -145,21 +142,15 @@ export function DownloadSettingsModal({
})}
onClose={onClose}
>
<div className={styles.container}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
<div className="download-settings-modal__container">
<div className="download-settings-modal__downloads-path-field">
<span>{t("downloader")}</span>
<div className={styles.downloaders}>
<div className="download-settings-modal__downloaders">
{downloaders.map((downloader) => (
<Button
key={downloader}
className={styles.downloaderOption}
className="download-settings-modal__downloader-option"
theme={
selectedDownloader === downloader ? "primary" : "outline"
}
@ -170,7 +161,7 @@ export function DownloadSettingsModal({
onClick={() => setSelectedDownloader(downloader)}
>
{selectedDownloader === downloader && (
<CheckCircleFillIcon className={styles.downloaderIcon} />
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
)}
{DOWNLOADER_NAME[downloader]}
</Button>
@ -178,13 +169,7 @@ export function DownloadSettingsModal({
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
<div className="download-settings-modal__downloads-path-field">
<TextField
value={selectedPath}
readOnly
@ -193,7 +178,7 @@ export function DownloadSettingsModal({
error={
hasWritePermission === false ? (
<span
className={styles.pathError}
className="download-settings-modal__path-error"
data-open-article="cannot-write-directory"
>
{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">
<Link to="/settings" />
</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 { FileDirectoryIcon, FileIcon } from "@primer/octicons-react";
import { debounce } from "lodash-es";
import "./game-options-modal.scss";
export interface GameOptionsModalProps {
visible: boolean;
@ -199,10 +200,10 @@ export function GameOptionsModal({
onClose={onClose}
large={true}
>
<div className={styles.optionsContainer}>
<div className={styles.gameOptionHeader}>
<div className="game-options-modal__container">
<div className="game-options-modal__header">
<h2>{t("executable_section_title")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>
<h4 className="game-options-modal__header-description">
{t("executable_section_description")}
</h4>
</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 { Button, Modal } from "@renderer/components";
import * as styles from "./remove-from-library-modal.css";
import type { Game } from "@types";
import "./remove-from-library-modal.scss";
interface RemoveGameFromLibraryModalProps {
visible: boolean;
@ -30,7 +30,7 @@ export function RemoveGameFromLibraryModal({
description={t("remove_from_library_description", { game: game.title })}
onClose={onClose}
>
<div className={styles.deleteActionsButtonsCtn}>
<div className="remove-from-library-modal__actions">
<Button onClick={handleRemoveGame} theme="outline">
{t("remove")}
</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 type { GameRepack } from "@types";
import * as styles from "./repacks-modal.css";
import { SPACING_UNIT } from "@renderer/theme.css";
import { DownloadSettingsModal } from "./download-settings-modal";
import { gameDetailsContext } from "@renderer/context";
import { Downloader } from "@shared";
import { orderBy } from "lodash-es";
import { useDate } from "@renderer/hooks";
import "./repacks-modal.scss";
export interface RepacksModalProps {
visible: boolean;
@ -90,7 +89,7 @@ export function RepacksModal({
<TextField placeholder={t("filter")} onChange={handleFilter} />
</div>
<div className={styles.repacks}>
<div className="repacks-modal__repacks">
{filteredRepacks.map((repack) => {
const isLastDownloadedOption = checkIfLastDownloadedOption(repack);
@ -99,17 +98,15 @@ export function RepacksModal({
key={repack.id}
theme="dark"
onClick={() => handleRepackClick(repack)}
className={styles.repackButton}
className="repacks-modal__repack-button"
>
<p style={{ color: "#DADBE1", wordBreak: "break-word" }}>
{repack.title}
</p>
<p className="repacks-modal__repack-title">{repack.title}</p>
{isLastDownloadedOption && (
<Badge>{t("last_downloaded_option")}</Badge>
)}
<p style={{ fontSize: "12px" }}>
<p className="repacks-modal__repack-info">
{repack.fileSize} - {repack.repacker} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate!) : ""}
</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 { Button, Modal } from "@renderer/components";
import * as styles from "./remove-from-library-modal.css";
import type { Game } from "@types";
import "./reset-achievements-modal.scss";
type ResetAchievementsModalProps = Readonly<{
visible: boolean;
game: Game;
@ -34,7 +35,7 @@ export function ResetAchievementsModal({
game: game.title,
})}
>
<div className={styles.deleteActionsButtonsCtn}>
<div className="reset-achievements-modal__actions">
<Button onClick={handleResetAchievements} theme="outline">
{t("reset_achievements")}
</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 { useEffect, useRef, useState } from "react";
import * as styles from "./sidebar-section.css";
import "./sidebar-section.scss";
export interface SidebarSectionProps {
title: string;
@ -22,23 +21,25 @@ export function SidebarSection({ title, children }: SidebarSectionProps) {
}, [isOpen, children, height]);
return (
<div>
<div className="sidebar-section">
<button
type="button"
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>
</button>
<div
ref={content}
className="sidebar-section__content"
style={{
maxHeight: `${height}px`,
overflow: "hidden",
transition: "max-height 0.4s cubic-bezier(0, 1, 0, 1)",
position: "relative",
}}
>
{children}

View file

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