mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-06-02 06:14:48 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,8 @@
|
||||
background-color: #0e0e0e;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&__achievement-icon-locked {
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
@@ -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,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 };
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user