feat(big-picture): add GameAchievements page and navigation enhancements (#2228)

* feat(big-picture): add GameAchievements page and navigation enhancements

- Introduced GameAchievements component for displaying game achievements.
- Updated routing to include achievements path under game details.
- Enhanced navigation with NavigationHistoryBridge for improved user experience.
- Added new helper function to generate achievements path based on game identity.
- Cleaned up imports and improved code structure for better maintainability.

* style: enhance styles for ChallengeGameCard

- Modified SCSS styles for ChallengeGameCard to align text to the left, improving layout consistency.

* refactor(big-picture): rename useBackTargetTitle to useCurrentPageTitle and update navigation logic

- Renamed the `useBackTargetTitle` hook to `useCurrentPageTitle` for clarity.
- Updated the logic to return the current page title instead of the previous one.
- Adjusted navigation in the Home component to utilize the new achievements path for improved user experience.
- Marked the proto subproject as dirty to indicate local changes.

---------

Co-authored-by: Chubby Granny Chaser <weak.fern2638@heliokroger.com>
This commit is contained in:
Moyase
2026-05-16 19:34:45 +03:00
committed by GitHub
parent e1f1c8d8ce
commit e4f0feca90
24 changed files with 782 additions and 27 deletions
+2
View File
@@ -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() {
<Fragment>
<NavigationStateBridge />
<NavigationAutoScrollBridge />
<NavigationHistoryBridge />
<NavigationInputProvider>
<NavigationLayer
@@ -10,6 +10,7 @@
background-color: transparent;
outline: none;
outline-offset: 3px;
text-align: left;
transition:
background-color 0.2s ease-in-out,
border-color 0.2s ease-in-out,
@@ -43,3 +43,4 @@ export * from "./divider";
export * from "./user-profile";
export * from "./box";
export * from "./focus-carousel";
export * from "./navigation-history-bridge";
@@ -0,0 +1,65 @@
import { useEffect } from "react";
import { useLocation, useNavigationType } from "react-router-dom";
import { IS_DESKTOP } from "../../../constants";
import { useNavigationHistoryStore } from "../../../stores";
const basePath = IS_DESKTOP ? "/big-picture" : "";
const capitalize = (word: string) =>
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;
}
@@ -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<AchievementRowProps>) {
const { formatDateTime } = useDate();
return (
<FocusItem
id={getAchievementRowId(achievement.name)}
actions={{ primary: "off" }}
asChild
>
<li className="game-achievements-row">
<img
src={achievement.icon}
width={56}
height={56}
alt={achievement.displayName}
loading="lazy"
className={cn("game-achievements-row__icon", {
"game-achievements-row__icon--locked": !achievement.unlocked,
})}
/>
<div className="game-achievements-row__info">
<Typography className="game-achievements-row__title">
{achievement.hidden ? (
<span
className="game-achievements-row__hidden-icon"
title="Hidden achievement"
>
<EyeClosedIcon size={12} />
</span>
) : null}
{achievement.displayName}
</Typography>
{achievement.description ? (
<Typography className="game-achievements-row__description">
{achievement.description}
</Typography>
) : null}
</div>
<div className="game-achievements-row__meta">
{achievement.points != undefined ? (
<div
className="game-achievements-row__points"
title={`Earn ${achievement.points} points with this achievement`}
>
<MedalIcon size={16} weight="fill" />
<span>{achievement.points}</span>
</div>
) : null}
{achievement.unlocked && achievement.unlockTime != null ? (
<span className="game-achievements-row__unlock-time">
{formatDateTime(achievement.unlockTime)}
</span>
) : null}
<div className="game-achievements-row__status">
{achievement.unlocked ? (
<CheckCircleIcon size={24} weight="fill" />
) : (
<LockIcon size={24} />
)}
</div>
</div>
</li>
</FocusItem>
);
}
@@ -0,0 +1,29 @@
import { MedalIcon } from "@phosphor-icons/react";
export interface AvailablePointsBarProps {
earnedPoints: number;
totalPoints: number;
}
export function AvailablePointsBar({
earnedPoints,
totalPoints,
}: Readonly<AvailablePointsBarProps>) {
if (totalPoints <= 0) return null;
const formatter = new Intl.NumberFormat("en");
return (
<div className="game-achievements-page__points-bar">
<span className="game-achievements-page__points-label">
Earned points
</span>
<div className="game-achievements-page__points-value">
<MedalIcon size={16} weight="fill" />
<span>
{formatter.format(earnedPoints)} / {formatter.format(totalPoints)}
</span>
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import type { ShopDetailsWithAssets } from "@types";
import { motion } from "framer-motion";
export interface GameAchievementsHeroProps {
shopDetails: ShopDetailsWithAssets;
}
export function GameAchievementsHero({
shopDetails,
}: Readonly<GameAchievementsHeroProps>) {
return (
<section className="game-achievements-page__hero">
<motion.div
initial={{ scale: 1, x: 0, y: 0 }}
animate={{ scale: 1.08, x: -8, y: -8 }}
transition={{
duration: 20,
ease: "easeInOut",
repeat: Infinity,
repeatType: "mirror",
}}
className="game-achievements-page__hero-bg"
style={{
backgroundImage: `url(${shopDetails.assets?.libraryHeroImageUrl})`,
}}
/>
<div className="game-achievements-page__hero-overlay">
{shopDetails.assets?.logoImageUrl ? (
<img
src={shopDetails.assets.logoImageUrl}
alt={shopDetails.assets?.title || ""}
className="game-achievements-page__hero-logo"
/>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,5 @@
export * from "./achievement-row";
export * from "./available-points-bar";
export * from "./hero";
export * from "./navigation";
export * from "./user-summary";
@@ -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}`;
@@ -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<UserAchievementsSummaryProps>) {
const percentage = totalCount > 0 ? (unlockedCount / totalCount) * 100 : 0;
const formattedPercentage =
percentage === 0 || Number.isInteger(percentage)
? `${percentage}%`
: `${percentage.toFixed(1)}%`;
return (
<div className="game-achievements-page__summary">
<div className="game-achievements-page__summary-avatar">
{userDetails?.profileImageUrl ? (
<img
src={userDetails.profileImageUrl}
alt={userDetails.displayName}
draggable={false}
/>
) : (
<UserIcon size={32} />
)}
</div>
<div className="game-achievements-page__summary-content">
<div className="game-achievements-page__summary-row">
<div className="game-achievements-page__summary-info">
<p className="game-achievements-page__summary-name">
{userDetails?.displayName ?? "Anonymous"}
</p>
<div className="game-achievements-page__summary-count">
<TrophyIcon size={20} />
<span>
{unlockedCount} / {totalCount}
</span>
</div>
</div>
<span className="game-achievements-page__summary-percentage">
{formattedPercentage}
</span>
</div>
<div className="game-achievements-page__summary-progress">
<div className="game-achievements-page__summary-progress-track" />
<div
className="game-achievements-page__summary-progress-fill"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
</div>
);
}
@@ -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<AchievementsBoxProps>) {
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
>
<div className="game-page__achievements-title">
<Link to={viewAllPath} className="game-page__achievements-title">
<Typography>Achievements</Typography>
<span>
{achievements.filter((achievement) => achievement.unlocked).length}{" "}
/ {achievements.length}
</span>
</div>
</Link>
</FocusItem>
{achievements.slice(0, 5).map((achievement) => (
@@ -102,7 +108,7 @@ export function AchievementsBox({
id={GAME_ACHIEVEMENTS_VIEW_ALL_ID}
navigationOverrides={viewAllNavigationOverrides}
>
<Link to="/big-picture/library">
<Link to={viewAllPath}>
<Typography>View All Achievements</Typography>
</Link>
</FocusItem>
+5
View File
@@ -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 = {}
+1
View File
@@ -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";
@@ -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]);
}
+7 -19
View File
@@ -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<HTMLInputElement>(null);
const searchRef = useRef<HTMLButtonElement>(null);
+5
View File
@@ -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(
<Route path="settings" element={<Settings />} />
<Route path="library" element={<LibraryPage />} />
<Route path="game/:shop/:objectId" element={<Game />} />
<Route
path="game/:shop/:objectId/achievements"
element={<GameAchievements />}
/>
</Route>
</Routes>
</BrowserRouter>
@@ -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 (
<VerticalFocusGroup regionId={GAME_ACHIEVEMENTS_PAGE_REGION_ID} asChild>
<div className="game-achievements-page">
<p style={{ color: "white", padding: 24 }}>Loading...</p>
</div>
</VerticalFocusGroup>
);
}
return (
<VerticalFocusGroup regionId={GAME_ACHIEVEMENTS_PAGE_REGION_ID} asChild>
<div className="game-achievements-page">
<GameAchievementsHero shopDetails={shopDetails} />
<div className="game-achievements-page__content">
<UserAchievementsSummary
userDetails={userDetails}
unlockedCount={unlockedCount}
totalCount={achievements.length}
/>
<AvailablePointsBar
earnedPoints={earnedPoints}
totalPoints={totalPoints}
/>
<VerticalFocusGroup
regionId={GAME_ACHIEVEMENTS_LIST_REGION_ID}
asChild
>
<ul className="game-achievements-page__list">
{achievements.map((achievement) => (
<AchievementRow
key={achievement.name}
achievement={achievement}
/>
))}
</ul>
</VerticalFocusGroup>
</div>
</div>
</VerticalFocusGroup>
);
}
@@ -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);
}
}
}
+2
View File
@@ -122,6 +122,8 @@
background-color: #0e0e0e;
border-radius: 8px;
padding: 8px;
color: inherit;
text-decoration: none;
}
&__achievement-icon-locked {
+3 -1
View File
@@ -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);
}, []);
+2 -2
View File
@@ -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 () => {
+1
View File
@@ -1,3 +1,4 @@
export * from "./gamepad.store";
export * from "./navigation.store";
export * from "./navigation-history.store";
export * from "./downloads.store";
@@ -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<NavigationHistoryState>(
(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 };
}),
})
);
+5
View File
@@ -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(
<Route path="settings" element={<BigPictureSettings />} />
<Route path="library" element={<BigPictureLibrary />} />
<Route path="game/:shop/:objectId" element={<BigPictureGame />} />
<Route
path="game/:shop/:objectId/achievements"
element={<BigPictureGameAchievements />}
/>
</Route>
</Routes>
</HashRouter>