chore: merge with dev

This commit is contained in:
Chubby Granny Chaser 2025-02-01 18:04:22 +00:00
commit 7c87e121bc
No known key found for this signature in database
9 changed files with 269 additions and 403 deletions

View File

@ -14,12 +14,14 @@ import {
import { routes } from "./routes"; import { routes } from "./routes";
import * as styles from "./sidebar.css"; import "./sidebar.scss";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { SidebarProfile } from "./sidebar-profile"; import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
import cn from "classnames";
import { CommentDiscussionIcon } from "@primer/octicons-react"; import { CommentDiscussionIcon } from "@primer/octicons-react";
const SIDEBAR_MIN_WIDTH = 200; const SIDEBAR_MIN_WIDTH = 200;
@ -168,9 +170,9 @@ export function Sidebar() {
return ( return (
<aside <aside
ref={sidebarRef} ref={sidebarRef}
className={styles.sidebar({ className={cn("sidebar", {
resizing: isResizing, "sidebar--resizing": isResizing,
darwin: window.electron.platform === "darwin", "sidebar--darwin": window.electron.platform === "darwin",
})} })}
style={{ style={{
width: sidebarWidth, width: sidebarWidth,
@ -183,19 +185,19 @@ export function Sidebar() {
> >
<SidebarProfile /> <SidebarProfile />
<div className={styles.content}> <div className="sidebar__content">
<section className={styles.section}> <section className="sidebar__section">
<ul className={styles.menu}> <ul className="sidebar__menu">
{routes.map(({ nameKey, path, render }) => ( {routes.map(({ nameKey, path, render }) => (
<li <li
key={nameKey} key={nameKey}
className={styles.menuItem({ className={cn("sidebar__menu-item", {
active: location.pathname === path, "sidebar__menu-item--active": location.pathname === path,
})} })}
> >
<button <button
type="button" type="button"
className={styles.menuItemButton} className="sidebar__menu-item-button"
onClick={() => handleSidebarItemClick(path)} onClick={() => handleSidebarItemClick(path)}
> >
{render()} {render()}
@ -206,8 +208,8 @@ export function Sidebar() {
</ul> </ul>
</section> </section>
<section className={styles.section}> <section className="sidebar__section">
<small className={styles.sectionTitle}>{t("my_library")}</small> <small className="sidebar__section-title">{t("my_library")}</small>
<TextField <TextField
ref={filterRef} ref={filterRef}
@ -216,34 +218,35 @@ export function Sidebar() {
theme="dark" theme="dark"
/> />
<ul className={styles.menu}> <ul className="sidebar__menu">
{filteredLibrary.map((game) => ( {filteredLibrary.map((game) => (
<li <li
key={`${game.shop}-${game.objectId}`} key={game.id}
className={styles.menuItem({ className={cn("sidebar__menu-item", {
active: "sidebar__menu-item--active":
location.pathname === location.pathname ===
`/game/${game.shop}/${game.objectId}`, `/game/${game.shop}/${game.objectId}`,
muted: game.download?.status === "removed", "sidebar__menu-item--muted":
game.download?.status === "removed",
})} })}
> >
<button <button
type="button" type="button"
className={styles.menuItemButton} className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)} onClick={(event) => handleSidebarGameClick(event, game)}
> >
{game.iconUrl ? ( {game.iconUrl ? (
<img <img
className={styles.gameIcon} className="sidebar__game-icon"
src={game.iconUrl} src={game.iconUrl}
alt={game.title} alt={game.title}
loading="lazy" loading="lazy"
/> />
) : ( ) : (
<SteamLogo className={styles.gameIcon} /> <SteamLogo className="sidebar__game-icon" />
)} )}
<span className={styles.menuItemButtonLabel}> <span className="sidebar__menu-item-button-label">
{getGameTitle(game)} {getGameTitle(game)}
</span> </span>
</button> </button>
@ -257,10 +260,10 @@ export function Sidebar() {
{hasActiveSubscription && ( {hasActiveSubscription && (
<button <button
type="button" type="button"
className={styles.helpButton} className="sidebar__help-button"
data-open-support-chat data-open-support-chat
> >
<div className={styles.helpButtonIcon}> <div className="sidebar__help-button-icon">
<CommentDiscussionIcon size={14} /> <CommentDiscussionIcon size={14} />
</div> </div>
<span>{t("need_help")}</span> <span>{t("need_help")}</span>
@ -269,7 +272,7 @@ export function Sidebar() {
<button <button
type="button" type="button"
className={styles.handle} className="sidebar__handle"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
/> />
</aside> </aside>

View File

@ -1,97 +0,0 @@
import { keyframes, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { recipe } from "@vanilla-extract/recipes";
export const enter = keyframes({
"0%": {
opacity: 0,
transform: "translateY(100%)",
},
"100%": {
opacity: 1,
transform: "translateY(0)",
},
});
export const exit = keyframes({
"0%": {
opacity: 1,
transform: "translateY(0)",
},
"100%": {
opacity: 0,
transform: "translateY(100%)",
},
});
export const toast = recipe({
base: {
animationDuration: "0.15s",
animationTimingFunction: "ease-in-out",
maxWidth: "420px",
position: "absolute",
backgroundColor: vars.color.background,
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
right: "0",
bottom: "0",
overflow: "hidden",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
zIndex: vars.zIndex.toast,
},
variants: {
closing: {
true: {
animationName: exit,
transform: "translateY(100%)",
},
false: {
animationName: enter,
transform: "translateY(0)",
},
},
},
});
export const toastContent = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`,
justifyContent: "center",
alignItems: "center",
});
export const progress = style({
width: "100%",
height: "3px",
"::-webkit-progress-bar": {
backgroundColor: vars.color.darkBackground,
},
"::-webkit-progress-value": {
backgroundColor: vars.color.muted,
},
});
export const closeButton = style({
color: vars.color.body,
cursor: "pointer",
transition: "all ease 0.15s",
":hover": {
color: vars.color.muted,
},
});
export const successIcon = style({
color: vars.color.success,
});
export const errorIcon = style({
color: vars.color.danger,
});
export const warningIcon = style({
color: vars.color.warning,
});

View File

@ -6,12 +6,13 @@ import {
XIcon, XIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import * as styles from "./toast.css"; import "./toast.scss";
import { SPACING_UNIT } from "@renderer/theme.css"; import cn from "classnames";
export interface ToastProps { export interface ToastProps {
visible: boolean; visible: boolean;
message: string; title: string;
message?: string;
type: "success" | "error" | "warning"; type: "success" | "error" | "warning";
duration?: number; duration?: number;
onClose: () => void; onClose: () => void;
@ -21,9 +22,10 @@ const INITIAL_PROGRESS = 100;
export function Toast({ export function Toast({
visible, visible,
title,
message, message,
type, type,
duration = 5000, duration = 2500,
onClose, onClose,
}: Readonly<ToastProps>) { }: Readonly<ToastProps>) {
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
@ -79,12 +81,16 @@ export function Toast({
if (!visible) return null; if (!visible) return null;
return ( return (
<div className={styles.toast({ closing: isClosing })}> <div
<div className={styles.toastContent}> className={cn("toast", {
"toast--closing": isClosing,
})}
>
<div className="toast__content">
<div <div
style={{ style={{
display: "flex", display: "flex",
gap: `${SPACING_UNIT}px`, gap: `8px`,
flexDirection: "column", flexDirection: "column",
}} }}
> >
@ -93,24 +99,26 @@ export function Toast({
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
gap: `${SPACING_UNIT}px`, gap: `8px`,
}} }}
> >
{type === "success" && ( {type === "success" && (
<CheckCircleFillIcon className={styles.successIcon} /> <CheckCircleFillIcon className="toast__icon--success" />
)} )}
{type === "error" && ( {type === "error" && (
<XCircleFillIcon className={styles.errorIcon} /> <XCircleFillIcon className="toast__icon--error" />
)} )}
{type === "warning" && <AlertIcon className={styles.warningIcon} />} {type === "warning" && (
<AlertIcon className="toast__icon--warning" />
)}
<span style={{ fontWeight: "bold", flex: 1 }}>{message}</span> <span style={{ fontWeight: "bold", flex: 1 }}>{title}</span>
<button <button
type="button" type="button"
className={styles.closeButton} className="toast__close-button"
onClick={startAnimateClosing} onClick={startAnimateClosing}
aria-label="Close toast" aria-label="Close toast"
> >
@ -118,14 +126,11 @@ export function Toast({
</button> </button>
</div> </div>
<p> {message && <p>{message}</p>}
This is a really really long message that should wrap to the next
line
</p>
</div> </div>
</div> </div>
<progress className={styles.progress} value={progress} max={100} /> <progress className="toast__progress" value={progress} max={100} />
</div> </div>
); );
} }

View File

@ -1,43 +1,38 @@
import { useDate } from "@renderer/hooks"; import { useDate } from "@renderer/hooks";
import type { UserAchievement } from "@types"; import type { UserAchievement } from "@types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./achievements.css"; import "./achievements.scss";
import { EyeClosedIcon } from "@primer/octicons-react"; import { EyeClosedIcon } from "@primer/octicons-react";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription"; import { useSubscription } from "@renderer/hooks/use-subscription";
import { vars } from "@renderer/theme.css";
interface AchievementListProps { interface AchievementListProps {
achievements: UserAchievement[]; achievements: UserAchievement[];
} }
export function AchievementList({ achievements }: AchievementListProps) { export function AchievementList({
achievements,
}: Readonly<AchievementListProps>) {
const { t } = useTranslation("achievement"); const { t } = useTranslation("achievement");
const { showHydraCloudModal } = useSubscription(); const { showHydraCloudModal } = useSubscription();
const { formatDateTime } = useDate(); const { formatDateTime } = useDate();
return ( return (
<ul className={styles.list}> <ul className="achievements__list">
{achievements.map((achievement) => ( {achievements.map((achievement) => (
<li <li key={achievement.name} className="achievements__item">
key={achievement.name}
className={styles.listItem}
style={{ display: "flex" }}
>
<img <img
className={styles.listItemImage({ className={`achievements__item-image ${!achievement.unlocked ? "achievements__item-image--locked" : ""}`}
unlocked: achievement.unlocked,
})}
src={achievement.icon} src={achievement.icon}
alt={achievement.displayName} alt={achievement.displayName}
loading="lazy" loading="lazy"
/> />
<div style={{ flex: 1 }}> <div className="achievements__item-content">
<h4 style={{ display: "flex", alignItems: "center", gap: "4px" }}> <h4 className="achievements__item-title">
{achievement.hidden && ( {achievement.hidden && (
<span <span
style={{ display: "flex" }} className="achievements__item-hidden-icon"
title={t("hidden_achievement_tooltip")} title={t("hidden_achievement_tooltip")}
> >
<EyeClosedIcon size={12} /> <EyeClosedIcon size={12} />
@ -47,48 +42,36 @@ export function AchievementList({ achievements }: AchievementListProps) {
</h4> </h4>
<p>{achievement.description}</p> <p>{achievement.description}</p>
</div> </div>
<div
style={{ <div className="achievements__item-meta">
display: "flex",
flexDirection: "column",
gap: "8px",
alignItems: "flex-end",
}}
>
{achievement.points != undefined ? ( {achievement.points != undefined ? (
<div <div
style={{ display: "flex", alignItems: "center", gap: "4px" }} className="achievements__item-points"
title={t("achievement_earn_points", { title={t("achievement_earn_points", {
points: achievement.points, points: achievement.points,
})} })}
> >
<HydraIcon width={20} height={20} /> <HydraIcon className="achievements__item-points-icon" />
<p style={{ fontSize: "1.1em" }}>{achievement.points}</p> <p className="achievements__item-points-value">
{achievement.points}
</p>
</div> </div>
) : ( ) : (
<button <button
onClick={() => showHydraCloudModal("achievements")} onClick={() => showHydraCloudModal("achievements")}
style={{ className="achievements__item-points achievements__item-points--locked"
display: "flex", title={t("achievement_earn_points", { points: "???" })}
alignItems: "center",
gap: "4px",
cursor: "pointer",
color: vars.color.warning,
}}
title={t("achievement_earn_points", {
points: "???",
})}
> >
<HydraIcon width={20} height={20} /> <HydraIcon className="achievements__item-points-icon" />
<p style={{ fontSize: "1.1em" }}>???</p> <p className="achievements__item-points-value">???</p>
</button> </button>
)} )}
{achievement.unlockTime != null && ( {achievement.unlockTime != null && (
<div <div
className="achievements__item-unlock-time"
title={t("unlocked_at", { title={t("unlocked_at", {
date: formatDateTime(achievement.unlockTime), date: formatDateTime(achievement.unlockTime),
})} })}
style={{ whiteSpace: "nowrap", gap: "4px", display: "flex" }}
> >
<small>{formatDateTime(achievement.unlockTime)}</small> <small>{formatDateTime(achievement.unlockTime)}</small>
</div> </div>

View File

@ -12,9 +12,8 @@ import { Downloader, formatBytes, steamUrlBuilder } from "@shared";
import { DOWNLOADER_NAME } from "@renderer/constants"; import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useDownload } from "@renderer/hooks"; import { useAppSelector, useDownload } from "@renderer/hooks";
import * as styles from "./download-group.css"; import "./download-group.scss";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
DropdownMenu, DropdownMenu,
@ -260,44 +259,26 @@ export function DownloadGroup({
if (!library.length) return null; if (!library.length) return null;
return ( return (
<div className={styles.downloadGroup}> <div className="download-group">
<div <div className="download-group__header">
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h2>{title}</h2> <h2>{title}</h2>
<div className="download-group__header-divider" />
<div <h3 className="download-group__header-count">{library.length}</h3>
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>{library.length}</h3>
</div> </div>
<ul className={styles.downloads}> <ul className="download-group__downloads">
{library.map((game) => { {library.map((game) => {
return ( return (
<li <li key={game.id} className="download-group__item">
key={game.id} <div className="download-group__cover">
className={styles.download} <div className="download-group__cover-backdrop">
style={{ position: "relative" }}
>
<div className={styles.downloadCover}>
<div className={styles.downloadCoverBackdrop}>
<img <img
src={steamUrlBuilder.library(game.objectId)} src={steamUrlBuilder.library(game.objectId)}
className={styles.downloadCoverImage} className="download-group__cover-image"
alt={game.title} alt={game.title}
/> />
<div className={styles.downloadCoverContent}> <div className="download-group__cover-content">
<Badge> <Badge>
{ {
DOWNLOADER_NAME[ DOWNLOADER_NAME[
@ -308,12 +289,12 @@ export function DownloadGroup({
</div> </div>
</div> </div>
</div> </div>
<div className={styles.downloadRightContent}> <div className="download-group__right-content">
<div className={styles.downloadDetails}> <div className="download-group__details">
<div className={styles.downloadTitleWrapper}> <div className="download-group__title-wrapper">
<button <button
type="button" type="button"
className={styles.downloadTitle} className="download-group__title"
onClick={() => onClick={() =>
navigate( navigate(
buildGameDetailsPath({ buildGameDetailsPath({
@ -337,15 +318,7 @@ export function DownloadGroup({
sideOffset={-75} sideOffset={-75}
> >
<Button <Button
style={{ className="download-group__menu-button"
position: "absolute",
top: "12px",
right: "12px",
borderRadius: "50%",
border: "none",
padding: "8px",
minHeight: "unset",
}}
theme="outline" theme="outline"
> >
<ThreeBarsIcon /> <ThreeBarsIcon />

View File

@ -1,24 +1,18 @@
import { useContext, useEffect, useMemo, useState } from "react"; import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel.css";
import { formatDownloadProgress } from "@renderer/helpers"; import { formatDownloadProgress } from "@renderer/helpers";
import { useDate, useDownload, useFormat } from "@renderer/hooks"; import { useDate, useDownload, useFormat } from "@renderer/hooks";
import { Link } from "@renderer/components"; import { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import "./hero-panel-playtime.scss";
export function HeroPanelPlaytime() { export function HeroPanelPlaytime() {
const [lastTimePlayed, setLastTimePlayed] = useState(""); const [lastTimePlayed, setLastTimePlayed] = useState("");
const { game, isGameRunning } = useContext(gameDetailsContext); const { game, isGameRunning } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const { numberFormatter } = useFormat(); const { numberFormatter } = useFormat();
const { progress, lastPacket } = useDownload(); const { progress, lastPacket } = useDownload();
const { formatDistance } = useDate(); const { formatDistance } = useDate();
useEffect(() => { useEffect(() => {
@ -56,8 +50,8 @@ export function HeroPanelPlaytime() {
game.download?.status === "active" && lastPacket?.gameId === game.id; game.download?.status === "active" && lastPacket?.gameId === game.id;
const downloadInProgressInfo = ( const downloadInProgressInfo = (
<div className={styles.downloadDetailsRow}> <div className="hero-panel-playtime__download-details">
<Link to="/downloads" className={styles.downloadsLink}> <Link to="/downloads" className="hero-panel-playtime__downloads-link">
{game.download?.status === "active" {game.download?.status === "active"
? t("download_in_progress") ? t("download_in_progress")
: t("download_paused")} : t("download_paused")}
@ -84,7 +78,6 @@ export function HeroPanelPlaytime() {
return ( return (
<> <>
<p>{t("playing_now")}</p> <p>{t("playing_now")}</p>
{hasDownload && downloadInProgressInfo} {hasDownload && downloadInProgressInfo}
</> </>
); );

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,30 +54,28 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
game?.download?.status === "paused"; game?.download?.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?.progress
: game?.download?.progress
}
className={styles.progressBar({
disabled: game?.download?.status === "paused",
})}
/>
)}
</div> </div>
</>
{showProgressBar && (
<progress
max={1}
value={
isGameDownloading ? lastPacket?.progress : game?.download?.progress
}
className={`hero-panel__progress-bar ${
game?.download?.status === "paused"
? "hero-panel__progress-bar--disabled"
: ""
}`}
/>
)}
</div>
); );
} }

View File

@ -2,7 +2,6 @@ import { useContext, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components"; import { Button, Modal, TextField } from "@renderer/components";
import type { LibraryGame } from "@types"; import type { LibraryGame } from "@types";
import * as styles from "./game-options-modal.css";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal"; import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
import { useDownload, useToast, useUserDetails } from "@renderer/hooks"; import { useDownload, useToast, useUserDetails } from "@renderer/hooks";
@ -10,6 +9,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;
@ -21,7 +21,7 @@ export function GameOptionsModal({
visible, visible,
game, game,
onClose, onClose,
}: GameOptionsModalProps) { }: Readonly<GameOptionsModalProps>) {
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const { showSuccessToast, showErrorToast } = useToast(); const { showSuccessToast, showErrorToast } = useToast();
@ -192,14 +192,12 @@ export function GameOptionsModal({
onClose={() => setShowDeleteModal(false)} onClose={() => setShowDeleteModal(false)}
deleteGame={handleDeleteGame} deleteGame={handleDeleteGame}
/> />
<RemoveGameFromLibraryModal <RemoveGameFromLibraryModal
visible={showRemoveGameModal} visible={showRemoveGameModal}
onClose={() => setShowRemoveGameModal(false)} onClose={() => setShowRemoveGameModal(false)}
removeGameFromLibrary={handleRemoveGameFromLibrary} removeGameFromLibrary={handleRemoveGameFromLibrary}
game={game} game={game}
/> />
<ResetAchievementsModal <ResetAchievementsModal
visible={showResetAchievementsModal} visible={showResetAchievementsModal}
onClose={() => setShowResetAchievementsModal(false)} onClose={() => setShowResetAchievementsModal(false)}
@ -213,59 +211,66 @@ 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__section">
<h2>{t("executable_section_title")}</h2> <div className="game-options-modal__header">
<h4 className={styles.gameOptionHeaderDescription}> <h2>{t("executable_section_title")}</h2>
{t("executable_section_description")} <h4 className="game-options-modal__header-description">
</h4> {t("executable_section_description")}
</h4>
</div>
<div className="game-options-modal__executable-field">
<TextField
value={game.executablePath || ""}
readOnly
theme="dark"
disabled
placeholder={t("no_executable_selected")}
rightContent={
<>
<Button
type="button"
theme="outline"
onClick={handleChangeExecutableLocation}
>
<FileIcon />
{t("select_executable")}
</Button>
{game.executablePath && (
<Button
onClick={handleClearExecutablePath}
theme="outline"
>
{t("clear")}
</Button>
)}
</>
}
/>
{game.executablePath && (
<div className="game-options-modal__executable-field-buttons">
<Button
type="button"
theme="outline"
onClick={handleOpenGameExecutablePath}
>
{t("open_folder")}
</Button>
<Button onClick={handleCreateShortcut} theme="outline">
{t("create_shortcut")}
</Button>
</div>
)}
</div>
</div> </div>
<TextField
value={game.executablePath || ""}
readOnly
theme="dark"
disabled
placeholder={t("no_executable_selected")}
rightContent={
<>
<Button
type="button"
theme="outline"
onClick={handleChangeExecutableLocation}
>
<FileIcon />
{t("select_executable")}
</Button>
{game.executablePath && (
<Button onClick={handleClearExecutablePath} theme="outline">
{t("clear")}
</Button>
)}
</>
}
/>
{game.executablePath && (
<div className={styles.gameOptionRow}>
<Button
type="button"
theme="outline"
onClick={handleOpenGameExecutablePath}
>
{t("open_folder")}
</Button>
<Button onClick={handleCreateShortcut} theme="outline">
{t("create_shortcut")}
</Button>
</div>
)}
{shouldShowWinePrefixConfiguration && ( {shouldShowWinePrefixConfiguration && (
<div className={styles.optionsContainer}> <div className="game-options-modal__wine-prefix">
<div className={styles.gameOptionHeader}> <div className="game-options-modal__header">
<h2>{t("wine_prefix")}</h2> <h2>{t("wine_prefix")}</h2>
<h4 className={styles.gameOptionHeaderDescription}> <h4 className="game-options-modal__header-description">
{t("wine_prefix_description")} {t("wine_prefix_description")}
</h4> </h4>
</div> </div>
@ -300,11 +305,13 @@ export function GameOptionsModal({
)} )}
{shouldShowLaunchOptionsConfiguration && ( {shouldShowLaunchOptionsConfiguration && (
<div className={styles.gameOptionHeader}> <div className="game-options-modal__launch-options">
<h2>{t("launch_options")}</h2> <div className="game-options-modal__header">
<h4 className={styles.gameOptionHeaderDescription}> <h2>{t("launch_options")}</h2>
{t("launch_options_description")} <h4 className="game-options-modal__header-description">
</h4> {t("launch_options_description")}
</h4>
</div>
<TextField <TextField
value={launchOptions} value={launchOptions}
theme="dark" theme="dark"
@ -321,72 +328,76 @@ export function GameOptionsModal({
</div> </div>
)} )}
<div className={styles.gameOptionHeader}> <div className="game-options-modal__downloads">
<h2>{t("downloads_secion_title")}</h2> <div className="game-options-modal__header">
<h4 className={styles.gameOptionHeaderDescription}> <h2>{t("downloads_secion_title")}</h2>
{t("downloads_section_description")} <h4 className="game-options-modal__header-description">
</h4> {t("downloads_section_description")}
</h4>
</div>
<div className="game-options-modal__row">
<Button
onClick={() => setShowRepacksModal(true)}
theme="outline"
disabled={deleting || isGameDownloading || !repacks.length}
>
{t("open_download_options")}
</Button>
{game.download?.downloadPath && (
<Button
onClick={handleOpenDownloadFolder}
theme="outline"
disabled={deleting}
>
{t("open_download_location")}
</Button>
)}
</div>
</div> </div>
<div className={styles.gameOptionRow}> <div className="game-options-modal__danger-zone">
<Button <div className="game-options-modal__header">
onClick={() => setShowRepacksModal(true)} <h2>{t("danger_zone_section_title")}</h2>
theme="outline" <h4 className="game-options-modal__danger-zone-description">
disabled={deleting || isGameDownloading || !repacks.length} {t("danger_zone_section_description")}
> </h4>
{t("open_download_options")} </div>
</Button>
{game.download?.downloadPath && ( <div className="game-options-modal__danger-zone-buttons">
<Button <Button
onClick={handleOpenDownloadFolder} onClick={() => setShowRemoveGameModal(true)}
theme="outline" theme="danger"
disabled={deleting} disabled={deleting}
> >
{t("open_download_location")} {t("remove_from_library")}
</Button> </Button>
)}
</div>
<div className={styles.gameOptionHeader}> <Button
<h2>{t("danger_zone_section_title")}</h2> onClick={() => setShowResetAchievementsModal(true)}
<h4 className={styles.gameOptionHeaderDescription}> theme="danger"
{t("danger_zone_section_description")} disabled={
</h4> deleting ||
</div> isDeletingAchievements ||
!hasAchievements ||
!userDetails
}
>
{t("reset_achievements")}
</Button>
<div className={styles.gameOptionRow}> <Button
<Button onClick={() => {
onClick={() => setShowRemoveGameModal(true)} setShowDeleteModal(true);
theme="danger" }}
disabled={deleting} theme="danger"
> disabled={
{t("remove_from_library")} isGameDownloading || deleting || !game.download?.downloadPath
</Button> }
>
<Button {t("remove_files")}
onClick={() => setShowResetAchievementsModal(true)} </Button>
theme="danger" </div>
disabled={
deleting ||
isDeletingAchievements ||
!hasAchievements ||
!userDetails
}
>
{t("reset_achievements")}
</Button>
<Button
onClick={() => {
setShowDeleteModal(true);
}}
theme="danger"
disabled={
isGameDownloading || deleting || !game.download?.downloadPath
}
>
{t("remove_files")}
</Button>
</div> </div>
</div> </div>
</Modal> </Modal>

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 achievementsPlaceholder: UserAchievement[] = [ const achievementsPlaceholder: 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)" }}>
{achievementsPlaceholder.map((achievement, index) => ( {achievementsPlaceholder.map((achievement) => (
<li key={index}> <li key={achievement.displayName}>
<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} />
@ -182,21 +179,21 @@ export function Sidebar() {
</button> </button>
)} )}
{achievements.slice(0, 4).map((achievement, index) => ( {achievements.slice(0, 4).map((achievement) => (
<li key={index}> <li key={achievement.displayName}>
<Link <Link
to={buildGameAchievementPath({ to={buildGameAchievementPath({
shop: shop, shop: shop,
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}
/> />
@ -226,17 +223,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>
@ -252,9 +249,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"}
> >
@ -262,7 +259,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"}
> >
@ -271,7 +268,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] ??