mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-06-02 06:14:48 +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/
|
.vscode/
|
||||||
node_modules/
|
node_modules/
|
||||||
__pycache__
|
__pycache__
|
||||||
dist
|
dist
|
||||||
out
|
out
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log*
|
*.log*
|
||||||
.env
|
*.tsbuildinfo
|
||||||
.vite
|
.env
|
||||||
ludusavi/**
|
.vite
|
||||||
!ludusavi/config.yaml
|
ludusavi/**
|
||||||
hydra-python-rpc/
|
!ludusavi/config.yaml
|
||||||
/hydra-native/
|
hydra-python-rpc/
|
||||||
native/hydra-native/target/
|
/hydra-native/
|
||||||
.python-version
|
native/hydra-native/target/
|
||||||
|
.python-version
|
||||||
# Sentry Config File
|
|
||||||
.env.sentry-build-plugin
|
# Sentry Config File
|
||||||
|
.env.sentry-build-plugin
|
||||||
*storybook.log
|
|
||||||
Future-updates.md
|
*storybook.log
|
||||||
TO-DO.md
|
Future-updates.md
|
||||||
|
TO-DO.md
|
||||||
|
|||||||
+28
-3
@@ -1,12 +1,13 @@
|
|||||||
import { resolve } from "path";
|
import react from "@vitejs/plugin-react";
|
||||||
import {
|
import {
|
||||||
defineConfig,
|
defineConfig,
|
||||||
|
externalizeDepsPlugin,
|
||||||
loadEnv,
|
loadEnv,
|
||||||
swcPlugin,
|
swcPlugin,
|
||||||
externalizeDepsPlugin,
|
|
||||||
} from "electron-vite";
|
} from "electron-vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import { resolve } from "path";
|
||||||
import svgr from "vite-plugin-svgr";
|
import svgr from "vite-plugin-svgr";
|
||||||
|
import { scopeBigPictureCss } from "./src/big-picture/vite-scope-big-picture-css";
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
loadEnv(mode);
|
loadEnv(mode);
|
||||||
@@ -29,11 +30,35 @@ export default defineConfig(({ mode }) => {
|
|||||||
preload: {
|
preload: {
|
||||||
plugins: [externalizeDepsPlugin()],
|
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: {
|
renderer: {
|
||||||
build: {
|
build: {
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
css: {
|
css: {
|
||||||
|
postcss: {
|
||||||
|
plugins: [scopeBigPictureCss()],
|
||||||
|
},
|
||||||
preprocessorOptions: {
|
preprocessorOptions: {
|
||||||
scss: {
|
scss: {
|
||||||
api: "modern",
|
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: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: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",
|
"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",
|
"prepare": "husky",
|
||||||
"protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto"
|
"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/preload": "^3.0.2",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@fontsource/noto-sans": "^5.2.10",
|
"@fontsource/noto-sans": "^5.2.10",
|
||||||
|
"@fontsource/space-grotesk": "^5.2.10",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@primer/octicons-react": "^19.9.0",
|
"@primer/octicons-react": "^19.9.0",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@reduxjs/toolkit": "^2.2.3",
|
"@reduxjs/toolkit": "^2.2.3",
|
||||||
"@sentry/react": "^10.33.0",
|
"@sentry/react": "^10.33.0",
|
||||||
"@tiptap/extension-bold": "^3.6.2",
|
"@tiptap/extension-bold": "^3.6.2",
|
||||||
@@ -102,7 +106,8 @@
|
|||||||
"workwonders-sdk": "0.4.2",
|
"workwonders-sdk": "0.4.2",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
"yaml": "^2.8.3",
|
"yaml": "^2.8.3",
|
||||||
"yup": "^1.5.0"
|
"yup": "^1.5.0",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.705.0",
|
"@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