diff --git a/src/big-picture/src/app.tsx b/src/big-picture/src/app.tsx index d167f8cfe..bad92161f 100644 --- a/src/big-picture/src/app.tsx +++ b/src/big-picture/src/app.tsx @@ -16,6 +16,7 @@ import { IS_DESKTOP } from "./constants"; import { useNavigation } from "./hooks"; import { HorizontalFocusGroup, + NavigationHistoryBridge, NavigationLayer, NavigationAutoScrollBridge, NavigationInputProvider, @@ -83,6 +84,7 @@ export default function App() { + + word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + +export function getDefaultPageTitle(pathname: string): string { + const relative = basePath ? pathname.replace(basePath, "") : pathname; + const segments = relative.split("/").filter(Boolean); + + if (segments.length === 0) return "Home"; + + if (segments[0] === "game") { + if (segments[3] === "achievements") return "Achievements"; + return "Game Details"; + } + + return capitalize(segments[0]); +} + +export function NavigationHistoryBridge() { + const location = useLocation(); + const navigationType = useNavigationType(); + + useEffect(() => { + const store = useNavigationHistoryStore.getState(); + const entry = { + key: location.key, + pathname: location.pathname, + title: getDefaultPageTitle(location.pathname), + }; + + const top = store.stack[store.stack.length - 1]; + if (top && top.key === entry.key) return; + + if (store.stack.length === 0) { + store.push(entry); + return; + } + + if (navigationType === "POP") { + const idx = store.stack.findIndex((e) => e.key === entry.key); + if (idx >= 0) { + const popCount = store.stack.length - 1 - idx; + for (let i = 0; i < popCount; i++) store.pop(); + } else { + store.replaceTop(entry); + } + return; + } + + if (navigationType === "REPLACE") { + store.replaceTop(entry); + return; + } + + store.push(entry); + }, [location.key, location.pathname, navigationType]); + + return null; +} diff --git a/src/big-picture/src/components/pages/game-achievements/achievement-row/index.tsx b/src/big-picture/src/components/pages/game-achievements/achievement-row/index.tsx new file mode 100644 index 000000000..c0c8ff8f1 --- /dev/null +++ b/src/big-picture/src/components/pages/game-achievements/achievement-row/index.tsx @@ -0,0 +1,86 @@ +import { + CheckCircleIcon, + EyeClosedIcon, + LockIcon, + MedalIcon, +} from "@phosphor-icons/react"; +import type { UserAchievement } from "@types"; +import cn from "classnames"; +import { useDate } from "../../../../hooks"; +import { FocusItem, Typography } from "../../../common"; +import { getAchievementRowId } from "../navigation"; + +export interface AchievementRowProps { + achievement: UserAchievement; +} + +export function AchievementRow({ achievement }: Readonly) { + const { formatDateTime } = useDate(); + + return ( + +
  • + {achievement.displayName} + +
    + + {achievement.hidden ? ( + + + + ) : null} + {achievement.displayName} + + + {achievement.description ? ( + + {achievement.description} + + ) : null} +
    + +
    + {achievement.points != undefined ? ( +
    + + {achievement.points} +
    + ) : null} + + {achievement.unlocked && achievement.unlockTime != null ? ( + + {formatDateTime(achievement.unlockTime)} + + ) : null} + +
    + {achievement.unlocked ? ( + + ) : ( + + )} +
    +
    +
  • +
    + ); +} diff --git a/src/big-picture/src/components/pages/game-achievements/available-points-bar/index.tsx b/src/big-picture/src/components/pages/game-achievements/available-points-bar/index.tsx new file mode 100644 index 000000000..84da3ff3d --- /dev/null +++ b/src/big-picture/src/components/pages/game-achievements/available-points-bar/index.tsx @@ -0,0 +1,29 @@ +import { MedalIcon } from "@phosphor-icons/react"; + +export interface AvailablePointsBarProps { + earnedPoints: number; + totalPoints: number; +} + +export function AvailablePointsBar({ + earnedPoints, + totalPoints, +}: Readonly) { + if (totalPoints <= 0) return null; + + const formatter = new Intl.NumberFormat("en"); + + return ( +
    + + Earned points + +
    + + + {formatter.format(earnedPoints)} / {formatter.format(totalPoints)} + +
    +
    + ); +} diff --git a/src/big-picture/src/components/pages/game-achievements/hero/index.tsx b/src/big-picture/src/components/pages/game-achievements/hero/index.tsx new file mode 100644 index 000000000..6e2509e82 --- /dev/null +++ b/src/big-picture/src/components/pages/game-achievements/hero/index.tsx @@ -0,0 +1,39 @@ +import type { ShopDetailsWithAssets } from "@types"; +import { motion } from "framer-motion"; + +export interface GameAchievementsHeroProps { + shopDetails: ShopDetailsWithAssets; +} + +export function GameAchievementsHero({ + shopDetails, +}: Readonly) { + return ( +
    + + +
    + {shopDetails.assets?.logoImageUrl ? ( + {shopDetails.assets?.title + ) : null} +
    +
    + ); +} diff --git a/src/big-picture/src/components/pages/game-achievements/index.ts b/src/big-picture/src/components/pages/game-achievements/index.ts new file mode 100644 index 000000000..f4c2d5840 --- /dev/null +++ b/src/big-picture/src/components/pages/game-achievements/index.ts @@ -0,0 +1,5 @@ +export * from "./achievement-row"; +export * from "./available-points-bar"; +export * from "./hero"; +export * from "./navigation"; +export * from "./user-summary"; diff --git a/src/big-picture/src/components/pages/game-achievements/navigation.ts b/src/big-picture/src/components/pages/game-achievements/navigation.ts new file mode 100644 index 000000000..5465e0f6b --- /dev/null +++ b/src/big-picture/src/components/pages/game-achievements/navigation.ts @@ -0,0 +1,5 @@ +export const GAME_ACHIEVEMENTS_PAGE_REGION_ID = "game-achievements-page"; +export const GAME_ACHIEVEMENTS_LIST_REGION_ID = "game-achievements-list"; + +export const getAchievementRowId = (achievementName: string) => + `achievement-row-${achievementName}`; diff --git a/src/big-picture/src/components/pages/game-achievements/user-summary/index.tsx b/src/big-picture/src/components/pages/game-achievements/user-summary/index.tsx new file mode 100644 index 000000000..2561f259c --- /dev/null +++ b/src/big-picture/src/components/pages/game-achievements/user-summary/index.tsx @@ -0,0 +1,64 @@ +import { TrophyIcon, UserIcon } from "@phosphor-icons/react"; +import type { UserDetails } from "@types"; + +export interface UserAchievementsSummaryProps { + userDetails: UserDetails | null; + unlockedCount: number; + totalCount: number; +} + +export function UserAchievementsSummary({ + userDetails, + unlockedCount, + totalCount, +}: Readonly) { + const percentage = totalCount > 0 ? (unlockedCount / totalCount) * 100 : 0; + const formattedPercentage = + percentage === 0 || Number.isInteger(percentage) + ? `${percentage}%` + : `${percentage.toFixed(1)}%`; + + return ( +
    +
    + {userDetails?.profileImageUrl ? ( + {userDetails.displayName} + ) : ( + + )} +
    + +
    +
    +
    +

    + {userDetails?.displayName ?? "Anonymous"} +

    +
    + + + {unlockedCount} / {totalCount} + +
    +
    + + + {formattedPercentage} + +
    + +
    +
    +
    +
    +
    +
    + ); +} diff --git a/src/big-picture/src/components/pages/game/achievements/index.tsx b/src/big-picture/src/components/pages/game/achievements/index.tsx index 0629aa294..441b916ec 100644 --- a/src/big-picture/src/components/pages/game/achievements/index.tsx +++ b/src/big-picture/src/components/pages/game/achievements/index.tsx @@ -1,5 +1,5 @@ -import type { UserAchievement } from "@types"; -import { Link } from "react-router-dom"; +import type { GameShop, UserAchievement } from "@types"; +import { Link, useParams } from "react-router-dom"; import { Box, FocusItem, Typography } from "../../../common"; import cn from "classnames"; import { EyeIcon } from "@phosphor-icons/react/dist/ssr"; @@ -11,6 +11,7 @@ import { GAME_SCREENSHOT_CAROUSEL_PREV_BUTTON_ID, } from "../navigation"; import { FocusOverrides } from "src/big-picture/src/services/navigation.service"; +import { getBigPictureGameAchievementsPath } from "../../../../helpers"; export interface AchievementsBoxProps { achievements: UserAchievement[]; @@ -19,6 +20,11 @@ export interface AchievementsBoxProps { export function AchievementsBox({ achievements, }: Readonly) { + const { shop, objectId } = useParams<{ shop: GameShop; objectId: string }>(); + const viewAllPath = + shop && objectId + ? getBigPictureGameAchievementsPath({ shop, objectId }) + : ""; const achievementsNavigationOverrides: FocusOverrides = { down: { type: "item", @@ -58,14 +64,14 @@ export function AchievementsBox({ navigationOverrides={achievementsNavigationOverrides} asChild > -
    + Achievements {achievements.filter((achievement) => achievement.unlocked).length}{" "} / {achievements.length} -
    + {achievements.slice(0, 5).map((achievement) => ( @@ -102,7 +108,7 @@ export function AchievementsBox({ id={GAME_ACHIEVEMENTS_VIEW_ALL_ID} navigationOverrides={viewAllNavigationOverrides} > - + View All Achievements diff --git a/src/big-picture/src/helpers/game.ts b/src/big-picture/src/helpers/game.ts index da03028ef..002df4159 100644 --- a/src/big-picture/src/helpers/game.ts +++ b/src/big-picture/src/helpers/game.ts @@ -53,6 +53,11 @@ export function getBigPictureGameDetailsPath( return `${basePath}/game/${game.shop}/${game.objectId}${querySuffix}`; } +export function getBigPictureGameAchievementsPath(game: GameIdentity) { + const basePath = IS_DESKTOP ? "/big-picture" : ""; + return `${basePath}/game/${game.shop}/${game.objectId}/achievements`; +} + export function getGameIdentityKey( game: GameIdentity, options: GameIdentityKeyOptions = {} diff --git a/src/big-picture/src/hooks/index.ts b/src/big-picture/src/hooks/index.ts index 30622aa29..a426d3ce9 100644 --- a/src/big-picture/src/hooks/index.ts +++ b/src/big-picture/src/hooks/index.ts @@ -16,3 +16,4 @@ export * from "./use-format.hook"; export * from "./use-date.hook"; export * from "./use-game-details.hook"; export * from "./use-floating-panel-position.hook"; +export * from "./use-header-title.hook"; diff --git a/src/big-picture/src/hooks/use-header-title.hook.ts b/src/big-picture/src/hooks/use-header-title.hook.ts new file mode 100644 index 000000000..1e55b4ee0 --- /dev/null +++ b/src/big-picture/src/hooks/use-header-title.hook.ts @@ -0,0 +1,11 @@ +import { useEffect } from "react"; +import { useNavigationHistoryStore } from "../stores"; + +export function useHeaderTitle(title: string | null | undefined) { + const setTopTitle = useNavigationHistoryStore((s) => s.setTopTitle); + + useEffect(() => { + if (!title) return; + setTopTitle(title); + }, [title, setTopTitle]); +} diff --git a/src/big-picture/src/layout/header/index.tsx b/src/big-picture/src/layout/header/index.tsx index 7e264ea02..103df2e2a 100644 --- a/src/big-picture/src/layout/header/index.tsx +++ b/src/big-picture/src/layout/header/index.tsx @@ -3,37 +3,25 @@ import { ArrowLeftIcon, MagnifyingGlassIcon } from "@phosphor-icons/react"; import cn from "classnames"; import { AnimatePresence, motion } from "framer-motion"; import { useEffect, useRef, useState } from "react"; -import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { FocusItem, HorizontalFocusGroup, Typography } from "../../components"; -import { IS_DESKTOP } from "../../constants"; import { BIG_PICTURE_HEADER_REGION_ID } from "../navigation"; import type { FocusOverrides } from "../../services"; +import { useNavigationHistoryStore } from "../../stores"; import "./styles.scss"; -const basePath = IS_DESKTOP ? "/big-picture" : ""; - -const capitalize = (word: string) => - word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); - const HEADER_BACK_BUTTON_ID = "header-back-button"; const HEADER_SEARCH_INPUT_ID = "header-search-input"; -const usePageTitle = () => { - const { pathname } = useLocation(); - const { slug } = useParams<{ slug: string }>(); - - if (pathname.startsWith("/game/")) { - return slug ? slug.split("-").map(capitalize).join(" ") : "Game Details"; - } - - const relativePath = basePath ? pathname.replace(basePath, "") : pathname; - const firstSegment = relativePath.split("/")[1]; - return firstSegment ? capitalize(firstSegment) : "Home"; +const useCurrentPageTitle = () => { + const stack = useNavigationHistoryStore((s) => s.stack); + if (stack.length >= 1) return stack[stack.length - 1].title; + return "Home"; }; function Header() { const navigate = useNavigate(); - const pageTitle = usePageTitle(); + const pageTitle = useCurrentPageTitle(); const [isSearchOpen, setIsSearchOpen] = useState(false); const inputRef = useRef(null); const searchRef = useRef(null); diff --git a/src/big-picture/src/main.tsx b/src/big-picture/src/main.tsx index 2ff5028d8..80edd3176 100644 --- a/src/big-picture/src/main.tsx +++ b/src/big-picture/src/main.tsx @@ -5,6 +5,7 @@ import App from "./app"; import Catalogue from "./pages/catalogue/catalogue"; import Downloads from "./pages/downloads/downloads"; import Game from "./pages/game/game"; +import GameAchievements from "./pages/game-achievements/game-achievements"; import Home from "./pages/home/home"; import LibraryPage from "./pages/library/page"; import Settings from "./pages/settings/settings"; @@ -26,6 +27,10 @@ ReactDOM.createRoot(rootElement).render( } /> } /> } /> + } + /> diff --git a/src/big-picture/src/pages/game-achievements/game-achievements.tsx b/src/big-picture/src/pages/game-achievements/game-achievements.tsx new file mode 100644 index 000000000..41044958a --- /dev/null +++ b/src/big-picture/src/pages/game-achievements/game-achievements.tsx @@ -0,0 +1,103 @@ +import type { GameShop } from "@types"; +import { useMemo } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { VerticalFocusGroup } from "../../components"; +import { + AchievementRow, + AvailablePointsBar, + GAME_ACHIEVEMENTS_LIST_REGION_ID, + GAME_ACHIEVEMENTS_PAGE_REGION_ID, + GameAchievementsHero, + UserAchievementsSummary, +} from "../../components/pages/game-achievements"; +import { + useGameDetails, + useHeaderTitle, + useNavigationScreenActions, + useUserDetails, +} from "../../hooks"; +import "./styles.scss"; + +export default function GameAchievements() { + const { shop, objectId } = useParams<{ shop: GameShop; objectId: string }>(); + const navigate = useNavigate(); + const { shopDetails, achievements, isLoading } = useGameDetails( + objectId!, + shop! + ); + const { userDetails } = useUserDetails(); + + const unlockedCount = useMemo( + () => achievements.filter((a) => a.unlocked).length, + [achievements] + ); + + const totalPoints = useMemo( + () => achievements.reduce((sum, a) => sum + (a.points ?? 0), 0), + [achievements] + ); + + const earnedPoints = useMemo( + () => + achievements.reduce( + (sum, a) => sum + (a.unlocked ? (a.points ?? 0) : 0), + 0 + ), + [achievements] + ); + + useHeaderTitle(shopDetails?.assets?.title); + + useNavigationScreenActions({ + press: { + b: () => { + navigate(-1); + }, + }, + }); + + if (isLoading || !shopDetails) { + return ( + +
    +

    Loading...

    +
    +
    + ); + } + + return ( + +
    + + +
    + + + + + +
      + {achievements.map((achievement) => ( + + ))} +
    +
    +
    +
    +
    + ); +} diff --git a/src/big-picture/src/pages/game-achievements/styles.scss b/src/big-picture/src/pages/game-achievements/styles.scss new file mode 100644 index 000000000..8e69dd9e6 --- /dev/null +++ b/src/big-picture/src/pages/game-achievements/styles.scss @@ -0,0 +1,282 @@ +.game-achievements-page { + height: 100%; + overflow-y: auto; + position: relative; + + &__hero { + position: relative; + height: 440px; + overflow: hidden; + } + + &__hero-bg { + position: absolute; + inset: 0; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + z-index: 0; + } + + &__hero-overlay { + position: relative; + z-index: 1; + height: 100%; + display: flex; + align-items: flex-end; + padding: calc(var(--spacing-unit) * 12) calc(var(--spacing-unit) * 24) + calc(var(--spacing-unit) * 32) calc(var(--spacing-unit) * 24); + background: + linear-gradient( + 90deg, + #080808 0%, + rgba(8, 8, 8, 0) 25%, + rgba(8, 8, 8, 0) 75%, + #080808 99.53% + ), + linear-gradient(180deg, rgba(8, 8, 8, 0) 0%, #080808 97%); + } + + &__hero-logo { + width: 337px; + max-width: 50%; + height: auto; + object-fit: contain; + } + + &__content { + position: relative; + z-index: 2; + margin-top: -64px; + padding: 0 calc(var(--spacing-unit) * 24) calc(var(--spacing-unit) * 16); + display: flex; + flex-direction: column; + gap: calc(var(--spacing-unit) * 6); + } + + &__summary { + display: flex; + align-items: center; + gap: 16px; + width: 100%; + } + + &__summary-avatar { + flex-shrink: 0; + width: 80px; + height: 80px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.5); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &__summary-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + } + + &__summary-row { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + width: 100%; + } + + &__summary-info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + &__summary-name { + font-family: inherit; + font-weight: 700; + font-size: 16px; + color: rgba(255, 255, 255, 0.8); + margin: 0; + } + + &__summary-count { + display: flex; + align-items: center; + gap: 8px; + color: rgba(255, 255, 255, 0.5); + font-size: 14px; + } + + &__summary-percentage { + color: rgba(255, 255, 255, 0.8); + font-size: 14px; + white-space: nowrap; + } + + &__summary-progress { + position: relative; + width: 100%; + height: 4px; + } + + &__summary-progress-track { + position: absolute; + inset: 0; + background-color: rgba(255, 255, 255, 0.15); + border-radius: 300px; + } + + &__summary-progress-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background-color: rgba(255, 255, 255, 0.8); + border-radius: 300px; + transition: width 0.4s ease; + } + + &__points-bar { + display: flex; + align-items: center; + gap: 16px; + background-color: #0e0e0e; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + padding: 16px; + } + + &__points-label { + color: rgba(255, 255, 255, 0.5); + font-size: 14px; + } + + &__points-value { + display: flex; + align-items: center; + gap: 8px; + color: rgba(255, 255, 255, 0.8); + font-size: 14px; + font-weight: 700; + } + + &__list { + display: flex; + flex-direction: column; + gap: 4px; + list-style: none; + margin: 0; + padding: 0; + + > *:last-child .game-achievements-row { + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + } + } +} + +.game-achievements-row { + display: flex; + align-items: center; + gap: 16px; + background-color: #0e0e0e; + padding: 8px; + outline: none; + cursor: default; + + &[data-focused="true"] { + outline: 2px solid #fff !important; + outline-offset: -2px; + } + + &__icon { + flex-shrink: 0; + border-radius: 4px; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); + } + + &__icon--locked { + filter: grayscale(100%); + opacity: 0.6; + } + + &__info { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + &__title { + display: flex; + align-items: center; + gap: 6px; + color: rgba(255, 255, 255, 0.8); + font-size: 14px; + } + + &__hidden-icon { + display: inline-flex; + align-items: center; + color: #fbbf24; + } + + &__description { + color: rgba(255, 255, 255, 0.5); + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + &__meta { + display: flex; + align-items: center; + gap: 24px; + flex-shrink: 0; + } + + &__points { + display: flex; + align-items: center; + gap: 6px; + color: rgba(255, 255, 255, 0.8); + font-size: 14px; + font-weight: 700; + } + + &__unlock-time { + color: rgba(255, 255, 255, 0.5); + font-size: 12px; + white-space: nowrap; + } + + &__status { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + color: rgba(255, 255, 255, 0.5); + + [data-focused="true"] & { + color: rgba(255, 255, 255, 0.9); + } + } +} diff --git a/src/big-picture/src/pages/game/game.scss b/src/big-picture/src/pages/game/game.scss index 535d3f426..9c03fb8de 100644 --- a/src/big-picture/src/pages/game/game.scss +++ b/src/big-picture/src/pages/game/game.scss @@ -122,6 +122,8 @@ background-color: #0e0e0e; border-radius: 8px; padding: 8px; + color: inherit; + text-decoration: none; } &__achievement-icon-locked { diff --git a/src/big-picture/src/pages/game/game.tsx b/src/big-picture/src/pages/game/game.tsx index cc916d001..1da8846bf 100644 --- a/src/big-picture/src/pages/game/game.tsx +++ b/src/big-picture/src/pages/game/game.tsx @@ -29,7 +29,7 @@ import { GAME_STATS_REGION_ID, GAME_STATS_TITLE_ID, } from "../../components/pages/game/navigation"; -import { useGameDetails } from "../../hooks"; +import { useGameDetails, useHeaderTitle } from "../../hooks"; import { FocusOverrides } from "../../services/navigation.service"; import "./game.scss"; @@ -54,6 +54,8 @@ export default function Game() { const resolvedGameTitle = shopDetails?.assets?.title ?? game?.title ?? "Download Game"; + useHeaderTitle(shopDetails?.assets?.title ?? game?.title); + const handleOpenDownloadModal = useCallback(() => { setIsDownloadModalOpen(true); }, []); diff --git a/src/big-picture/src/pages/home/home.tsx b/src/big-picture/src/pages/home/home.tsx index 04dbb8024..6e5f35ac2 100644 --- a/src/big-picture/src/pages/home/home.tsx +++ b/src/big-picture/src/pages/home/home.tsx @@ -22,6 +22,7 @@ import { HOME_WEEKLY_GAMES_CAROUSEL_REGION_ID, } from "./navigation"; import { + getBigPictureGameAchievementsPath, getBigPictureGameDetailsPath, getGameLandscapeImageSource, getItemFocusTarget, @@ -287,8 +288,7 @@ export default function Home() { if (!target) return; - const basePath = getBigPictureGameDetailsPath(target); - navigate(`${basePath}#game-achievements-title`); + navigate(getBigPictureGameAchievementsPath(target)); }, [menuState.catalogGame, navigate]); const handleCatalogShareFromMenu = useCallback(async () => { diff --git a/src/big-picture/src/stores/index.ts b/src/big-picture/src/stores/index.ts index f70e6a4e9..b00f852ce 100644 --- a/src/big-picture/src/stores/index.ts +++ b/src/big-picture/src/stores/index.ts @@ -1,3 +1,4 @@ export * from "./gamepad.store"; export * from "./navigation.store"; +export * from "./navigation-history.store"; export * from "./downloads.store"; diff --git a/src/big-picture/src/stores/navigation-history.store.ts b/src/big-picture/src/stores/navigation-history.store.ts new file mode 100644 index 000000000..906c2ab28 --- /dev/null +++ b/src/big-picture/src/stores/navigation-history.store.ts @@ -0,0 +1,47 @@ +import { create } from "zustand"; + +export interface NavigationHistoryEntry { + key: string; + pathname: string; + title: string; +} + +interface NavigationHistoryState { + stack: NavigationHistoryEntry[]; + push: (entry: NavigationHistoryEntry) => void; + pop: () => void; + replaceTop: (entry: NavigationHistoryEntry) => void; + setTopTitle: (title: string) => void; +} + +export const useNavigationHistoryStore = create( + (set) => ({ + stack: [], + push: (entry) => + set((state) => { + const last = state.stack[state.stack.length - 1]; + if (last && last.key === entry.key) return state; + return { stack: [...state.stack, entry] }; + }), + pop: () => + set((state) => + state.stack.length === 0 ? state : { stack: state.stack.slice(0, -1) } + ), + replaceTop: (entry) => + set((state) => { + if (state.stack.length === 0) return { stack: [entry] }; + const stack = state.stack.slice(0, -1); + stack.push(entry); + return { stack }; + }), + setTopTitle: (title) => + set((state) => { + if (state.stack.length === 0) return state; + const stack = [...state.stack]; + const top = stack[stack.length - 1]; + if (top.title === title) return state; + stack[stack.length - 1] = { ...top, title }; + return { stack }; + }), + }) +); diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 6b2f82c8b..f5928f068 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -42,6 +42,7 @@ import BigPictureHome from "../../big-picture/src/pages/home/home"; import BigPictureSettings from "../../big-picture/src/pages/settings/settings"; import BigPictureLibrary from "../../big-picture/src/pages/library/page"; import BigPictureGame from "../../big-picture/src/pages/game/game"; +import BigPictureGameAchievements from "../../big-picture/src/pages/game-achievements/game-achievements"; console.log = logger.log; @@ -122,6 +123,10 @@ ReactDOM.createRoot(document.getElementById("root")!).render( } /> } /> } /> + } + />