Feature/big picture beta (#2178)

* feat(big-picture): initialize Big Picture module with HTML, Vite config, and main app structure

* feat(big-picture): add Vite configuration and build scripts for Big Picture module

* feat(big-picture): implement openBigPictureWindow functionality in main and preload processes

* feat(big-picture): add Big Picture window functionality and integrate into sidebar

* feat(i18n): add translation key for "Big Picture" in English, Portuguese, and Russian

* refactor(big-picture): change App export to default and update import in main

* refactor(window-manager): streamline URL loading for Big Picture window and enable DevTools in non-packaged environments

* feat(constants): add IS_BROWSER and IS_DESKTOP constants for environment detection

* feat(big-picture): add SCSS styling and layout structure for Big Picture component

* feat(dependencies): add @fontsource/space-grotesk and @phosphor-icons/react packages to package.json

* feat(big-picture): implement routing and add Catalogue, Downloads, and Settings pages

* feat(big-picture): add Sidebar component with routing and styling

* feat(big-picture): implement close functionality in Settings page with responsive behavior

* style(big-picture): reset margins for headings and paragraphs in app.scss

* feat(big-picture): add Library page and update routing in Sidebar and main application

* chore(package): remove build script for big-picture from package.json

* feat(big-picture): add Library page functionality with sorting and searching capabilities

* feat(big-picture): add GameCard component with styling and structure

* refactor(big-picture): reorganize Library page structure and update styles for improved layout

* refactor: centralize globals and normalize sidebar/game-card layout

* feat: implement gamepad service and navigation services for enhanced input handling

* feat(big-picture): export services for gamepad and navigation functionalities

* feat(big-picture): add gamepad and navigation stores for state management

* feat(big-picture): add navigation hooks for enhanced user navigation and gamepad support

* refactor(big-picture): reorganize component structure by creating common directory and adding GameCard component

* feat(big-picture): add context providers for focus item actions, layer, and region

* feat(big-picture): implement NavigationInputProvider for gamepad navigation handling

* refactor(big-picture): simplify callback function formatting in useNavigationActions hook

* feat(big-picture): add NavigationStateBridge provider for navigation state synchronization

* feat(big-picture): add new focus-related components and update exports for improved navigation handling

* feat(big-picture): add gamepad layout definitions and export helper functions for gamepad handling

* feat(big-picture): introduce focus item actions and gamepad types for enhanced navigation handling

* refactor(big-picture): update import paths for types and services to improve module structure

* chore(dependencies): add zustand package for state management

* fix(big-picture): address accessibility issue by adding tabIndex for focus item

* refactor(big-picture): comment out unused updateAxisState method in GamepadService for future reference

* feat(big-picture): add GridFocusGroup component and update navigation service for grid orientation support

* feat(big-picture): integrate NavigationInputProvider and refactor App component structure for improved navigation

* feat(big-picture): add NavigationDiagnostics component for enhanced gamepad input debugging and integrate into App component

* feat(big-picture): implement gamepad visualizer components for enhanced input feedback and diagnostics

* fix(big-picture): set initial state of NavigationDiagnostics to open and enhance logging structure for better diagnostics

* feat(big-picture): enhance NavigationDiagnostics to display gamepad layout and connected pads for improved input diagnostics

* refactor(big-picture): rename NavigationDiagnostics to NavigationDiagnosticsPanel and restructure component for improved clarity and functionality

* refactor(big-picture): update GamepadService to include event parameters in button press and stick move callbacks for improved event handling

* feat(big-picture): add GamepadButtonPressEvent and GamepadStickMoveEvent interfaces for enhanced event handling

* refactor(big-picture): update useGamepad hook to include event parameters in button press and stick move callbacks for improved type safety

* feat(big-picture): add lastGamepadEvent state and display in NavigationDiagnosticsPanel for enhanced gamepad input tracking

* refactor(big-picture): enhance GamepadService to manage active gamepad state and improve input event handling with new meta information

* refactor(big-picture): improve gamepad input handling by adding event validation and resetting hold sessions based on active gamepad index

* refactor(big-picture): extend useGamepad hook to include active gamepad details and event validation for improved input handling

* refactor(big-picture): add activeGamepadIndex to gamepad store for improved state management and input handling

* refactor(big-picture): introduce GamepadInputStatus and GamepadInputEventMeta for enhanced gamepad event handling

* refactor(big-picture): enhance NavigationDiagnosticsPanel to include gamepad event status and active gamepad index for improved diagnostics

* refactor(big-picture): restructure GamepadService to manage stick states in a centralized map for improved state handling and timer management

* refactor(big-picture): enhance gamepad input handling by adding echo suppression features and updating diagnostics to reflect echo events

* style(big-picture): update game card and library page styles for improved layout and responsiveness

* refactor(big-picture): enhance gamepad input mapping by introducing new axis button and trigger mappings, and updating GamepadService to handle diverse input types

* refactor(big-picture): add support for multiple gamepad platforms and enhance input mappings for improved compatibility

* refactor(big-picture): add additional Xbox platform support in gamepad layout for enhanced compatibility

* refactor(big-picture): enhance NavigationDiagnosticsPanel with detailed gamepad debugging information and runtime platform detection

* refactor(big-picture): add Linux Standard Gamepad layout and update gamepad layout retrieval logic for platform-specific handling

* refactor(big-picture): implement D-pad repeat functionality in GamepadService for improved input responsiveness

* refactor(big-picture): improve D-pad and stick repeat functionality in GamepadService with accelerated repeat intervals for enhanced responsiveness

* refactor(big-picture): implement smooth scrolling for focused elements with new scrollFocusedElementIntoView helper

* feat(big-picture): add common UI components including Button, Input, Divider, Typography, ScrollArea, RouterAnchor, and UserProfile

* feat(big-picture): add string utility function to convert names to slugs

* feat(big-picture): add user details and search hooks for improved user experience

* feat(big-picture): enhance sidebar with library and profile components for improved navigation and user experience

* feat(big-picture): add PostCSS plugin for scoping CSS styles to the big picture component

* refactor(big-picture): streamline library hook and page by consolidating update logic into the hook

* refactor(big-picture): integrate HorizontalFocusGroup for improved layout management in the app component

* refactor(big-picture): update button, input, and user profile components to use FocusItem for improved accessibility and styling consistency

* refactor(big-picture): enhance sidebar layout and accessibility by replacing divs with RouteAnchor and integrating VerticalFocusGroup

* style(big-picture): add global styles for unordered lists and list items to ensure consistent spacing and appearance

* refactor(big-picture): improve sidebar layout by adding a focus region for the library list and updating styles

* refactor(big-picture): update import paths to use 'node:path' and enhance component props with Readonly types for better immutability

* refactor(big-picture): comment out unused UserProfile and user details hooks in sidebar for future implementation

* refactor(big-picture): simplify global checks and improve conditional logic in various services and components

* style(big-picture): refine button styles by removing redundant border and adjusting cursor property

* refactor(big-picture): replace clsx with classnames for improved class name management in ScrollArea component

* feat(big-picture): add common UI components including Accordion, Button, Checkbox, Chip, Divider, HorizontalCard, ImageLightbox, Input, ListCard, Modal, RouteAnchor, ScrollArea, SourceAnchor, and Tooltip with corresponding styles

* refactor(big-picture): improve code readability by formatting function and variable declarations in Button and Modal components

* refactor(big-picture): enhance layout structure and styling for improved responsiveness in App component

* feat(big-picture): enhance Chip component with variant support for solid and ghost styles

* style(big-picture): update Accordion component styles to enhance interactivity and visual consistency

* refactor(big-picture): enhance Divider component styles for improved orientation handling

* refactor(big-picture): simplify Tooltip component by removing unnecessary state and event handlers for improved performance

* feat(big-picture): implement Catalogue page with comprehensive component showcase and styling

* fix(big-picture): address accessibility issues in Checkbox component by adding role and ARIA attributes

* refactor(big-picture): rename router-anchor to route-anchor and update icon import for consistency

* chore(deps): add @radix-ui/react-slot package to dependencies

* refactor(settings): replace native button with custom Button component for consistency and improved styling

* refactor(big-picture): add asChild prop to focus groups for flexible component rendering

* feat(big-picture): add Header component with search functionality and integrate into layout

* refactor(big-picture): integrate Header and VerticalFocusGroup into layout for improved structure

* refactor(big-picture): remove unused styles and consolidate button styles for improved maintainability

* style(big-picture): rename style file to styles.scss and add header styles for improved layout

* refactor(big-picture): clean up Sidebar component by removing unused imports and commented-out code for better readability

* refactor(big-picture): remove RouteAnchor component to streamline codebase and eliminate redundancy

* refactor(big-picture): streamline UserProfile component by removing unnecessary wrappers and ensuring consistent image handling

* refactor(big-picture): update layout structure by removing inline styles and applying class names for better maintainability

* refactor(big-picture): enhance CSS scoping by adding renderer style handling and exclusion logic

* style(big-picture): add custom scrollbar styles and enhance layout structure for better UI experience

* refactor(big-picture): update header navigation logic to use dynamic base path for improved flexibility

* refactor(big-picture): update Sidebar component to use 'asChild' prop for improved flexibility in rendering

* fix: sonar comments

* refactor(big-picture): update Input component to use 'asChild' prop and clean up Sidebar layout by removing commented code

* refactor(big-picture): reorganize NavigationDiagnostics component for improved layout and functionality

* feat(big-picture): add pages export structure for improved component organization

* feat(big-picture): add Steam types for enhanced integration with Steam API

* feat(big-picture): add AnimatedHeroImage component for enhanced visual effects

* feat(big-picture): enhance AnimatedHeroImage component with advanced blending effects and improved styling

* feat(big-picture): add LibraryHero component with styling and functionality for displaying last played games

* refactor(big-picture): add exclusion logic for CSS selectors to improve scoping functionality

* feat(big-picture): enhance Button component with custom color support and improved styling transitions

* feat(big-picture): update LibraryHero component with enhanced background layering, responsive design, and improved content layout

* feat(big-picture): add color and date helper functions for improved color manipulation and relative date formatting

* refactor(big-picture): simplify LibraryPage layout by removing unused elements and integrating LibraryHero for last played games display

* feat(big-picture): introduce VerticalGameCard component with customizable progress display and hover effects

* feat(big-picture): update Catalogue component to include VerticalGameCard with new hover effects and progress display

* feat(big-picture): export VerticalGameCard component from common index for improved accessibility

* fix(big-picture): adjust card width styling to exclude vertical-game-card for better layout consistency

* feat(big-picture): enhance LibraryPage layout with improved styling and new components for better user experience

* feat(big-picture): enhance LibraryHero component with focus navigation and additional props for improved accessibility

* feat(big-picture): add game and image helper functions for improved game achievement progress and image source resolution

* feat(big-picture): add useDominantColor hook for extracting dominant color from images

* feat(big-picture): wrap button components with FocusItem for improved accessibility and focus management

* feat(big-picture): add asChild prop to FocusItem for flexible component rendering

* feat(big-picture): implement LibraryFocusGrid component with responsive grid layout for game cards

* feat(big-picture): enhance navigation service with block type handling for focus overrides

* feat(big-picture): update game subtitle formatting to use formatPlayedTime helper for dynamic time display

* fix(big-picture): update FocusItem component usage to include asChild prop for better rendering consistency

* fix(big-picture): refine FocusItem component to conditionally apply focus styles and attributes for better accessibility

* refactor(big-picture): update VerticalGameCard component to improve image error handling and enhance styles for better focus visibility

* fix(big-picture): adjust FocusItem component to conditionally apply outline style based on asChild prop for improved rendering

* fix(big-picture): update grid layout in FocusGrid component for improved responsiveness and adjust game card dimensions

* feat(big-picture): integrate NavigationAutoScrollBridge for enhanced navigation experience

* refactor(big-picture): remove unused scrollFocusedElementIntoView import from FocusItem component

* feat(big-picture): add autoScrollMode and getScrollAnchor props to GridFocusGroup for enhanced navigation control

* feat(big-picture): add autoScrollMode and getScrollAnchor props to HorizontalFocusGroup for improved navigation functionality

* feat(big-picture): add autoScrollMode and getScrollAnchor props to VerticalFocusGroup for improved navigation functionality

* feat(big-picture): enhance VerticalGameCard with progress completion state and dynamic icon rendering

* feat(big-picture): add placeholder and completed state styles to VerticalGameCard for enhanced visual feedback

* refactor(big-picture): optimize LibraryHero component with improved layer handling and refactoring of event callbacks

* feat(big-picture): export new components from library and providers for enhanced modularity

* feat(big-picture): add library navigation constants and utility functions for focus grid management

* feat(big-picture): implement useLibraryGridNavigation hook for managing focus grid navigation in the library

* feat(big-picture): create VerticalLibraryGameCard component for displaying game details in the library

* feat(big-picture): implement NavigationAutoScrollBridge for automatic scrolling based on focus changes

* feat(big-picture): enhance date formatting and improve focus auto-scroll functionality with new options and utility functions

* feat(big-picture): add FocusAutoScrollMode and update navigation region interface for enhanced scrolling options

* feat(big-picture): integrate library filters and grid components for improved game library management

* style(library): format SCSS for success border color to improve readability

* feat(big-picture): enhance LibraryFilters component with tab navigation overrides and new tab IDs for improved focus management

* refactor(library): streamline tab ID exports in navigation component for improved clarity

* i dont know at this point

* style(home): fix SCSS padding formatting for improved consistency

* refactor(navigation): change variable declaration from `let` to `const` for animation frame ID in NavigationAutoScrollBridge

* feat(big-picture): add new components for game UI including Box, AchievementsBox, Hero, PlaytimeBar, and RequirementsToPlay

* feat(big-picture): implement GameReviews component with review loading, voting, and sorting functionality

* feat(big-picture): add normalizeRequirementsHtml function to process HTML lists for improved rendering

* feat(big-picture): add tertiary button variant and update styles for improved UI

* feat(big-picture): add HowLongToBeatBox component for displaying game duration information

* feat(big-picture): add ScreenshotCarousel and VideoPlayer components for enhanced media display in game pages

* feat(big-picture): create index file for game components and add library update event handling

* feat(big-picture): implement Game page layout and styles, including game details and sidebar components

* refactor(big-picture): remove background color on hover for game button styles

* refactor(big-picture): remove unused setIsModalOpen prop from Hero component and update Game component to reflect changes

* refactor(big-picture): migrate useHlsVideo hook to shared directory and update imports in VideoPlayer components

* refactor(big-picture): enhance Input component with focus management and update styles for better layout

* refactor(big-picture): improve header component focus management and update styles for better responsiveness

* feat(big-picture): add Game component and enhance navigation with new game details path

* refactor(big-picture): update vertical game card components to improve accessibility and keyboard navigation

* refactor(big-picture): simplify navigation logic and remove unused search parameters in header component

* refactor(big-picture): update header styles to use CSS variables for height and adjust layout positioning

* fix(i18n): remove duplicate russian resume seeding key

Co-authored-by: Hachi-R <58823742+Hachi-R@users.noreply.github.com>

* fix(navigation): correct regex for trailing slashes in pathname normalization

* fix(navigation): optimize pathname normalization by replacing regex with a loop to remove trailing slashes

* refactor(big-picture): simplify header component search logic and enhance styles for focus visibility

* refactor(big-picture): remove unused firstPopularGameId prop from HomePageHero and update navigation logic

* refactor(big-picture): enhance HomePageHero component with improved game navigation and state management

* refactor(big-picture): add library-game-state hook and enhance navigation constants for HomePageHero

* feat(big-picture): add launch option for Big Picture mode in settings and translations

* feat(window-manager): implement user preferences for Big Picture mode and manage main window state

* feat(navigation): add constants and utility functions for game navigation elements

* feat(hero): enhance action button functionality and navigation for game hero component

* feat(game): implement navigation overrides and enhance focus management for How Long to Beat component

* feat(achievements): add focus navigation overrides and enhance accessibility for achievements component

* feat(requirements): add focus navigation overrides for minimum and recommended buttons to enhance accessibility

* feat(supported-languages): add focus navigation overrides for supported languages component to improve accessibility

* feat(screenshot-carousel): implement focus navigation overrides and enhance accessibility for screenshot carousel component

* feat(game-reviews): implement focus navigation overrides for game reviews component to enhance accessibility

* feat(game-stats): implement focus navigation overrides for game stats section to enhance accessibility

* feat(game-reviews): add load more button functionality and focus management for game reviews component

* feat(home): integrate featured game into HomePageHero and enhance focus management for PopularGames component

* feat(home): refactor Home component to use VerticalFocusGroup for improved focus management and accessibility

* style: update game screenshot carousel button focus styles

- Added focus styles for the previous and next buttons in the game screenshot carousel to improve accessibility and visual feedback.
- Removed duplicate focus styles from the previous location in the stylesheet for cleaner code.

* feat(home): add normalization functions for shop assets and trending games, enhance focus management in Home component

* fix: game pag navigation

* feat(library): enhance game launch functionality and update button labels

* style: enhance tab and header focus styles, update global overflow property

---------

Co-authored-by: japa2k <teruyuki709@gmail.com>
Co-authored-by: japa2k <71810047+japa2k@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Eight
2026-04-26 14:45:26 -03:00
committed by GitHub
parent 18e5125ecc
commit dbc6f33815
190 changed files with 22973 additions and 156 deletions
+23 -22
View File
@@ -1,22 +1,23 @@
.vscode/
node_modules/
__pycache__
dist
out
.DS_Store
*.log*
.env
.vite
ludusavi/**
!ludusavi/config.yaml
hydra-python-rpc/
/hydra-native/
native/hydra-native/target/
.python-version
# Sentry Config File
.env.sentry-build-plugin
*storybook.log
Future-updates.md
TO-DO.md
.vscode/
node_modules/
__pycache__
dist
out
.DS_Store
*.log*
*.tsbuildinfo
.env
.vite
ludusavi/**
!ludusavi/config.yaml
hydra-python-rpc/
/hydra-native/
native/hydra-native/target/
.python-version
# Sentry Config File
.env.sentry-build-plugin
*storybook.log
Future-updates.md
TO-DO.md
+28 -3
View File
@@ -1,12 +1,13 @@
import { resolve } from "path";
import react from "@vitejs/plugin-react";
import {
defineConfig,
externalizeDepsPlugin,
loadEnv,
swcPlugin,
externalizeDepsPlugin,
} from "electron-vite";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
import svgr from "vite-plugin-svgr";
import { scopeBigPictureCss } from "./src/big-picture/vite-scope-big-picture-css";
export default defineConfig(({ mode }) => {
loadEnv(mode);
@@ -29,11 +30,35 @@ export default defineConfig(({ mode }) => {
preload: {
plugins: [externalizeDepsPlugin()],
},
bigPicture: {
root: "src/big-picture",
build: {
outDir: "out/big-picture",
rollupOptions: {
input: resolve("src/big-picture/index.html"),
},
},
css: {
postcss: {
plugins: [scopeBigPictureCss()],
},
},
resolve: {
alias: {
"@locales": resolve("src/locales"),
"@shared": resolve("src/shared"),
},
},
plugins: [react()],
},
renderer: {
build: {
sourcemap: true,
},
css: {
postcss: {
plugins: [scopeBigPictureCss()],
},
preprocessorOptions: {
scss: {
api: "modern",
+6 -1
View File
@@ -30,6 +30,7 @@
"build:win": "npm run build:native && npm run build:python-rpc && electron-vite build && electron-builder --win",
"build:mac": "npm run build:native && npm run build:python-rpc && electron-vite build && electron-builder --mac",
"build:linux": "npm run build:native && npm run build:python-rpc && electron-vite build && electron-builder --linux",
"dev:big-picture": "vite dev src/big-picture",
"prepare": "husky",
"protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto"
},
@@ -37,10 +38,13 @@
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@fontsource/noto-sans": "^5.2.10",
"@fontsource/space-grotesk": "^5.2.10",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.6.0",
"@phosphor-icons/react": "^2.1.10",
"@primer/octicons-react": "^19.9.0",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
"@reduxjs/toolkit": "^2.2.3",
"@sentry/react": "^10.33.0",
"@tiptap/extension-bold": "^3.6.2",
@@ -102,7 +106,8 @@
"workwonders-sdk": "0.4.2",
"ws": "^8.18.1",
"yaml": "^2.8.3",
"yup": "^1.5.0"
"yup": "^1.5.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.705.0",
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hydra Big Picture</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' * data: local:; media-src 'self' 'unsafe-inline' * data: local: blob:;"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
import { Fragment, useEffect } from "react";
import { Outlet, useLocation } from "react-router-dom";
import {
BIG_PICTURE_APP_LAYER_ID,
BIG_PICTURE_CONTENT_REGION_ID,
BIG_PICTURE_SHELL_REGION_ID,
getBigPictureSidebarItemIdFromPathname,
Header,
Sidebar,
} from "./layout";
import { IS_DESKTOP } from "./constants";
import {
HorizontalFocusGroup,
NavigationLayer,
NavigationAutoScrollBridge,
NavigationInputProvider,
NavigationStateBridge,
NavigationDiagnostics,
VerticalFocusGroup,
} from "./components";
import type { FocusOverrides } from "./services";
import "./styles/globals.scss";
export default function App() {
const { pathname } = useLocation();
const showNavigationDiagnostics = import.meta.env.DEV;
const activeSidebarItemId = getBigPictureSidebarItemIdFromPathname(pathname);
const contentNavigationOverrides: FocusOverrides = {
left: {
type: "item",
itemId: activeSidebarItemId,
},
};
useEffect(() => {
if (!IS_DESKTOP) {
document.documentElement.style.colorScheme = "dark";
}
}, []);
return (
<Fragment>
<NavigationStateBridge />
<NavigationAutoScrollBridge />
<NavigationInputProvider>
<NavigationLayer
layerId={BIG_PICTURE_APP_LAYER_ID}
rootRegionId={BIG_PICTURE_SHELL_REGION_ID}
initialFocusRegionId={BIG_PICTURE_CONTENT_REGION_ID}
>
<HorizontalFocusGroup
regionId={BIG_PICTURE_SHELL_REGION_ID}
autoScrollMode="auto"
asChild
>
<div id="big-picture">
<Sidebar />
<VerticalFocusGroup
regionId={BIG_PICTURE_CONTENT_REGION_ID}
navigationOverrides={contentNavigationOverrides}
autoScrollMode="auto"
asChild
>
<div className="big-picture__layout">
<Header />
<article className="big-picture__content">
<Outlet />
</article>
</div>
</VerticalFocusGroup>
{showNavigationDiagnostics && <NavigationDiagnostics />}
</div>
</HorizontalFocusGroup>
</NavigationLayer>
</NavigationInputProvider>
</Fragment>
);
}
@@ -0,0 +1,118 @@
import "./styles.scss";
import { useState, useEffect, type ReactNode } from "react";
import { Typography } from "../typography";
import { CaretUpIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "framer-motion";
import cn from "classnames";
export interface AccordionProps {
title: string;
hint?: string;
open?: boolean;
icon?: ReactNode;
children: ReactNode;
onOpenChange?: (isOpen: boolean) => void;
}
interface AccordionHeaderProps {
title: string;
hint?: string;
icon?: ReactNode;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
onOpenChange?: (isOpen: boolean) => void;
}
interface AccordionContentProps {
children: ReactNode;
}
function AccordionHeader({
title,
hint,
icon,
isOpen,
setIsOpen,
onOpenChange,
}: Readonly<AccordionHeaderProps>) {
return (
<button
onClick={() => {
setIsOpen(!isOpen);
onOpenChange?.(!isOpen);
}}
className={cn("accordion__header", {
"accordion__header--open": isOpen,
})}
>
<div className="accordion__header__label">
{icon}
<Typography variant="label">{title}</Typography>
</div>
<div className="accordion__header__indicators">
{hint && <Typography variant="label">{hint}</Typography>}
<motion.div
animate={{ rotate: isOpen ? 0 : 180, y: isOpen ? 1 : 0 }}
transition={{ duration: 0.2 }}
className="accordion__header__indicators__icon"
>
<CaretUpIcon size={18} />
</motion.div>
</div>
</button>
);
}
function AccordionContent({ children }: Readonly<AccordionContentProps>) {
return <div className="accordion__content">{children}</div>;
}
export function Accordion({
title,
hint,
icon,
open = false,
children,
onOpenChange,
}: Readonly<AccordionProps>) {
const [isOpen, setIsOpen] = useState(open);
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
return (
<div className="accordion">
<AccordionHeader
title={title}
hint={hint}
icon={icon}
isOpen={isOpen}
setIsOpen={setIsOpen}
onOpenChange={onOpenChange}
/>
<AnimatePresence>
{isOpen && (
<motion.div
initial={
!hasMounted && open
? { height: "auto", scaleY: 1 }
: { height: 0, scaleY: 0 }
}
exit={{ height: 0, scaleY: 0 }}
animate={{ height: "auto", scaleY: 1 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
style={{ originY: 0 }}
>
<AccordionContent>{children}</AccordionContent>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,64 @@
.accordion {
display: flex;
flex-direction: column;
gap: var(--spacing-unit);
}
.accordion__header {
display: flex;
flex-direction: row;
padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 4);
justify-content: space-between;
align-items: center;
align-self: stretch;
background-color: var(--surface);
color: var(--text);
cursor: pointer;
transition: border-radius 0.05s ease-in-out;
border-radius: calc(var(--spacing-unit) * 2);
&.accordion__header--open {
border-radius: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 2)
var(--spacing-unit) var(--spacing-unit);
}
}
.accordion__header__label,
.accordion__header__indicators {
display: flex;
flex-direction: row;
align-items: center;
gap: calc(var(--spacing-unit) * 2);
text-transform: capitalize;
> svg {
color: currentColor;
}
}
.accordion__header__indicators {
color: var(--text-secondary);
}
.accordion__header__indicators__icon {
display: flex;
align-items: center;
justify-content: center;
color: currentColor;
> svg {
align-items: center;
justify-content: center;
color: currentColor;
}
}
.accordion__content {
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
background-color: var(--surface);
padding: calc(var(--spacing-unit) * 4);
border-radius: var(--spacing-unit) var(--spacing-unit)
calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 2);
}
@@ -0,0 +1,406 @@
import "./styles.scss";
import { motion, type HTMLMotionProps } from "framer-motion";
import { useCallback, useEffect, useRef } from "react";
export interface AnimatedHeroImageProps
extends Omit<HTMLMotionProps<"img">, "src"> {
imageUrl: string;
}
const FALLBACK_BACKGROUND_COLOR = "rgba(8, 8, 8, 1)";
const RGBA_WITH_ALPHA =
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*[\d.]+\s*\)$/;
function getBackgroundColor(element: HTMLElement) {
const elementStyles = globalThis.getComputedStyle(element);
const rootStyles = globalThis.getComputedStyle(
globalThis.document.documentElement
);
return (
elementStyles.getPropertyValue("--background").trim() ||
rootStyles.getPropertyValue("--background").trim() ||
elementStyles.backgroundColor ||
FALLBACK_BACKGROUND_COLOR
);
}
function withAlpha(color: string, alpha: number) {
const normalizedAlpha = Math.max(0, Math.min(1, alpha));
if (color.startsWith("rgb(")) {
return color.replace("rgb(", "rgba(").replace(")", `, ${normalizedAlpha})`);
}
const rgbaMatch = RGBA_WITH_ALPHA.exec(color);
if (rgbaMatch) {
return `rgba(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}, ${normalizedAlpha})`;
}
return FALLBACK_BACKGROUND_COLOR;
}
function getCoverCrop(
imageWidth: number,
imageHeight: number,
containerWidth: number,
containerHeight: number
) {
const imageAspectRatio = imageWidth / imageHeight;
const containerAspectRatio = containerWidth / containerHeight;
if (imageAspectRatio > containerAspectRatio) {
const cropWidth = imageHeight * containerAspectRatio;
return {
sourceX: (imageWidth - cropWidth) / 2,
sourceY: 0,
sourceWidth: cropWidth,
sourceHeight: imageHeight,
};
}
const cropHeight = imageWidth / containerAspectRatio;
return {
sourceX: 0,
sourceY: (imageHeight - cropHeight) / 2,
sourceWidth: imageWidth,
sourceHeight: cropHeight,
};
}
function createLayer(width: number, height: number, devicePixelRatio: number) {
const canvas = globalThis.document.createElement("canvas");
canvas.width = Math.round(width * devicePixelRatio);
canvas.height = Math.round(height * devicePixelRatio);
const context = canvas.getContext("2d");
if (!context) return null;
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
return { canvas, context };
}
export function AnimatedHeroImage({
imageUrl,
alt = "",
className = "",
onLoad,
onError,
...props
}: Readonly<AnimatedHeroImageProps>) {
const containerRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const blendCanvasRef = useRef<HTMLCanvasElement | null>(null);
const frameRef = useRef<number | null>(null);
const clearBlendCanvas = useCallback(() => {
const canvas = blendCanvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
context.clearRect(0, 0, canvas.width, canvas.height);
}, []);
const renderBlend = useCallback(() => {
const container = containerRef.current;
const image = imageRef.current;
const canvas = blendCanvasRef.current;
if (
!container ||
!image ||
!canvas ||
!image.complete ||
!image.naturalWidth
) {
clearBlendCanvas();
return;
}
const bounds = container.getBoundingClientRect();
const width = bounds.width;
const height = bounds.height;
if (width <= 0 || height <= 0) {
clearBlendCanvas();
return;
}
const devicePixelRatio = Math.min(globalThis.devicePixelRatio || 1, 2);
const expectedWidth = Math.round(width * devicePixelRatio);
const expectedHeight = Math.round(height * devicePixelRatio);
if (canvas.width !== expectedWidth || canvas.height !== expectedHeight) {
canvas.width = expectedWidth;
canvas.height = expectedHeight;
}
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
context.clearRect(0, 0, width, height);
const backgroundColor = getBackgroundColor(container);
const { sourceX, sourceY, sourceWidth, sourceHeight } = getCoverCrop(
image.naturalWidth,
image.naturalHeight,
width,
height
);
const bottomLayer = createLayer(width, height, devicePixelRatio);
const leftLayer = createLayer(width, height, devicePixelRatio);
const rightLayer = createLayer(width, height, devicePixelRatio);
if (!bottomLayer || !leftLayer || !rightLayer) return;
const bottomSourceHeight = sourceHeight * 0.34;
const sideSourceWidth = sourceWidth * 0.075;
bottomLayer.context.save();
bottomLayer.context.filter = `blur(${Math.max(32, height * 0.08)}px) saturate(1.08)`;
bottomLayer.context.globalAlpha = 0.56;
bottomLayer.context.drawImage(
image,
sourceX,
sourceY + sourceHeight - bottomSourceHeight,
sourceWidth,
bottomSourceHeight,
-width * 0.03,
height * 0.28,
width * 1.06,
height * 0.9
);
bottomLayer.context.filter = `blur(${Math.max(64, height * 0.13)}px)`;
bottomLayer.context.globalAlpha = 0.28;
bottomLayer.context.drawImage(
image,
sourceX,
sourceY + sourceHeight - bottomSourceHeight * 0.8,
sourceWidth,
bottomSourceHeight * 0.8,
-width * 0.06,
height * 0.36,
width * 1.12,
height * 0.84
);
bottomLayer.context.restore();
bottomLayer.context.globalCompositeOperation = "destination-in";
const bottomMask = bottomLayer.context.createLinearGradient(
0,
0,
0,
height
);
bottomMask.addColorStop(0, "rgba(0, 0, 0, 0)");
bottomMask.addColorStop(0.22, "rgba(0, 0, 0, 0)");
bottomMask.addColorStop(0.42, "rgba(0, 0, 0, 0.03)");
bottomMask.addColorStop(0.62, "rgba(0, 0, 0, 0.16)");
bottomMask.addColorStop(0.78, "rgba(0, 0, 0, 0.42)");
bottomMask.addColorStop(0.92, "rgba(0, 0, 0, 0.72)");
bottomMask.addColorStop(1, "rgba(0, 0, 0, 1)");
bottomLayer.context.fillStyle = bottomMask;
bottomLayer.context.fillRect(0, 0, width, height);
bottomLayer.context.globalCompositeOperation = "destination-out";
const seamSoftener = bottomLayer.context.createLinearGradient(
0,
height * 0.2,
0,
height * 0.58
);
seamSoftener.addColorStop(0, "rgba(0, 0, 0, 1)");
seamSoftener.addColorStop(0.38, "rgba(0, 0, 0, 0.4)");
seamSoftener.addColorStop(1, "rgba(0, 0, 0, 0)");
bottomLayer.context.fillStyle = seamSoftener;
bottomLayer.context.fillRect(0, height * 0.2, width, height * 0.38);
bottomLayer.context.globalCompositeOperation = "source-over";
leftLayer.context.save();
leftLayer.context.filter = `blur(${Math.max(28, width * 0.035)}px) saturate(1.02)`;
leftLayer.context.globalAlpha = 0.78;
leftLayer.context.drawImage(
image,
sourceX,
sourceY,
sideSourceWidth,
sourceHeight,
0,
-height * 0.02,
width * 0.16,
height * 1.04
);
leftLayer.context.restore();
leftLayer.context.globalCompositeOperation = "destination-in";
const leftMask = leftLayer.context.createLinearGradient(
0,
0,
width * 0.2,
0
);
leftMask.addColorStop(0, "rgba(0, 0, 0, 1)");
leftMask.addColorStop(0.52, "rgba(0, 0, 0, 0.9)");
leftMask.addColorStop(1, "rgba(0, 0, 0, 0)");
leftLayer.context.fillStyle = leftMask;
leftLayer.context.fillRect(0, 0, width * 0.2, height);
leftLayer.context.globalCompositeOperation = "source-over";
rightLayer.context.save();
rightLayer.context.filter = `blur(${Math.max(28, width * 0.035)}px) saturate(1.02)`;
rightLayer.context.globalAlpha = 0.78;
rightLayer.context.drawImage(
image,
sourceX + sourceWidth - sideSourceWidth,
sourceY,
sideSourceWidth,
sourceHeight,
width * 0.84,
-height * 0.02,
width * 0.16,
height * 1.04
);
rightLayer.context.restore();
rightLayer.context.globalCompositeOperation = "destination-in";
const rightMask = rightLayer.context.createLinearGradient(
width,
0,
width * 0.8,
0
);
rightMask.addColorStop(0, "rgba(0, 0, 0, 1)");
rightMask.addColorStop(0.52, "rgba(0, 0, 0, 0.9)");
rightMask.addColorStop(1, "rgba(0, 0, 0, 0)");
rightLayer.context.fillStyle = rightMask;
rightLayer.context.fillRect(width * 0.8, 0, width * 0.2, height);
rightLayer.context.globalCompositeOperation = "source-over";
context.drawImage(bottomLayer.canvas, 0, 0, width, height);
context.drawImage(leftLayer.canvas, 0, 0, width, height);
context.drawImage(rightLayer.canvas, 0, 0, width, height);
const verticalFade = context.createLinearGradient(
0,
height * 0.32,
0,
height
);
verticalFade.addColorStop(0, withAlpha(backgroundColor, 0));
verticalFade.addColorStop(0.14, withAlpha(backgroundColor, 0.02));
verticalFade.addColorStop(0.32, withAlpha(backgroundColor, 0.08));
verticalFade.addColorStop(0.54, withAlpha(backgroundColor, 0.18));
verticalFade.addColorStop(0.72, withAlpha(backgroundColor, 0.38));
verticalFade.addColorStop(0.86, withAlpha(backgroundColor, 0.68));
verticalFade.addColorStop(0.94, withAlpha(backgroundColor, 0.9));
verticalFade.addColorStop(1, backgroundColor);
context.fillStyle = verticalFade;
context.fillRect(0, height * 0.32, width, height * 0.68);
const leftFade = context.createLinearGradient(0, 0, width * 0.12, 0);
leftFade.addColorStop(0, backgroundColor);
leftFade.addColorStop(0.26, withAlpha(backgroundColor, 0.58));
leftFade.addColorStop(1, withAlpha(backgroundColor, 0));
context.fillStyle = leftFade;
context.fillRect(0, 0, width * 0.12, height);
const rightFade = context.createLinearGradient(width, 0, width * 0.88, 0);
rightFade.addColorStop(0, backgroundColor);
rightFade.addColorStop(0.26, withAlpha(backgroundColor, 0.58));
rightFade.addColorStop(1, withAlpha(backgroundColor, 0));
context.fillStyle = rightFade;
context.fillRect(width * 0.88, 0, width * 0.12, height);
}, [clearBlendCanvas]);
const scheduleRender = useCallback(() => {
if (frameRef.current != null) {
globalThis.cancelAnimationFrame(frameRef.current);
}
frameRef.current = globalThis.requestAnimationFrame(() => {
renderBlend();
frameRef.current = null;
});
}, [renderBlend]);
useEffect(() => {
if (!imageUrl) {
clearBlendCanvas();
return;
}
const container = containerRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver(() => {
scheduleRender();
});
resizeObserver.observe(container);
scheduleRender();
return () => {
resizeObserver.disconnect();
if (frameRef.current != null) {
globalThis.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}, [clearBlendCanvas, imageUrl, scheduleRender]);
return (
<div
ref={containerRef}
className={`animated-hero-image ${className}`.trim()}
>
{imageUrl ? (
<motion.img
ref={imageRef}
src={imageUrl}
alt={alt}
className="animated-hero-image__main"
initial={{ scale: 1, x: 0, y: 0 }}
animate={{
scale: 1.1,
x: -10,
y: -10,
}}
transition={{
duration: 20,
ease: "easeInOut",
repeat: Infinity,
repeatType: "mirror",
}}
onLoad={(event) => {
scheduleRender();
onLoad?.(event);
}}
onError={(event) => {
clearBlendCanvas();
onError?.(event);
}}
{...props}
/>
) : null}
<div className="animated-hero-image__blend-wrap" aria-hidden="true">
<canvas ref={blendCanvasRef} className="animated-hero-image__blend" />
</div>
</div>
);
}
@@ -0,0 +1,34 @@
.animated-hero-image {
width: 100%;
height: 100%;
position: relative;
display: block;
overflow: hidden;
isolation: isolate;
}
.animated-hero-image__main {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
object-position: center;
will-change: transform;
}
.animated-hero-image__blend-wrap {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
.animated-hero-image__blend {
display: block;
width: 100%;
height: 100%;
}
@@ -0,0 +1,22 @@
import "./styles.scss";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
export interface BackdropProps {
children: ReactNode;
}
export function Backdrop({ children }: Readonly<BackdropProps>) {
return (
<motion.div
className="backdrop"
initial={{ backgroundColor: "rgba(0, 0, 0, 0)" }}
animate={{ backgroundColor: "rgba(0, 0, 0, 0.7)" }}
exit={{ backgroundColor: "rgba(0, 0, 0, 0)" }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
{children}
</motion.div>
);
}
@@ -0,0 +1,11 @@
.backdrop {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
top: 0;
padding: calc(var(--spacing-unit) * 3);
}
@@ -0,0 +1,52 @@
import { Typography } from "..";
interface BoxProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
title: string;
value: string;
}
function Box({
children,
...props
}: Readonly<Omit<BoxProps, "title" | "value">>) {
const { style, ...rest } = props;
return (
<div
style={{
backgroundColor: "#0E0E0E",
padding: 8,
...style,
}}
{...rest}
>
{children}
</div>
);
}
function TitleBox({ title }: Readonly<Omit<BoxProps, "value">>) {
return (
<Box>
<Typography
style={{ textAlign: "center", color: "rgba(255, 255, 255, 0.5)" }}
>
{title}
</Typography>
</Box>
);
}
function SingleLineBox({ title, value }: Readonly<BoxProps>) {
return (
<Box style={{ display: "flex", justifyContent: "space-between" }}>
<Typography style={{ color: "rgba(255, 255, 255, 0.5)" }}>
{title}
</Typography>
<Typography style={{ fontWeight: "700" }}>{value}</Typography>
</Box>
);
}
export { Box, TitleBox, SingleLineBox };
@@ -0,0 +1,182 @@
import "./styles.scss";
import { SpinnerIcon } from "@phosphor-icons/react";
import cn from "classnames";
import { Link } from "react-router-dom";
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from "react";
import { getContrastTextColor } from "../../../helpers";
import { FocusItem } from "..";
import type { FocusOverrides } from "../../../services";
const variants = {
primary: "button--primary",
secondary: "button--secondary",
tertiary: "button--tertiary",
rounded: "button--rounded",
danger: "button--danger",
link: "button--link",
};
const sizes = {
icon: "button--icon",
small: "button--small",
medium: "button--medium",
large: "button--large",
};
export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
loading?: boolean;
variant?: keyof typeof variants;
size?: keyof typeof sizes;
children: ReactNode;
icon?: ReactNode;
href?: string;
iconPosition?: "left" | "right";
target?: "_blank" | "_self" | "_parent" | "_top";
className?: string;
color?: string;
focusId?: string;
focusNavigationOverrides?: FocusOverrides;
}
function isExternalHref(href: string) {
return (
/^(?:[a-z][a-z\d+.-]*:)?\/\//i.test(href) || href.startsWith("mailto:")
);
}
export function Button({
loading = false,
disabled = false,
size = "medium",
variant = "primary",
iconPosition = "left",
href,
icon,
onClick,
children,
target,
className,
color,
style,
focusId,
focusNavigationOverrides,
"aria-label": ariaLabel,
...props
}: Readonly<ButtonProps>) {
const buttonClassName = cn(
"button",
variants[variant],
sizes[size],
className,
{
"button--disabled": disabled || loading,
}
);
const buttonStyle = {
...style,
...(color
? {
"--button-custom-color": color,
"--button-custom-hover-color": `color-mix(in srgb, ${color} 80%, white)`,
"--button-custom-text-color": getContrastTextColor(color),
}
: {}),
} as CSSProperties;
if (!href) {
return (
<FocusItem
id={focusId}
navigationOverrides={focusNavigationOverrides}
asChild
>
<button
onClick={onClick}
disabled={disabled || loading}
aria-busy={loading}
aria-label={size === "icon" ? ariaLabel : undefined}
className={buttonClassName}
style={buttonStyle}
{...props}
>
{loading && (
<div
className={`button__icon-container--${iconPosition} button__icon-container`}
>
<SpinnerIcon size={20} className="button__loading-icon" />
</div>
)}
{icon && !loading && (
<div
className={`button__icon-container--${iconPosition} button__icon-container`}
>
{icon}
</div>
)}
{children && (!loading || typeof children === "string") && (
<p className="button__text">{children}</p>
)}
</button>
</FocusItem>
);
}
const linkContent = (
<>
{icon && (
<div
className={`button__icon-container--${iconPosition} button__icon-container`}
>
{icon}
</div>
)}
{children && <p className="button__text">{children}</p>}
</>
);
if (target === "_blank" || isExternalHref(href)) {
return (
<FocusItem
id={focusId}
navigationOverrides={focusNavigationOverrides}
asChild
>
<a
href={href}
target={target}
rel={target === "_blank" ? "noreferrer" : undefined}
aria-label={size === "icon" ? ariaLabel : undefined}
className={buttonClassName}
style={buttonStyle}
onClick={onClick as never}
>
{linkContent}
</a>
</FocusItem>
);
}
return (
<FocusItem
id={focusId}
navigationOverrides={focusNavigationOverrides}
asChild
>
<Link
to={href}
aria-label={size === "icon" ? ariaLabel : undefined}
className={buttonClassName}
style={buttonStyle}
onClick={onClick as never}
>
{linkContent}
</Link>
</FocusItem>
);
}
@@ -0,0 +1,211 @@
.button {
--button-custom-color: var(--primary);
--button-custom-hover-color: var(--primary-hover);
--button-custom-text-color: var(--background);
appearance: none;
cursor: pointer;
height: 44px;
padding: 0px calc(var(--spacing-unit) * 4);
border-radius: var(--spacing-unit);
border: none;
font-family: var(--font-space-grotesk);
font-weight: 400;
font-size: 14px;
line-height: 16.59px;
letter-spacing: 0px;
text-align: center;
display: inline-flex;
align-items: center;
justify-content: center;
gap: calc(var(--spacing-unit) * 8);
position: relative;
transition:
background-color 0.25s ease-in-out,
color 0.25s ease-in-out;
flex-shrink: 0;
&--primary {
color: var(--button-custom-text-color);
background-color: var(--button-custom-color);
&:hover {
background-color: var(--button-custom-hover-color);
}
&:focus-visible,
&[data-focus-visible="true"] {
outline: 1px solid var(--primary);
outline-offset: 2px;
}
}
&--secondary {
color: var(--text);
background-color: var(--secondary);
border: 1px solid var(--secondary-border);
&:hover {
background-color: var(--secondary-hover);
}
&:focus-visible,
&[data-focus-visible="true"] {
outline: 1px solid var(--primary);
outline-offset: 2px;
}
}
&--tertiary {
color: var(--text);
background-color: var(--tertiary);
border: 1px solid var(--tertiary-border);
&:hover {
background-color: var(--tertiary-hover);
}
&:focus-visible {
outline: 1px solid var(--tertiary-border);
outline-offset: 2px;
}
}
&--rounded {
color: var(--text);
background-color: transparent;
border-radius: calc(var(--spacing-unit) * 2);
border: 1px solid var(--secondary-border);
&:hover {
background-color: var(--secondary);
}
&:focus-visible,
&[data-focus-visible="true"] {
outline: 1px solid var(--primary);
outline-offset: 2px;
}
}
&--danger {
color: var(--error);
background-color: var(--error-background);
border: 1px solid var(--error-border);
&:hover {
background-color: var(--error-hover);
}
&:focus-visible,
&[data-focus-visible="true"] {
outline: 1px solid var(--error);
outline-offset: 2px;
}
}
&--link {
color: var(--text);
background-color: transparent;
border: none;
padding: 0;
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: var(--text);
opacity: 0.8;
text-align: left;
line-height: normal;
text-decoration-style: solid;
text-decoration-skip-ink: auto;
text-decoration-thickness: auto;
text-underline-position: from-font;
&:hover {
opacity: 1;
}
&:focus-visible,
&[data-focus-visible="true"] {
text-underline-offset: 2px;
opacity: 1;
outline: none;
}
}
&--disabled {
opacity: 0.5;
pointer-events: none;
}
&--icon {
padding: 0;
width: 44px;
}
&--small {
height: 40px;
}
&--large {
height: 48px;
}
&__loading-icon {
animation: spin 1s linear infinite;
}
&__text {
padding: calc(var(--spacing-unit) * 2) 0px;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
&__icon-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: calc(var(--spacing-unit) * 2) 0px;
&--left::after {
content: "";
position: absolute;
right: calc(var(--spacing-unit) * -4);
top: 0;
transform: none;
height: 100%;
width: 1px;
background-color: currentColor;
opacity: 0.2;
}
&--right::before {
content: "";
position: absolute;
left: calc(var(--spacing-unit) * -4);
top: 0;
transform: none;
height: 100%;
width: 1px;
background-color: currentColor;
opacity: 0.2;
}
&--right {
order: 1;
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,65 @@
import "./styles.scss";
import { CheckIcon } from "@phosphor-icons/react";
import { useId, type MouseEvent } from "react";
import cn from "classnames";
export interface CheckboxProps {
id?: string;
label?: string;
checked?: boolean;
block?: boolean;
disabled?: boolean;
onChange?: (checked: boolean) => void;
}
export const Checkbox = ({ label, ...props }: Readonly<CheckboxProps>) => {
const generatedId = useId();
const id = props.id ?? generatedId;
const isChecked = props.checked ?? false;
const handleChange = (checked: boolean) => {
props.onChange?.(checked);
};
const handleBlockClick = (e: MouseEvent<HTMLDivElement>) => {
if (!props.block) return;
e.preventDefault();
handleChange(!isChecked);
};
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
onClick={handleBlockClick}
className={cn("checkbox", {
"checkbox--block": props.block,
"checkbox--block--active": props.block && isChecked,
"checkbox--disabled": props.disabled,
})}
>
<button // NOSONAR - custom styled checkbox, not native input
id={id}
disabled={props.disabled}
className={cn("checkbox__input", {
"checkbox__input--checked": isChecked,
})}
onClick={() => handleChange(!isChecked)}
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- styled button
role="checkbox"
aria-checked={isChecked}
aria-labelledby={label ? `${id}-label` : undefined}
>
{isChecked && <CheckIcon className="checkbox__input__icon" size={14} />}
</button>
{label && (
<label className="checkbox__label" id={`${id}-label`} htmlFor={id}>
{label}
</label>
)}
</div>
);
};
@@ -0,0 +1,65 @@
.checkbox {
display: flex;
align-items: center;
justify-content: flex-start;
gap: calc(var(--spacing-unit) * 2);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
padding: calc(var(--spacing-unit) * 2);
user-select: none;
cursor: pointer;
&--block {
width: 100%;
border-radius: calc(var(--spacing-unit) * 2);
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: var(--secondary);
}
&--active {
background-color: var(--secondary-hover);
&:hover {
background-color: var(--secondary-hover);
}
}
}
&--disabled {
opacity: 0.5;
pointer-events: none;
}
&__native-input {
appearance: none;
}
&__input {
width: 16px;
height: 16px;
border-radius: 2px;
border: 1px solid var(--primary);
background-color: transparent;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease-in-out;
cursor: pointer;
&--checked {
background-color: var(--primary);
}
&__icon {
color: var(--background);
}
}
&__label {
cursor: pointer;
}
}
@@ -0,0 +1,55 @@
import "./styles.scss";
import cn from "classnames";
import { Typography } from "../typography";
import { XIcon } from "@phosphor-icons/react";
import type { ReactNode } from "react";
const variants = {
solid: "chips--solid",
ghost: "chips--ghost",
};
export interface ChipProps {
label: string;
color: string;
icon?: ReactNode;
variant?: keyof typeof variants;
onRemove?: () => void;
}
export interface ColorDotProps {
color: string;
}
export function ColorDot({ color }: Readonly<ColorDotProps>) {
return (
<div className="chips__content__color" style={{ backgroundColor: color }} />
);
}
export function Chip({
label,
color,
icon,
variant = "solid",
onRemove,
}: Readonly<ChipProps>) {
return (
<div className={cn("chips", variants[variant])}>
<div className="chips__content">
{icon && <div className="chips__content__icon">{icon}</div>}
{color && <ColorDot color={color} />}
<Typography variant="body" className="chips__content__label">
{label}
</Typography>
</div>
<button className="chips__close-button" onClick={onRemove}>
<XIcon size={14} />
</button>
</div>
);
}
@@ -0,0 +1,58 @@
.chips {
display: flex;
flex-direction: row;
padding: calc(var(--spacing-unit) * 1) calc(var(--spacing-unit) * 2);
justify-content: center;
align-items: center;
gap: calc(var(--spacing-unit) * 1);
border-radius: calc(var(--spacing-unit) * 2);
&--solid {
background-color: var(--surface);
}
&--ghost {
background-color: transparent;
}
}
.chips__content {
display: flex;
flex-direction: row;
align-items: center;
gap: calc(var(--spacing-unit) * 2);
padding: var(--spacing-unit);
&__label {
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
margin-top: calc(var(--spacing-unit) * 0.5);
}
&__color {
width: 11px;
height: 11px;
border-radius: 50%;
}
}
.chips__close-button {
display: flex;
align-items: center;
justify-content: center;
margin-top: calc(var(--spacing-unit) * 0.5);
padding: var(--spacing-unit);
border-radius: var(--spacing-unit);
color: var(--text-secondary);
cursor: pointer;
transition:
background-color 0.2s ease-in-out,
color 0.2s ease-in-out;
&:hover {
background-color: var(--secondary);
color: var(--primary);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
import "./styles.scss";
import cn from "classnames";
export interface DividerProps {
orientation?: "horizontal" | "vertical";
color?: string;
}
export function Divider({
orientation = "horizontal",
color,
}: Readonly<DividerProps>) {
return (
<div
className={cn("divider-container", {
[`divider-container--${orientation}`]: true,
})}
>
<div
className={cn("divider", {
[`divider--${orientation}`]: true,
})}
style={{ backgroundColor: color }}
/>
</div>
);
}
@@ -0,0 +1,32 @@
.divider {
background-color: rgba(255, 255, 255, 0.1);
display: inline-block;
min-width: 1px;
min-height: 1px;
&--horizontal {
width: 100%;
height: 1px;
}
&--vertical {
width: 1px;
height: 100%;
}
}
.divider-container {
display: flex;
align-items: center;
justify-content: center;
&--horizontal {
width: 100%;
}
&--vertical {
width: 1px;
height: 100%;
flex: 0 0 1px;
}
}
@@ -0,0 +1,129 @@
import { Slot } from "@radix-ui/react-slot";
import {
FocusItemActionsMetaContext,
useFocusLayerId,
useFocusRegionId,
} from "../../context";
import {
getFocusItemActionsMeta,
resolveFocusItemActions,
type FocusItemActions,
} from "../../../types";
import {
type FocusOverrides,
NavigationItemActionsService,
NavigationService,
type NavigationNodeState,
} from "../../../services";
import { useNavigationIsFocused } from "../../../stores";
import { type ReactNode, useEffect, useId, useMemo, useRef } from "react";
interface FocusItemProps {
id?: string;
actions?: FocusItemActions;
navigationState?: NavigationNodeState;
navigationOverrides?: FocusOverrides;
asChild?: boolean;
children: ReactNode;
}
export function FocusItem({
id,
actions,
navigationState = "active",
navigationOverrides,
asChild = false,
children,
}: Readonly<FocusItemProps>) {
const generatedId = useId();
const regionId = useFocusRegionId();
const layerId = useFocusLayerId();
const navigation = NavigationService.getInstance();
const navigationItemActions = NavigationItemActionsService.getInstance();
const ref = useRef<HTMLDivElement | null>(null);
const initialNavigationStateRef = useRef(navigationState);
const initialNavigationOverridesRef = useRef(navigationOverrides);
const resolvedId = id ?? `focus-item-${generatedId.replaceAll(":", "")}`;
const isFocused = useNavigationIsFocused(resolvedId);
const resolvedActions = useMemo(
() => resolveFocusItemActions(actions),
[actions]
);
const actionsMeta = useMemo(
() => getFocusItemActionsMeta(resolvedActions),
[resolvedActions]
);
if (!regionId) {
throw new Error("FocusItem must be rendered inside a focus group.");
}
useEffect(() => {
return navigation.registerNavigationNode({
id: resolvedId,
regionId,
layerId,
navigationState: initialNavigationStateRef.current,
navigationOverrides: initialNavigationOverridesRef.current,
getElement: () => ref.current,
});
}, [layerId, navigation, regionId, resolvedId]);
useEffect(() => {
navigation.updateNavigationNode(resolvedId, {
navigationState,
navigationOverrides,
});
}, [navigation, navigationOverrides, navigationState, resolvedId]);
useEffect(() => {
return navigationItemActions.registerItemActions({
itemId: resolvedId,
actions: resolvedActions,
getElement: () => ref.current,
});
}, [navigationItemActions, resolvedActions, resolvedId]);
useEffect(() => {
if (!isFocused) return;
const element = ref.current;
if (!element) return;
try {
element.focus({ preventScroll: true });
} catch {
element.focus();
}
}, [isFocused]);
const Component = asChild ? Slot : "div";
return (
<FocusItemActionsMetaContext.Provider value={actionsMeta}>
<Component
id={resolvedId}
ref={ref}
data-focused={isFocused}
data-focus-visible={isFocused || undefined}
data-focus-wrapper={!asChild || undefined}
data-has-primary={actionsMeta.hasPrimary || undefined}
data-has-secondary={actionsMeta.hasSecondary || undefined}
data-has-press-x={actionsMeta.hasPressX || undefined}
data-has-press-y={actionsMeta.hasPressY || undefined}
data-has-hold-a={actionsMeta.hasHoldA || undefined}
data-has-hold-b={actionsMeta.hasHoldB || undefined}
data-has-hold-x={actionsMeta.hasHoldX || undefined}
data-navigation-state={navigationState}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={isFocused ? 0 : -1}
style={asChild ? undefined : { outline: "none" }}
>
{children}
</Component>
</FocusItemActionsMetaContext.Provider>
);
}
@@ -0,0 +1,23 @@
import "./styles.scss";
interface GameCardProps {
coverImageUrl?: string | null;
gameTitle: string;
}
function GameCard({ coverImageUrl, gameTitle }: Readonly<GameCardProps>) {
return (
<div className="big-picture__game-card">
<div className="big-picture__game-card__cover">
{coverImageUrl ? (
<img src={coverImageUrl} alt={gameTitle} draggable={false} />
) : (
<div className="big-picture__game-card__cover--placeholder" />
)}
</div>
<span className="big-picture__game-card__title">{gameTitle}</span>
</div>
);
}
export { GameCard };
@@ -0,0 +1,43 @@
.big-picture__game-card {
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) * 2);
width: 100%;
min-width: 0;
cursor: pointer;
&__cover {
width: 100%;
aspect-ratio: 3 / 4;
border-radius: calc(var(--spacing-unit) * 2);
overflow: hidden;
background-color: var(--surface);
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
}
&--placeholder {
width: 100%;
height: 100%;
background-color: var(--secondary);
}
}
&:hover &__cover img {
transform: scale(1.04);
}
&__title {
color: var(--text);
font-size: 13px;
font-weight: 500;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@@ -0,0 +1,95 @@
import {
FocusRegionContext,
useFocusLayerId,
useFocusRegionId,
} from "../../context";
import {
type FocusAutoScrollMode,
type FocusOverrides,
NavigationService,
} from "../../../services";
import {
type CSSProperties,
type ReactNode,
useEffect,
useId,
useRef,
} from "react";
interface GridFocusGroupProps {
regionId?: string;
navigationOverrides?: FocusOverrides;
autoScrollMode?: FocusAutoScrollMode;
getScrollAnchor?: () => HTMLElement | null;
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export function GridFocusGroup({
regionId,
navigationOverrides,
autoScrollMode = "row",
getScrollAnchor,
className,
style,
children,
}: Readonly<GridFocusGroupProps>) {
const generatedId = useId();
const parentRegionId = useFocusRegionId();
const layerId = useFocusLayerId();
const navigation = NavigationService.getInstance();
const initialNavigationOverridesRef = useRef(navigationOverrides);
const initialGetScrollAnchorRef = useRef(getScrollAnchor);
const ref = useRef<HTMLDivElement | null>(null);
const resolvedRegionId =
regionId ?? `focus-region-${generatedId.replaceAll(":", "")}`;
useEffect(() => {
return navigation.registerRegion({
id: resolvedRegionId,
parentRegionId,
orientation: "grid",
layerId,
navigationOverrides: initialNavigationOverridesRef.current,
autoScrollMode,
isPersistent: Boolean(regionId),
getElement: () => ref.current,
getScrollAnchor: initialGetScrollAnchorRef.current,
});
}, [
autoScrollMode,
layerId,
navigation,
parentRegionId,
regionId,
resolvedRegionId,
]);
useEffect(() => {
navigation.updateRegion(resolvedRegionId, {
autoScrollMode,
getScrollAnchor,
navigationOverrides,
});
}, [
autoScrollMode,
getScrollAnchor,
navigation,
navigationOverrides,
resolvedRegionId,
]);
return (
<FocusRegionContext.Provider value={resolvedRegionId}>
<div
ref={ref}
data-focus-region-id={resolvedRegionId}
className={className}
style={style}
>
{children}
</div>
</FocusRegionContext.Provider>
);
}
@@ -0,0 +1,40 @@
import "./styles.scss";
import type { ReactNode } from "react";
interface HorizontalCardProps {
image: string;
title: string;
description: string;
action: ReactNode;
}
export function HorizontalCard({
image,
title,
description,
action,
}: Readonly<HorizontalCardProps>) {
return (
<div className="horizontal-card">
<div className="horizontal-card__image">
<img
src={image}
width={268}
height={136}
alt={title}
draggable={false}
/>
</div>
<div className="horizontal-card__content">
<div className="horizontal-card__content__info">
<h3 className="horizontal-card__content__info__title">{title}</h3>
<p className="horizontal-card__content__info__description">
{description}
</p>
</div>
<div className="horizontal-card__content__action">{action}</div>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
.horizontal-card {
display: flex;
flex-direction: column;
align-items: center;
border-radius: var(--spacing-unit);
transition:
background-color 0.1s ease-in-out,
border 0.1s ease-in-out;
border: 1px solid transparent;
cursor: pointer;
&:hover {
background-color: var(--secondary);
border: 1px solid var(--secondary-border);
.horizontal-card__content__action {
opacity: 1;
}
}
&__image {
width: 268px;
height: 136px;
border-radius: var(--spacing-unit);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
&__content {
display: flex;
flex-direction: row;
gap: calc(var(--spacing-unit) * 2);
align-items: center;
justify-content: space-between;
padding: calc(var(--spacing-unit) * 2);
width: 100%;
&__info {
display: flex;
flex-direction: column;
gap: var(--spacing-unit);
&__title {
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: normal;
color: var(--primary);
opacity: 0.8;
}
&__description {
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
color: var(--primary);
opacity: 0.5;
}
}
&__action {
opacity: 0;
transition: opacity 0.1s ease-in-out;
}
}
}
@@ -0,0 +1,109 @@
import { Slot } from "@radix-ui/react-slot";
import {
FocusRegionContext,
useFocusLayerId,
useFocusRegionId,
} from "../../context";
import {
type FocusAutoScrollMode,
type FocusOverrides,
NavigationService,
} from "../../../services";
import {
type HTMLAttributes,
type ReactNode,
useEffect,
useId,
useRef,
} from "react";
interface HorizontalFocusGroupProps extends HTMLAttributes<HTMLDivElement> {
regionId?: string;
navigationOverrides?: FocusOverrides;
autoScrollMode?: FocusAutoScrollMode;
getScrollAnchor?: () => HTMLElement | null;
asChild?: boolean;
children: ReactNode;
}
export function HorizontalFocusGroup({
regionId,
navigationOverrides,
autoScrollMode = "region",
getScrollAnchor,
asChild = false,
className,
style,
children,
...props
}: Readonly<HorizontalFocusGroupProps>) {
const generatedId = useId();
const parentRegionId = useFocusRegionId();
const layerId = useFocusLayerId();
const navigation = NavigationService.getInstance();
const initialNavigationOverridesRef = useRef(navigationOverrides);
const initialGetScrollAnchorRef = useRef(getScrollAnchor);
const ref = useRef<HTMLDivElement | null>(null);
const resolvedRegionId =
regionId ?? `focus-region-${generatedId.replaceAll(":", "")}`;
useEffect(() => {
return navigation.registerRegion({
id: resolvedRegionId,
parentRegionId,
orientation: "horizontal",
layerId,
navigationOverrides: initialNavigationOverridesRef.current,
autoScrollMode,
isPersistent: Boolean(regionId),
getElement: () => ref.current,
getScrollAnchor: initialGetScrollAnchorRef.current,
});
}, [
autoScrollMode,
layerId,
navigation,
parentRegionId,
regionId,
resolvedRegionId,
]);
useEffect(() => {
navigation.updateRegion(resolvedRegionId, {
autoScrollMode,
getScrollAnchor,
navigationOverrides,
});
}, [
autoScrollMode,
getScrollAnchor,
navigation,
navigationOverrides,
resolvedRegionId,
]);
const Component = asChild ? Slot : "div";
return (
<FocusRegionContext.Provider value={resolvedRegionId}>
<Component
ref={ref}
className={className}
data-focus-region-id={resolvedRegionId}
style={
asChild
? style
: {
display: "flex",
alignItems: "center",
gap: 16,
...style,
}
}
{...props}
>
{children}
</Component>
</FocusRegionContext.Provider>
);
}
@@ -0,0 +1,16 @@
import "./styles.scss";
import { Backdrop } from "../backdrop";
export interface ImageLightboxProps {
src: string;
alt: string;
}
export function ImageLightbox({ src, alt }: Readonly<ImageLightboxProps>) {
return (
<Backdrop>
<img src={src} alt={alt} className="image-lightbox" draggable={false} />
</Backdrop>
);
}
@@ -0,0 +1,6 @@
.image-lightbox {
object-fit: cover;
user-select: none;
width: 60%;
max-width: 1000px;
}
@@ -0,0 +1,36 @@
export * from "./accordion";
export * from "./animated-hero-image";
export * from "./backdrop";
export * from "./button";
export * from "./checkbox";
export * from "./chip";
export * from "./divider";
export * from "./game-card";
export * from "./horizontal-card";
export * from "./image-lightbox";
export * from "./input";
export * from "./list-card";
export * from "./modal";
export * from "./route-anchor";
export * from "./scroll-area";
export * from "./source-anchor";
export * from "./tabs";
export * from "./tooltip";
export * from "./typography";
export * from "./user-profile";
export * from "./vertical-game-card";
export * from "./vertical-store-game-card";
export * from "./horizontal-focus-group";
export * from "./vertical-focus-group";
export * from "./grid-focus-group";
export * from "./navigation-layer";
export * from "./focus-item";
export * from "./diagnostics";
export * from "./scroll-area";
export * from "./route-anchor";
export * from "./button";
export * from "./input";
export * from "./typography";
export * from "./divider";
export * from "./user-profile";
export * from "./box";
@@ -0,0 +1,98 @@
import "./styles.scss";
import cn from "classnames";
import { Typography } from "../typography";
import {
forwardRef,
type InputHTMLAttributes,
type ReactNode,
useRef,
} from "react";
import { FocusItem } from "..";
import type { FocusOverrides, NavigationNodeState } from "../../../services";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
hint?: string;
error?: boolean;
iconLeft?: ReactNode;
iconRight?: ReactNode;
focusId?: string;
focusNavigationOverrides?: FocusOverrides;
focusNavigationState?: NavigationNodeState;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
type = "text",
placeholder = "Placeholder",
label,
hint,
error = false,
disabled = false,
iconLeft,
iconRight,
focusId,
focusNavigationOverrides,
focusNavigationState,
...props
},
ref
) {
const inputRef = useRef<HTMLInputElement | null>(null);
const resolvedFocusNavigationState =
focusNavigationState ?? (disabled ? "disabled" : "active");
const setInputRef = (element: HTMLInputElement | null) => {
inputRef.current = element;
if (typeof ref === "function") {
ref(element);
} else if (ref) {
ref.current = element;
}
};
return (
<div className="input-container">
{label && (
<Typography variant="label" className="input-label">
{label}
</Typography>
)}
<div className="input-wrapper">
<FocusItem
id={focusId}
actions={{ primary: () => inputRef.current?.focus() }}
navigationOverrides={focusNavigationOverrides}
navigationState={resolvedFocusNavigationState}
>
<input
ref={setInputRef}
id="input"
type={type}
placeholder={placeholder}
disabled={disabled}
data-icon-left={iconLeft ? "true" : undefined}
data-icon-right={iconRight ? "true" : undefined}
className={`input ${error ? "input--error" : ""} ${
disabled ? "input--disabled" : ""
}`}
{...props}
/>
</FocusItem>
{iconLeft && (
<div className="input-icon input-icon--left">{iconLeft}</div>
)}
{iconRight && (
<div className="input-icon input-icon--right">{iconRight}</div>
)}
</div>
{hint && (
<p className={cn("input-hint", { "input-hint--error": error })}>
{hint}
</p>
)}
</div>
);
});
@@ -0,0 +1,121 @@
.input {
width: 100%;
height: 44px;
padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 4);
border-radius: calc(var(--spacing-unit) * 2);
font-family: var(--font-space-grotesk);
font-size: 14px;
line-height: 16.59px;
border: 1px solid var(--secondary-border);
background-color: var(--background);
color: var(--text);
transition: border-color 0.2s ease-in-out;
&[data-icon-left="true"] {
padding-left: 46px;
}
&[data-icon-right="true"] {
padding-right: 46px;
}
&:hover {
border-color: var(--secondary-hover);
}
&:focus-visible,
[data-focus-visible="true"] & {
outline: none;
border-color: rgba(255, 255, 255, 0.6);
}
&:disabled {
opacity: 0.5;
pointer-events: none;
}
&::placeholder {
color: var(--text-secondary);
}
&--error {
border-color: var(--error);
&:hover {
border-color: var(--error);
}
&:focus-visible {
border-color: var(--error);
}
}
&-container {
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) * 2);
width: 100%;
}
&-wrapper {
position: relative;
display: flex;
align-items: center;
gap: calc(var(--spacing-unit) * 2);
width: 100%;
> [data-focus-wrapper] {
width: 100%;
}
&:hover {
.input-icon--left {
color: var(--text-primary);
}
}
&--icon::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
z-index: 2;
}
}
&-icon {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: color 0.2s ease-in-out;
&--left {
left: 10px;
}
&--right {
right: 10px;
}
}
&-label {
color: var(--text-secondary);
font-size: 12px;
line-height: normal;
}
&-hint {
color: var(--text-secondary);
font-size: 10px;
line-height: normal;
&--error {
color: var(--error);
}
}
}
@@ -0,0 +1,55 @@
import "./styles.scss";
import { SourceAnchor } from "../source-anchor";
import type { AnchorHTMLAttributes, ReactNode } from "react";
export interface ListCardProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
title: string;
description: string;
image: string;
action?: ReactNode;
sources?: string[];
href: string;
}
export function ListCard({
title,
description,
image,
action,
sources,
href,
...props
}: Readonly<ListCardProps>) {
return (
<a href={href} {...props}>
<div className="list-card">
<div className="list-card__image">
<img
src={image}
alt="Game List Card"
width={200}
height={100}
draggable={false}
/>
</div>
<div className="list-card__content">
<div className="list-card__content__info">
<h3 className="list-card__content__info__title">{title}</h3>
<p className="list-card__content__info__description">
{description}
</p>
{sources && (
<div className="list-card__content__info__sources">
{sources.map((source) => (
<SourceAnchor title={source} href="/" key={source} />
))}
</div>
)}
</div>
<div className="list-card__content__action">{action}</div>
</div>
</div>
</a>
);
}
@@ -0,0 +1,81 @@
.list-card {
display: flex;
flex-direction: row;
height: 100px;
width: 100%;
border-radius: calc(var(--spacing-unit) * 2);
align-items: center;
overflow: hidden;
transition: background-color 0.1s ease-in-out;
&:hover {
background-color: var(--secondary);
}
&__image {
width: 200px;
height: 100px;
border-radius: var(--spacing-unit);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
&__content {
display: flex;
flex-direction: row;
gap: var(--spacing-unit);
padding: calc(var(--spacing-unit) * 4);
justify-content: space-between;
flex: 1;
height: 100px;
&:hover &__action {
opacity: 1;
}
&__info {
display: flex;
flex-direction: column;
gap: var(--spacing-unit);
&__title {
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: normal;
color: var(--primary);
opacity: 0.8;
}
&__description {
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
color: var(--primary);
opacity: 0.5;
}
&__sources {
margin-top: var(--spacing-unit);
display: flex;
flex-direction: row;
gap: calc(var(--spacing-unit) * 2);
}
}
&__action {
opacity: 0;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.05s ease-in-out;
margin-right: calc(var(--spacing-unit) * 2);
}
}
}
@@ -0,0 +1,103 @@
import "./styles.scss";
import { useCallback, useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import cn from "classnames";
import { Backdrop } from "../backdrop";
import { IS_BROWSER } from "../../../constants";
export interface ModalProps {
visible: boolean;
onClose: () => void;
children: ReactNode;
clickOutsideToClose?: boolean;
className?: string;
}
export function Modal({
visible,
onClose,
children,
clickOutsideToClose = true,
className,
}: Readonly<ModalProps>) {
const modalContentRef = useRef<HTMLDivElement | null>(null);
const isTopMostModal = () => {
if (
document.querySelector(
".featurebase-widget-overlay.featurebase-display-block"
)
)
return false;
const openModals = document.querySelectorAll("[role=dialog]");
return (
openModals.length &&
openModals[openModals.length - 1] === modalContentRef.current
);
};
const handleCloseClick = useCallback(() => {
onClose();
}, [onClose]);
useEffect(() => {
if (!visible) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isTopMostModal()) {
handleCloseClick();
}
};
globalThis.window.addEventListener("keydown", onKeyDown);
return () => globalThis.window.removeEventListener("keydown", onKeyDown);
}, [visible, handleCloseClick]);
useEffect(() => {
if (!clickOutsideToClose || !visible) return;
const onMouseDown = (e: MouseEvent) => {
if (!isTopMostModal()) return;
if (
modalContentRef.current &&
!modalContentRef.current.contains(e.target as Node)
) {
handleCloseClick();
}
};
globalThis.window.addEventListener("mousedown", onMouseDown);
return () =>
globalThis.window.removeEventListener("mousedown", onMouseDown);
}, [clickOutsideToClose, visible, handleCloseClick]);
if (!IS_BROWSER) return null;
const portalTarget = document.getElementById("root") ?? document.body;
return createPortal(
<AnimatePresence>
{visible && (
<Backdrop>
<motion.aside
role="dialog"
ref={modalContentRef}
data-hydra-dialog
className={cn("modal", className)}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.2, ease: [0.33, 1, 0.68, 1] }}
>
{children}
</motion.aside>
</Backdrop>
)}
</AnimatePresence>,
portalTarget
);
}
@@ -0,0 +1,10 @@
.modal {
border-radius: 8px;
min-width: 400px;
max-width: 600px;
max-height: 100%;
overflow: hidden;
background-color: #0e0e0e;
display: flex;
flex-direction: column;
}
@@ -0,0 +1,55 @@
import { NavigationService } from "../../../services";
import { FocusLayerContext } from "../../context";
import { type ReactNode, useEffect, useId } from "react";
interface NavigationLayerProps {
layerId?: string;
rootRegionId?: string;
initialFocusId?: string;
initialFocusRegionId?: string;
children: ReactNode;
}
export function NavigationLayer({
layerId,
rootRegionId,
initialFocusId,
initialFocusRegionId,
children,
}: Readonly<NavigationLayerProps>) {
const generatedId = useId();
const navigation = NavigationService.getInstance();
const resolvedLayerId =
layerId ?? `navigation-layer-${generatedId.replaceAll(":", "")}`;
useEffect(() => {
const unregisterLayer = navigation.registerLayer({
id: resolvedLayerId,
rootRegionId,
isPersistent: Boolean(layerId),
});
navigation.focusInitialInLayer({
layerId: resolvedLayerId,
initialFocusId,
initialFocusRegionId,
});
return () => {
unregisterLayer();
};
}, [
initialFocusId,
initialFocusRegionId,
layerId,
navigation,
resolvedLayerId,
rootRegionId,
]);
return (
<FocusLayerContext.Provider value={resolvedLayerId}>
{children}
</FocusLayerContext.Provider>
);
}
@@ -0,0 +1,74 @@
import "./styles.scss";
import { HeartStraightIcon } from "@phosphor-icons/react";
import type { AnchorHTMLAttributes, ReactNode } from "react";
import { Link } from "react-router-dom";
import { FocusItem } from "..";
import type { FocusOverrides } from "../../../services";
export interface RouteAnchorProps
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
label: string;
icon: ReactNode | string;
href: string;
active?: boolean;
disabled?: boolean;
isFavorite?: boolean;
focusId?: string;
focusNavigationOverrides?: FocusOverrides;
}
export const RouteAnchor = ({
href,
label,
icon,
active = false,
disabled = false,
isFavorite = false,
focusId,
focusNavigationOverrides,
...props
}: Readonly<RouteAnchorProps>) => {
const isGameIcon = typeof icon === "string";
return (
<div
className={`state-wrapper ${disabled ? "state-wrapper--disabled" : ""} ${active ? "state-wrapper--active" : ""}`}
>
<FocusItem id={focusId} navigationOverrides={focusNavigationOverrides}>
<Link to={href} {...props}>
<div
className={`route-anchor ${active ? "route-anchor--active" : ""} ${!isGameIcon ? "route-anchor--extra-padding" : ""}`}
>
<div
className={`route-anchor__icon ${isGameIcon ? "route-anchor__icon--large-size" : "route-anchor__icon--small-size"}`}
>
{isGameIcon ? (
<img
src={icon}
alt={label}
width={32}
height={32}
draggable={false}
/>
) : (
icon
)}
</div>
<div className="route-anchor__label">{label}</div>
{isFavorite && (
<div className="route-anchor__favorite">
<HeartStraightIcon
size={18}
weight="fill"
className="route-anchor__favorite__icon"
/>
</div>
)}
</div>
</Link>
</FocusItem>
</div>
);
};
@@ -0,0 +1,125 @@
.route-anchor {
display: flex;
align-items: center;
gap: calc(var(--spacing-unit) * 3);
padding: calc(var(--spacing-unit) * 2);
border-radius: var(--spacing-unit);
transition: background-color 0.2s ease-in-out;
width: 100%;
color: var(--text);
user-select: none;
&--extra-padding {
padding: calc(var(--spacing-unit) * 3);
}
&--active {
color: var(--primary);
background-color: var(--secondary-hover);
}
&:not(&--active):hover {
background-color: var(--secondary);
}
[data-focus-visible="true"] & {
box-shadow: inset 0 0 0 1px var(--text-secondary);
}
[data-focus-visible="true"] &:not(&--active) {
background-color: var(--secondary);
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: calc(var(--spacing-unit) / 2);
flex-shrink: 0;
overflow: hidden;
&--small-size {
width: 24px;
height: 24px;
}
&--large-size {
width: 32px;
height: 32px;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
&__label {
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
line-clamp: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__favorite {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
}
}
.state-wrapper {
width: 100%;
&--disabled {
opacity: 0.5;
pointer-events: none;
}
&--active {
pointer-events: none;
}
}
.tooltip-content {
background-color: var(--secondary);
color: var(--text);
padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 4);
border-radius: var(--spacing-unit);
justify-content: center;
align-items: center;
display: flex;
animation-duration: 200ms;
animation-timing-function: ease-in-out;
will-change: transform, opacity;
&[data-state="delayed-open"] {
animation-name: fadeIn;
}
&__label {
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(-2px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@@ -0,0 +1,52 @@
import "./styles.scss";
import { type ReactNode, useRef, useEffect } from "react";
import cn from "classnames";
export interface ScrollAreaProps {
children: ReactNode;
className?: string;
onScroll?: (info: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}) => void;
showScrollbar?: boolean;
}
export function ScrollArea({
children,
className,
onScroll,
showScrollbar = false,
}: Readonly<ScrollAreaProps>) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!onScroll) return;
const el = ref.current;
if (!el) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = el;
onScroll({ scrollTop, scrollHeight, clientHeight });
};
el.addEventListener("scroll", handleScroll);
return () => el.removeEventListener("scroll", handleScroll);
}, [onScroll]);
return (
<div
ref={ref}
className={cn(
"scroll-area",
className,
!showScrollbar && "scroll-area--hide-scrollbar"
)}
>
{children}
</div>
);
}
@@ -0,0 +1,14 @@
.scroll-area {
height: 100%;
width: 100%;
overflow-y: auto;
&--hide-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
}
@@ -0,0 +1,31 @@
import "./styles.scss";
import type { AnchorHTMLAttributes } from "react";
export interface SourceAnchorProps
extends AnchorHTMLAttributes<HTMLAnchorElement> {
title: string;
href?: string;
}
export function SourceAnchor({
title,
href,
...props
}: Readonly<SourceAnchorProps>) {
return (
<>
{href ? (
<a href={href} {...props}>
<div className="source-anchor source-anchor--link">
<p className="source-anchor__title">{title}</p>
</div>
</a>
) : (
<div className="source-anchor">
<p className="source-anchor__title">{title}</p>
</div>
)}
</>
);
}
@@ -0,0 +1,30 @@
.source-anchor {
background-color: var(--surface);
padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3);
justify-content: center;
align-items: center;
display: flex;
border-radius: var(--spacing-unit);
transition: background-color 0.15s ease-in-out;
.source-anchor__title {
color: var(--primary-hover);
font-size: 12px;
font-style: normal;
font-weight: 400;
}
}
.source-anchor--link {
cursor: pointer;
&:hover {
background-color: var(--secondary);
}
&:focus-visible {
outline: 1px solid var(--primary);
outline-offset: 2px;
border-radius: var(--spacing-unit);
}
}
@@ -0,0 +1,135 @@
import "./styles.scss";
import { motion } from "framer-motion";
import cn from "classnames";
import {
type CSSProperties,
type ReactNode,
useId,
useMemo,
useState,
} from "react";
import { FocusItem } from "../focus-item";
import { HorizontalFocusGroup } from "../horizontal-focus-group";
import type { FocusOverrides } from "../../../services";
export interface TabsItem<TValue extends string = string> {
id?: string;
value: TValue;
label: ReactNode;
disabled?: boolean;
navigationOverrides?: FocusOverrides;
}
export interface TabsProps<TValue extends string = string> {
items: Array<TabsItem<TValue>>;
value?: TValue;
defaultValue?: TValue;
onValueChange?: (value: TValue) => void;
trailingAction?: ReactNode;
regionId?: string;
navigationOverrides?: FocusOverrides;
ariaLabel?: string;
className?: string;
}
export function Tabs<TValue extends string = string>({
items,
value,
defaultValue,
onValueChange,
trailingAction,
regionId,
navigationOverrides,
ariaLabel = "Tabs",
className,
}: Readonly<TabsProps<TValue>>) {
const generatedId = useId();
const [internalValue, setInternalValue] = useState<TValue | undefined>(
defaultValue ?? items[0]?.value
);
const selectedValue = value ?? internalValue;
const indicatorLayoutId = `tabs-indicator-${generatedId}`;
const selectedItem = useMemo(
() => items.find((item) => item.value === selectedValue),
[items, selectedValue]
);
const handleSelect = (nextValue: TValue) => {
setInternalValue(nextValue);
onValueChange?.(nextValue);
};
if (items.length === 0) {
return null;
}
return (
<div className={cn("tabs", className)}>
<div className="tabs__content">
<HorizontalFocusGroup
regionId={regionId}
navigationOverrides={navigationOverrides}
autoScrollMode="region"
className="tabs__list"
role="tablist"
aria-label={ariaLabel}
style={
{
gap: "calc(var(--spacing-unit) * 12)",
alignItems: "flex-start",
} as CSSProperties
}
>
{items.map((item) => {
const isSelected = selectedItem?.value === item.value;
return (
<FocusItem
key={item.value}
id={item.id}
asChild
navigationState={item.disabled ? "disabled" : "active"}
navigationOverrides={item.navigationOverrides}
>
<button
type="button"
role="tab"
aria-selected={isSelected}
disabled={item.disabled}
className={cn("tabs__tab", {
"tabs__tab--active": isSelected,
"tabs__tab--disabled": item.disabled,
})}
onClick={() => handleSelect(item.value)}
>
<span className="tabs__tab-label">{item.label}</span>
{isSelected && (
<motion.span
className="tabs__indicator"
layoutId={indicatorLayoutId}
transition={{
type: "spring",
stiffness: 420,
damping: 34,
mass: 0.8,
}}
/>
)}
</button>
</FocusItem>
);
})}
</HorizontalFocusGroup>
{trailingAction && (
<div className="tabs__trailing-action">{trailingAction}</div>
)}
</div>
<div className="tabs__divider" aria-hidden="true" />
</div>
);
}
@@ -0,0 +1,98 @@
.tabs {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
&__content {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: calc(var(--spacing-unit) * 12);
padding: 0 calc(var(--spacing-unit) * 4);
}
&__list {
min-width: 0;
flex: 1;
}
&__tab {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 0 0 calc(var(--spacing-unit) * 3 + 3px);
border: 0;
background: transparent;
color: var(--primary);
opacity: 0.5;
font-family: var(--font-space-grotesk);
font-size: 14px;
font-weight: 400;
line-height: normal;
letter-spacing: 0;
white-space: nowrap;
cursor: pointer;
transition: opacity 0.2s ease-in-out;
&:hover,
&:focus-visible,
&[data-focus-visible="true"],
&--active {
opacity: 0.8;
}
&:focus-visible,
&[data-focus-visible="true"] {
outline: none;
}
&--disabled {
cursor: not-allowed;
opacity: 0.3;
}
}
&__tab-label {
display: inline-block;
transform: scale(1);
transform-origin: center bottom;
transition: transform 0.2s cubic-bezier(0.33, 1, 0.68, 1);
}
&__tab:focus-visible &__tab-label,
&__tab[data-focus-visible="true"]:not(:hover) &__tab-label {
transform: scale(1.03);
}
@media (prefers-reduced-motion: reduce) {
&__tab-label {
transition: none;
}
}
&__indicator {
position: absolute;
right: calc(var(--spacing-unit) * -1);
bottom: 0;
left: calc(var(--spacing-unit) * -1);
height: 3px;
background-color: var(--text);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.15);
}
&__trailing-action {
display: flex;
align-items: center;
flex-shrink: 0;
}
&__divider {
width: 100%;
height: 1px;
margin-top: -1px;
background-color: var(--secondary-border);
}
}
@@ -0,0 +1,61 @@
import "./styles.scss";
import { useState, type CSSProperties, type ReactNode } from "react";
export interface TooltipProps {
children: ReactNode;
content: string;
position?: "top" | "bottom" | "left" | "right";
showArrow?: boolean;
offset?: number;
active?: boolean;
className?: string;
id?: string;
style?: CSSProperties;
}
export function Tooltip({
children,
content,
position = "top",
offset = 8,
showArrow = true,
active = true,
className = "",
id,
style,
}: Readonly<TooltipProps>) {
const [isHovering, setIsHovering] = useState(false);
if (!active) return children;
const tooltipStyle = {
"--tooltip-offset": `${offset}px`,
} as CSSProperties;
return (
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className="tooltip"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsHovering(true)}
onBlur={() => setIsHovering(false)}
style={style}
>
{children}
{isHovering && (
<div
className={`tooltip__portal tooltip__content--${position} ${className}`}
data-offset={offset}
data-show-arrow={showArrow}
style={tooltipStyle}
role="tooltip"
id={id}
>
{content}
</div>
)}
</div>
);
}
@@ -0,0 +1,142 @@
.tooltip {
position: relative;
display: inline-flex;
width: fit-content;
height: fit-content;
}
.tooltip__portal {
--tooltip-offset: 8px;
position: absolute;
background-color: var(--secondary);
color: #fff;
display: flex;
justify-content: center;
align-items: center;
padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 4);
border-radius: var(--spacing-unit);
white-space: nowrap;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
z-index: 10000;
pointer-events: none;
&.tooltip__content--top {
left: 50%;
bottom: calc(100% + var(--tooltip-offset));
transform: translateX(-50%);
animation: fadeInUp 0.3s ease;
&[data-show-arrow="true"]::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: var(--secondary) transparent transparent transparent;
}
}
&.tooltip__content--bottom {
left: 50%;
top: calc(100% + var(--tooltip-offset));
transform: translateX(-50%);
animation: fadeInDown 0.3s ease;
&[data-show-arrow="true"]::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: transparent transparent var(--secondary) transparent;
}
}
&.tooltip__content--left {
right: calc(100% + var(--tooltip-offset));
top: 50%;
transform: translateY(-50%);
animation: fadeInLeft 0.3s ease;
&[data-show-arrow="true"]::after {
content: "";
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
border-width: 5px;
border-style: solid;
border-color: transparent transparent transparent var(--secondary);
}
}
&.tooltip__content--right {
left: calc(100% + var(--tooltip-offset));
top: 50%;
transform: translateY(-50%);
animation: fadeInRight 0.3s ease;
&[data-show-arrow="true"]::after {
content: "";
position: absolute;
right: 100%;
top: 50%;
transform: translateY(-50%);
border-width: 5px;
border-style: solid;
border-color: transparent var(--secondary) transparent transparent;
}
}
}
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateY(-50%) translateX(-2px);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateY(-50%) translateX(2px);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateX(-50%) translateY(2px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-2px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@@ -0,0 +1,64 @@
import "./styles.scss";
import cn from "classnames";
import type { HTMLAttributes } from "react";
export interface TypographyProps
extends HTMLAttributes<
HTMLHeadingElement | HTMLParagraphElement | HTMLLabelElement
> {
variant?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "body" | "label";
}
export function Typography({
children,
variant = "body",
...props
}: Readonly<TypographyProps>) {
const { className, ...rest } = props;
switch (variant) {
case "h1":
return (
<h1 {...rest} className={cn("typography typography--h1", className)}>
{children}
</h1>
);
case "h2":
return (
<h2 {...rest} className={cn("typography typography--h2", className)}>
{children}
</h2>
);
case "h3":
return (
<h3 {...rest} className={cn("typography typography--h3", className)}>
{children}
</h3>
);
case "h4":
return (
<h4 {...rest} className={cn("typography typography--h4", className)}>
{children}
</h4>
);
case "h5":
return (
<h5 {...rest} className={cn("typography typography--h5", className)}>
{children}
</h5>
);
case "h6":
return (
<h6 {...rest} className={cn("typography typography--h6", className)}>
{children}
</h6>
);
default:
return (
<p {...rest} className={cn("typography typography--body", className)}>
{children}
</p>
);
}
}
@@ -0,0 +1,40 @@
.typography {
color: var(--text);
&--h1 {
font-size: 2.5rem;
font-weight: 700;
line-height: 3.5rem;
}
&--h2 {
font-size: 2rem;
font-weight: 700;
}
&--h3 {
font-size: 1.5rem;
font-weight: 700;
}
&--h4 {
font-size: 1.25rem;
font-weight: 700;
}
&--h5 {
font-size: 1rem;
font-weight: 700;
}
&--h6 {
font-size: 0.875rem;
font-weight: 700;
}
&--body,
&--label {
font-size: 0.875rem;
font-weight: 400;
}
}
@@ -0,0 +1,122 @@
import "./styles.scss";
import {
BellIcon,
CheckIcon,
CopyIcon,
UsersIcon,
} from "@phosphor-icons/react";
import { useState } from "react";
import { Link } from "react-router-dom";
export interface UserProfileProps {
image: string;
name: string;
friendCode: string;
}
interface UserProfileContentProps {
image: string;
name: string;
friendCode: string;
}
interface UserProfileActionsProps {
friendsCount: number;
}
function UserProfileActions({
friendsCount,
}: Readonly<UserProfileActionsProps>) {
const [isHovering, setIsHovering] = useState(false);
return (
<div className="user-profile__actions">
<Link to="/friends" className="user-profile__actions__friends">
<UsersIcon size={20} className="user-profile__actions__friends__icon" />
<p className="user-profile__actions__friends__count">
<span className="user-profile__actions__friends__count__number">
{friendsCount}
</span>{" "}
<span className="user-profile__actions__friends__count__text">
friends online
</span>
</p>
</Link>
<button
className="user-profile__actions__notification"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<BellIcon size={20} weight={isHovering ? "fill" : "regular"} />
</button>
</div>
);
}
function UserProfileContent({
image,
name,
friendCode,
}: Readonly<UserProfileContentProps>) {
const [isCopied, setIsCopied] = useState(false);
const handleCopy = () => {
if (isCopied) return;
setIsCopied(true);
navigator.clipboard.writeText(friendCode).catch(() => {});
globalThis.window.setTimeout(() => {
setIsCopied(false);
}, 2000);
};
return (
<div className="user-profile-content">
<img
src={image}
alt={name}
className="user-profile-content__image"
width={48}
height={48}
draggable={false}
/>
<div className="user-profile-content__info">
<p className="user-profile-content__info__name">{name}</p>
<button
className="user-profile-content__info__friend-code"
onClick={handleCopy}
>
{friendCode}
{isCopied ? (
<CheckIcon
size={14}
className="user-profile-content__info__friend-code__icon"
/>
) : (
<CopyIcon
size={14}
className="user-profile-content__info__friend-code__icon"
/>
)}
</button>
</div>
</div>
);
}
export function UserProfile({
image,
name,
friendCode,
}: Readonly<UserProfileProps>) {
return (
<div className="user-profile-container">
<UserProfileContent image={image} name={name} friendCode={friendCode} />
<UserProfileActions friendsCount={8} />
</div>
);
}
@@ -0,0 +1,123 @@
.user-profile-container {
cursor: pointer;
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
margin-top: calc(var(--spacing-unit) * 2);
transition: background-color 0.2s ease-in-out;
background-color: var(--surface);
border-top: 1px solid var(--secondary-hover);
padding: calc(var(--spacing-unit) * 4) calc(var(--spacing-unit) * 3);
gap: calc(var(--spacing-unit) * 4);
}
.user-profile-content {
display: flex;
align-items: center;
gap: calc(var(--spacing-unit) * 3);
align-self: stretch;
&__image {
display: flex;
align-items: center;
width: 48px;
height: 48px;
border-radius: calc(var(--spacing-unit) * 1.5);
object-fit: cover;
object-position: center;
overflow: hidden;
flex-shrink: 0;
}
&__info {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: var(--spacing-unit);
&__name {
color: var(--Beta-Text-Main, #cecece);
font-size: 16px;
font-style: normal;
font-weight: 700;
line-height: normal;
}
&__friend-code {
color: var(--Beta-Text-Secondary, #838383);
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
display: flex;
align-items: center;
gap: var(--spacing-unit);
cursor: pointer;
&:hover {
.user-profile-content__info__friend-code__icon {
opacity: 1;
}
}
&__icon {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
}
}
}
.user-profile__actions {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
align-self: stretch;
&__friends {
display: flex;
flex-direction: row;
align-items: center;
gap: calc(var(--spacing-unit) * 2);
opacity: 0.5;
transition: opacity 0.2s ease-in-out;
padding: calc(var(--spacing-unit) * 1) calc(var(--spacing-unit) * 1.5);
&:hover {
opacity: 1;
}
&__count {
color: #fff;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
&__count__number {
font-weight: 600;
}
}
&__notification {
display: flex;
padding: var(--spacing-unit);
align-items: center;
opacity: 0.5;
transition: opacity 0.2s ease-in-out;
&:hover {
opacity: 1;
}
}
&__button {
display: flex;
align-items: center;
gap: var(--spacing-unit);
}
}
@@ -0,0 +1,109 @@
import { Slot } from "@radix-ui/react-slot";
import {
FocusRegionContext,
useFocusLayerId,
useFocusRegionId,
} from "../../context";
import {
type FocusAutoScrollMode,
type FocusOverrides,
NavigationService,
} from "../../../services";
import {
type HTMLAttributes,
type ReactNode,
useEffect,
useId,
useRef,
} from "react";
interface VerticalFocusGroupProps extends HTMLAttributes<HTMLDivElement> {
regionId?: string;
navigationOverrides?: FocusOverrides;
autoScrollMode?: FocusAutoScrollMode;
getScrollAnchor?: () => HTMLElement | null;
asChild?: boolean;
children: ReactNode;
}
export function VerticalFocusGroup({
regionId,
navigationOverrides,
autoScrollMode = "auto",
getScrollAnchor,
asChild = false,
className,
style,
children,
...props
}: Readonly<VerticalFocusGroupProps>) {
const generatedId = useId();
const parentRegionId = useFocusRegionId();
const layerId = useFocusLayerId();
const navigation = NavigationService.getInstance();
const initialNavigationOverridesRef = useRef(navigationOverrides);
const initialGetScrollAnchorRef = useRef(getScrollAnchor);
const ref = useRef<HTMLDivElement | null>(null);
const resolvedRegionId =
regionId ?? `focus-region-${generatedId.replaceAll(":", "")}`;
useEffect(() => {
return navigation.registerRegion({
id: resolvedRegionId,
parentRegionId,
orientation: "vertical",
layerId,
navigationOverrides: initialNavigationOverridesRef.current,
autoScrollMode,
isPersistent: Boolean(regionId),
getElement: () => ref.current,
getScrollAnchor: initialGetScrollAnchorRef.current,
});
}, [
autoScrollMode,
layerId,
navigation,
parentRegionId,
regionId,
resolvedRegionId,
]);
useEffect(() => {
navigation.updateRegion(resolvedRegionId, {
autoScrollMode,
getScrollAnchor,
navigationOverrides,
});
}, [
autoScrollMode,
getScrollAnchor,
navigation,
navigationOverrides,
resolvedRegionId,
]);
const Component = asChild ? Slot : "div";
return (
<FocusRegionContext.Provider value={resolvedRegionId}>
<Component
ref={ref}
className={className}
data-focus-region-id={resolvedRegionId}
style={
asChild
? style
: {
display: "flex",
flexDirection: "column",
gap: 16,
...style,
}
}
{...props}
>
{children}
</Component>
</FocusRegionContext.Provider>
);
}
@@ -0,0 +1,115 @@
import "./styles.scss";
import { SparkleIcon, TrophyIcon } from "@phosphor-icons/react";
import cn from "classnames";
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
export interface VerticalGameCardProps {
coverImageUrl?: string | null;
gameTitle: string;
subtitle: string;
progressLabel?: string;
progressValue?: number;
progressColor?: string;
action?: ReactNode;
forceHovered?: boolean;
className?: string;
onClick?: () => void;
onCoverImageError?: () => void;
}
const DEFAULT_PROGRESS_COLOR = "var(--alert)";
function clampProgress(value: number) {
return Math.min(1, Math.max(0, value));
}
export function VerticalGameCard({
coverImageUrl,
gameTitle,
subtitle,
progressLabel,
progressValue,
progressColor = DEFAULT_PROGRESS_COLOR,
action,
forceHovered = false,
className,
onClick,
onCoverImageError,
}: Readonly<VerticalGameCardProps>) {
const hasProgress =
progressLabel != null &&
progressValue != null &&
!Number.isNaN(progressValue);
const normalizedProgress = hasProgress ? clampProgress(progressValue) : 0;
const isCompleted = hasProgress && normalizedProgress === 1;
const customProperties = {
"--vertical-game-card-progress-color": progressColor,
"--vertical-game-card-progress-value": normalizedProgress,
} as CSSProperties;
const ProgressIcon = isCompleted ? SparkleIcon : TrophyIcon;
const handleCardKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (onClick == null) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
};
return (
<div
className={cn("vertical-game-card", className, {
"vertical-game-card--completed": isCompleted,
"vertical-game-card--force-hovered": forceHovered,
})}
style={customProperties}
onClick={onClick}
onKeyDown={onClick != null ? handleCardKeyDown : undefined}
role={onClick != null ? "button" : undefined}
tabIndex={onClick != null ? 0 : undefined}
>
<div className="vertical-game-card__cover">
{coverImageUrl ? (
<img
src={coverImageUrl}
alt={gameTitle}
draggable={false}
onError={onCoverImageError}
/>
) : (
<div
className="vertical-game-card__cover-placeholder"
aria-hidden="true"
/>
)}
</div>
<div className="vertical-game-card__body">
<div className="vertical-game-card__info">
<div className="vertical-game-card__text">
<h3 className="vertical-game-card__title">{gameTitle}</h3>
<p className="vertical-game-card__subtitle">{subtitle}</p>
</div>
<div
className={cn("vertical-game-card__progress", {
"vertical-game-card__progress--placeholder": !hasProgress,
})}
aria-hidden={!hasProgress || undefined}
>
<div className="vertical-game-card__progress-label">
<ProgressIcon size={16} weight="fill" />
<span>{progressLabel ?? "0/0"}</span>
</div>
<div className="vertical-game-card__progress-track" />
</div>
</div>
{action && <div className="vertical-game-card__action">{action}</div>}
</div>
</div>
);
}
@@ -0,0 +1,269 @@
.vertical-game-card {
--vertical-game-card-progress-color: var(--alert);
--vertical-game-card-progress-value: 0;
width: min(350px, 100%);
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) * 2);
border: 1px solid transparent;
border-radius: calc(var(--spacing-unit) * 2);
background-color: transparent;
outline: none;
outline-offset: 3px;
transition:
background-color 0.2s ease-in-out,
border-color 0.2s ease-in-out,
outline-color 0.2s ease-in-out;
overflow: hidden;
&__cover {
position: relative;
width: 100%;
aspect-ratio: 268 / 400;
border-radius: var(--spacing-unit);
overflow: hidden;
background-color: var(--surface);
img,
&-placeholder {
width: 100%;
height: 100%;
display: block;
}
img {
object-fit: cover;
}
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 172%;
background: linear-gradient(
35deg,
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0.07) 51.5%,
rgba(255, 255, 255, 0.15) 64%,
rgba(255, 255, 255, 0.1) 100%
);
transform: translateY(-36%);
opacity: 0.5;
transition:
opacity 0.3s ease,
transform 0.3s ease;
pointer-events: none;
z-index: 1;
}
}
&__cover-placeholder {
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.02) 100%
),
var(--secondary);
}
&__body {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(var(--spacing-unit) * 2);
padding: 0 0 calc(var(--spacing-unit) * 2);
transition: padding 0.2s ease-in-out;
}
&__info {
min-width: 0;
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) * 2);
}
&__text {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-unit);
}
&__title,
&__subtitle,
&__progress-label span {
margin: 0;
font-size: 14px;
line-height: normal;
letter-spacing: 0;
}
&__title,
&__subtitle,
&__progress-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__title {
color: var(--text);
font-weight: 700;
}
&__subtitle {
color: var(--text-secondary);
font-weight: 400;
}
&__progress {
display: flex;
align-items: center;
gap: calc(var(--spacing-unit) * 2);
&--placeholder {
visibility: hidden;
}
}
&__progress-label {
min-width: 0;
display: flex;
align-items: center;
gap: var(--spacing-unit);
color: color-mix(
in srgb,
var(--vertical-game-card-progress-color) 70%,
white
);
font-weight: 400;
}
&__progress-track {
position: relative;
width: calc(var(--spacing-unit) * 25);
height: var(--spacing-unit);
border-radius: 999px;
background-color: color-mix(
in srgb,
var(--vertical-game-card-progress-color) 20%,
transparent
);
overflow: hidden;
flex-shrink: 0;
&::after {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: calc(100% * var(--vertical-game-card-progress-value));
border-radius: inherit;
background-color: var(--vertical-game-card-progress-color);
z-index: 1;
}
&::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
opacity: 0;
pointer-events: none;
z-index: 2;
}
}
&__action {
max-width: 0;
opacity: 0;
flex-shrink: 0;
overflow: hidden;
pointer-events: none;
transition:
max-width 0.2s ease-in-out,
opacity 0.2s ease-in-out;
}
&:hover,
&:focus-within,
&--force-hovered,
[data-focused="true"] > &,
[data-focus-visible="true"] > & {
background-color: var(--surface);
border-color: var(--secondary-border);
.vertical-game-card__cover::before {
opacity: 1;
transform: translateY(-20%);
}
.vertical-game-card__body {
padding-inline: calc(var(--spacing-unit) * 2);
}
.vertical-game-card__action {
max-width: calc(var(--spacing-unit) * 11);
opacity: 1;
pointer-events: auto;
}
}
[data-focus-visible="true"] > & {
outline: 2px solid var(--primary);
}
&--completed {
.vertical-game-card__progress-track {
&::before {
opacity: 0;
inset: 0 auto 0 -62%;
width: 62%;
background: linear-gradient(
100deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.03) 10%,
rgba(255, 255, 255, 0.08) 26%,
rgba(255, 255, 255, 0.16) 40%,
rgba(255, 255, 255, 0.25) 50%,
rgba(255, 255, 255, 0.16) 60%,
rgba(255, 255, 255, 0.08) 74%,
rgba(255, 255, 255, 0.03) 90%,
rgba(255, 255, 255, 0) 100%
);
animation: vertical-game-card-complete-shine 1.7s
cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
}
}
}
@keyframes vertical-game-card-complete-shine {
0% {
opacity: 0;
transform: translateX(0);
}
14% {
opacity: 0.5;
}
82% {
opacity: 0.5;
}
100% {
opacity: 0;
transform: translateX(262%);
}
}
@media (prefers-reduced-motion: reduce) {
.vertical-game-card--completed .vertical-game-card__progress-track::before {
animation: none;
opacity: 0;
}
}
@@ -0,0 +1,74 @@
import "./styles.scss";
import cn from "classnames";
import type { KeyboardEvent } from "react";
export interface VerticalStoreGameCardProps {
coverImageUrl?: string | null;
gameTitle: string;
downloadSourceCount: number;
forceHovered?: boolean;
className?: string;
onClick?: () => void;
onCoverImageError?: () => void;
}
function getDownloadSourcesLabel(downloadSourceCount: number) {
const normalizedCount = Math.max(0, downloadSourceCount);
const suffix = normalizedCount === 1 ? "source" : "sources";
return `${normalizedCount} download ${suffix}`;
}
export function VerticalStoreGameCard({
coverImageUrl,
gameTitle,
downloadSourceCount,
forceHovered = false,
className,
onClick,
onCoverImageError,
}: Readonly<VerticalStoreGameCardProps>) {
const handleCardKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (onClick == null) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
};
return (
<div
className={cn("vertical-store-game-card", className, {
"vertical-store-game-card--force-hovered": forceHovered,
})}
onClick={onClick}
onKeyDown={onClick != null ? handleCardKeyDown : undefined}
role={onClick != null ? "button" : undefined}
tabIndex={onClick != null ? 0 : undefined}
>
<div className="vertical-store-game-card__cover">
{coverImageUrl ? (
<img
src={coverImageUrl}
alt={gameTitle}
draggable={false}
onError={onCoverImageError}
/>
) : (
<div
className="vertical-store-game-card__cover-placeholder"
aria-hidden="true"
/>
)}
</div>
<div className="vertical-store-game-card__body">
<h3 className="vertical-store-game-card__title">{gameTitle}</h3>
<p className="vertical-store-game-card__subtitle">
{getDownloadSourcesLabel(downloadSourceCount)}
</p>
</div>
</div>
);
}
@@ -0,0 +1,122 @@
.vertical-store-game-card {
width: min(350px, 100%);
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) * 5);
border: 1px solid transparent;
border-radius: calc(var(--spacing-unit) * 2);
background-color: transparent;
outline: none;
outline-offset: 3px;
overflow: hidden;
transition:
background-color 0.2s ease-in-out,
border-color 0.2s ease-in-out,
outline-color 0.2s ease-in-out;
&__cover {
position: relative;
width: 100%;
aspect-ratio: 268 / 400;
border-radius: var(--spacing-unit);
overflow: hidden;
background-color: var(--surface);
img,
&-placeholder {
width: 100%;
height: 100%;
display: block;
}
img {
object-fit: cover;
}
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 172%;
background: linear-gradient(
35deg,
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0.07) 51.5%,
rgba(255, 255, 255, 0.15) 64%,
rgba(255, 255, 255, 0.1) 100%
);
transform: translateY(-36%);
opacity: 0.5;
transition:
opacity 0.3s ease,
transform 0.3s ease;
pointer-events: none;
z-index: 1;
}
}
&__cover-placeholder {
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.02) 100%
),
var(--secondary);
}
&__body {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-unit);
padding: 0 0 calc(var(--spacing-unit) * 2);
transition: padding 0.2s ease-in-out;
}
&__title,
&__subtitle {
margin: 0;
line-height: normal;
letter-spacing: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__title {
color: var(--text);
font-size: 14px;
font-weight: 700;
}
&__subtitle {
color: var(--text-secondary);
font-size: 14px;
font-weight: 400;
}
&:hover,
&:focus-within,
&--force-hovered,
[data-focused="true"] > &,
[data-focus-visible="true"] > & {
background-color: var(--surface);
border-color: var(--secondary-border);
.vertical-store-game-card__cover::before {
opacity: 1;
transform: translateY(-20%);
}
.vertical-store-game-card__body {
padding-inline: calc(var(--spacing-unit) * 2);
}
}
[data-focus-visible="true"] > & {
outline: 2px solid var(--primary);
}
}
@@ -0,0 +1,20 @@
import type { FocusItemActionsMeta } from "../../types";
import { createContext, useContext } from "react";
const defaultFocusItemActionsMeta: FocusItemActionsMeta = {
hasPrimary: false,
hasSecondary: false,
hasPressX: false,
hasPressY: false,
hasHoldA: false,
hasHoldB: false,
hasHoldX: false,
};
export const FocusItemActionsMetaContext = createContext<FocusItemActionsMeta>(
defaultFocusItemActionsMeta
);
export function useFocusItemActionsMeta() {
return useContext(FocusItemActionsMetaContext);
}
@@ -0,0 +1,10 @@
import { ROOT_NAVIGATION_LAYER_ID } from "../../services";
import { createContext, useContext } from "react";
export const FocusLayerContext = createContext<string>(
ROOT_NAVIGATION_LAYER_ID
);
export function useFocusLayerId() {
return useContext(FocusLayerContext);
}
@@ -0,0 +1,7 @@
import { createContext, useContext } from "react";
export const FocusRegionContext = createContext<string | null>(null);
export function useFocusRegionId() {
return useContext(FocusRegionContext);
}
@@ -0,0 +1,3 @@
export * from "./focus-item-actions.context";
export * from "./focus-layer.context";
export * from "./focus-region.context";
+4
View File
@@ -0,0 +1,4 @@
export * from "./common";
export * from "./context";
export * from "./providers";
export * from "./pages";
@@ -0,0 +1,117 @@
import type { UserAchievement } from "@types";
import { Link } from "react-router-dom";
import { Box, FocusItem, Typography } from "../../../common";
import cn from "classnames";
import { EyeIcon } from "@phosphor-icons/react/dist/ssr";
import {
GAME_ACHIEVEMENTS_TITLE_ID,
GAME_ACHIEVEMENTS_VIEW_ALL_ID,
GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID,
GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID,
} from "../navigation";
import { FocusOverrides } from "src/big-picture/src/services/navigation.service";
export interface AchievementsBoxProps {
achievements: UserAchievement[];
}
export function AchievementsBox({
achievements,
}: Readonly<AchievementsBoxProps>) {
const achievementsNavigationOverrides: FocusOverrides = {
down: {
type: "item",
itemId: GAME_ACHIEVEMENTS_VIEW_ALL_ID,
},
right: {
type: "block",
},
left: {
type: "item",
itemId: GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
},
};
const viewAllNavigationOverrides: FocusOverrides = {
up: {
type: "item",
itemId: GAME_ACHIEVEMENTS_TITLE_ID,
},
left: {
type: "item",
itemId: GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID,
},
right: {
type: "block",
},
down: {
type: "item",
itemId: GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID,
},
};
return (
<div className="game-page__box-group">
<FocusItem
id={GAME_ACHIEVEMENTS_TITLE_ID}
navigationOverrides={achievementsNavigationOverrides}
asChild
>
<div className="game-page__achievements-title">
<Typography>Achievements</Typography>
<span>
{achievements.filter((achievement) => achievement.unlocked).length}{" "}
/ {achievements.length}
</span>
</div>
</FocusItem>
{achievements.slice(0, 5).map((achievement) => (
<Box key={achievement.name} className="game-page__achievement">
<img
src={achievement.icon}
width={56}
height={56}
className={cn("game-page__achievement-icon", {
"game-page__achievement-icon--locked": !achievement.unlocked,
})}
alt={achievement.displayName}
loading="lazy"
/>
<div className="game-page__achievement-info">
<Typography>{achievement.displayName}</Typography>
<Typography style={{ color: "rgba(255, 255, 255, 0.5)" }}>
{achievement.description}
</Typography>
</div>
</Box>
))}
<Box className="game-page__achievement-box">
<div className="game-page__achievement-icon-locked">
<EyeIcon size={24} />
</div>
<div className="game-page__achievement-box-content">
<div className="game-page__achievement-box-link">
<FocusItem
id={GAME_ACHIEVEMENTS_VIEW_ALL_ID}
navigationOverrides={viewAllNavigationOverrides}
>
<Link to="/big-picture/library">
<Typography>View All Achievements</Typography>
</Link>
</FocusItem>
<span>Time to platinum!</span>
</div>
<span>{achievements.length}</span>
</div>
</Box>
</div>
);
}
@@ -0,0 +1,517 @@
import {
ClockIcon,
StarIcon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "@phosphor-icons/react";
import { sanitizeHtml } from "@shared";
import type { GameReview, GameShop } from "@types";
import { useCallback, useEffect, useRef, useState } from "react";
import { IS_DESKTOP } from "../../../../constants";
import { useDate, useFormat } from "../../../../hooks";
import { BIG_PICTURE_SIDEBAR_ITEM_IDS } from "../../../../layout";
import {
FocusOverrides,
NavigationService,
} from "../../../../services/navigation.service";
import {
Box,
Button,
FocusItem,
HorizontalFocusGroup,
Typography,
} from "../../../common";
import {
GAME_REVIEWS_LOAD_MORE_ID,
GAME_REVIEWS_PRIMARY_FILTER_BUTTON_ID,
GAME_REVIEWS_REGION_ID,
GAME_REVIEWS_SECONDARY_FILTER_BUTTON_ID,
GAME_REVIEWS_THIRD_FILTER_BUTTON_ID,
GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID,
GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID,
getGameReviewVoteButtonDownvoteId,
getGameReviewVoteButtonUpvoteId,
getGameReviewVotesRegionId,
} from "../navigation";
type ReviewSortOption =
| "newest"
| "oldest"
| "score_high"
| "score_low"
| "most_voted";
interface GameReviewsProps {
shop: GameShop;
objectId: string;
}
const REVIEWS_PER_PAGE = 5;
export function GameReviews({ shop, objectId }: Readonly<GameReviewsProps>) {
const [reviews, setReviews] = useState<GameReview[]>([]);
const [reviewsLoading, setReviewsLoading] = useState(false);
const [totalReviewCount, setTotalReviewCount] = useState(0);
const [sortBy, setSortBy] = useState<ReviewSortOption>("newest");
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [votingReviews, setVotingReviews] = useState<Set<string>>(new Set());
const { formatDistance } = useDate();
const { formatNumber, formatPlayTime } = useFormat();
const abortControllerRef = useRef<AbortController | null>(null);
const prevHasMoreRef = useRef(true);
const loadReviews = useCallback(
async (reset = false) => {
if (!IS_DESKTOP || !objectId || shop === "custom") return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
setReviewsLoading(true);
try {
const skip = reset ? 0 : page * REVIEWS_PER_PAGE;
const params = new URLSearchParams({
take: String(REVIEWS_PER_PAGE),
skip: String(skip),
sortBy,
});
const response = await globalThis.window.electron.hydraApi.get<{
reviews: GameReview[];
totalCount: number;
}>(`/games/${shop}/${objectId}/reviews?${params.toString()}`, {
needsAuth: false,
});
if (abortController.signal.aborted) return;
const reviewsData = response?.reviews ?? [];
const reviewCount = response?.totalCount ?? 0;
if (reset) {
setReviews(reviewsData);
setPage(0);
} else {
setReviews((prev) => [...prev, ...reviewsData]);
}
setTotalReviewCount(reviewCount);
setHasMore(reviewsData.length === REVIEWS_PER_PAGE);
} catch (error) {
if (!abortController.signal.aborted) {
console.error("Failed to load reviews:", error);
}
} finally {
if (!abortController.signal.aborted) {
setReviewsLoading(false);
}
}
},
[objectId, shop, page, sortBy]
);
const applyVote = (
review: GameReview,
voteType: "upvote" | "downvote"
): GameReview => {
const updated = { ...review };
const isToggleOff =
(voteType === "upvote" && updated.hasUpvoted) ||
(voteType === "downvote" && updated.hasDownvoted);
if (isToggleOff) {
if (voteType === "upvote") {
updated.hasUpvoted = false;
updated.upvotes = Math.max(0, (updated.upvotes || 0) - 1);
} else {
updated.hasDownvoted = false;
updated.downvotes = Math.max(0, (updated.downvotes || 0) - 1);
}
return updated;
}
if (voteType === "upvote") {
updated.hasUpvoted = true;
updated.upvotes = (updated.upvotes || 0) + 1;
} else if (voteType === "downvote") {
updated.hasDownvoted = true;
updated.downvotes = (updated.downvotes || 0) + 1;
}
if (voteType === "upvote" && updated.hasDownvoted) {
updated.hasDownvoted = false;
updated.downvotes = Math.max(0, (updated.downvotes || 0) - 1);
} else if (voteType === "downvote" && updated.hasUpvoted) {
updated.hasUpvoted = false;
updated.upvotes = Math.max(0, (updated.upvotes || 0) - 1);
}
return updated;
};
const handleVote = async (
reviewId: string,
voteType: "upvote" | "downvote"
) => {
if (!objectId || votingReviews.has(reviewId)) return;
setVotingReviews((prev) => new Set(prev).add(reviewId));
const reviewIndex = reviews.findIndex((r) => r.id === reviewId);
if (reviewIndex === -1) {
setVotingReviews((prev) => {
const next = new Set(prev);
next.delete(reviewId);
return next;
});
return;
}
const originalReview = { ...reviews[reviewIndex] };
const updatedReviews = [...reviews];
updatedReviews[reviewIndex] = applyVote(originalReview, voteType);
setReviews(updatedReviews);
try {
await globalThis.window.electron.hydraApi.put(
`/games/${shop}/${objectId}/reviews/${reviewId}/${voteType}`,
{ data: {} }
);
} catch {
const rolledBack = [...reviews];
rolledBack[reviewIndex] = originalReview;
setReviews(rolledBack);
} finally {
setTimeout(() => {
setVotingReviews((prev) => {
const next = new Set(prev);
next.delete(reviewId);
return next;
});
}, 500);
}
};
const handleSortChange = (newSortBy: ReviewSortOption) => {
if (newSortBy !== sortBy) {
setSortBy(newSortBy);
setPage(0);
setHasMore(true);
}
};
const loadMore = () => {
if (!reviewsLoading && hasMore) {
setPage((prev) => prev + 1);
}
};
useEffect(() => {
loadReviews(true);
}, [sortBy, objectId, loadReviews]);
useEffect(() => {
if (page > 0) {
loadReviews(false);
}
}, [page, loadReviews]);
useEffect(() => {
if (prevHasMoreRef.current && !hasMore && reviews.length > 0) {
const lastReview = reviews[reviews.length - 1];
const targetId = getGameReviewVoteButtonUpvoteId(lastReview.id);
requestAnimationFrame(() => {
NavigationService.getInstance().setFocus(targetId);
});
}
prevHasMoreRef.current = hasMore;
}, [hasMore, reviews]);
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
if (reviewsLoading && reviews.length === 0) {
return null;
}
const primaryFilterNavigationOverrides: FocusOverrides = {
up: {
type: "item",
itemId: GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID,
},
left: {
type: "item",
itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.home,
},
right: {
type: "item",
itemId: GAME_REVIEWS_SECONDARY_FILTER_BUTTON_ID,
},
};
const secondaryFilterNavigationOverrides: FocusOverrides = {
up: {
type: "item",
itemId: GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID,
},
left: {
type: "item",
itemId: GAME_REVIEWS_PRIMARY_FILTER_BUTTON_ID,
},
right: {
type: "item",
itemId: GAME_REVIEWS_THIRD_FILTER_BUTTON_ID,
},
};
const thirdFilterNavigationOverrides: FocusOverrides = {
up: {
type: "item",
itemId: GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID,
},
left: {
type: "item",
itemId: GAME_REVIEWS_SECONDARY_FILTER_BUTTON_ID,
},
right: {
type: "item",
itemId: GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID,
},
};
const upvoteButtonNavigationOverrides = (
reviewId: string
): FocusOverrides => ({
left: {
type: "item",
itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.home,
},
right: {
type: "item",
itemId: getGameReviewVoteButtonDownvoteId(reviewId),
},
});
const downvoteButtonNavigationOverrides = (
reviewId: string
): FocusOverrides => ({
left: {
type: "item",
itemId: getGameReviewVoteButtonUpvoteId(reviewId),
},
right: {
type: "block",
},
});
const loadMoreNavigationOverrides: FocusOverrides = {
right: {
type: "block",
},
down: {
type: "block",
},
};
return (
<div className="game-page__box-group">
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Typography>Reviews</Typography>
<div
style={{
color: "rgba(255, 255, 255, 0.5)",
padding: "4px 8px",
borderRadius: "4px",
backgroundColor: "rgba(255, 255, 255, 0.05)",
}}
>
{totalReviewCount}
</div>
</div>
<HorizontalFocusGroup regionId={GAME_REVIEWS_REGION_ID} asChild>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Button
focusId={GAME_REVIEWS_PRIMARY_FILTER_BUTTON_ID}
focusNavigationOverrides={primaryFilterNavigationOverrides}
variant="link"
size="small"
onClick={() =>
handleSortChange(sortBy === "newest" ? "oldest" : "newest")
}
>
{sortBy === "oldest" ? "Oldest" : "Newest"}
</Button>
<Button
focusId={GAME_REVIEWS_SECONDARY_FILTER_BUTTON_ID}
focusNavigationOverrides={secondaryFilterNavigationOverrides}
variant="link"
size="small"
onClick={() =>
handleSortChange(
sortBy === "score_high" ? "score_low" : "score_high"
)
}
>
{sortBy === "score_low" ? "Lowest Score" : "Highest Score"}
</Button>
<Button
focusId={GAME_REVIEWS_THIRD_FILTER_BUTTON_ID}
focusNavigationOverrides={thirdFilterNavigationOverrides}
variant="link"
size="small"
onClick={() => handleSortChange("most_voted")}
>
Most Voted
</Button>
</div>
</HorizontalFocusGroup>
{reviews.length === 0 ? (
<Box>
<Typography
style={{ color: "rgba(255, 255, 255, 0.5)", textAlign: "center" }}
>
No reviews yet
</Typography>
</Box>
) : (
reviews.map((review) => {
const isVoting = votingReviews.has(review.id);
const row = (
<Box key={review.id} className="game-page__review-item">
<div className="game-page__review-header">
<div className="game-page__review-user">
{review.user.profileImageUrl ? (
<img
src={review.user.profileImageUrl}
alt={review.user.displayName}
className="game-page__review-avatar"
/>
) : (
<div className="game-page__review-avatar game-page__review-avatar--placeholder" />
)}
<div className="game-page__review-user-info">
<Typography className="game-page__review-display-name">
{review.user.displayName || "Anonymous"}
</Typography>
<div className="game-page__review-meta">
<div className="game-page__review-score">
<StarIcon size={12} weight="fill" />
<span>{review.score}/5</span>
</div>
{Boolean(
review.playTimeInSeconds && review.playTimeInSeconds > 0
) && (
<div className="game-page__review-playtime">
<ClockIcon size={12} />
<span>
{formatPlayTime(review.playTimeInSeconds ?? 0)}
</span>
</div>
)}
</div>
</div>
</div>
<Typography className="game-page__review-date">
{formatDistance(new Date(review.createdAt), new Date(), {
addSuffix: true,
})}
</Typography>
</div>
<div
className="game-page__review-content"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(review.reviewHtml),
}}
/>
<HorizontalFocusGroup
regionId={getGameReviewVotesRegionId(review.id)}
asChild
>
<div className="game-page__review-votes">
<FocusItem
id={getGameReviewVoteButtonUpvoteId(review.id)}
navigationOverrides={upvoteButtonNavigationOverrides(
review.id
)}
asChild
>
<button
className={`game-page__review-vote-button ${review.hasUpvoted ? "game-page__review-vote-button--active" : ""}`}
onClick={() => handleVote(review.id, "upvote")}
disabled={isVoting}
aria-label="Upvote"
>
<ThumbsUpIcon
size={14}
weight={review.hasUpvoted ? "fill" : "regular"}
/>
<span>{formatNumber(review.upvotes || 0)}</span>
</button>
</FocusItem>
<FocusItem
id={getGameReviewVoteButtonDownvoteId(review.id)}
navigationOverrides={downvoteButtonNavigationOverrides(
review.id
)}
asChild
>
<button
className={`game-page__review-vote-button ${review.hasDownvoted ? "game-page__review-vote-button--active-down" : ""}`}
onClick={() => handleVote(review.id, "downvote")}
disabled={isVoting}
aria-label="Downvote"
>
<ThumbsDownIcon
size={14}
weight={review.hasDownvoted ? "fill" : "regular"}
/>
<span>{formatNumber(review.downvotes || 0)}</span>
</button>
</FocusItem>
</div>
</HorizontalFocusGroup>
</Box>
);
return row;
})
)}
{hasMore && reviews.length > 0 && (
<Button
focusId={GAME_REVIEWS_LOAD_MORE_ID}
variant="rounded"
onClick={loadMore}
disabled={reviewsLoading}
loading={reviewsLoading}
focusNavigationOverrides={loadMoreNavigationOverrides}
>
Load More
</Button>
)}
</div>
);
}
@@ -0,0 +1,207 @@
import {
HeartIcon,
PlayIcon,
PlusCircleIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import type { LibraryGame, ShopDetailsWithAssets } from "@types";
import { motion } from "framer-motion";
import { DownloadIcon } from "lucide-react";
import { useMemo } from "react";
import {
FocusOverrides,
FocusOverrideTarget,
} from "src/big-picture/src/services/navigation.service";
import { useDominantColor } from "../../../../hooks";
import { BIG_PICTURE_SIDEBAR_ITEM_IDS } from "../../../../layout";
import {
Button,
Divider,
HorizontalFocusGroup,
Tooltip,
Typography,
} from "../../../common";
import {
GAME_HERO_ACTIONS_REGION_ID,
GAME_HERO_PRIMARY_ACTION_ID,
GAME_HERO_TOGGLE_FAVORITE_ID,
GAME_STATS_REGION_ID,
GAME_STATS_TITLE_ID,
} from "../navigation";
export interface HeroProps {
shopDetails: ShopDetailsWithAssets;
game: LibraryGame | null;
isGameRunning: boolean;
isFavorite: boolean;
toggleFavorite: () => void;
onPlay: () => void;
onClose: () => void;
}
export function Hero({
shopDetails,
game,
isGameRunning,
isFavorite,
toggleFavorite,
onPlay,
onClose,
}: Readonly<HeroProps>) {
const dominantColor = useDominantColor(game?.libraryHeroImageUrl ?? null);
const heroDownNavigationTarget: FocusOverrideTarget = {
type: "region",
regionId: GAME_STATS_REGION_ID,
entryDirection: "down",
};
const toggleFavoriteNavigationOverrides: FocusOverrides = {
left: {
type: "item",
itemId: GAME_HERO_PRIMARY_ACTION_ID,
},
right: {
type: "item",
itemId: GAME_STATS_TITLE_ID,
},
down: heroDownNavigationTarget,
};
const renderActionButton = useMemo(() => {
const downloadNavigationOverrides: FocusOverrides = {
left: {
type: "item",
itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.home,
},
right: {
type: "item",
itemId: GAME_HERO_TOGGLE_FAVORITE_ID,
},
down: heroDownNavigationTarget,
};
if (isGameRunning) {
return (
<Button
focusId={GAME_HERO_PRIMARY_ACTION_ID}
variant="primary"
icon={<XCircleIcon size={24} />}
onClick={onClose}
>
Close Game
</Button>
);
}
if (game?.executablePath) {
return (
<Button
focusId={GAME_HERO_PRIMARY_ACTION_ID}
focusNavigationOverrides={downloadNavigationOverrides}
variant="primary"
color={dominantColor ?? undefined}
iconPosition="right"
icon={<PlayIcon size={24} weight="fill" />}
onClick={onPlay}
>
Launch Game
</Button>
);
}
if (game) {
return (
<Button
focusId={GAME_HERO_PRIMARY_ACTION_ID}
focusNavigationOverrides={downloadNavigationOverrides}
icon={<DownloadIcon size={24} />}
onClick={() => console.log("Download")}
>
Download
</Button>
);
}
return (
<Button
focusId={GAME_HERO_PRIMARY_ACTION_ID}
variant="secondary"
icon={<PlusCircleIcon size={24} />}
>
Add to Library
</Button>
);
}, [isGameRunning, game, onClose, onPlay, heroDownNavigationTarget]);
return (
<section style={{ position: "relative", height: 620, overflow: "hidden" }}>
<motion.div
initial={{ scale: 1, x: 0, y: 0 }}
animate={{
scale: 1.1,
x: -10,
y: -10,
}}
transition={{
duration: 20,
ease: "easeInOut",
repeat: Infinity,
repeatType: "mirror",
}}
className="game-page__hero"
style={{
backgroundImage: `url(${shopDetails.assets?.libraryHeroImageUrl})`,
}}
/>
<div className="game-page__hero-overlay">
<img
src={shopDetails.assets?.logoImageUrl || ""}
style={{ width: 337 }}
alt={shopDetails.assets?.title || ""}
/>
<Typography
style={{ maxWidth: 512, color: "rgba(255, 255, 255, 0.8)" }}
dangerouslySetInnerHTML={{
__html: shopDetails.short_description || "",
}}
/>
<HorizontalFocusGroup
regionId={GAME_HERO_ACTIONS_REGION_ID}
className="game-page__hero-actions"
>
{renderActionButton}
<Divider orientation="vertical" />
<Tooltip
content={isFavorite ? "Remove from Favorites" : "Add to Favorites"}
>
<Button
variant="secondary"
onClick={() => toggleFavorite()}
focusId={GAME_HERO_TOGGLE_FAVORITE_ID}
focusNavigationOverrides={toggleFavoriteNavigationOverrides}
>
<motion.span
key={isFavorite ? "filled" : "empty"}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{isFavorite ? (
<HeartIcon size={24} weight="fill" />
) : (
<HeartIcon size={24} />
)}
</motion.span>
</Button>
</Tooltip>
</HorizontalFocusGroup>
</div>
</section>
);
}
@@ -0,0 +1,76 @@
import { ClockIcon } from "@phosphor-icons/react";
import { Typography, Box, TitleBox, FocusItem } from "../../../common";
import type { HowLongToBeatCategory } from "@types";
import {
GAME_HOW_LONG_TO_BEAT_TITLE_ID,
GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
GAME_STATS_TITLE_ID,
} from "../navigation";
import { FocusOverrides } from "src/big-picture/src/services/navigation.service";
export interface HowLongToBeatBoxProps {
howLongToBeat: HowLongToBeatCategory[];
}
export function HowLongToBeatBox({
howLongToBeat,
}: Readonly<HowLongToBeatBoxProps>) {
const howLongToBeatNavigationOverrides: FocusOverrides = {
up: {
type: "item",
itemId: GAME_STATS_TITLE_ID,
},
right: {
type: "block",
},
left: {
type: "item",
itemId: GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
},
};
return (
<div className="game-page__box-group">
<FocusItem
id={GAME_HOW_LONG_TO_BEAT_TITLE_ID}
navigationOverrides={howLongToBeatNavigationOverrides}
>
<TitleBox title="How Long to Beat" />
</FocusItem>
<ul
style={{
display: "flex",
justifyContent: "space-between",
gap: 8,
listStyle: "none",
}}
>
{howLongToBeat?.map((item) => (
<li
key={item.title}
style={{
display: "flex",
flexDirection: "column",
flex: 1,
gap: 4,
}}
>
<Box className="game-page__how-long-to-beat-duration">
<ClockIcon size={20} />
<Typography variant="h3" style={{ textAlign: "center" }}>
{item.duration.split(" ")[0]}
</Typography>
</Box>
<Box className="game-page__how-long-to-beat-title">
<Typography style={{ textAlign: "center" }}>
{item.title}
</Typography>
</Box>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,8 @@
export * from "./hero";
export * from "./playtime-bar";
export * from "./screenshot-carousel";
export * from "./how-long-to-beat";
export * from "./achievements";
export * from "./requirements-to-play";
export * from "./supported-languages";
export * from "./game-reviews";
@@ -0,0 +1,51 @@
export const GAME_HERO_ACTIONS_REGION_ID = "game-hero-actions";
export const GAME_HERO_PRIMARY_ACTION_ID = "game-hero-primary-action";
export const GAME_HERO_TOGGLE_FAVORITE_ID = "game-hero-toggle-favorite";
export const GAME_SCREENSHOT_CAROUSEL_DOTS_REGION_ID =
"game-screenshot-carousel-dots";
export const GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID =
"game-screenshot-carousel-prev-button";
export const GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID =
"game-screenshot-carousel-next-button";
export const GAME_STATS_REGION_ID = "game-stats";
export const GAME_STATS_TITLE_ID = "game-stats-title";
export const GAME_HOW_LONG_TO_BEAT_TITLE_ID = "game-how-long-to-beat-title";
export const GAME_ACHIEVEMENTS_TITLE_ID = "game-achievements-title";
export const GAME_ACHIEVEMENTS_VIEW_ALL_ID = "game-achievements-view-all";
export const GAME_REQUIREMENTS_TO_PLAY_BUTTONS_REGION_ID =
"game-requirements-to-play-buttons";
export const GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID =
"game-requirements-to-play-minimum-button";
export const GAME_REQUIREMENTS_TO_PLAY_RECOMMENDED_BUTTON_ID =
"game-requirements-to-play-recommended-button";
export const GAME_SUPPORTED_LANGUAGES_TITLE_ID =
"game-supported-languages-title";
export const GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID =
"game-supported-languages-last-row";
export const GAME_REVIEWS_REGION_ID = "game-reviews";
export const GAME_REVIEWS_SORT_OPTIONS_REGION_ID = "game-reviews-sort-options";
export const GAME_REVIEWS_PRIMARY_FILTER_BUTTON_ID =
"game-reviews-primary-filter-button";
export const GAME_REVIEWS_SECONDARY_FILTER_BUTTON_ID =
"game-reviews-secondary-filter-button";
export const GAME_REVIEWS_THIRD_FILTER_BUTTON_ID =
"game-reviews-third-filter-button";
export const GAME_REVIEWS_LOAD_MORE_ID = "game-reviews-load-more";
export function getGameReviewVotesRegionId(reviewId: string) {
return `game-review-votes-${reviewId}`;
}
export function getGameReviewFirstVoteButtonId(reviewId?: string | null) {
if (!reviewId) return null;
return getGameReviewVoteButtonUpvoteId(reviewId);
}
export function getGameReviewVoteButtonUpvoteId(reviewId: string) {
return `game-review-vote-button-upvote-${reviewId}`;
}
export function getGameReviewVoteButtonDownvoteId(reviewId: string) {
return `game-review-vote-button-downvote-${reviewId}`;
}
@@ -0,0 +1,37 @@
import { Typography } from "../../../common";
import type { LibraryGame } from "@types";
import { useDate, useFormat } from "../../../../hooks";
export interface PlaytimeBarProps {
game: LibraryGame | null;
}
export function PlaytimeBar({ game }: Readonly<PlaytimeBarProps>) {
const { formatDistance } = useDate();
const { formatPlayTime } = useFormat();
const playTimeInSeconds = (game?.playTimeInMilliseconds ?? 0) / 1000;
return (
<div className="game-page__playtime-bar">
<div>
<Typography>
Played for <strong>{formatPlayTime(playTimeInSeconds)}</strong>
</Typography>
<Typography style={{ color: "rgba(255, 255, 255, 0.5)" }}>
{game?.lastTimePlayed ? (
<>
Last played{" "}
{formatDistance(game.lastTimePlayed, new Date(), {
addSuffix: true,
})}
</>
) : (
<>You haven&apos;t played {game?.title} yet</>
)}
</Typography>
</div>
</div>
);
}
@@ -0,0 +1,103 @@
import { ShopDetails } from "@types";
import { useMemo, useState } from "react";
import { FocusOverrides } from "src/big-picture/src/services/navigation.service";
import { normalizeRequirementsHtml } from "../../../../helpers";
import { Box, Button, HorizontalFocusGroup, Typography } from "../../../common";
import {
GAME_ACHIEVEMENTS_VIEW_ALL_ID,
GAME_REQUIREMENTS_TO_PLAY_BUTTONS_REGION_ID,
GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID,
GAME_REQUIREMENTS_TO_PLAY_RECOMMENDED_BUTTON_ID,
GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
} from "../navigation";
export interface RequirementsToPlayProps {
shopDetails: ShopDetails;
}
export function RequirementsToPlay({
shopDetails,
}: Readonly<RequirementsToPlayProps>) {
const [activeRequirement, setActiveRequirement] = useState<
"minimum" | "recommended"
>("minimum");
const normalizedHtml = useMemo(() => {
const raw =
activeRequirement === "minimum"
? shopDetails.pc_requirements.minimum
: shopDetails.pc_requirements.recommended;
return normalizeRequirementsHtml(raw);
}, [activeRequirement, shopDetails.pc_requirements]);
const minimumButtonNavigationOverrides: FocusOverrides = {
up: {
type: "item",
itemId: GAME_ACHIEVEMENTS_VIEW_ALL_ID,
},
left: {
type: "item",
itemId: GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
},
right: {
type: "item",
itemId: GAME_REQUIREMENTS_TO_PLAY_RECOMMENDED_BUTTON_ID,
},
};
const recommendedButtonNavigationOverrides: FocusOverrides = {
up: {
type: "item",
itemId: GAME_ACHIEVEMENTS_VIEW_ALL_ID,
},
left: {
type: "item",
itemId: GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID,
},
right: {
type: "block",
},
};
return (
<div className="game-page__box-group">
<div className="game-page__requirements-to-play-header">
<div className="game-page__requirements-to-play-title">
<Typography>System Requirements</Typography>
</div>
<HorizontalFocusGroup
regionId={GAME_REQUIREMENTS_TO_PLAY_BUTTONS_REGION_ID}
>
<div className="game-page__requirements-to-play-buttons">
<Button
focusId={GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID}
focusNavigationOverrides={minimumButtonNavigationOverrides}
onClick={() => setActiveRequirement("minimum")}
variant={activeRequirement === "minimum" ? "primary" : "rounded"}
>
Minimum
</Button>
<Button
focusId={GAME_REQUIREMENTS_TO_PLAY_RECOMMENDED_BUTTON_ID}
focusNavigationOverrides={recommendedButtonNavigationOverrides}
onClick={() => setActiveRequirement("recommended")}
variant={
activeRequirement === "recommended" ? "primary" : "rounded"
}
>
Recommended
</Button>
</div>
</HorizontalFocusGroup>
</div>
<Box
dangerouslySetInnerHTML={{ __html: normalizedHtml }}
className="game-page__requirements-to-play-content"
/>
</div>
);
}
@@ -0,0 +1,321 @@
import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react";
import type { SteamMovie, SteamScreenshot } from "@types";
import useEmblaCarousel from "embla-carousel-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FocusOverrides } from "src/big-picture/src/services/navigation.service";
import { BIG_PICTURE_SIDEBAR_ITEM_IDS } from "../../../../layout";
import { useNavigationIsFocused } from "../../../../stores";
import { FocusItem, HorizontalFocusGroup } from "../../../common";
import {
GAME_HERO_ACTIONS_REGION_ID,
GAME_REVIEWS_REGION_ID,
GAME_SCREENSHOT_CAROUSEL_DOTS_REGION_ID,
GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID,
GAME_STATS_TITLE_ID,
} from "../navigation";
import { VideoPlayer } from "./video-player";
interface ScreenshotCarouselProps {
screenshots: SteamScreenshot[];
videos: SteamMovie[];
}
type MediaItem = {
id: string;
type: "video" | "image";
src?: string;
poster?: string;
videoSrc?: string;
videoType?: string;
};
export function ScreenshotCarousel({
screenshots,
videos,
}: Readonly<ScreenshotCarouselProps>) {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false });
const [selectedIndex, setSelectedIndex] = useState(0);
const carouselContainerRef = useRef<HTMLDivElement | null>(null);
const videoRefs = useRef<Array<HTMLVideoElement | null>>([]);
const isPrevFocused = useNavigationIsFocused(
GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID
);
const isNextFocused = useNavigationIsFocused(
GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID
);
useEffect(() => {
if ((isPrevFocused || isNextFocused) && carouselContainerRef.current) {
carouselContainerRef.current.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}, [isPrevFocused, isNextFocused]);
const scrollPrev = () => emblaApi?.scrollPrev();
const scrollNext = () => emblaApi?.scrollNext();
const onSelect = useCallback(() => {
if (!emblaApi) return;
const index = emblaApi.selectedScrollSnap();
setSelectedIndex(index);
videoRefs.current.forEach((video, idx) => {
if (!video) return;
if (idx === index) {
video.play().catch(() => {});
} else {
video.pause();
}
});
}, [emblaApi]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", onSelect);
onSelect();
}, [emblaApi, onSelect]);
const mediaItems: MediaItem[] = useMemo(() => {
const items: MediaItem[] = [];
videos.forEach((video) => {
let videoSrc: string | undefined;
let videoType: string | undefined;
if (video.hls_h264) {
videoSrc = video.hls_h264;
videoType = "application/x-mpegURL";
} else if (video.dash_h264) {
videoSrc = video.dash_h264;
videoType = "application/dash+xml";
} else if (video.dash_av1) {
videoSrc = video.dash_av1;
videoType = "application/dash+xml";
} else if (video.mp4?.max) {
videoSrc = video.mp4.max;
videoType = "video/mp4";
} else if (video.webm?.max) {
videoSrc = video.webm.max;
videoType = "video/webm";
}
if (videoSrc) {
items.push({
id: `video-${video.id}`,
type: "video",
poster: video.thumbnail,
videoSrc: videoSrc.startsWith("http://")
? videoSrc.replace("http://", "https://")
: videoSrc,
videoType,
});
}
});
screenshots.forEach((image) => {
items.push({
id: `screenshot-${image.id}`,
type: "image",
src: image.path_full,
});
});
return items;
}, [videos, screenshots]);
const isVisible = (index: number) => Math.abs(index - selectedIndex) <= 1;
const renderSlideContent = (item: MediaItem, idx: number) => {
if (!isVisible(idx)) {
return (
<div
style={{
width: "100%",
aspectRatio: "16 / 9",
backgroundColor: "#111",
borderRadius: 8,
}}
/>
);
}
if (item.type === "video") {
return (
<VideoPlayer
videoSrc={item.videoSrc}
videoType={item.videoType}
poster={item.poster}
muted
loop
controls
style={{
width: "100%",
borderRadius: 8,
objectFit: "cover",
aspectRatio: "16 / 9",
}}
videoRef={(el) => {
videoRefs.current[idx] = el;
}}
/>
);
}
return (
<img
src={item.src}
alt={`Screenshot ${idx + 1}`}
loading="lazy"
style={{
width: "100%",
borderRadius: 8,
objectFit: "cover",
aspectRatio: "16 / 9",
}}
/>
);
};
const prevButtonNavigationOverrides: FocusOverrides = {
left: {
type: "item",
itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.home,
},
up: {
type: "region",
regionId: GAME_HERO_ACTIONS_REGION_ID,
},
down: {
type: "region",
regionId: GAME_REVIEWS_REGION_ID,
},
right: {
type: "item",
itemId: GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID,
},
};
const nextButtonNavigationOverrides: FocusOverrides = {
left: {
type: "item",
itemId: GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID,
},
right: {
type: "item",
itemId: GAME_STATS_TITLE_ID,
},
up: {
type: "region",
regionId: GAME_HERO_ACTIONS_REGION_ID,
},
down: {
type: "region",
regionId: GAME_REVIEWS_REGION_ID,
},
};
return (
<div
ref={carouselContainerRef}
style={{ overflow: "hidden", width: "100%", marginBottom: 32 }}
>
<div className="embla" ref={emblaRef} style={{ overflow: "hidden" }}>
<div
className="embla__container"
style={{
display: "flex",
gap: 16,
}}
>
{mediaItems.map((item, idx) => (
<div
key={item.id}
className="embla__slide"
style={{ flex: "0 0 80%", maxWidth: "80%" }}
>
{renderSlideContent(item, idx)}
</div>
))}
</div>
</div>
<HorizontalFocusGroup
regionId={GAME_SCREENSHOT_CAROUSEL_DOTS_REGION_ID}
asChild
>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 16,
marginTop: 8,
}}
>
<FocusItem
id={GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID}
navigationOverrides={prevButtonNavigationOverrides}
asChild
>
<button
onClick={scrollPrev}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: 4,
}}
aria-label="Previous slide"
>
<CaretLeftIcon size={24} color="#fff" />
</button>
</FocusItem>
<div style={{ display: "flex", gap: 8 }}>
{mediaItems.map((item, i) => (
<button
key={`dot-${item.id}`}
onClick={() => emblaApi?.scrollTo(i)}
aria-label={`Go to slide ${i + 1}`}
style={{
display: "inline-block",
width: i === selectedIndex ? 24 : 10,
height: 10,
borderRadius: 9999,
backgroundColor: i === selectedIndex ? "#fff" : "#555",
transition: "all 0.3s ease",
cursor: "pointer",
border: "none",
padding: 0,
}}
/>
))}
</div>
<FocusItem
id={GAME_SCREENSHOT_CAROUSEL_NEXT_BUTTON_ID}
navigationOverrides={nextButtonNavigationOverrides}
asChild
>
<button
onClick={scrollNext}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: 4,
}}
aria-label="Next slide"
>
<CaretRightIcon size={24} color="#fff" />
</button>
</FocusItem>
</div>
</HorizontalFocusGroup>
</div>
);
}
@@ -0,0 +1,76 @@
import { useHlsVideo } from "@shared";
import { useRef } from "react";
interface VideoPlayerProps {
videoSrc?: string;
videoType?: string;
poster?: string;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
controls?: boolean;
style?: React.CSSProperties;
videoRef?: (el: HTMLVideoElement | null) => void;
}
export function VideoPlayer({
videoSrc,
videoType,
poster,
autoplay = false,
muted = true,
loop = false,
controls = true,
style,
videoRef,
}: Readonly<VideoPlayerProps>) {
const internalRef = useRef<HTMLVideoElement>(null);
const isHls = videoType === "application/x-mpegURL";
useHlsVideo(internalRef, {
videoSrc,
videoType,
autoplay,
muted,
loop,
});
const setRef = (el: HTMLVideoElement | null) => {
(internalRef as React.MutableRefObject<HTMLVideoElement | null>).current =
el;
videoRef?.(el);
};
if (isHls) {
return (
<video
ref={setRef}
controls={controls}
poster={poster}
loop={loop}
muted={muted}
autoPlay={autoplay}
playsInline
style={style}
>
<track kind="captions" />
</video>
);
}
return (
<video
ref={setRef}
controls={controls}
poster={poster}
loop={loop}
muted={muted}
autoPlay={autoplay}
playsInline
style={style}
>
{videoSrc && <source src={videoSrc} type={videoType} />}
<track kind="captions" />
</video>
);
}
@@ -0,0 +1,144 @@
import { CheckIcon, XIcon } from "@phosphor-icons/react";
import { ShopDetails } from "@types";
import { useMemo } from "react";
import { FocusOverrides } from "src/big-picture/src/services/navigation.service";
import { Box, FocusItem, Typography } from "../../../common";
import {
GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID,
GAME_REVIEWS_PRIMARY_FILTER_BUTTON_ID,
GAME_REVIEWS_THIRD_FILTER_BUTTON_ID,
GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID,
GAME_SUPPORTED_LANGUAGES_TITLE_ID,
} from "../navigation";
export interface SupportedLanguagesProps {
shopDetails: ShopDetails;
}
export function SupportedLanguages({
shopDetails,
}: Readonly<SupportedLanguagesProps>) {
const languages = useMemo(() => {
const supportedLanguages = shopDetails.supported_languages;
if (!supportedLanguages) return [];
const languagesString = supportedLanguages.split("<br>")[0];
const languageArray = languagesString?.split(",") || [];
return languageArray.map((lang) => ({
language: lang.replace("<strong>*</strong>", "").trim(),
hasAudio: lang.includes("*"),
}));
}, [shopDetails.supported_languages]);
if (languages.length === 0) {
return null;
}
const supportedLanguagesNavigationOverrides: FocusOverrides = {
up: {
type: "item",
itemId: GAME_REQUIREMENTS_TO_PLAY_MINIMUM_BUTTON_ID,
},
down: {
type: "item",
itemId: GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID,
},
left: {
type: "item",
itemId: GAME_REVIEWS_PRIMARY_FILTER_BUTTON_ID,
},
right: {
type: "block",
},
};
const lastRowNavigationOverrides: FocusOverrides = {
up: {
type: "item",
itemId: GAME_SUPPORTED_LANGUAGES_TITLE_ID,
},
left: {
type: "item",
itemId: GAME_REVIEWS_THIRD_FILTER_BUTTON_ID,
},
right: {
type: "block",
},
down: {
type: "item",
itemId: GAME_REVIEWS_PRIMARY_FILTER_BUTTON_ID,
},
};
const renderRow = (
lang: { language: string; hasAudio: boolean },
index: number
) => {
const row = (
<div key={lang.language} className="game-page__languages-row">
<Typography className="game-page__languages-cell game-page__languages-cell--language">
{lang.language}
</Typography>
<div className="game-page__languages-cell game-page__languages-cell--center">
<CheckIcon size={14} weight="bold" />
</div>
<div className="game-page__languages-cell game-page__languages-cell--center">
{lang.hasAudio ? (
<CheckIcon size={14} weight="bold" />
) : (
<XIcon
size={14}
weight="bold"
className="game-page__languages-cross"
/>
)}
</div>
</div>
);
if (index === languages.length - 1) {
return (
<FocusItem
key={lang.language}
id={GAME_SUPPORTED_LANGUAGES_LAST_ROW_ID}
navigationOverrides={lastRowNavigationOverrides}
asChild
>
{row}
</FocusItem>
);
}
return row;
};
return (
<div className="game-page__box-group">
<FocusItem
id={GAME_SUPPORTED_LANGUAGES_TITLE_ID}
navigationOverrides={supportedLanguagesNavigationOverrides}
asChild
>
<div className="game-page__languages-title">
<Typography>Languages</Typography>
<div className="game-page__languages-labels">
<Typography className="game-page__languages-label">
Caption
</Typography>
<Typography className="game-page__languages-label">
Audio
</Typography>
</div>
</div>
</FocusItem>
<Box className="game-page__languages">
<div className="game-page__languages-content">
{languages.map((lang, index) => renderRow(lang, index))}
</div>
</Box>
</div>
);
}
@@ -0,0 +1 @@
export * from "./library";
@@ -0,0 +1,29 @@
.library-filters {
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) * 4);
padding: 0 calc(var(--spacing-unit) * 16);
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(var(--spacing-unit) * 4);
}
&__search {
flex: 1;
max-width: 600px;
}
&__view-actions {
display: flex;
align-items: center;
gap: calc(var(--spacing-unit) * 2);
}
&__tabs {
flex-shrink: 0;
padding: calc(var(--spacing-unit) * 6) 0 calc(var(--spacing-unit) * 4) 0;
}
}
@@ -0,0 +1,226 @@
import "./filters.scss";
import { Button, HorizontalFocusGroup, Input, Tabs } from "../../../common";
import {
ListDashesIcon,
MagnifyingGlassIcon,
SquaresFourIcon,
} from "@phosphor-icons/react";
import type { FocusOverrides } from "../../../../services";
import { BIG_PICTURE_SIDEBAR_ITEM_IDS } from "../../../../layout";
import {
LIBRARY_FILTERS_ALL_TAB_ID,
LIBRARY_FILTERS_COMPLETED_TAB_ID,
LIBRARY_FILTERS_FAVORITES_TAB_ID,
LIBRARY_FILTERS_GRID_VIEW_BUTTON_ID,
LIBRARY_FILTERS_LIST_VIEW_BUTTON_ID,
LIBRARY_FILTERS_SEARCH_INPUT_ID,
LIBRARY_FILTERS_TABS_REGION_ID,
LIBRARY_FILTERS_TOOLBAR_REGION_ID,
LIBRARY_HERO_ACTIONS_REGION_ID,
} from "../navigation";
import type { LibraryFilterCounts, LibraryFilterTab } from "../library-data";
import type { TabsItem } from "../../../common";
export interface LibraryFiltersProps {
selectedTab: LibraryFilterTab;
onSelectedTabChange: (tab: LibraryFilterTab) => void;
search: string;
onSearchChange: (search: string) => void;
counts: LibraryFilterCounts;
firstGridItemId?: string | null;
}
export function LibraryFilters({
selectedTab,
onSelectedTabChange,
search,
onSearchChange,
counts,
firstGridItemId = null,
}: Readonly<LibraryFiltersProps>) {
const tabDownOverride = firstGridItemId
? ({
type: "item",
itemId: firstGridItemId,
} as const)
: ({
type: "block",
} as const);
const tabUpOverride = {
type: "region",
regionId: LIBRARY_FILTERS_TOOLBAR_REGION_ID,
entryDirection: "up",
} as const;
const sidebarLibraryOverride = {
type: "item",
itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.library,
} as const;
const tabs = [
{
id: LIBRARY_FILTERS_ALL_TAB_ID,
value: "all",
label: `All (${counts.all})`,
navigationOverrides: {
left: sidebarLibraryOverride,
right: {
type: "item",
itemId: LIBRARY_FILTERS_FAVORITES_TAB_ID,
},
up: tabUpOverride,
down: tabDownOverride,
},
},
{
id: LIBRARY_FILTERS_FAVORITES_TAB_ID,
value: "favorites",
label: `Favorites (${counts.favorites})`,
navigationOverrides: {
left: {
type: "item",
itemId: LIBRARY_FILTERS_ALL_TAB_ID,
},
right: {
type: "item",
itemId: LIBRARY_FILTERS_COMPLETED_TAB_ID,
},
up: tabUpOverride,
down: tabDownOverride,
},
},
{
id: LIBRARY_FILTERS_COMPLETED_TAB_ID,
value: "completed",
label: `Completed (${counts.completed})`,
navigationOverrides: {
left: {
type: "item",
itemId: LIBRARY_FILTERS_FAVORITES_TAB_ID,
},
right: { type: "block" },
up: tabUpOverride,
down: tabDownOverride,
},
},
] satisfies Array<TabsItem<LibraryFilterTab>>;
const toolbarNavigationOverrides: FocusOverrides = {
up: {
type: "region",
regionId: LIBRARY_HERO_ACTIONS_REGION_ID,
entryDirection: "up",
},
down: {
type: "item",
itemId: LIBRARY_FILTERS_ALL_TAB_ID,
},
};
const toolbarUpOverride = {
type: "region",
regionId: LIBRARY_HERO_ACTIONS_REGION_ID,
entryDirection: "up",
} as const;
const toolbarDownOverride = {
type: "item",
itemId: LIBRARY_FILTERS_ALL_TAB_ID,
} as const;
const searchNavigationOverrides: FocusOverrides = {
left: sidebarLibraryOverride,
right: {
type: "item",
itemId: LIBRARY_FILTERS_LIST_VIEW_BUTTON_ID,
},
up: toolbarUpOverride,
down: toolbarDownOverride,
};
const listViewNavigationOverrides: FocusOverrides = {
left: {
type: "item",
itemId: LIBRARY_FILTERS_SEARCH_INPUT_ID,
},
right: {
type: "item",
itemId: LIBRARY_FILTERS_GRID_VIEW_BUTTON_ID,
},
up: toolbarUpOverride,
down: toolbarDownOverride,
};
const gridViewNavigationOverrides: FocusOverrides = {
left: {
type: "item",
itemId: LIBRARY_FILTERS_LIST_VIEW_BUTTON_ID,
},
right: {
type: "block",
},
up: toolbarUpOverride,
down: toolbarDownOverride,
};
const tabsNavigationOverrides: FocusOverrides = {
up: tabUpOverride,
down: tabDownOverride,
};
return (
<div className="library-filters">
<div className="library-filters__header">
<h2 className="library-filters__title">Your Library</h2>
</div>
<HorizontalFocusGroup
className="library-filters__toolbar"
regionId={LIBRARY_FILTERS_TOOLBAR_REGION_ID}
navigationOverrides={toolbarNavigationOverrides}
>
<div className="library-filters__search">
<Input
focusId={LIBRARY_FILTERS_SEARCH_INPUT_ID}
focusNavigationOverrides={searchNavigationOverrides}
type="text"
placeholder="Search library"
iconLeft={<MagnifyingGlassIcon size={24} />}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
<div className="library-filters__view-actions">
<Button
focusId={LIBRARY_FILTERS_LIST_VIEW_BUTTON_ID}
focusNavigationOverrides={listViewNavigationOverrides}
className="library-filters__view-button library-filters__view-button--list"
variant="secondary"
size="icon"
>
<ListDashesIcon
className="library-filters__view-icon library-filters__view-icon--list"
size={24}
/>
</Button>
<Button
focusId={LIBRARY_FILTERS_GRID_VIEW_BUTTON_ID}
focusNavigationOverrides={gridViewNavigationOverrides}
className="library-filters__view-button library-filters__view-button--grid"
variant="secondary"
size="icon"
>
<SquaresFourIcon
className="library-filters__view-icon library-filters__view-icon--grid"
size={24}
/>
</Button>
</div>
</HorizontalFocusGroup>
<div className="library-filters__tabs">
<Tabs
items={tabs}
value={selectedTab}
onValueChange={onSelectedTabChange}
regionId={LIBRARY_FILTERS_TABS_REGION_ID}
navigationOverrides={tabsNavigationOverrides}
ariaLabel="Library filters"
/>
</div>
</div>
);
}
@@ -0,0 +1,84 @@
import type { LibraryGame } from "@types";
import { DotsThreeVerticalIcon } from "@phosphor-icons/react";
import { FocusItem, VerticalGameCard } from "../../common";
import {
formatPlayedTime,
getBigPictureGameDetailsPath,
getGameAchievementProgress,
getGameImageSources,
} from "../../../helpers";
import type { FocusOverrides } from "../../../services";
import { useDominantColor } from "../../../hooks";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getLibraryFocusGridItemId } from "./navigation";
export interface VerticalLibraryGameCardProps {
game: LibraryGame;
navigationOverrides?: FocusOverrides;
}
export function VerticalLibraryGameCard({
game,
navigationOverrides,
}: Readonly<VerticalLibraryGameCardProps>) {
const navigate = useNavigate();
const imageSources = useMemo(() => getGameImageSources(game), [game]);
const [imageSourceIndex, setImageSourceIndex] = useState(0);
const [imageExhausted, setImageExhausted] = useState(false);
useEffect(() => {
setImageSourceIndex(0);
setImageExhausted(false);
}, [game.id, imageSources]);
const activeImageSource = imageExhausted
? null
: (imageSources[imageSourceIndex] ?? null);
const dominantColor = useDominantColor(activeImageSource);
const achievementProgress = getGameAchievementProgress(game);
const gameDetailsPath = getBigPictureGameDetailsPath(game);
const handleCoverImageError = () => {
if (imageSourceIndex < imageSources.length - 1) {
setImageSourceIndex((currentIndex) => currentIndex + 1);
return;
}
setImageExhausted(true);
};
return (
<FocusItem
id={getLibraryFocusGridItemId(game.id)}
actions={{
primary: () => navigate(gameDetailsPath),
secondary: "off",
}}
navigationOverrides={navigationOverrides}
>
<VerticalGameCard
className="library-focus-grid__card"
coverImageUrl={activeImageSource}
gameTitle={game.title}
subtitle={formatPlayedTime(game.playTimeInMilliseconds, {
zeroFallback: "Never played",
})}
progressLabel={achievementProgress.label}
progressValue={achievementProgress.value}
progressColor={dominantColor ?? undefined}
onClick={() => navigate(gameDetailsPath)}
action={
<div
className="vertical-library-game-card__action-button button button--secondary button--icon"
aria-hidden="true"
>
<DotsThreeVerticalIcon size={24} />
</div>
}
onCoverImageError={handleCoverImageError}
/>
</FocusItem>
);
}
@@ -0,0 +1,211 @@
import type { LibraryGame } from "@types";
import type { FocusOverrides } from "../../../services";
import { useEffect, useMemo, useState } from "react";
import {
getLibraryFocusGridItemId,
LIBRARY_FILTERS_TABS_REGION_ID,
LIBRARY_FOCUS_GRID_REGION_ID,
} from "./navigation";
interface GridItemPosition {
id: string;
top: number;
left: number;
centerX: number;
}
const ROW_TOLERANCE_PX = 24;
function groupItemsIntoRows(items: GridItemPosition[]) {
const sortedItems = [...items].sort((a, b) => {
if (Math.abs(a.top - b.top) > ROW_TOLERANCE_PX) {
return a.top - b.top;
}
return a.left - b.left;
});
return sortedItems.reduce<GridItemPosition[][]>((rows, item) => {
const lastRow = rows.at(-1);
if (!lastRow || Math.abs(lastRow[0].top - item.top) > ROW_TOLERANCE_PX) {
rows.push([item]);
return rows;
}
lastRow.push(item);
lastRow.sort((leftItem, rightItem) => leftItem.left - rightItem.left);
return rows;
}, []);
}
function getClosestItemByCenterX(
items: GridItemPosition[] | undefined,
centerX: number
) {
if (!items?.length) return null;
return [...items].sort((leftItem, rightItem) => {
return (
Math.abs(leftItem.centerX - centerX) -
Math.abs(rightItem.centerX - centerX)
);
})[0];
}
function buildFocusOverridesForGridItem(
item: GridItemPosition,
row: GridItemPosition[],
itemIndex: number,
rowIndex: number,
rows: GridItemPosition[][]
): FocusOverrides {
const leftItem = row[itemIndex - 1];
const rightItem = row[itemIndex + 1];
const upItem = getClosestItemByCenterX(rows[rowIndex - 1], item.centerX);
const downItem = getClosestItemByCenterX(rows[rowIndex + 1], item.centerX);
return {
...(leftItem
? {
left: {
type: "item",
itemId: leftItem.id,
},
}
: {}),
...(rightItem
? {
right: {
type: "item",
itemId: rightItem.id,
},
}
: {
right: {
type: "block",
},
}),
...(upItem
? {
up: {
type: "item",
itemId: upItem.id,
},
}
: {
up: {
type: "region",
regionId: LIBRARY_FILTERS_TABS_REGION_ID,
entryDirection: "down",
},
}),
...(downItem
? {
down: {
type: "item",
itemId: downItem.id,
},
}
: {
down: {
type: "block",
},
}),
};
}
function buildOverridesMapFromRows(rows: GridItemPosition[][]) {
const nextOverridesByItemId: Record<string, FocusOverrides> = {};
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
const row = rows[rowIndex];
for (let itemIndex = 0; itemIndex < row.length; itemIndex++) {
const item = row[itemIndex];
nextOverridesByItemId[item.id] = buildFocusOverridesForGridItem(
item,
row,
itemIndex,
rowIndex,
rows
);
}
}
return nextOverridesByItemId;
}
export function useLibraryGridNavigation(games: LibraryGame[]) {
const [overridesByItemId, setOverridesByItemId] = useState<
Record<string, FocusOverrides>
>({});
const itemIds = useMemo(() => {
return games.map((game) => getLibraryFocusGridItemId(game.id));
}, [games]);
useEffect(() => {
if (itemIds.length === 0) {
setOverridesByItemId({});
return;
}
let animationFrameId = 0;
let resizeObserver: ResizeObserver | null = null;
const computeOverrides = () => {
const items = itemIds
.map((id) => {
const element = globalThis.document.getElementById(id);
const rect = element?.getBoundingClientRect();
if (!element || !rect) return null;
return {
id,
top: rect.top,
left: rect.left,
centerX: rect.left + rect.width / 2,
};
})
.filter((item): item is GridItemPosition => item !== null);
const rows = groupItemsIntoRows(items);
setOverridesByItemId(buildOverridesMapFromRows(rows));
};
const scheduleCompute = () => {
globalThis.cancelAnimationFrame(animationFrameId);
animationFrameId = globalThis.requestAnimationFrame(computeOverrides);
};
scheduleCompute();
globalThis.addEventListener("resize", scheduleCompute);
const gridElement = globalThis.document.querySelector(
`[data-focus-region-id="${LIBRARY_FOCUS_GRID_REGION_ID}"]`
);
if (gridElement instanceof HTMLElement) {
resizeObserver = new ResizeObserver(scheduleCompute);
resizeObserver.observe(gridElement);
itemIds.forEach((id) => {
const itemElement = globalThis.document.getElementById(id);
if (!itemElement) return;
resizeObserver?.observe(itemElement);
});
}
return () => {
globalThis.cancelAnimationFrame(animationFrameId);
globalThis.removeEventListener("resize", scheduleCompute);
resizeObserver?.disconnect();
};
}, [itemIds]);
return overridesByItemId;
}
@@ -0,0 +1,37 @@
.library-focus-grid {
width: 100%;
padding: 0 calc(var(--spacing-unit) * 16);
}
.library-focus-grid__grid {
display: grid;
grid-template-columns: repeat(
var(--library-focus-grid-column-count, 1),
var(--library-focus-grid-card-width, 350px)
);
column-gap: var(
--library-focus-grid-column-gap,
calc(var(--spacing-unit) * 8)
);
row-gap: calc(var(--spacing-unit) * 16);
justify-content: start;
}
.vertical-library-game-card__action-button {
pointer-events: none;
}
@media (max-width: 720px) {
.library-focus-grid {
padding: 0 calc(var(--spacing-unit) * 6);
}
.library-focus-grid__grid {
grid-template-columns: repeat(
auto-fit,
minmax(var(--library-focus-grid-card-width, 350px), 1fr)
);
column-gap: calc(var(--spacing-unit) * 4);
justify-content: initial;
}
}
@@ -0,0 +1,42 @@
import type { LibraryGame } from "@types";
import { GridFocusGroup } from "../../../common";
import {
getLibraryFocusGridItemId,
LIBRARY_FOCUS_GRID_REGION_ID,
} from "../navigation";
import { useLibraryGridNavigation } from "../grid-navigation";
import { useLibraryGridLayout } from "./use-library-grid-layout";
import { VerticalLibraryGameCard } from "../game-card";
import "./focus-grid.scss";
export interface LibraryFocusGridProps {
games: LibraryGame[];
}
export function LibraryFocusGrid({ games }: Readonly<LibraryFocusGridProps>) {
const navigationOverridesByItemId = useLibraryGridNavigation(games);
const style = useLibraryGridLayout(games.length);
if (games.length === 0) return null;
return (
<section className="library-focus-grid">
<GridFocusGroup
className="library-focus-grid__grid"
regionId={LIBRARY_FOCUS_GRID_REGION_ID}
style={style}
>
{games.map((game) => (
<VerticalLibraryGameCard
key={game.id}
game={game}
navigationOverrides={
navigationOverridesByItemId[getLibraryFocusGridItemId(game.id)]
}
/>
))}
</GridFocusGroup>
</section>
);
}
@@ -0,0 +1,82 @@
import { type CSSProperties, useEffect, useMemo, useState } from "react";
import { LIBRARY_FOCUS_GRID_REGION_ID } from "../navigation";
const GRID_CARD_WIDTH = 350;
const GRID_MIN_COLUMN_GAP = 32;
const GRID_MOBILE_BREAKPOINT = 720;
interface LibraryGridLayout {
columnCount: number;
columnGap: number;
}
function getLayoutForWidth(width: number): LibraryGridLayout {
if (width <= 0) {
return {
columnCount: 1,
columnGap: GRID_MIN_COLUMN_GAP,
};
}
const columnCount = Math.max(
1,
Math.floor(
(width + GRID_MIN_COLUMN_GAP) / (GRID_CARD_WIDTH + GRID_MIN_COLUMN_GAP)
)
);
return {
columnCount,
columnGap:
columnCount > 1
? Math.max(
GRID_MIN_COLUMN_GAP,
(width - columnCount * GRID_CARD_WIDTH) / (columnCount - 1)
)
: GRID_MIN_COLUMN_GAP,
};
}
export function useLibraryGridLayout(itemCount: number) {
const [layout, setLayout] = useState(() => getLayoutForWidth(0));
useEffect(() => {
if (itemCount === 0) return;
const gridElement = globalThis.document.querySelector(
`[data-focus-region-id="${LIBRARY_FOCUS_GRID_REGION_ID}"]`
);
if (!(gridElement instanceof HTMLElement)) return;
const updateLayout = () => {
if (globalThis.innerWidth <= GRID_MOBILE_BREAKPOINT) {
setLayout(getLayoutForWidth(0));
return;
}
setLayout(getLayoutForWidth(gridElement.getBoundingClientRect().width));
};
updateLayout();
const resizeObserver = new ResizeObserver(updateLayout);
resizeObserver.observe(gridElement);
globalThis.addEventListener("resize", updateLayout);
return () => {
resizeObserver.disconnect();
globalThis.removeEventListener("resize", updateLayout);
};
}, [itemCount]);
return useMemo(
() =>
({
"--library-focus-grid-card-width": `${GRID_CARD_WIDTH}px`,
"--library-focus-grid-column-count": `${layout.columnCount}`,
"--library-focus-grid-column-gap": `${layout.columnGap}px`,
}) as CSSProperties,
[layout]
);
}
@@ -0,0 +1,246 @@
.hero {
position: relative;
width: 100%;
height: 600px;
overflow: hidden;
border: none;
background: var(--background);
box-shadow: 0px 0px 0px 0px transparent;
}
.hero__bg-layer {
position: absolute;
inset: 0;
z-index: 0;
transition: opacity 0.3s cubic-bezier(0.33, 1, 0.68, 1);
pointer-events: none;
}
.hero__bg-layer--base {
opacity: 1;
}
.hero__bg-layer--incoming {
z-index: 1;
opacity: 0;
}
.hero__bg-layer--visible {
opacity: 1;
}
.hero__bg {
width: 100%;
height: 100%;
}
.hero__overlay {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background:
linear-gradient(
90deg,
var(--background) 0%,
transparent 25% 75%,
var(--background) 99.53%
),
linear-gradient(transparent 0%, var(--background) 97%);
}
.hero__content {
position: absolute;
inset: auto 0 0;
z-index: 3;
display: flex;
align-items: flex-end;
flex-direction: row;
justify-content: space-between;
gap: calc(var(--spacing-unit) * 12);
padding: 0 calc(var(--spacing-unit) * 16) calc(var(--spacing-unit) * 16);
}
.hero__content__left {
position: relative;
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) * 6);
max-width: 337px;
min-width: 0;
}
.hero__logo {
display: flex;
align-items: flex-start;
justify-content: flex-start;
width: 337px;
height: 210px;
}
.hero__logo__image {
width: 100%;
height: 100%;
object-fit: contain;
object-position: left center;
}
.hero__logo__fallback {
max-width: 100%;
font-size: 48px;
font-weight: 700;
line-height: 1;
color: var(--primary);
}
.hero__copy {
display: flex;
flex-direction: column;
align-items: flex-start;
color: var(--primary);
gap: calc(var(--spacing-unit) * 1);
}
.hero__eyebrow {
font-size: 20px;
font-weight: 700;
line-height: 1;
color: var(--text);
}
.hero__description {
margin-top: var(--spacing-unit);
font-size: 16px;
font-weight: 500;
line-height: 1;
color: var(--text-secondary);
}
.hero__actions {
display: flex;
align-items: center;
gap: calc(var(--spacing-unit) * 4);
}
.hero__action__divider {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 1px;
width: 1px;
height: 28px;
pointer-events: none;
}
.hero__action__divider .divider-container--vertical {
width: 1px;
height: 28px;
flex-basis: 1px;
}
.hero__action__divider .divider--vertical {
width: 1px;
height: 28px;
min-height: 28px;
background-color: var(--text-secondary);
opacity: 0.7;
}
.hero__stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-unit);
width: min(100%, 336px);
flex-shrink: 0;
}
.hero__stat {
display: flex;
flex-direction: column;
min-width: 0;
gap: var(--spacing-unit);
}
.hero__stat__value {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-unit);
height: 91px;
background: var(--surface);
color: var(--text);
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.hero__stat__label {
display: flex;
align-items: center;
justify-content: center;
min-height: 37px;
padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 4);
background: var(--surface);
color: var(--text-secondary);
font-size: 16px;
font-weight: 400;
line-height: 1;
}
.hero__stat--achievements .hero__stat__value {
border-radius: calc(var(--spacing-unit) * 2) 0 0 0;
}
.hero__stat--achievements .hero__stat__label {
border-radius: 0 0 0 calc(var(--spacing-unit) * 2);
}
.hero__stat--playtime .hero__stat__value {
border-radius: 0 calc(var(--spacing-unit) * 2) 0 0;
}
.hero__stat--playtime .hero__stat__label {
border-radius: 0 0 calc(var(--spacing-unit) * 2) 0;
}
@media (max-width: 1100px) {
.hero {
height: 820px;
}
.hero__content {
flex-direction: column;
align-items: flex-start;
gap: calc(var(--spacing-unit) * 8);
}
.hero__stats {
width: 100%;
max-width: 336px;
}
}
@media (max-width: 720px) {
.hero {
height: 900px;
}
.hero__content {
padding: 0 calc(var(--spacing-unit) * 6) calc(var(--spacing-unit) * 6);
}
.hero__content__left {
max-width: 100%;
width: 100%;
}
.hero__logo {
width: min(100%, 337px);
height: auto;
aspect-ratio: 337 / 210;
}
.hero__actions {
flex-wrap: wrap;
}
}
@@ -0,0 +1,278 @@
import type { LibraryGame } from "@types";
import {
ClockIcon,
DownloadSimpleIcon,
GearIcon,
HeartIcon,
PlayIcon,
TrophyIcon,
} from "@phosphor-icons/react";
import { useCallback, useEffect, useRef, useState } from "react";
import cn from "classnames";
import {
Button,
AnimatedHeroImage,
Divider,
HorizontalFocusGroup,
} from "../../../common";
import { formatRelativeDate } from "../../../../helpers";
import { useDominantColor } from "../../../../hooks";
import { type FocusOverrides } from "../../../../services";
import { BIG_PICTURE_SIDEBAR_ITEM_IDS } from "../../../../layout";
import {
LIBRARY_FILTERS_SEARCH_INPUT_ID,
LIBRARY_HERO_ACTIONS_REGION_ID,
LIBRARY_HERO_FAVORITE_BUTTON_ID,
LIBRARY_HERO_LAUNCH_BUTTON_ID,
LIBRARY_HERO_OPTIONS_BUTTON_ID,
} from "../navigation";
import { getHeroPlaytimeLabel } from "../library-data";
import { useLibraryLaunchGame } from "../use-library-launch-game";
import { useHeroBackgroundLayers } from "./use-hero-background-layers";
import "./hero.scss";
interface LibraryHeroProps {
lastPlayedGames: LibraryGame[];
onOpenGameSettings?: (game: LibraryGame) => void;
onToggleFavorite?: (game: LibraryGame) => Promise<void> | void;
favoriteLoadingGameId?: string | null;
}
const FEATURED_GAME_INTERVAL = 60000;
function getLastPlayedLabel(lastTimePlayed: Date | string | null | undefined) {
const relativeDate = formatRelativeDate(lastTimePlayed, {
fallback: "recently",
});
return `Last played ${relativeDate}`;
}
export function LibraryHero({
lastPlayedGames,
onOpenGameSettings,
onToggleFavorite,
favoriteLoadingGameId = null,
}: Readonly<LibraryHeroProps>) {
const [featuredGameIndex, setFeaturedGameIndex] = useState(0);
const heroRef = useRef<HTMLElement | null>(null);
const featuredGame = lastPlayedGames[featuredGameIndex] ?? null;
const launchGame = useLibraryLaunchGame(
useCallback(() => {
console.log("library-hero download");
}, [])
);
const getHeroScrollAnchor = useCallback(() => heroRef.current, []);
const dominantColor = useDominantColor(
featuredGame?.libraryHeroImageUrl ?? null
);
const { backgroundLayers, getLayerEventHandlers } = useHeroBackgroundLayers(
featuredGame?.libraryHeroImageUrl
);
useEffect(() => {
if (lastPlayedGames.length <= 1) {
setFeaturedGameIndex(0);
return;
}
const intervalId = globalThis.setInterval(() => {
setFeaturedGameIndex((currentIndex) => {
return (currentIndex + 1) % lastPlayedGames.length;
});
}, FEATURED_GAME_INTERVAL);
return () => {
globalThis.clearInterval(intervalId);
};
}, [lastPlayedGames]);
const achievementCount =
featuredGame?.unlockedAchievementCount ??
featuredGame?.achievementCount ??
0;
const playtime = getHeroPlaytimeLabel(featuredGame?.playTimeInMilliseconds);
const lastPlayedLabel = getLastPlayedLabel(featuredGame?.lastTimePlayed);
const isFavoriteLoading =
Boolean(featuredGame) && favoriteLoadingGameId === featuredGame?.id;
const hasExecutable = Boolean(featuredGame?.executablePath);
const handlePlayOrDownloadClick = () => {
if (!featuredGame) return;
void launchGame(featuredGame);
};
const heroActionsNavigationOverrides: FocusOverrides = {
down: {
type: "item",
itemId: LIBRARY_FILTERS_SEARCH_INPUT_ID,
},
};
const sidebarLibraryOverride = {
type: "item",
itemId: BIG_PICTURE_SIDEBAR_ITEM_IDS.library,
} as const;
const launchNavigationOverrides: FocusOverrides = {
left: sidebarLibraryOverride,
right: { type: "item", itemId: LIBRARY_HERO_OPTIONS_BUTTON_ID },
down: { type: "item", itemId: LIBRARY_FILTERS_SEARCH_INPUT_ID },
};
const optionsNavigationOverrides: FocusOverrides = {
left: { type: "item", itemId: LIBRARY_HERO_LAUNCH_BUTTON_ID },
right: { type: "item", itemId: LIBRARY_HERO_FAVORITE_BUTTON_ID },
down: { type: "item", itemId: LIBRARY_FILTERS_SEARCH_INPUT_ID },
};
const favoriteNavigationOverrides: FocusOverrides = {
left: { type: "item", itemId: LIBRARY_HERO_OPTIONS_BUTTON_ID },
right: { type: "block" },
down: { type: "item", itemId: LIBRARY_FILTERS_SEARCH_INPUT_ID },
};
return (
<section ref={heroRef} className="hero">
{backgroundLayers.map((layer) => {
const layerHandlers = getLayerEventHandlers(layer);
return (
<div
key={layer.key}
className={cn(
`hero__bg-layer hero__bg-layer--${layer.role}`,
layer.isVisible && "hero__bg-layer--visible"
)}
onTransitionEnd={layerHandlers.onTransitionEnd}
>
<AnimatedHeroImage
className="hero__bg"
imageUrl={layer.imageUrl}
onLoad={layerHandlers.onLoad}
onError={layerHandlers.onError}
/>
</div>
);
})}
<div className="hero__overlay" />
<div className="hero__content">
<div className="hero__content__left">
<div className="hero__logo">
{featuredGame?.logoImageUrl ? (
<img
src={featuredGame.logoImageUrl}
alt={featuredGame.title}
className="hero__logo__image"
/>
) : (
<span className="hero__logo__fallback">
{featuredGame?.title ?? ""}
</span>
)}
</div>
<div className="hero__copy">
<p className="hero__eyebrow">Continue playing:</p>
<p className="hero__description">{lastPlayedLabel}</p>
</div>
<div className="hero__actions">
<HorizontalFocusGroup
regionId={LIBRARY_HERO_ACTIONS_REGION_ID}
navigationOverrides={heroActionsNavigationOverrides}
getScrollAnchor={getHeroScrollAnchor}
>
{hasExecutable ? (
<Button
variant="primary"
icon={<PlayIcon size={24} weight="fill" />}
color={dominantColor ?? undefined}
focusId={LIBRARY_HERO_LAUNCH_BUTTON_ID}
focusNavigationOverrides={launchNavigationOverrides}
disabled={!featuredGame}
onClick={handlePlayOrDownloadClick}
>
Launch Game
</Button>
) : (
<Button
variant="primary"
icon={<DownloadSimpleIcon size={24} />}
color={dominantColor ?? undefined}
focusId={LIBRARY_HERO_LAUNCH_BUTTON_ID}
focusNavigationOverrides={launchNavigationOverrides}
disabled={!featuredGame}
onClick={handlePlayOrDownloadClick}
>
Launch Game
</Button>
)}
<div className="hero__action__divider">
<Divider orientation="vertical" color="var(--text-secondary)" />
</div>
<Button
variant="secondary"
icon={<GearIcon size={24} />}
focusId={LIBRARY_HERO_OPTIONS_BUTTON_ID}
focusNavigationOverrides={optionsNavigationOverrides}
onClick={() => {
if (featuredGame) {
onOpenGameSettings?.(featuredGame);
}
}}
>
Options
</Button>
<Button
variant="secondary"
size="icon"
focusId={LIBRARY_HERO_FAVORITE_BUTTON_ID}
focusNavigationOverrides={favoriteNavigationOverrides}
aria-label={
featuredGame?.favorite
? "Remove from favorites"
: "Add to favorites"
}
disabled={!featuredGame}
loading={isFavoriteLoading}
onClick={() => {
if (featuredGame) {
void onToggleFavorite?.(featuredGame);
}
}}
>
{featuredGame?.favorite ? (
<HeartIcon size={24} weight="fill" />
) : (
<HeartIcon size={24} />
)}
</Button>
</HorizontalFocusGroup>
</div>
</div>
<div className="hero__stats">
<div className="hero__stat hero__stat--achievements">
<div className="hero__stat__value">
<TrophyIcon size={28} />
<span>{achievementCount}</span>
</div>
<div className="hero__stat__label">Achievements</div>
</div>
<div className="hero__stat hero__stat--playtime">
<div className="hero__stat__value">
<ClockIcon size={28} />
<span>{playtime}</span>
</div>
<div className="hero__stat__label">Hours Played</div>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useState } from "react";
export interface HeroBackgroundLayer {
key: number;
imageUrl: string;
role: "base" | "incoming";
isVisible: boolean;
}
function getNextLayerKey() {
return Date.now();
}
export function useHeroBackgroundLayers(imageUrl: string | null | undefined) {
const [backgroundLayers, setBackgroundLayers] = useState<
HeroBackgroundLayer[]
>([]);
useEffect(() => {
const nextImageUrl = imageUrl ?? "";
if (!nextImageUrl) {
setBackgroundLayers([]);
return;
}
setBackgroundLayers((currentLayers) => {
const baseLayer =
currentLayers.find((layer) => layer.role === "base") ?? null;
const incomingLayer =
currentLayers.find((layer) => layer.role === "incoming") ?? null;
if (
baseLayer?.imageUrl === nextImageUrl ||
incomingLayer?.imageUrl === nextImageUrl
) {
return currentLayers;
}
const nextLayer = {
key: getNextLayerKey(),
imageUrl: nextImageUrl,
role: "base" as const,
isVisible: true,
};
if (!baseLayer) return [nextLayer];
return [
baseLayer,
{
...nextLayer,
key: nextLayer.key + 1,
role: "incoming" as const,
isVisible: false,
},
];
});
}, [imageUrl]);
const showIncomingLayer = useCallback((layerKey: number) => {
setBackgroundLayers((currentLayers) => {
return currentLayers.map((layer) => {
if (layer.key !== layerKey || layer.role !== "incoming") return layer;
return {
...layer,
isVisible: true,
};
});
});
}, []);
const removeIncomingLayer = useCallback((layerKey: number) => {
setBackgroundLayers((currentLayers) => {
return currentLayers.filter((layer) => layer.key !== layerKey);
});
}, []);
const promoteIncomingLayer = useCallback((layerKey: number) => {
setBackgroundLayers((currentLayers) => {
const incomingLayer = currentLayers.find(
(layer) => layer.key === layerKey && layer.role === "incoming"
);
if (!incomingLayer?.isVisible) return currentLayers;
return currentLayers
.filter((layer) => layer.key === layerKey)
.map((layer) => ({
...layer,
role: "base" as const,
isVisible: true,
}));
});
}, []);
const getLayerEventHandlers = useCallback(
(layer: HeroBackgroundLayer) => {
const runForIncomingLayer = (run: (layerKey: number) => void) => () => {
if (layer.role !== "incoming") return;
run(layer.key);
};
return {
onLoad: runForIncomingLayer(showIncomingLayer),
onError: runForIncomingLayer(removeIncomingLayer),
onTransitionEnd: runForIncomingLayer(promoteIncomingLayer),
};
},
[promoteIncomingLayer, removeIncomingLayer, showIncomingLayer]
);
return {
backgroundLayers,
getLayerEventHandlers,
};
}
@@ -0,0 +1,9 @@
export * from "./hero/hero";
export * from "./grid/focus-grid";
export * from "./game-card";
export * from "./settings-modal";
export * from "./filters/filters";
export * from "./library-data";
export * from "./use-library-favorite";
export * from "./use-library-launch-game";
export * from "./use-library-page-data";
@@ -0,0 +1,99 @@
import type { LibraryGame } from "@types";
export type LibraryFilterTab = "all" | "favorites" | "completed";
export interface LibraryFilterCounts {
all: number;
favorites: number;
completed: number;
}
export const LAST_PLAYED_GAMES_COUNT = 3;
export function sortByLastPlayed(a: LibraryGame, b: LibraryGame) {
const aLastPlayed = a.lastTimePlayed
? new Date(a.lastTimePlayed as Date).getTime()
: null;
const bLastPlayed = b.lastTimePlayed
? new Date(b.lastTimePlayed as Date).getTime()
: null;
if (aLastPlayed !== null && bLastPlayed !== null) {
const lastPlayedDifference = bLastPlayed - aLastPlayed;
if (lastPlayedDifference !== 0) return lastPlayedDifference;
}
if (aLastPlayed !== null) return -1;
if (bLastPlayed !== null) return 1;
return a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
}
export function isCompletedGame(game: LibraryGame) {
const achievementCount = game.achievementCount ?? 0;
return (
achievementCount > 0 &&
(game.unlockedAchievementCount ?? 0) >= achievementCount
);
}
export function matchesSearchQuery(game: LibraryGame, searchQuery: string) {
const normalizedQuery = searchQuery.trim().toLowerCase();
if (!normalizedQuery) return true;
const titleLower = game.title.toLowerCase();
let queryIndex = 0;
for (
let titleIndex = 0;
titleIndex < titleLower.length && queryIndex < normalizedQuery.length;
titleIndex++
) {
if (titleLower[titleIndex] === normalizedQuery[queryIndex]) queryIndex++;
}
return queryIndex === normalizedQuery.length;
}
export function filterLibraryByTab(
library: LibraryGame[],
selectedTab: LibraryFilterTab
) {
if (selectedTab === "favorites") {
return library.filter((game) => game.favorite);
}
if (selectedTab === "completed") {
return library.filter(isCompletedGame);
}
return library;
}
export function getLibraryFilterCounts(
library: LibraryGame[]
): LibraryFilterCounts {
return {
all: library.length,
favorites: library.filter((game) => game.favorite).length,
completed: library.filter(isCompletedGame).length,
};
}
export function getLastPlayedGames(library: LibraryGame[]) {
return library
.filter((game) => game.lastTimePlayed != null)
.slice(0, LAST_PLAYED_GAMES_COUNT);
}
export function getHeroPlaytimeLabel(playTimeInMilliseconds?: number | null) {
if (!playTimeInMilliseconds) return "0h";
const totalHours = Math.max(
1,
Math.round(playTimeInMilliseconds / 3_600_000)
);
return `${totalHours}h`;
}
@@ -0,0 +1,25 @@
export const LIBRARY_HERO_ACTIONS_REGION_ID = "library-hero-actions";
export const LIBRARY_HERO_LAUNCH_BUTTON_ID = "library-hero-launch-button";
export const LIBRARY_HERO_OPTIONS_BUTTON_ID = "library-hero-options-button";
export const LIBRARY_HERO_FAVORITE_BUTTON_ID = "library-hero-favorite-button";
export const LIBRARY_FILTERS_TOOLBAR_REGION_ID = "library-filters-toolbar";
export const LIBRARY_FILTERS_TABS_REGION_ID = "library-filters-tabs";
export const LIBRARY_FILTERS_SEARCH_INPUT_ID = "library-filters-search-input";
export const LIBRARY_FILTERS_LIST_VIEW_BUTTON_ID =
"library-filters-list-view-button";
export const LIBRARY_FILTERS_GRID_VIEW_BUTTON_ID =
"library-filters-grid-view-button";
export const LIBRARY_FILTERS_ALL_TAB_ID = "library-filters-tab-all";
export const LIBRARY_FILTERS_FAVORITES_TAB_ID = "library-filters-tab-favorites";
export const LIBRARY_FILTERS_COMPLETED_TAB_ID = "library-filters-tab-completed";
export const LIBRARY_FOCUS_GRID_REGION_ID = "library-focus-grid";
export function getLibraryFocusGridItemId(gameId: string) {
return `library-focus-grid-item-${gameId}`;
}
export function getFirstLibraryFocusGridItemId(gameId?: string | null) {
if (!gameId) return null;
return getLibraryFocusGridItemId(gameId);
}
@@ -0,0 +1,113 @@
import type { LibraryGame } from "@types";
import { GameSettingsCloudSyncState } from "./use-game-settings-cloud-sync";
import type {
GameSettingsAssetType,
GameSettingsCategoryId,
} from "./use-game-settings-controller";
export const GAME_SETTINGS_ASSET_TYPES = [
"icon",
"logo",
"hero",
] satisfies GameSettingsAssetType[];
export function categoryLabel(categoryId: GameSettingsCategoryId) {
switch (categoryId) {
case "general":
return "General";
case "assets":
return "Assets";
case "hydra_cloud":
return "Hydra Cloud";
case "compatibility":
return "Compatibility";
case "downloads":
return "Downloads";
case "danger_zone":
return "Danger Zone";
}
}
export function assetLabel(assetType: GameSettingsAssetType) {
if (assetType === "icon") return "Icon";
if (assetType === "logo") return "Logo";
return "Hero";
}
export function formatDateTime(value: string | Date) {
return new Date(value).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
export function formatBackupStateLabel(
state: GameSettingsCloudSyncState,
loadingPreview: boolean,
uploadingBackup: boolean,
restoringBackup: boolean,
progress: number
) {
if (uploadingBackup) return "Uploading backup...";
if (restoringBackup) return `Restoring backup ${Math.round(progress * 100)}%`;
if (loadingPreview) return "Loading save preview...";
if (state === GameSettingsCloudSyncState.New) return "New save files found";
if (state === GameSettingsCloudSyncState.Different)
return "Save files changed";
if (state === GameSettingsCloudSyncState.Same) return "Save files are synced";
return "No backup preview";
}
export function getGameAssetUrl(
game: LibraryGame,
assetType: GameSettingsAssetType
) {
if (assetType === "icon") return game.customIconUrl ?? game.iconUrl ?? null;
if (assetType === "logo")
return game.customLogoImageUrl ?? game.logoImageUrl ?? null;
return game.customHeroImageUrl ?? game.libraryHeroImageUrl ?? null;
}
export function getGameSidebarCoverUrl(game: LibraryGame) {
return (
game.customHeroImageUrl ??
game.libraryHeroImageUrl ??
game.libraryImageUrl ??
game.coverImageUrl ??
game.customIconUrl ??
game.iconUrl ??
null
);
}
export function getGameOriginalAssetPath(
game: LibraryGame,
assetType: GameSettingsAssetType
) {
if (game.shop === "custom") {
if (assetType === "icon") return game.originalIconPath ?? game.iconUrl;
if (assetType === "logo") return game.originalLogoPath ?? game.logoImageUrl;
return game.originalHeroPath ?? game.libraryHeroImageUrl;
}
if (assetType === "icon")
return game.customOriginalIconPath ?? game.customIconUrl ?? game.iconUrl;
if (assetType === "logo") {
return (
game.customOriginalLogoPath ??
game.customLogoImageUrl ??
game.logoImageUrl
);
}
return (
game.customOriginalHeroPath ??
game.customHeroImageUrl ??
game.libraryHeroImageUrl
);
}
@@ -0,0 +1,230 @@
import "./styles.scss";
import type { GameArtifact, LibraryGame } from "@types";
import cn from "classnames";
import { useMemo, useState } from "react";
import {
HorizontalFocusGroup,
Modal,
NavigationLayer,
VerticalFocusGroup,
} from "../../../common";
import { GameSettingsSidebar } from "./sidebar";
import { GameSettingsPanelContent } from "./sections";
import {
ChangePlaytimeModal,
ConfirmationModal,
CreateSteamShortcutModal,
ManageFilesModal,
RenameArtifactModal,
} from "./submodals";
import type { GameSettingsConfirmation } from "./types";
import { useGameSettingsCloudSync } from "./use-game-settings-cloud-sync";
import {
type GameSettingsCategoryId,
useGameSettingsController,
} from "./use-game-settings-controller";
export interface GameSettingsModalProps {
visible: boolean;
game: LibraryGame | null;
onClose: () => void;
onGameUpdated?: (game: LibraryGame | null) => void;
}
const ROOT_REGION_ID = "library-game-settings-modal-root";
const CATEGORIES_REGION_ID = "library-game-settings-modal-categories";
const PANEL_REGION_ID = "library-game-settings-modal-panel";
function getSettingsCategories() {
const categories: GameSettingsCategoryId[] = [
"general",
"assets",
"hydra_cloud",
"downloads",
"danger_zone",
];
if (globalThis.window.electron?.platform === "linux") {
categories.splice(3, 0, "compatibility");
}
return categories;
}
export function GameSettingsModal({
visible,
game: initialGame,
onClose,
onGameUpdated,
}: Readonly<GameSettingsModalProps>) {
const controller = useGameSettingsController({
visible,
initialGame,
onClose,
onGameUpdated,
});
const cloudSync = useGameSettingsCloudSync({
visible,
game: controller.game,
hasActiveSubscription: controller.hasActiveSubscription,
onFeedback: controller.notify,
});
const [showSteamShortcutModal, setShowSteamShortcutModal] = useState(false);
const [confirmation, setConfirmation] =
useState<GameSettingsConfirmation | null>(null);
const [showChangePlaytimeModal, setShowChangePlaytimeModal] = useState(false);
const [artifactToRename, setArtifactToRename] = useState<GameArtifact | null>(
null
);
const [artifactToDelete, setArtifactToDelete] = useState<GameArtifact | null>(
null
);
const [showManageFilesModal, setShowManageFilesModal] = useState(false);
const categories = useMemo(getSettingsCategories, []);
const game = controller.game;
if (!game) return null;
return (
<>
<Modal
visible={visible}
onClose={onClose}
className="game-settings-modal"
>
<NavigationLayer
rootRegionId={ROOT_REGION_ID}
initialFocusRegionId={CATEGORIES_REGION_ID}
>
<HorizontalFocusGroup
regionId={ROOT_REGION_ID}
className="game-settings-modal__shell"
style={{ gap: 0, alignItems: "stretch" }}
>
<GameSettingsSidebar
game={game}
categories={categories}
selectedCategory={controller.selectedCategory}
regionId={CATEGORIES_REGION_ID}
onCategoryChange={controller.setSelectedCategory}
/>
<main className="game-settings-modal__panel">
{controller.feedback && (
<div
className={cn(
"game-settings-modal__feedback",
`game-settings-modal__feedback--${controller.feedback.type}`
)}
>
{controller.feedback.message}
</div>
)}
<VerticalFocusGroup
regionId={PANEL_REGION_ID}
autoScrollMode="region"
>
<GameSettingsPanelContent
controller={controller}
cloudSync={cloudSync}
onOpenSteamShortcutModal={() =>
setShowSteamShortcutModal(true)
}
onOpenChangePlaytimeModal={() =>
setShowChangePlaytimeModal(true)
}
onOpenManageFilesModal={() => setShowManageFilesModal(true)}
onRenameArtifact={setArtifactToRename}
onDeleteArtifact={(artifact) => {
setArtifactToDelete(artifact);
setConfirmation("delete-artifact");
}}
onRequestConfirmation={setConfirmation}
/>
</VerticalFocusGroup>
</main>
</HorizontalFocusGroup>
</NavigationLayer>
</Modal>
<CreateSteamShortcutModal
visible={showSteamShortcutModal}
creating={controller.busyAction === "create-steam-shortcut"}
onClose={() => setShowSteamShortcutModal(false)}
onConfirm={controller.handleCreateSteamShortcut}
/>
<ChangePlaytimeModal
visible={showChangePlaytimeModal}
game={game}
onClose={() => setShowChangePlaytimeModal(false)}
onConfirm={controller.handleChangePlaytime}
/>
<RenameArtifactModal
visible={Boolean(artifactToRename)}
artifact={artifactToRename}
onClose={() => setArtifactToRename(null)}
onConfirm={cloudSync.renameGameArtifact}
/>
<ManageFilesModal
visible={showManageFilesModal}
backupPreview={cloudSync.backupPreview}
onClose={() => setShowManageFilesModal(false)}
onSetBackupPath={cloudSync.setBackupPath}
/>
<ConfirmationModal
visible={confirmation === "remove-library"}
title="Remove from library?"
description={`Remove ${game.title} from your library. Downloaded files will not be deleted.`}
confirmLabel="Remove"
danger
onClose={() => setConfirmation(null)}
onConfirm={controller.handleRemoveFromLibrary}
/>
<ConfirmationModal
visible={confirmation === "reset-achievements"}
title="Reset achievements?"
description="This removes local achievement progress for this game."
confirmLabel="Reset"
danger
onClose={() => setConfirmation(null)}
onConfirm={controller.handleResetAchievements}
/>
<ConfirmationModal
visible={confirmation === "remove-files"}
title="Remove downloaded files?"
description="This deletes the downloaded game files from disk."
confirmLabel="Remove files"
danger
onClose={() => setConfirmation(null)}
onConfirm={controller.handleRemoveGameFiles}
/>
<ConfirmationModal
visible={confirmation === "delete-artifact"}
title="Delete backup?"
description={`Delete ${
artifactToDelete?.label ?? "this backup"
}. Frozen backups cannot be deleted.`}
confirmLabel="Delete"
danger
onClose={() => {
setConfirmation(null);
setArtifactToDelete(null);
}}
onConfirm={async () => {
if (artifactToDelete) {
await cloudSync.deleteGameArtifact(artifactToDelete.id);
}
}}
/>
</>
);
}
@@ -0,0 +1,707 @@
import {
ArrowsClockwiseIcon,
CloudArrowDownIcon,
CloudArrowUpIcon,
CloudIcon,
FileIcon,
FloppyDiskIcon,
FolderOpenIcon,
HardDriveIcon,
ImageIcon,
PencilSimpleIcon,
PushPinIcon,
PushPinSlashIcon,
SparkleIcon,
TrashIcon,
} from "@phosphor-icons/react";
import type { ReactNode } from "react";
import type { GameArtifact, LibraryGame } from "@types";
import { formatBytes } from "@shared";
import { Button, HorizontalFocusGroup } from "../../../common";
import { resolveImageSource } from "../../../../helpers";
import type { GameSettingsCloudSync } from "./use-game-settings-cloud-sync";
import type { GameSettingsController } from "./use-game-settings-controller";
import type { GameSettingsConfirmation } from "./types";
import { FocusableInput, SettingsSection, ToggleAction } from "./shared";
import {
assetLabel,
formatBackupStateLabel,
formatDateTime,
GAME_SETTINGS_ASSET_TYPES,
getGameAssetUrl,
getGameOriginalAssetPath,
} from "./helpers";
type GameSettingsPanelContentProps = Readonly<{
controller: GameSettingsController;
cloudSync: GameSettingsCloudSync;
onOpenSteamShortcutModal: () => void;
onOpenChangePlaytimeModal: () => void;
onOpenManageFilesModal: () => void;
onRenameArtifact: (artifact: GameArtifact) => void;
onDeleteArtifact: (artifact: GameArtifact) => void;
onRequestConfirmation: (confirmation: GameSettingsConfirmation) => void;
}>;
function GeneralSection({
game,
controller,
onOpenSteamShortcutModal,
}: Readonly<{
game: LibraryGame;
controller: GameSettingsController;
onOpenSteamShortcutModal: () => void;
}>) {
const canOpenSaveFolder =
game.shop !== "custom" && globalThis.window.electron.platform === "win32";
let saveFolderButtonLabel = "No save folder found";
if (controller.loadingSaveFolder) {
saveFolderButtonLabel = "Searching save folder";
} else if (controller.saveFolderPath) {
saveFolderButtonLabel = "Open save folder";
}
return (
<>
<SettingsSection title="Title">
<div className="game-settings-modal__field-row">
<FocusableInput
label="Game title"
value={controller.gameTitle}
disabled={controller.updatingTitle}
onChange={controller.setGameTitle}
/>
<Button
icon={<FloppyDiskIcon size={18} />}
disabled={controller.updatingTitle}
onClick={controller.handleSaveTitle}
>
Save
</Button>
</div>
</SettingsSection>
<SettingsSection
title="Executable"
description="Choose the executable used to launch this game."
>
<div className="game-settings-modal__path-card">
<span>{game.executablePath || "No executable selected"}</span>
</div>
<HorizontalFocusGroup className="game-settings-modal__actions">
<Button
variant="secondary"
icon={<FileIcon size={18} />}
onClick={controller.handleSelectExecutable}
>
Select executable
</Button>
<Button
variant="secondary"
disabled={!game.executablePath}
onClick={controller.handleOpenExecutableFolder}
>
Open folder
</Button>
<Button
variant="secondary"
disabled={!game.executablePath}
onClick={controller.handleClearExecutable}
>
Clear
</Button>
{canOpenSaveFolder && (
<Button
variant="secondary"
disabled={
controller.loadingSaveFolder || !controller.saveFolderPath
}
onClick={controller.handleOpenSaveFolder}
>
{saveFolderButtonLabel}
</Button>
)}
</HorizontalFocusGroup>
</SettingsSection>
{game.executablePath && (
<SettingsSection
title="Shortcuts"
description="Create shortcuts for faster access."
>
<HorizontalFocusGroup className="game-settings-modal__actions">
<Button
variant="secondary"
onClick={() => controller.handleCreateShortcut("desktop")}
>
Desktop shortcut
</Button>
{globalThis.window.electron.platform === "win32" && (
<Button
variant="secondary"
onClick={() => controller.handleCreateShortcut("start_menu")}
>
Start menu shortcut
</Button>
)}
{game.shop !== "custom" &&
(controller.steamShortcutExists ? (
<Button
variant="danger"
onClick={controller.handleDeleteSteamShortcut}
>
Delete Steam shortcut
</Button>
) : (
<Button variant="secondary" onClick={onOpenSteamShortcutModal}>
Create Steam shortcut
</Button>
))}
</HorizontalFocusGroup>
</SettingsSection>
)}
<SettingsSection
title="Launch options"
description="Add arguments to the game launch command."
>
<FocusableInput
label="Arguments"
value={controller.launchOptions}
placeholder="%command% --fullscreen"
onChange={controller.setLaunchOptions}
/>
<HorizontalFocusGroup className="game-settings-modal__actions">
<Button onClick={controller.handleSaveLaunchOptions}>
Save launch options
</Button>
<Button
variant="secondary"
disabled={!game.launchOptions}
onClick={controller.handleClearLaunchOptions}
>
Clear
</Button>
</HorizontalFocusGroup>
</SettingsSection>
</>
);
}
function AssetsSection({
game,
controller,
}: Readonly<{
game: LibraryGame;
controller: GameSettingsController;
}>) {
return (
<SettingsSection
title="Assets"
description="Customize the library art used by Hydra."
>
<div className="game-settings-modal__assets">
{GAME_SETTINGS_ASSET_TYPES.map((assetType) => {
const assetUrl = getGameAssetUrl(game, assetType);
const originalPath = getGameOriginalAssetPath(game, assetType);
return (
<div key={assetType} className="game-settings-modal__asset-card">
<div className="game-settings-modal__asset-preview">
{assetUrl ? (
<img
src={resolveImageSource(assetUrl)}
alt={assetLabel(assetType)}
/>
) : (
<ImageIcon size={42} />
)}
</div>
<div className="game-settings-modal__asset-info">
<h4>{assetLabel(assetType)}</h4>
<p>{originalPath || "No custom asset selected"}</p>
<HorizontalFocusGroup className="game-settings-modal__actions">
<Button
variant="secondary"
onClick={() => controller.handleChooseAsset(assetType)}
>
Select image
</Button>
<Button
variant="secondary"
disabled={!originalPath}
onClick={() => controller.handleResetAsset(assetType)}
>
Reset
</Button>
<Button
variant="danger"
disabled={!assetUrl}
onClick={() =>
controller.handleUpdateAsset(assetType, null)
}
>
Remove
</Button>
</HorizontalFocusGroup>
</div>
</div>
);
})}
</div>
</SettingsSection>
);
}
function HydraCloudSection({
game,
controller,
cloudSync,
onOpenManageFilesModal,
onRenameArtifact,
onDeleteArtifact,
}: Readonly<{
game: LibraryGame;
controller: GameSettingsController;
cloudSync: GameSettingsCloudSync;
onOpenManageFilesModal: () => void;
onRenameArtifact: (artifact: GameArtifact) => void;
onDeleteArtifact: (artifact: GameArtifact) => void;
}>) {
const cloudActionsDisabled =
cloudSync.uploadingBackup ||
cloudSync.restoringBackup ||
cloudSync.freezingArtifact ||
cloudSync.deletingArtifact;
const backupsLimit = controller.userDetails?.quirks?.backupsPerGameLimit ?? 0;
const hasReachedBackupsLimit =
backupsLimit > 0 && cloudSync.artifacts.length >= backupsLimit;
let hydraCloudBody: ReactNode;
if (game.shop === "custom") {
hydraCloudBody = (
<p className="game-settings-modal__empty-note">
Settings are not available for custom games.
</p>
);
} else if (controller.hasActiveSubscription) {
hydraCloudBody = (
<>
<ToggleAction
label="Automatic cloud sync"
checked={game.automaticCloudSync === true}
disabled={!game.executablePath}
onToggle={controller.handleToggleAutomaticCloudSync}
/>
<div className="game-settings-modal__cloud-header">
<div>
<p>
{formatBackupStateLabel(
cloudSync.backupState,
cloudSync.loadingPreview,
cloudSync.uploadingBackup,
cloudSync.restoringBackup,
cloudSync.backupDownloadProgress?.progress ?? 0
)}
</p>
<Button
variant="link"
disabled={cloudActionsDisabled}
onClick={onOpenManageFilesModal}
>
Manage files
</Button>
</div>
<Button
icon={
cloudSync.uploadingBackup ? (
<ArrowsClockwiseIcon size={18} />
) : (
<CloudArrowUpIcon size={18} />
)
}
disabled={
cloudActionsDisabled ||
!cloudSync.backupPreview?.overall.totalGames ||
hasReachedBackupsLimit
}
onClick={cloudSync.uploadSaveGame}
>
Create backup
</Button>
</div>
<div className="game-settings-modal__backups-title">
<h4>Backups</h4>
<span>{cloudSync.artifacts.length}</span>
</div>
<div className="game-settings-modal__backups">
{cloudSync.artifacts.length === 0 ? (
<p className="game-settings-modal__empty-note">
No backups created.
</p>
) : (
cloudSync.artifacts
.toSorted((a, b) => Number(b.isFrozen) - Number(a.isFrozen))
.map((artifact) => (
<BackupCard
key={artifact.id}
artifact={artifact}
cloudActionsDisabled={cloudActionsDisabled}
cloudSync={cloudSync}
onRenameArtifact={onRenameArtifact}
onDeleteArtifact={onDeleteArtifact}
/>
))
)}
</div>
</>
);
} else {
hydraCloudBody = (
<div className="game-settings-modal__upgrade-card">
<CloudIcon size={28} />
<p>Hydra Cloud is available with an active subscription.</p>
<Button onClick={() => globalThis.electron.openCheckout()}>
Learn more
</Button>
</div>
);
}
return (
<SettingsSection
title="Hydra Cloud"
description="Manage cloud saves and backups for this game."
>
{hydraCloudBody}
</SettingsSection>
);
}
function BackupCard({
artifact,
cloudActionsDisabled,
cloudSync,
onRenameArtifact,
onDeleteArtifact,
}: Readonly<{
artifact: GameArtifact;
cloudActionsDisabled: boolean;
cloudSync: GameSettingsCloudSync;
onRenameArtifact: (artifact: GameArtifact) => void;
onDeleteArtifact: (artifact: GameArtifact) => void;
}>) {
const artifactName =
artifact.label ?? `Backup from ${formatDateTime(artifact.createdAt)}`;
return (
<div className="game-settings-modal__backup-card">
<div>
<Button
variant="link"
icon={<PencilSimpleIcon size={16} />}
onClick={() => onRenameArtifact(artifact)}
>
{artifactName}
</Button>
<p>
{formatBytes(artifact.artifactLengthInBytes)} - {artifact.hostname} -{" "}
{formatDateTime(artifact.createdAt)}
</p>
<p>{artifact.downloadOptionTitle ?? "No download option info"}</p>
</div>
<HorizontalFocusGroup className="game-settings-modal__actions">
<Button
variant="secondary"
icon={<CloudArrowDownIcon size={18} />}
disabled={cloudActionsDisabled}
onClick={() => cloudSync.downloadGameArtifact(artifact.id)}
>
Install
</Button>
<Button
variant="secondary"
icon={
artifact.isFrozen ? (
<PushPinSlashIcon size={18} />
) : (
<PushPinIcon size={18} />
)
}
disabled={cloudActionsDisabled}
onClick={() =>
cloudSync.toggleArtifactFreeze(artifact.id, !artifact.isFrozen)
}
>
{artifact.isFrozen ? "Unfreeze" : "Freeze"}
</Button>
<Button
variant="danger"
icon={<TrashIcon size={18} />}
disabled={cloudActionsDisabled || artifact.isFrozen}
onClick={() => onDeleteArtifact(artifact)}
>
Delete
</Button>
</HorizontalFocusGroup>
</div>
);
}
function CompatibilitySection({
game,
controller,
}: Readonly<{
game: LibraryGame;
controller: GameSettingsController;
}>) {
return (
<>
<SettingsSection
title="Wine prefix"
description="Select the Wine prefix used by this game."
>
<div className="game-settings-modal__path-card">
<span>
{controller.displayedWinePrefixPath || "No directory selected"}
</span>
</div>
<HorizontalFocusGroup className="game-settings-modal__actions">
<Button
variant="secondary"
onClick={controller.handleChangeWinePrefixPath}
>
Select directory
</Button>
<Button
variant="secondary"
disabled={!game.winePrefixPath}
onClick={controller.handleClearWinePrefixPath}
>
Clear
</Button>
<Button
variant="secondary"
disabled={!controller.winetricksAvailable}
onClick={controller.handleOpenWinetricks}
>
Open Winetricks
</Button>
</HorizontalFocusGroup>
</SettingsSection>
<SettingsSection title="Additional options">
<HorizontalFocusGroup className="game-settings-modal__actions">
<ToggleAction
label="Run with GameMode"
checked={
controller.autoRunGamemode || controller.globalAutoRunGamemode
}
disabled={
!controller.gamemodeAvailable || controller.globalAutoRunGamemode
}
onToggle={controller.handleToggleGamemode}
/>
<ToggleAction
label="Run with MangoHud"
checked={
controller.autoRunMangohud || controller.globalAutoRunMangohud
}
disabled={
!controller.mangohudAvailable || controller.globalAutoRunMangohud
}
onToggle={controller.handleToggleMangohud}
/>
</HorizontalFocusGroup>
</SettingsSection>
<SettingsSection
title="Proton version"
description="Choose a Proton version for this game."
>
<div className="game-settings-modal__proton-list">
<Button
variant={
controller.selectedProtonPath === "" ? "primary" : "secondary"
}
onClick={() => controller.handleChangeProtonVersion("")}
>
Auto
</Button>
{controller.protonVersions.map((version) => (
<Button
key={version.path}
variant={
controller.selectedProtonPath === version.path
? "primary"
: "secondary"
}
onClick={() => controller.handleChangeProtonVersion(version.path)}
>
{version.name}
</Button>
))}
</div>
</SettingsSection>
</>
);
}
function DownloadsSection({
game,
controller,
}: Readonly<{
game: LibraryGame;
controller: GameSettingsController;
}>) {
return (
<SettingsSection
title="Downloads"
description="Manage download options and local download files."
>
{game.shop === "custom" ? (
<p className="game-settings-modal__empty-note">
Settings are not available for custom games.
</p>
) : (
<HorizontalFocusGroup className="game-settings-modal__actions">
<Button variant="secondary" disabled>
Open download options
</Button>
<Button
variant="secondary"
icon={<FolderOpenIcon size={18} />}
disabled={!game.download?.downloadPath}
onClick={controller.handleOpenDownloadFolder}
>
Open download location
</Button>
</HorizontalFocusGroup>
)}
</SettingsSection>
);
}
function DangerZoneSection({
game,
controller,
onOpenChangePlaytimeModal,
onRequestConfirmation,
}: Readonly<{
game: LibraryGame;
controller: GameSettingsController;
onOpenChangePlaytimeModal: () => void;
onRequestConfirmation: (confirmation: GameSettingsConfirmation) => void;
}>) {
return (
<SettingsSection
title="Danger Zone"
description="These actions can permanently change this game."
danger
>
<div className="game-settings-modal__danger-actions">
<Button
variant="danger"
icon={<TrashIcon size={18} />}
onClick={() => onRequestConfirmation("remove-library")}
>
Remove from library
</Button>
{game.shop !== "custom" && (
<Button
variant="danger"
icon={<SparkleIcon size={18} />}
disabled={
controller.isDeletingAchievements ||
!controller.hasAchievements ||
!controller.userDetails
}
onClick={() => onRequestConfirmation("reset-achievements")}
>
Reset achievements
</Button>
)}
<Button
variant="danger"
icon={<HardDriveIcon size={18} />}
onClick={onOpenChangePlaytimeModal}
>
Update playtime
</Button>
{game.shop !== "custom" && (
<Button
variant="danger"
icon={<TrashIcon size={18} />}
disabled={
controller.isGameDownloading ||
controller.isDeletingGameFiles ||
!game.download?.downloadPath
}
onClick={() => onRequestConfirmation("remove-files")}
>
Remove files
</Button>
)}
</div>
</SettingsSection>
);
}
export function GameSettingsPanelContent({
controller,
cloudSync,
onOpenSteamShortcutModal,
onOpenChangePlaytimeModal,
onOpenManageFilesModal,
onRenameArtifact,
onDeleteArtifact,
onRequestConfirmation,
}: GameSettingsPanelContentProps) {
const game = controller.game;
if (!game) return null;
if (controller.selectedCategory === "general") {
return (
<GeneralSection
game={game}
controller={controller}
onOpenSteamShortcutModal={onOpenSteamShortcutModal}
/>
);
}
if (controller.selectedCategory === "assets") {
return <AssetsSection game={game} controller={controller} />;
}
if (controller.selectedCategory === "hydra_cloud") {
return (
<HydraCloudSection
game={game}
controller={controller}
cloudSync={cloudSync}
onOpenManageFilesModal={onOpenManageFilesModal}
onRenameArtifact={onRenameArtifact}
onDeleteArtifact={onDeleteArtifact}
/>
);
}
if (controller.selectedCategory === "compatibility") {
return <CompatibilitySection game={game} controller={controller} />;
}
if (controller.selectedCategory === "downloads") {
return <DownloadsSection game={game} controller={controller} />;
}
return (
<DangerZoneSection
game={game}
controller={controller}
onOpenChangePlaytimeModal={onOpenChangePlaytimeModal}
onRequestConfirmation={onRequestConfirmation}
/>
);
}
@@ -0,0 +1,78 @@
import cn from "classnames";
import { type ReactNode } from "react";
import { Button, Input } from "../../../common";
export function SettingsSection({
title,
description,
children,
danger = false,
}: Readonly<{
title: string;
description?: string;
children: ReactNode;
danger?: boolean;
}>) {
return (
<section
className={cn("game-settings-modal__section", {
"game-settings-modal__section--danger": danger,
})}
>
<div className="game-settings-modal__section-header">
<h3>{title}</h3>
{description && <p>{description}</p>}
</div>
{children}
</section>
);
}
export function FocusableInput({
value,
onChange,
label,
placeholder,
disabled,
type = "text",
}: Readonly<{
value: string;
onChange: (value: string) => void;
label: string;
placeholder?: string;
disabled?: boolean;
type?: string;
}>) {
return (
<Input
label={label}
type={type}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
/>
);
}
export function ToggleAction({
label,
checked,
disabled,
onToggle,
}: Readonly<{
label: string;
checked: boolean;
disabled?: boolean;
onToggle: () => void;
}>) {
return (
<Button
variant={checked ? "primary" : "secondary"}
disabled={disabled}
onClick={onToggle}
>
{checked ? "On" : "Off"} - {label}
</Button>
);
}
@@ -0,0 +1,82 @@
import type { LibraryGame } from "@types";
import cn from "classnames";
import { FocusItem, VerticalFocusGroup } from "../../../common";
import { resolveImageSource } from "../../../../helpers";
import type { GameSettingsCategoryId } from "./use-game-settings-controller";
import { categoryLabel, getGameSidebarCoverUrl } from "./helpers";
function SidebarItem({
categoryId,
active,
onClick,
}: Readonly<{
categoryId: GameSettingsCategoryId;
active: boolean;
onClick: () => void;
}>) {
return (
<FocusItem asChild>
<button
type="button"
className={cn("game-settings-modal__sidebar-item", {
"game-settings-modal__sidebar-item--active": active,
})}
onClick={onClick}
>
<span className="game-settings-modal__sidebar-item-label">
{categoryLabel(categoryId)}
</span>
</button>
</FocusItem>
);
}
export function GameSettingsSidebar({
game,
categories,
selectedCategory,
regionId,
onCategoryChange,
}: Readonly<{
game: LibraryGame;
categories: GameSettingsCategoryId[];
selectedCategory: GameSettingsCategoryId;
regionId: string;
onCategoryChange: (categoryId: GameSettingsCategoryId) => void;
}>) {
const sidebarCoverUrl = getGameSidebarCoverUrl(game);
return (
<aside className="game-settings-modal__sidebar">
<div className="game-settings-modal__sidebar-cover">
{sidebarCoverUrl ? (
<img
src={resolveImageSource(sidebarCoverUrl)}
alt=""
aria-hidden="true"
/>
) : null}
<h2>{game.title}</h2>
</div>
<div
className="game-settings-modal__sidebar-divider"
aria-hidden="true"
/>
<VerticalFocusGroup
regionId={regionId}
className="game-settings-modal__sidebar-list"
style={{ gap: 0 }}
>
{categories.map((categoryId) => (
<SidebarItem
key={categoryId}
categoryId={categoryId}
active={selectedCategory === categoryId}
onClick={() => onCategoryChange(categoryId)}
/>
))}
</VerticalFocusGroup>
</aside>
);
}
@@ -0,0 +1,466 @@
.modal.game-settings-modal {
width: min(1200px, calc(100vw - calc(var(--spacing-unit) * 12)));
max-width: none;
height: min(820px, calc(100vh - calc(var(--spacing-unit) * 10)));
max-height: none;
border: 1px solid var(--secondary-border);
border-radius: calc(var(--spacing-unit) * 2);
background-color: var(--surface);
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.55);
flex-direction: row;
}
.modal.game-settings-modal__submodal {
width: min(520px, calc(100vw - calc(var(--spacing-unit) * 12)));
max-width: none;
border: 1px solid var(--secondary-border);
border-radius: calc(var(--spacing-unit) * 2);
background-color: var(--surface);
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.55);
}
.modal.game-settings-modal__submodal--wide {
width: min(760px, calc(100vw - calc(var(--spacing-unit) * 12)));
}
.game-settings-modal {
&__shell {
display: flex;
align-items: stretch;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
padding: 0;
}
&__sidebar {
display: flex;
flex-direction: column;
gap: 0;
width: 320px;
min-width: 320px;
height: 100%;
padding: 0;
border-right: 1px solid var(--secondary-border);
background-color: var(--background);
}
&__sidebar-cover {
position: relative;
width: 100%;
height: 88px;
min-height: 88px;
overflow: hidden;
background:
linear-gradient(
180deg,
rgba(0, 0, 0, 0.08) 0%,
rgba(0, 0, 0, 0.28) 56%,
var(--background) 100%
),
var(--secondary);
h2 {
position: absolute;
right: 28px;
bottom: 24px;
left: 28px;
max-width: calc(100% - 56px);
margin: 0;
overflow: hidden;
color: var(--text);
font-family: var(--font-space-grotesk);
font-size: 20px;
font-weight: 700;
line-height: 24px;
letter-spacing: -0.02em;
text-overflow: ellipsis;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
white-space: nowrap;
z-index: 1;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
filter: saturate(1.08);
}
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
transparent 45%,
color-mix(in srgb, var(--background) 72%, transparent) 78%,
var(--background) 100%
);
pointer-events: none;
}
}
&__sidebar-divider {
width: calc(100% - 48px);
height: 1px;
margin: 0 24px 24px;
flex-shrink: 0;
background-color: var(--secondary-border);
}
&__sidebar-list {
width: 100%;
}
&__feedback {
margin: 0 0 calc(var(--spacing-unit) * 4);
padding: calc(var(--spacing-unit) * 2.5) calc(var(--spacing-unit) * 3);
border: 1px solid var(--secondary-border);
border-radius: var(--spacing-unit);
background-color: var(--background);
color: var(--text);
font-family: var(--font-space-grotesk);
font-size: 13px;
&--success {
border-color: color-mix(
in srgb,
var(--success) 32%,
var(--secondary-border)
);
color: var(--success);
}
&--error {
border-color: var(--error-border);
color: var(--error);
background-color: var(--error-background);
}
}
&__sidebar-item {
appearance: none;
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
min-height: 0;
padding: 16px 24px;
border: 0;
border-radius: 0;
background-color: transparent;
color: var(--text);
font-family: var(--font-space-grotesk);
font-size: 16px;
font-weight: 400;
line-height: normal;
letter-spacing: -0.02em;
text-align: left;
cursor: pointer;
outline: none;
transition:
background-color 0.18s ease,
color 0.18s ease,
opacity 0.18s ease;
&:hover,
&[data-focus-visible="true"] {
background-color: rgba(255, 255, 255, 0.08);
color: var(--text);
}
&--active {
background-color: rgba(255, 255, 255, 0.15);
color: var(--text);
}
}
&__sidebar-item-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__panel {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
height: 100%;
overflow: auto;
padding: calc(var(--spacing-unit) * 5);
background-color: var(--surface);
}
&__section {
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) * 2.5);
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
& + & {
padding-top: calc(var(--spacing-unit) * 4);
border-top: 1px solid var(--secondary-border);
}
&--danger {
color: var(--error);
}
}
&__section-header {
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) * 1);
h3 {
margin: 0;
color: var(--text);
font-family: var(--font-space-grotesk);
font-size: 16px;
font-weight: 700;
line-height: 1.2;
}
p {
margin: 0;
color: var(--text-secondary);
font-family: var(--font-space-grotesk);
font-size: 13px;
font-weight: 400;
line-height: 1.4;
}
}
&__section--danger &__section-header h3 {
color: var(--error);
}
&__field-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: calc(var(--spacing-unit) * 2.5);
}
&__form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: calc(var(--spacing-unit) * 2.5);
}
&__actions {
flex-wrap: wrap;
gap: calc(var(--spacing-unit) * 2) !important;
margin-top: 0;
}
&__path-card {
min-height: 44px;
padding: calc(var(--spacing-unit) * 2.5);
border: 1px solid var(--secondary-border);
border-radius: var(--spacing-unit);
background-color: var(--background);
color: var(--text-secondary);
font-family: var(--font-space-grotesk);
font-size: 13px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__assets,
&__backups,
&__danger-actions,
&__proton-list,
&__file-list {
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) * 2.5);
}
&__asset-card,
&__backup-card {
display: grid;
grid-template-columns: 148px minmax(0, 1fr);
gap: calc(var(--spacing-unit) * 3);
padding: calc(var(--spacing-unit) * 2.5);
border: 1px solid var(--secondary-border);
border-radius: var(--spacing-unit);
background-color: var(--background);
}
&__asset-preview {
display: grid;
place-items: center;
min-height: 92px;
overflow: hidden;
border-radius: var(--spacing-unit);
background-color: var(--secondary);
color: var(--text-secondary);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__asset-info,
&__backup-card > div:first-child {
min-width: 0;
h4 {
margin: 0 0 calc(var(--spacing-unit) * 1.5);
color: var(--text);
font-family: var(--font-space-grotesk);
font-size: 14px;
font-weight: 700;
}
p {
margin: 0;
color: var(--text-secondary);
font-family: var(--font-space-grotesk);
font-size: 12px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&__upgrade-card,
&__cloud-header,
&__backups-title,
&__file-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(var(--spacing-unit) * 2.5);
}
&__upgrade-card,
&__cloud-header {
padding: calc(var(--spacing-unit) * 2.5);
border: 1px solid var(--secondary-border);
border-radius: var(--spacing-unit);
background-color: var(--background);
p {
margin: 0;
color: var(--text-secondary);
font-family: var(--font-space-grotesk);
font-size: 13px;
line-height: 1.4;
}
}
&__backups-title {
h4 {
margin: 0;
color: var(--text);
font-family: var(--font-space-grotesk);
font-size: 14px;
font-weight: 700;
}
span {
min-width: 24px;
padding: 2px calc(var(--spacing-unit) * 2);
border-radius: var(--spacing-unit);
background-color: var(--secondary);
color: var(--text-secondary);
font-size: 12px;
text-align: center;
}
}
&__backup-card {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
&__empty-note {
margin: 0;
color: var(--text-secondary);
font-family: var(--font-space-grotesk);
font-size: 13px;
}
&__submodal-content {
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) * 4);
padding: calc(var(--spacing-unit) * 5);
h2 {
margin: 0;
color: var(--text);
font-family: var(--font-space-grotesk);
font-size: 20px;
font-weight: 700;
}
p {
margin: 0;
color: var(--text-secondary);
font-family: var(--font-space-grotesk);
font-size: 13px;
line-height: 1.4;
}
}
&__file-list {
max-height: 320px;
overflow: auto;
padding: 0;
list-style: none;
}
&__file-item {
padding: calc(var(--spacing-unit) * 3);
border-radius: var(--spacing-unit);
background-color: var(--background);
span {
color: var(--text-secondary);
font-family: var(--font-space-grotesk);
font-size: 12px;
}
}
}
@media (max-width: 900px) {
.modal.game-settings-modal {
width: calc(100vw - calc(var(--spacing-unit) * 6));
height: calc(100vh - calc(var(--spacing-unit) * 6));
}
.game-settings-modal {
&__shell {
flex-direction: column;
}
&__sidebar {
width: 100%;
min-width: 0;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--secondary-border);
}
&__asset-card,
&__backup-card,
&__field-row {
grid-template-columns: 1fr;
}
}
}
@@ -0,0 +1,308 @@
import type {
CreateSteamShortcutOptions,
GameArtifact,
LibraryGame,
} from "@types";
import { formatBytes } from "@shared";
import { useMemo, useState } from "react";
import {
Button,
HorizontalFocusGroup,
Modal,
NavigationLayer,
VerticalFocusGroup,
} from "../../../common";
import type { useGameSettingsCloudSync } from "./use-game-settings-cloud-sync";
import { FocusableInput, ToggleAction } from "./shared";
export function ConfirmationModal({
visible,
title,
description,
confirmLabel,
danger = false,
onClose,
onConfirm,
}: Readonly<{
visible: boolean;
title: string;
description: string;
confirmLabel: string;
danger?: boolean;
onClose: () => void;
onConfirm: () => Promise<void> | void;
}>) {
return (
<Modal
visible={visible}
onClose={onClose}
className="game-settings-modal__submodal"
>
<NavigationLayer>
<VerticalFocusGroup>
<div className="game-settings-modal__submodal-content">
<h2>{title}</h2>
<p>{description}</p>
<HorizontalFocusGroup className="game-settings-modal__actions">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
variant={danger ? "danger" : "primary"}
onClick={async () => {
await onConfirm();
onClose();
}}
>
{confirmLabel}
</Button>
</HorizontalFocusGroup>
</div>
</VerticalFocusGroup>
</NavigationLayer>
</Modal>
);
}
export function CreateSteamShortcutModal({
visible,
creating,
onClose,
onConfirm,
}: Readonly<{
visible: boolean;
creating: boolean;
onClose: () => void;
onConfirm: (options: CreateSteamShortcutOptions) => Promise<void>;
}>) {
const [openVr, setOpenVr] = useState(false);
return (
<Modal
visible={visible}
onClose={onClose}
className="game-settings-modal__submodal"
>
<NavigationLayer>
<VerticalFocusGroup>
<div className="game-settings-modal__submodal-content">
<h2>Create Steam shortcut</h2>
<ToggleAction
label="Launch with OpenVR"
checked={openVr}
disabled={creating}
onToggle={() => setOpenVr((value) => !value)}
/>
<HorizontalFocusGroup className="game-settings-modal__actions">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
disabled={creating}
onClick={async () => {
await onConfirm({ openVr });
onClose();
}}
>
Create
</Button>
</HorizontalFocusGroup>
</div>
</VerticalFocusGroup>
</NavigationLayer>
</Modal>
);
}
export function ChangePlaytimeModal({
visible,
game,
onClose,
onConfirm,
}: Readonly<{
visible: boolean;
game: LibraryGame | null;
onClose: () => void;
onConfirm: (playtimeInSeconds: number) => Promise<void>;
}>) {
const totalMinutes = Math.floor((game?.playTimeInMilliseconds ?? 0) / 60_000);
const [hours, setHours] = useState(
totalMinutes ? String(Math.floor(totalMinutes / 60)) : ""
);
const [minutes, setMinutes] = useState(
totalMinutes ? String(totalMinutes % 60) : ""
);
return (
<Modal
visible={visible}
onClose={onClose}
className="game-settings-modal__submodal"
>
<NavigationLayer>
<VerticalFocusGroup>
<div className="game-settings-modal__submodal-content">
<h2>Update playtime</h2>
<p>Set the manual playtime for {game?.title ?? "this game"}.</p>
<div className="game-settings-modal__form-grid">
<FocusableInput
label="Hours"
type="number"
value={hours}
onChange={setHours}
placeholder="0"
/>
<FocusableInput
label="Minutes"
type="number"
value={minutes}
onChange={setMinutes}
placeholder="0"
/>
</div>
<HorizontalFocusGroup className="game-settings-modal__actions">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
onClick={async () => {
const totalSeconds =
(Number(hours) || 0) * 3600 + (Number(minutes) || 0) * 60;
await onConfirm(totalSeconds);
onClose();
}}
>
Update
</Button>
</HorizontalFocusGroup>
</div>
</VerticalFocusGroup>
</NavigationLayer>
</Modal>
);
}
export function RenameArtifactModal({
visible,
artifact,
onClose,
onConfirm,
}: Readonly<{
visible: boolean;
artifact: GameArtifact | null;
onClose: () => void;
onConfirm: (artifactId: string, label: string) => Promise<void>;
}>) {
const [label, setLabel] = useState(artifact?.label ?? "");
return (
<Modal
visible={visible}
onClose={onClose}
className="game-settings-modal__submodal"
>
<NavigationLayer>
<VerticalFocusGroup>
<div className="game-settings-modal__submodal-content">
<h2>Rename backup</h2>
<FocusableInput
label="Backup name"
value={label}
onChange={setLabel}
placeholder="Backup name"
/>
<HorizontalFocusGroup className="game-settings-modal__actions">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
disabled={!artifact || !label.trim()}
onClick={async () => {
if (!artifact) return;
await onConfirm(artifact.id, label.trim());
onClose();
}}
>
Save
</Button>
</HorizontalFocusGroup>
</div>
</VerticalFocusGroup>
</NavigationLayer>
</Modal>
);
}
export function ManageFilesModal({
visible,
backupPreview,
onClose,
onSetBackupPath,
}: Readonly<{
visible: boolean;
backupPreview: ReturnType<typeof useGameSettingsCloudSync>["backupPreview"];
onClose: () => void;
onSetBackupPath: (path: string | null) => Promise<void>;
}>) {
const files = useMemo(() => {
if (!backupPreview) return [];
const [gameBackup] = Object.values(backupPreview.games);
if (!gameBackup) return [];
return Object.entries(gameBackup.files).map(([path, file]) => ({
path,
bytes: file.bytes,
}));
}, [backupPreview]);
return (
<Modal
visible={visible}
onClose={onClose}
className="game-settings-modal__submodal game-settings-modal__submodal--wide"
>
<NavigationLayer>
<VerticalFocusGroup>
<div className="game-settings-modal__submodal-content">
<h2>Manage files</h2>
<p>Choose automatic mapping or a custom folder for this game.</p>
<HorizontalFocusGroup className="game-settings-modal__actions">
<Button variant="secondary" onClick={() => onSetBackupPath(null)}>
Automatic mapping
</Button>
<Button
variant="secondary"
onClick={async () => {
const { filePaths } =
await globalThis.window.electron.showOpenDialog({
properties: ["openDirectory"],
});
if (filePaths[0]) await onSetBackupPath(filePaths[0]);
}}
>
Select folder
</Button>
</HorizontalFocusGroup>
<ul className="game-settings-modal__file-list">
{files.map((file) => (
<li key={file.path} className="game-settings-modal__file-item">
<Button
variant="link"
onClick={() =>
globalThis.window.electron.showItemInFolder(file.path)
}
>
{file.path.split(/[\\/]/).at(-1) ?? file.path}
</Button>
<span>{formatBytes(file.bytes)}</span>
</li>
))}
</ul>
</div>
</VerticalFocusGroup>
</NavigationLayer>
</Modal>
);
}
@@ -0,0 +1,5 @@
export type GameSettingsConfirmation =
| "remove-library"
| "reset-achievements"
| "remove-files"
| "delete-artifact";
@@ -0,0 +1,283 @@
import type { GameArtifact, LibraryGame, LudusaviBackup } from "@types";
import type { AxiosProgressEvent } from "axios";
import { useCallback, useEffect, useMemo, useState } from "react";
interface UseGameSettingsCloudSyncProps {
visible: boolean;
game: LibraryGame | null;
hasActiveSubscription: boolean;
onFeedback: (type: "success" | "error" | "info", message: string) => void;
}
export enum GameSettingsCloudSyncState {
New,
Different,
Same,
Unknown,
}
export function useGameSettingsCloudSync({
visible,
game,
hasActiveSubscription,
onFeedback,
}: UseGameSettingsCloudSyncProps) {
const [artifacts, setArtifacts] = useState<GameArtifact[]>([]);
const [backupPreview, setBackupPreview] = useState<LudusaviBackup | null>(
null
);
const [loadingPreview, setLoadingPreview] = useState(false);
const [uploadingBackup, setUploadingBackup] = useState(false);
const [restoringBackup, setRestoringBackup] = useState(false);
const [freezingArtifact, setFreezingArtifact] = useState(false);
const [deletingArtifact, setDeletingArtifact] = useState(false);
const [backupDownloadProgress, setBackupDownloadProgress] =
useState<AxiosProgressEvent | null>(null);
const getGameArtifacts = useCallback(async () => {
if (!game || game.shop === "custom" || !hasActiveSubscription) {
setArtifacts([]);
return;
}
const params = new URLSearchParams({
objectId: game.objectId,
shop: game.shop,
});
const { electron } = globalThis as typeof globalThis & Window;
const results = await electron.hydraApi
.get<GameArtifact[]>(`/profile/games/artifacts?${params.toString()}`, {
needsSubscription: true,
})
.catch(() => []);
setArtifacts(results);
}, [game, hasActiveSubscription]);
const getGameBackupPreview = useCallback(async () => {
if (!game || game.shop === "custom" || !hasActiveSubscription) {
setBackupPreview(null);
return;
}
setLoadingPreview(true);
try {
const preview = await globalThis.window.electron.getGameBackupPreview(
game.objectId,
game.shop
);
setBackupPreview(preview);
} catch {
setBackupPreview(null);
onFeedback("error", "Could not load backup preview");
} finally {
setLoadingPreview(false);
}
}, [game, hasActiveSubscription, onFeedback]);
const refreshCloudSync = useCallback(async () => {
await Promise.all([getGameBackupPreview(), getGameArtifacts()]);
}, [getGameArtifacts, getGameBackupPreview]);
useEffect(() => {
if (!visible || !game || game.shop === "custom" || !hasActiveSubscription) {
setArtifacts([]);
setBackupPreview(null);
return;
}
void refreshCloudSync();
}, [game, hasActiveSubscription, refreshCloudSync, visible]);
useEffect(() => {
if (!visible || !game) return;
const removeUploadCompleteListener =
globalThis.window.electron.onUploadComplete(
game.objectId,
game.shop,
() => {
setUploadingBackup(false);
onFeedback("success", "Backup uploaded");
void refreshCloudSync();
}
);
const removeDownloadCompleteListener =
globalThis.window.electron.onBackupDownloadComplete(
game.objectId,
game.shop,
() => {
setRestoringBackup(false);
setBackupDownloadProgress(null);
onFeedback("success", "Backup restored");
void refreshCloudSync();
}
);
const removeDownloadProgressListener =
globalThis.window.electron.onBackupDownloadProgress(
game.objectId,
game.shop,
setBackupDownloadProgress
);
return () => {
removeUploadCompleteListener();
removeDownloadCompleteListener();
removeDownloadProgressListener();
};
}, [game, onFeedback, refreshCloudSync, visible]);
useEffect(() => {
setArtifacts([]);
setBackupPreview(null);
setUploadingBackup(false);
setRestoringBackup(false);
setBackupDownloadProgress(null);
}, [game?.id]);
const uploadSaveGame = useCallback(async () => {
if (!game) return;
setUploadingBackup(true);
try {
await globalThis.window.electron.uploadSaveGame(
game.objectId,
game.shop,
null
);
} catch {
setUploadingBackup(false);
onFeedback("error", "Backup failed");
}
}, [game, onFeedback]);
const downloadGameArtifact = useCallback(
async (gameArtifactId: string) => {
if (!game) return;
setRestoringBackup(true);
setBackupDownloadProgress(null);
try {
await globalThis.window.electron.downloadGameArtifact(
game.objectId,
game.shop,
gameArtifactId
);
} catch {
setRestoringBackup(false);
onFeedback("error", "Could not restore backup");
}
},
[game, onFeedback]
);
const deleteGameArtifact = useCallback(
async (gameArtifactId: string) => {
setDeletingArtifact(true);
try {
await globalThis.window.electron.hydraApi.delete(
`/profile/games/artifacts/${gameArtifactId}`
);
await refreshCloudSync();
onFeedback("success", "Backup deleted");
} catch {
onFeedback("error", "Could not delete backup");
} finally {
setDeletingArtifact(false);
}
},
[onFeedback, refreshCloudSync]
);
const renameGameArtifact = useCallback(
async (gameArtifactId: string, label: string) => {
await globalThis.window.electron.hydraApi.put(
`/profile/games/artifacts/${gameArtifactId}`,
{
data: {
label,
},
}
);
await getGameArtifacts();
onFeedback("success", "Backup renamed");
},
[getGameArtifacts, onFeedback]
);
const toggleArtifactFreeze = useCallback(
async (gameArtifactId: string, freeze: boolean) => {
setFreezingArtifact(true);
try {
const endpoint = freeze ? "freeze" : "unfreeze";
await globalThis.window.electron.hydraApi.put(
`/profile/games/artifacts/${gameArtifactId}/${endpoint}`
);
await getGameArtifacts();
onFeedback("success", freeze ? "Backup frozen" : "Backup unfrozen");
} catch {
onFeedback("error", "Could not update backup");
} finally {
setFreezingArtifact(false);
}
},
[getGameArtifacts, onFeedback]
);
const setBackupPath = useCallback(
async (backupPath: string | null) => {
if (!game) return;
await globalThis.window.electron.selectGameBackupPath(
game.shop,
game.objectId,
backupPath
);
await getGameBackupPreview();
onFeedback(
"success",
backupPath ? "Custom backup location set" : "Automatic mapping enabled"
);
},
[game, getGameBackupPreview, onFeedback]
);
const backupState = useMemo(() => {
if (!backupPreview) return GameSettingsCloudSyncState.Unknown;
if (backupPreview.overall.changedGames.new)
return GameSettingsCloudSyncState.New;
if (backupPreview.overall.changedGames.different)
return GameSettingsCloudSyncState.Different;
if (backupPreview.overall.changedGames.same)
return GameSettingsCloudSyncState.Same;
return GameSettingsCloudSyncState.Unknown;
}, [backupPreview]);
return {
artifacts,
backupPreview,
backupState,
loadingPreview,
uploadingBackup,
restoringBackup,
freezingArtifact,
deletingArtifact,
backupDownloadProgress,
getGameArtifacts,
getGameBackupPreview,
refreshCloudSync,
uploadSaveGame,
downloadGameArtifact,
deleteGameArtifact,
renameGameArtifact,
toggleArtifactFreeze,
setBackupPath,
};
}
export type GameSettingsCloudSync = ReturnType<typeof useGameSettingsCloudSync>;
@@ -0,0 +1,861 @@
import type {
CreateSteamShortcutOptions,
LibraryGame,
ProtonVersion,
ShortcutLocation,
UserAchievement,
UserDetails,
UserPreferences,
} from "@types";
import { useCallback, useEffect, useMemo, useState } from "react";
type FeedbackType = "success" | "error" | "info";
export interface GameSettingsFeedback {
type: FeedbackType;
message: string;
}
export type GameSettingsCategoryId =
| "general"
| "assets"
| "hydra_cloud"
| "compatibility"
| "downloads"
| "danger_zone";
export type GameSettingsAssetType = "icon" | "logo" | "hero";
interface UseGameSettingsControllerProps {
visible: boolean;
initialGame: LibraryGame | null;
onClose: () => void;
onGameUpdated?: (game: LibraryGame | null) => void;
}
function getLocalPath(value?: string | null) {
return value?.startsWith("local:") ? value.slice("local:".length) : value;
}
function getAssetField(assetType: GameSettingsAssetType) {
if (assetType === "icon") {
return {
customUrl: "customIconUrl",
customOriginalPath: "customOriginalIconPath",
url: "iconUrl",
originalPath: "originalIconPath",
} as const;
}
if (assetType === "logo") {
return {
customUrl: "customLogoImageUrl",
customOriginalPath: "customOriginalLogoPath",
url: "logoImageUrl",
originalPath: "originalLogoPath",
} as const;
}
return {
customUrl: "customHeroImageUrl",
customOriginalPath: "customOriginalHeroPath",
url: "libraryHeroImageUrl",
originalPath: "originalHeroPath",
} as const;
}
function isSubscriptionActive(userDetails: UserDetails | null) {
const expiresAt = new Date(userDetails?.subscription?.expiresAt ?? 0);
return expiresAt > new Date();
}
async function updateGameTitle(game: LibraryGame, title: string) {
if (game.shop === "custom") {
await globalThis.window.electron.updateCustomGame({
shop: game.shop,
objectId: game.objectId,
title,
iconUrl: game.iconUrl || undefined,
logoImageUrl: game.logoImageUrl || undefined,
libraryHeroImageUrl: game.libraryHeroImageUrl || undefined,
originalIconPath: getLocalPath(game.iconUrl) || undefined,
originalLogoPath: getLocalPath(game.logoImageUrl) || undefined,
originalHeroPath: getLocalPath(game.libraryHeroImageUrl) || undefined,
});
return;
}
await globalThis.window.electron.updateGameCustomAssets({
shop: game.shop,
objectId: game.objectId,
title,
});
}
async function updateGameAsset(
game: LibraryGame,
assetType: GameSettingsAssetType,
copiedAssetUrl: string | null,
sourcePath: string | null
) {
const fields = getAssetField(assetType);
if (game.shop === "custom") {
await globalThis.window.electron.updateCustomGame({
shop: game.shop,
objectId: game.objectId,
title: game.title,
iconUrl:
fields.url === "iconUrl"
? copiedAssetUrl || undefined
: game.iconUrl || undefined,
logoImageUrl:
fields.url === "logoImageUrl"
? copiedAssetUrl || undefined
: game.logoImageUrl || undefined,
libraryHeroImageUrl:
fields.url === "libraryHeroImageUrl"
? copiedAssetUrl || undefined
: game.libraryHeroImageUrl || undefined,
[fields.originalPath]: sourcePath || undefined,
});
return;
}
await globalThis.window.electron.updateGameCustomAssets({
shop: game.shop,
objectId: game.objectId,
title: game.title,
[fields.customUrl]: copiedAssetUrl,
[fields.customOriginalPath]: sourcePath,
});
}
export function useGameSettingsController({
visible,
initialGame,
onClose,
onGameUpdated,
}: UseGameSettingsControllerProps) {
const [game, setGame] = useState<LibraryGame | null>(initialGame);
const [selectedCategory, setSelectedCategory] =
useState<GameSettingsCategoryId>("general");
const [feedback, setFeedback] = useState<GameSettingsFeedback | null>(null);
const [gameTitle, setGameTitle] = useState(initialGame?.title ?? "");
const [launchOptions, setLaunchOptions] = useState(
initialGame?.launchOptions ?? ""
);
const [updatingTitle, setUpdatingTitle] = useState(false);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [saveFolderPath, setSaveFolderPath] = useState<string | null>(null);
const [loadingSaveFolder, setLoadingSaveFolder] = useState(false);
const [steamShortcutExists, setSteamShortcutExists] = useState(false);
const [userPreferences, setUserPreferences] =
useState<UserPreferences | null>(null);
const [userDetails, setUserDetails] = useState<UserDetails | null>(null);
const [achievements, setAchievements] = useState<UserAchievement[]>([]);
const [protonVersions, setProtonVersions] = useState<ProtonVersion[]>([]);
const [selectedProtonPath, setSelectedProtonPath] = useState(
initialGame?.protonPath ?? ""
);
const [defaultWinePrefixPath, setDefaultWinePrefixPath] = useState<
string | null
>(null);
const [gamemodeAvailable, setGamemodeAvailable] = useState(false);
const [mangohudAvailable, setMangohudAvailable] = useState(false);
const [winetricksAvailable, setWinetricksAvailable] = useState(false);
const [autoRunGamemode, setAutoRunGamemode] = useState(false);
const [autoRunMangohud, setAutoRunMangohud] = useState(false);
const [isDeletingGameFiles, setIsDeletingGameFiles] = useState(false);
const [isDeletingAchievements, setIsDeletingAchievements] = useState(false);
const notify = useCallback((type: FeedbackType, message: string) => {
setFeedback({ type, message });
}, []);
const refreshGame = useCallback(async () => {
if (!initialGame) return null;
const updatedGame = await globalThis.window.electron.getGameByObjectId(
initialGame.shop,
initialGame.objectId
);
setGame(updatedGame);
onGameUpdated?.(updatedGame);
return updatedGame;
}, [initialGame, onGameUpdated]);
useEffect(() => {
if (!visible) return;
setGame(initialGame);
setSelectedCategory("general");
setFeedback(null);
setGameTitle(initialGame?.title ?? "");
setLaunchOptions(initialGame?.launchOptions ?? "");
setSelectedProtonPath(initialGame?.protonPath ?? "");
setAutoRunGamemode(initialGame?.autoRunGamemode === true);
setAutoRunMangohud(initialGame?.autoRunMangohud === true);
}, [initialGame, visible]);
useEffect(() => {
if (!visible || !game) return;
globalThis.window.electron
.getUserPreferences()
.then(setUserPreferences)
.catch(noop);
globalThis.window.electron
.getMe()
.then(setUserDetails)
.catch(() => setUserDetails(null));
if (game.shop === "custom") return;
globalThis.window.electron
.getUnlockedAchievements(game.objectId, game.shop)
.then(setAchievements)
.catch(() => setAchievements([]));
globalThis.window.electron
.checkSteamShortcut(game.shop, game.objectId)
.then(setSteamShortcutExists)
.catch(() => setSteamShortcutExists(false));
}, [game, visible]);
useEffect(() => {
if (
!visible ||
!game ||
game.shop === "custom" ||
globalThis.window.electron.platform !== "win32"
) {
setSaveFolderPath(null);
return;
}
setLoadingSaveFolder(true);
globalThis.window.electron
.getGameSaveFolder(game.shop, game.objectId)
.then(setSaveFolderPath)
.catch(() => setSaveFolderPath(null))
.finally(() => setLoadingSaveFolder(false));
}, [game, visible]);
useEffect(() => {
if (!visible || globalThis.window.electron.platform !== "linux") {
setProtonVersions([]);
setDefaultWinePrefixPath(null);
setGamemodeAvailable(false);
setMangohudAvailable(false);
setWinetricksAvailable(false);
return;
}
globalThis.window.electron
.getInstalledProtonVersions()
.then(setProtonVersions)
.catch(() => setProtonVersions([]));
globalThis.window.electron
.getDefaultWinePrefixSelectionPath()
.then(setDefaultWinePrefixPath)
.catch(() => setDefaultWinePrefixPath(null));
globalThis.window.electron
.isGamemodeAvailable()
.then(setGamemodeAvailable)
.catch(() => setGamemodeAvailable(false));
globalThis.window.electron
.isMangohudAvailable()
.then(setMangohudAvailable)
.catch(() => setMangohudAvailable(false));
globalThis.window.electron
.isWinetricksAvailable()
.then(setWinetricksAvailable)
.catch(() => setWinetricksAvailable(false));
}, [visible]);
const runAction = useCallback(
async (actionId: string, action: () => Promise<void>, success?: string) => {
setBusyAction(actionId);
setFeedback(null);
try {
await action();
if (success) notify("success", success);
} catch (error) {
notify(
"error",
error instanceof Error ? error.message : "Something went wrong"
);
} finally {
setBusyAction(null);
}
},
[notify]
);
const handleSaveTitle = useCallback(async () => {
if (!game) return;
const trimmedTitle = gameTitle.trim();
if (!trimmedTitle) {
notify("error", "Title is required");
setGameTitle(game.title);
return;
}
if (trimmedTitle === game.title) return;
setUpdatingTitle(true);
try {
await updateGameTitle(game, trimmedTitle);
await refreshGame();
notify("success", "Game title updated");
} catch (error) {
setGameTitle(game.title);
notify(
"error",
error instanceof Error ? error.message : "Could not update title"
);
} finally {
setUpdatingTitle(false);
}
}, [game, gameTitle, notify, refreshGame]);
const handleSelectExecutable = useCallback(async () => {
if (!game) return;
await runAction(
"select-executable",
async () => {
const defaultPath =
userPreferences?.downloadsPath ??
(await globalThis.window.electron.getDefaultDownloadsPath());
const { filePaths } = await globalThis.window.electron.showOpenDialog({
properties: ["openFile"],
defaultPath,
filters: [
{
name: "Game executable",
extensions: ["exe", "lnk"],
},
],
});
const executablePath = filePaths[0];
if (!executablePath) return;
const gameUsingPath =
await globalThis.window.electron.verifyExecutablePathInUse(
executablePath
);
if (gameUsingPath) {
throw new Error(`Executable already used by ${gameUsingPath.title}`);
}
await globalThis.window.electron.updateExecutablePath(
game.shop,
game.objectId,
executablePath
);
await refreshGame();
},
"Executable path updated"
);
}, [game, refreshGame, runAction, userPreferences?.downloadsPath]);
const handleClearExecutable = useCallback(async () => {
if (!game) return;
await runAction(
"clear-executable",
async () => {
await globalThis.window.electron.updateExecutablePath(
game.shop,
game.objectId,
null
);
await refreshGame();
},
"Executable path cleared"
);
}, [game, refreshGame, runAction]);
const handleSaveLaunchOptions = useCallback(async () => {
if (!game) return;
await runAction(
"save-launch-options",
async () => {
const trimmedValue = launchOptions.trim();
await globalThis.window.electron.updateLaunchOptions(
game.shop,
game.objectId,
trimmedValue || null
);
await refreshGame();
},
"Launch options updated"
);
}, [game, launchOptions, refreshGame, runAction]);
const handleClearLaunchOptions = useCallback(async () => {
setLaunchOptions("");
if (!game) return;
await runAction(
"clear-launch-options",
async () => {
await globalThis.window.electron.updateLaunchOptions(
game.shop,
game.objectId,
null
);
await refreshGame();
},
"Launch options cleared"
);
}, [game, refreshGame, runAction]);
const handleOpenExecutableFolder = useCallback(async () => {
if (!game) return;
await runAction("open-executable", async () => {
await globalThis.window.electron.openGameExecutablePath(
game.shop,
game.objectId
);
});
}, [game, runAction]);
const handleOpenSaveFolder = useCallback(async () => {
if (!game || !saveFolderPath) return;
await runAction("open-save-folder", async () => {
await globalThis.window.electron.openGameSaveFolder(
game.shop,
game.objectId,
saveFolderPath
);
});
}, [game, runAction, saveFolderPath]);
const handleCreateShortcut = useCallback(
async (location: ShortcutLocation) => {
if (!game) return;
await runAction(
`create-shortcut-${location}`,
async () => {
const success = await globalThis.window.electron.createGameShortcut(
game.shop,
game.objectId,
location
);
if (!success) throw new Error("Could not create shortcut");
},
"Shortcut created"
);
},
[game, runAction]
);
const handleCreateSteamShortcut = useCallback(
async (options: CreateSteamShortcutOptions) => {
if (!game) return;
await runAction(
"create-steam-shortcut",
async () => {
await globalThis.window.electron.createSteamShortcut(
game.shop,
game.objectId,
options
);
const exists = await globalThis.window.electron.checkSteamShortcut(
game.shop,
game.objectId
);
setSteamShortcutExists(exists);
await refreshGame();
},
"Steam shortcut created"
);
},
[game, refreshGame, runAction]
);
const handleDeleteSteamShortcut = useCallback(async () => {
if (!game) return;
await runAction(
"delete-steam-shortcut",
async () => {
await globalThis.window.electron.deleteSteamShortcut(
game.shop,
game.objectId
);
setSteamShortcutExists(false);
await refreshGame();
},
"Steam shortcut deleted"
);
}, [game, refreshGame, runAction]);
const handleUpdateAsset = useCallback(
async (assetType: GameSettingsAssetType, sourcePath: string | null) => {
if (!game) return;
await runAction(
`update-asset-${assetType}`,
async () => {
const copiedAssetUrl = sourcePath
? await globalThis.window.electron.copyCustomGameAsset(
sourcePath,
assetType
)
: null;
await updateGameAsset(game, assetType, copiedAssetUrl, sourcePath);
await refreshGame();
},
sourcePath ? "Asset updated" : "Asset removed"
);
},
[game, refreshGame, runAction]
);
const handleChooseAsset = useCallback(
async (assetType: GameSettingsAssetType) => {
const { filePaths } = await globalThis.window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Images",
extensions: ["jpg", "jpeg", "png", "webp", "ico"],
},
],
});
if (filePaths[0]) {
await handleUpdateAsset(assetType, filePaths[0]);
}
},
[handleUpdateAsset]
);
const handleResetAsset = useCallback(
async (assetType: GameSettingsAssetType) => {
if (game?.shop !== "custom") {
await handleUpdateAsset(assetType, null);
return;
}
const fields = getAssetField(assetType);
const originalPath = getLocalPath(game?.[fields.url]);
await handleUpdateAsset(assetType, originalPath ?? null);
},
[game, handleUpdateAsset]
);
const handleChangeWinePrefixPath = useCallback(async () => {
if (!game) return;
await runAction(
"wine-prefix",
async () => {
const { filePaths } = await globalThis.window.electron.showOpenDialog({
properties: ["openDirectory"],
defaultPath: game.winePrefixPath ?? defaultWinePrefixPath ?? "",
});
if (!filePaths[0]) return;
await globalThis.window.electron.selectGameWinePrefix(
game.shop,
game.objectId,
filePaths[0]
);
await refreshGame();
},
"Wine prefix updated"
);
}, [defaultWinePrefixPath, game, refreshGame, runAction]);
const handleClearWinePrefixPath = useCallback(async () => {
if (!game) return;
await runAction(
"clear-wine-prefix",
async () => {
await globalThis.window.electron.selectGameWinePrefix(
game.shop,
game.objectId,
null
);
await refreshGame();
},
"Wine prefix cleared"
);
}, [game, refreshGame, runAction]);
const handleChangeProtonVersion = useCallback(
async (protonPath: string) => {
if (!game) return;
setSelectedProtonPath(protonPath);
await runAction(
"proton-version",
async () => {
await globalThis.window.electron.selectGameProtonPath(
game.shop,
game.objectId,
protonPath || null
);
await refreshGame();
},
"Proton version updated"
);
},
[game, refreshGame, runAction]
);
const handleToggleGamemode = useCallback(async () => {
if (!game) return;
const nextValue = !autoRunGamemode;
setAutoRunGamemode(nextValue);
await runAction("gamemode", async () => {
await globalThis.window.electron.toggleGameGamemode(
game.shop,
game.objectId,
nextValue
);
await refreshGame();
});
}, [autoRunGamemode, game, refreshGame, runAction]);
const handleToggleMangohud = useCallback(async () => {
if (!game) return;
const nextValue = !autoRunMangohud;
setAutoRunMangohud(nextValue);
await runAction("mangohud", async () => {
await globalThis.window.electron.toggleGameMangohud(
game.shop,
game.objectId,
nextValue
);
await refreshGame();
});
}, [autoRunMangohud, game, refreshGame, runAction]);
const handleOpenWinetricks = useCallback(async () => {
if (!game) return;
await runAction(
"winetricks",
async () => {
const success = await globalThis.window.electron.openGameWinetricks(
game.shop,
game.objectId
);
if (!success) throw new Error("Could not open Winetricks");
},
"Winetricks opened"
);
}, [game, runAction]);
const handleToggleAutomaticCloudSync = useCallback(async () => {
if (!game) return;
const nextValue = !game.automaticCloudSync;
await runAction("automatic-cloud-sync", async () => {
await globalThis.window.electron.toggleAutomaticCloudSync(
game.shop,
game.objectId,
nextValue
);
await refreshGame();
});
}, [game, refreshGame, runAction]);
const handleOpenDownloadFolder = useCallback(async () => {
if (!game) return;
await runAction("download-folder", async () => {
await globalThis.window.electron.openGameInstallerPath(
game.shop,
game.objectId
);
});
}, [game, runAction]);
const handleRemoveGameFiles = useCallback(async () => {
if (!game) return;
setIsDeletingGameFiles(true);
await runAction(
"remove-files",
async () => {
await globalThis.window.electron.deleteGameFolder(
game.shop,
game.objectId
);
await refreshGame();
},
"Game files removed"
);
setIsDeletingGameFiles(false);
}, [game, refreshGame, runAction]);
const handleRemoveFromLibrary = useCallback(async () => {
if (!game) return;
await runAction(
"remove-library",
async () => {
if (game.download?.status === "active") {
await globalThis.window.electron.cancelGameDownload(
game.shop,
game.objectId
);
}
await globalThis.window.electron.removeGameFromLibrary(
game.shop,
game.objectId
);
onGameUpdated?.(null);
onClose();
},
"Game removed from library"
);
}, [game, onClose, onGameUpdated, runAction]);
const handleResetAchievements = useCallback(async () => {
if (!game) return;
setIsDeletingAchievements(true);
await runAction(
"reset-achievements",
async () => {
await globalThis.window.electron.resetGameAchievements(
game.shop,
game.objectId
);
await refreshGame();
setAchievements([]);
},
"Achievements reset"
);
setIsDeletingAchievements(false);
}, [game, refreshGame, runAction]);
const handleChangePlaytime = useCallback(
async (playtimeInSeconds: number) => {
if (!game) return;
await runAction(
"change-playtime",
async () => {
await globalThis.window.electron.changeGamePlayTime(
game.shop,
game.objectId,
playtimeInSeconds
);
await refreshGame();
},
"Playtime updated"
);
},
[game, refreshGame, runAction]
);
const displayedWinePrefixPath = useMemo(() => {
if (!game) return null;
return (
game.winePrefixPath ??
(defaultWinePrefixPath
? `${defaultWinePrefixPath}/${game.objectId}`
: null)
);
}, [defaultWinePrefixPath, game]);
const hasAchievements =
achievements.some((achievement) => achievement.unlocked) ||
(game?.unlockedAchievementCount ?? 0) > 0;
const hasActiveSubscription = isSubscriptionActive(userDetails);
const globalAutoRunGamemode = userPreferences?.autoRunGamemode === true;
const globalAutoRunMangohud = userPreferences?.autoRunMangohud === true;
const isGameDownloading = game?.download?.status === "active";
return {
game,
selectedCategory,
setSelectedCategory,
feedback,
setFeedback,
gameTitle,
setGameTitle,
launchOptions,
setLaunchOptions,
updatingTitle,
busyAction,
saveFolderPath,
loadingSaveFolder,
steamShortcutExists,
userDetails,
hasActiveSubscription,
hasAchievements,
protonVersions,
selectedProtonPath,
displayedWinePrefixPath,
gamemodeAvailable,
mangohudAvailable,
winetricksAvailable,
autoRunGamemode,
autoRunMangohud,
globalAutoRunGamemode,
globalAutoRunMangohud,
isGameDownloading,
isDeletingGameFiles,
isDeletingAchievements,
notify,
refreshGame,
handleSaveTitle,
handleSelectExecutable,
handleClearExecutable,
handleSaveLaunchOptions,
handleClearLaunchOptions,
handleOpenExecutableFolder,
handleOpenSaveFolder,
handleCreateShortcut,
handleCreateSteamShortcut,
handleDeleteSteamShortcut,
handleChooseAsset,
handleUpdateAsset,
handleResetAsset,
handleChangeWinePrefixPath,
handleClearWinePrefixPath,
handleChangeProtonVersion,
handleToggleGamemode,
handleToggleMangohud,
handleOpenWinetricks,
handleToggleAutomaticCloudSync,
handleOpenDownloadFolder,
handleRemoveGameFiles,
handleRemoveFromLibrary,
handleResetAchievements,
handleChangePlaytime,
};
}
export type GameSettingsController = ReturnType<
typeof useGameSettingsController
>;
function noop() {
return undefined;
}
@@ -0,0 +1,34 @@
import type { LibraryGame } from "@types";
import { useCallback, useState } from "react";
import { IS_DESKTOP } from "../../../constants";
export function useLibraryFavorite(updateLibrary: () => Promise<void>) {
const [favoriteLoadingGameId, setFavoriteLoadingGameId] = useState<
string | null
>(null);
const toggleFavorite = useCallback(
async (game: LibraryGame) => {
if (!IS_DESKTOP) return;
setFavoriteLoadingGameId(game.id);
try {
const toggle = game.favorite
? globalThis.window.electron.removeGameFromFavorites
: globalThis.window.electron.addGameToFavorites;
await toggle(game.shop, game.objectId);
await updateLibrary();
} finally {
setFavoriteLoadingGameId(null);
}
},
[updateLibrary]
);
return {
favoriteLoadingGameId,
toggleFavorite,
};
}
@@ -0,0 +1,26 @@
import type { LibraryGame } from "@types";
import { useCallback } from "react";
import { IS_DESKTOP } from "../../../constants";
export function useLibraryLaunchGame(
onMissingExecutable: (game: LibraryGame) => void
) {
return useCallback(
async (game: LibraryGame) => {
if (!IS_DESKTOP) return;
if (!game.executablePath) {
onMissingExecutable(game);
return;
}
await globalThis.window.electron.openGame(
game.shop,
game.objectId,
game.executablePath,
game.launchOptions
);
},
[onMissingExecutable]
);
}
@@ -0,0 +1,48 @@
import type { LibraryGame } from "@types";
import { useDeferredValue, useMemo } from "react";
import { getFirstLibraryFocusGridItemId } from "./navigation";
import {
filterLibraryByTab,
getLastPlayedGames,
getLibraryFilterCounts,
matchesSearchQuery,
sortByLastPlayed,
type LibraryFilterTab,
} from "./library-data";
export function useLibraryPageData(
library: LibraryGame[],
selectedTab: LibraryFilterTab,
search: string
) {
const deferredSearch = useDeferredValue(search);
const sortedLibrary = useMemo(() => {
return [...library].sort(sortByLastPlayed);
}, [library]);
const filteredLibrary = useMemo(() => {
return filterLibraryByTab(sortedLibrary, selectedTab).filter((game) =>
matchesSearchQuery(game, deferredSearch)
);
}, [deferredSearch, selectedTab, sortedLibrary]);
const filterCounts = useMemo(() => {
return getLibraryFilterCounts(library);
}, [library]);
const lastPlayedGames = useMemo(() => {
return getLastPlayedGames(sortedLibrary);
}, [sortedLibrary]);
const firstGridItemId = useMemo(() => {
return getFirstLibraryFocusGridItemId(filteredLibrary[0]?.id);
}, [filteredLibrary]);
return {
filteredLibrary,
filterCounts,
firstGridItemId,
lastPlayedGames,
};
}

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