mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-06-01 22:09:27 +02:00
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:
+23
-22
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
@@ -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'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";
|
||||
+283
@@ -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>;
|
||||
+861
@@ -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
Reference in New Issue
Block a user