feat: massive front-end overhaul and initial github release
This commit is contained in:
parent
a2b40e38bf
commit
d5a0e70580
303 changed files with 19840 additions and 676 deletions
62
src/mainview/components/AnimatedBackground.tsx
Normal file
62
src/mainview/components/AnimatedBackground.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React, { createContext, Ref, useContext, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
|
||||
export const AnimatedBackgroundContext = createContext({} as { setBackground: (url: string) => void; });
|
||||
|
||||
export function AnimatedBackground (data: {
|
||||
children?: any;
|
||||
backgroundKey?: string;
|
||||
backgroundUrl?: string;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
className?: string;
|
||||
animated?: boolean,
|
||||
})
|
||||
{
|
||||
const [lastBackgroundUrl, setLastBackgroundUrl] = data.backgroundUrl ? useSessionStorage<string | undefined>(
|
||||
`${data.backgroundKey!}-last`,
|
||||
data.backgroundUrl,
|
||||
) : useState<string | undefined>();
|
||||
|
||||
const [backgroundUrl, setBackgroundUrl] = data.backgroundUrl ? useSessionStorage<string | undefined>(
|
||||
data.backgroundKey!,
|
||||
data.backgroundUrl,
|
||||
) : useState(data.backgroundUrl);
|
||||
|
||||
function handleSetBackground (url: string)
|
||||
{
|
||||
setLastBackgroundUrl(backgroundUrl);
|
||||
setBackgroundUrl(url);
|
||||
}
|
||||
|
||||
const bgColor = "bg-base-content";
|
||||
|
||||
let backgroundStyle = (url: string) => `linear-gradient(
|
||||
color-mix(in srgb, var(--color-base-300) 60%, transparent),
|
||||
color-mix(in srgb, var(--color-base-100) 80%, transparent)
|
||||
), url('${url}') center / cover`;
|
||||
|
||||
return (
|
||||
<AnimatedBackgroundContext value={{ setBackground: handleSetBackground }}>
|
||||
<div ref={data.ref}
|
||||
className={twMerge("w-full h-full flex flex-col overflow-hidden", data.className)}
|
||||
>
|
||||
{!!lastBackgroundUrl && <div className='absolute w-full h-full' style={{ background: backgroundStyle(lastBackgroundUrl), zIndex: -4 }}></div>}
|
||||
{!!backgroundUrl && <div key={backgroundUrl} className='absolute w-full h-full animate__animated animate__fadeIn' style={{ background: backgroundStyle(backgroundUrl), zIndex: -3 }}></div>}
|
||||
<div className="absolute w-full h-full backdrop-blur-3xl" style={{ zIndex: -2 }}></div>
|
||||
{data.animated && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}>
|
||||
<div id="container">
|
||||
<div id="container-inside">
|
||||
<div className={bgColor} id="circle-small"></div>
|
||||
<div className={bgColor} id="circle-medium"></div>
|
||||
<div className={bgColor} id="circle-large"></div>
|
||||
<div className={bgColor} id="circle-xlarge"></div>
|
||||
<div className={bgColor} id="circle-xxlarge"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
{data.children}
|
||||
</div>
|
||||
</AnimatedBackgroundContext>
|
||||
);
|
||||
}
|
||||
30
src/mainview/components/AutoFocus.tsx
Normal file
30
src/mainview/components/AutoFocus.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function AutoFocus (data: { focus: () => void; force?: boolean; delay?: number; })
|
||||
{
|
||||
useEffect(() =>
|
||||
{
|
||||
let delayTimeout: number | undefined;
|
||||
|
||||
if (data.force || !getCurrentFocusKey() || !doesFocusableExist(getCurrentFocusKey()))
|
||||
{
|
||||
if (data.delay)
|
||||
{
|
||||
delayTimeout = window.setTimeout(() => data.focus(), data.delay);
|
||||
} else
|
||||
{
|
||||
data.focus();
|
||||
}
|
||||
|
||||
}
|
||||
return () =>
|
||||
{
|
||||
if (delayTimeout)
|
||||
{
|
||||
window.clearTimeout(delayTimeout);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return <></>;
|
||||
}
|
||||
82
src/mainview/components/CardList.tsx
Normal file
82
src/mainview/components/CardList.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import
|
||||
{
|
||||
FocusContext,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { GameMeta } from "../../shared/constants";
|
||||
import GameCard, { GameCardSkeleton } from "./GameCard";
|
||||
import { JSX, useEffect, useMemo, useState } from "react";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import { useScrollSave } from "../scripts/utils";
|
||||
import classNames from "classnames";
|
||||
|
||||
export interface GameMetaExtra extends GameMeta
|
||||
{
|
||||
preview?: JSX.Element;
|
||||
badge?: JSX.Element;
|
||||
focusKey: string;
|
||||
}
|
||||
|
||||
export function CardList (data: {
|
||||
id: string;
|
||||
type?: string;
|
||||
games: GameMetaExtra[];
|
||||
grid?: boolean;
|
||||
onSelectGame?: (id: number) => void;
|
||||
onGameFocus?: (id: number) => void;
|
||||
})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: data.id,
|
||||
});
|
||||
|
||||
function BuildGame (g: GameMetaExtra, i: number)
|
||||
{
|
||||
let preview: JSX.Element | string | undefined = g.preview;
|
||||
if (!preview && g.previewUrl)
|
||||
{
|
||||
preview = g.previewUrl;
|
||||
}
|
||||
return (
|
||||
<GameCard
|
||||
key={g.id}
|
||||
type={data.type}
|
||||
index={i}
|
||||
focusKey={g.focusKey}
|
||||
data-index={i}
|
||||
title={g.title}
|
||||
subtitle={g.subtitle ?? ""}
|
||||
onFocus={() =>
|
||||
{
|
||||
data.onGameFocus?.(g.id);
|
||||
}}
|
||||
onAction={() => data.onSelectGame?.(g.id)}
|
||||
preview={preview}
|
||||
badge={g.badge}
|
||||
id={g.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul
|
||||
title="Games"
|
||||
id={`card-list-${data.id}`}
|
||||
ref={ref}
|
||||
save-child-focus="session"
|
||||
className={classNames("my-6 items-center justify-center-safe h-(--game-card-height) ",
|
||||
data.grid ? "card-grid h-fit gap-5" : 'card-list gap-6'
|
||||
)}
|
||||
onKeyDown={(e) =>
|
||||
{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
{data.games.map(BuildGame)}
|
||||
</FocusContext.Provider>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
54
src/mainview/components/CollectionsDetail.tsx
Normal file
54
src/mainview/components/CollectionsDetail.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { AnimatedBackground } from './AnimatedBackground';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { HeaderUI } from './Header';
|
||||
import { GameList, GameListFilter } from './GameList';
|
||||
import { Search, Settings2 } from 'lucide-react';
|
||||
import ShortcutPrompt from './ShortcutPrompt';
|
||||
import { selfFocusSmart } from '../scripts/utils';
|
||||
import { JSX, Suspense, useEffect, useState } from 'react';
|
||||
import Shortcuts from './Shortcuts';
|
||||
import { AutoFocus } from './AutoFocus';
|
||||
|
||||
export interface CollectionsDetailParams
|
||||
{
|
||||
id?: string;
|
||||
setBackground: (url: string) => void;
|
||||
filters: GameListFilter;
|
||||
headerTitle?: JSX.Element;
|
||||
title?: JSX.Element;
|
||||
footer?: JSX.Element;
|
||||
}
|
||||
|
||||
export function CollectionsDetail (data: CollectionsDetailParams)
|
||||
{
|
||||
const focusKey = `game-list-${data.id}-${data.filters.platformIds?.join()}-${data.filters.collectionId}`;
|
||||
const { ref, focusSelf } = useFocusable({
|
||||
focusKey,
|
||||
preferredChildFocusKey: `${focusKey}-list`,
|
||||
});
|
||||
|
||||
return (
|
||||
<FocusContext value={focusKey}>
|
||||
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className='flex'>
|
||||
<div className="px-3 w-full pt-2">
|
||||
<HeaderUI title={data.headerTitle} buttons={[{ id: "search", icon: <Search /> }, { id: "filter", icon: <Settings2 /> }]} />
|
||||
</div>
|
||||
<div className="w-full grow mt-4 rounded-2xl px-2 overflow-y-scroll justify-center mask-alpha mask-t-from-transparent mask-t-to-20 mask-t-to-black">
|
||||
<div className="h-fit w-full px-6 pt-4 pb-32">
|
||||
{data.title}
|
||||
<Suspense>
|
||||
<GameList grid setBackground={data.setBackground} filters={data.filters} id={`${focusKey}-list`}></GameList>
|
||||
<AutoFocus focus={focusSelf} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="px-2 pb-2 absolute bottom-0 w-full h-12 flex items-center justify-between">
|
||||
<div>
|
||||
{data.footer}
|
||||
</div>
|
||||
<Shortcuts />
|
||||
</footer>
|
||||
</AnimatedBackground>
|
||||
</FocusContext>
|
||||
);
|
||||
}
|
||||
78
src/mainview/components/Filters.tsx
Normal file
78
src/mainview/components/Filters.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import
|
||||
{
|
||||
FocusContext,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import SvgIcon from "./SvgIcon";
|
||||
import classNames from "classnames";
|
||||
|
||||
function FilterCat (
|
||||
data: {
|
||||
id: string;
|
||||
children?: any;
|
||||
active: boolean;
|
||||
onFocus: () => void;
|
||||
} & FilterOption,
|
||||
)
|
||||
{
|
||||
const { ref, focusSelf, focused } = useFocusable({
|
||||
focusKey: data.id,
|
||||
onFocus: data.onFocus,
|
||||
onEnterPress: data.onAction,
|
||||
});
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
onClick={focusSelf}
|
||||
className={classNames(
|
||||
"flex px-4 h-12 items-center justify-center rounded-full transition-all",
|
||||
{
|
||||
"bg-primary text-primary-content drop-shadow-sm cursor-default":
|
||||
focused || data.active,
|
||||
"ring-base-content ring-7": focused,
|
||||
"hover:bg-base-300 cursor-pointer": !focused,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{data.children ?? data.label}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterUI (data: {
|
||||
id: string;
|
||||
options: Record<string, FilterOption>;
|
||||
selected: string;
|
||||
setSelected: (id: string) => void;
|
||||
})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `filter-${data.id}` });
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex items-center justify-center gap-2"
|
||||
save-child-focus="session"
|
||||
>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<ul className="flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm">
|
||||
<li className=" flex px-4 h-12 items-center justify-center rounded-full">
|
||||
<SvgIcon className="size-8" icon="steamdeck_button_l1_outline" />
|
||||
</li>
|
||||
{Object.entries(data.options)?.map(([id, option]) => (
|
||||
<FilterCat
|
||||
id={id}
|
||||
key={id}
|
||||
onFocus={() => data.setSelected(id)}
|
||||
active={id === data.selected}
|
||||
{...option}
|
||||
/>
|
||||
))}
|
||||
<li className=" flex px-4 h-12 items-center justify-center rounded-full">
|
||||
<SvgIcon className="size-8" icon="steamdeck_button_r1_outline" />
|
||||
</li>
|
||||
</ul>
|
||||
</FocusContext.Provider>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
91
src/mainview/components/GameCard.tsx
Normal file
91
src/mainview/components/GameCard.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { JSX, useEffect } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function GameCardSkeleton ()
|
||||
{
|
||||
return (
|
||||
<li className="game-card bg-base-100/80 p-4 z-0 mx-2 max-h-(--game-card-height) min-w-(--game-card-width) w-(--game-card-width)">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="skeleton h-60 w-full opacity-40"></div>
|
||||
<div className="skeleton h-4 w-full opacity-40"></div>
|
||||
<div className="skeleton h-4 w-28 opacity-40"></div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GameCard (data: {
|
||||
title: string;
|
||||
type?: string;
|
||||
subtitle: string;
|
||||
preview?: string | JSX.Element;
|
||||
focusKey: string;
|
||||
index: number;
|
||||
id: number;
|
||||
badge?: JSX.Element;
|
||||
onFocus?: (id: number) => void;
|
||||
onAction?: () => void;
|
||||
})
|
||||
{
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: data.focusKey,
|
||||
onFocus: () => data.onFocus?.(data.id),
|
||||
onEnterPress: () => data.onAction?.(),
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (focused)
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({
|
||||
behavior: "smooth",
|
||||
inline: "center",
|
||||
block: 'center'
|
||||
});
|
||||
}
|
||||
}, [focused]);
|
||||
|
||||
return (
|
||||
<li
|
||||
id={`game-entry-${data.id}`}
|
||||
key={data.id}
|
||||
data-index={data.id}
|
||||
role="button"
|
||||
ref={ref}
|
||||
style={{
|
||||
scrollSnapAlign: "center"
|
||||
}}
|
||||
onFocus={focusSelf}
|
||||
onDoubleClick={data.onAction}
|
||||
onClick={focused ? data.onAction : focusSelf}
|
||||
className={twMerge(
|
||||
`game-card game-card-height flex flex-col justify-end`,
|
||||
'max-h-(--game-card-height) min-w-(--game-card-width) w-(--game-card-width)',
|
||||
"overflow-hidden transition-all duration-200 drop-shadow-lg cursor-pointer",
|
||||
focused ?
|
||||
`animate-wiggle ring-7 bg-base-content text-base-300 ring-primary drop-shadow-xl drop-shadow-base-300/60 scale-102 z-10` :
|
||||
"bg-base-300 text-base-content",
|
||||
classNames({
|
||||
"h-(--game-card-height)": typeof data.preview === "string"
|
||||
})
|
||||
)}
|
||||
>
|
||||
<div className={twMerge("overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all", focused ? "mt-2 mx-2" : "mt-2 mx-2")}>
|
||||
{typeof data.preview === "string" ? (
|
||||
<img src={data.preview}></img>
|
||||
) : (
|
||||
data.preview
|
||||
)}</div>
|
||||
|
||||
<div className="h-0 flex pr-2 justify-end items-center">{data.badge}</div>
|
||||
<div className="flex flex-col p-4">
|
||||
<div className="text-xl font-bold text-nowrap text-ellipsis overflow-hidden">
|
||||
{data.title}
|
||||
</div>
|
||||
<div className="text-s">{data.subtitle}</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
74
src/mainview/components/GameList.tsx
Normal file
74
src/mainview/components/GameList.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { keepPreviousData, useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { getRomsApiRomsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
|
||||
import { GameMetaExtra, CardList } from "./CardList";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { gamesQueryOptions } from "../query-options";
|
||||
|
||||
export interface GameListFilter
|
||||
{
|
||||
platformIds?: number[];
|
||||
collectionId?: number;
|
||||
}
|
||||
|
||||
export interface GameListParams
|
||||
{
|
||||
id: string,
|
||||
filters?: GameListFilter,
|
||||
grid?: boolean,
|
||||
setBackground?: (url: string) => void;
|
||||
onGameSelect?: (id: number) => void;
|
||||
}
|
||||
|
||||
export function GameList (data: GameListParams)
|
||||
{
|
||||
const games = useSuspenseQuery(gamesQueryOptions(data.filters));
|
||||
const navigator = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleFocus = (id: number) =>
|
||||
{
|
||||
const game = games.data?.items.find((g) => g.id === id);
|
||||
if (game)
|
||||
{
|
||||
data.setBackground?.(
|
||||
`${RPC_URL(__HOST__)}/api/romm${game.path_cover_small}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function handleDefaultSelect (id: number)
|
||||
{
|
||||
SaveSource('details', location.pathname);
|
||||
navigator({ to: '/game/$id', params: { id: String(id) }, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardList
|
||||
id={data.id}
|
||||
type="game"
|
||||
grid={data.grid}
|
||||
games={games.data.items.sort(
|
||||
(a, b) =>
|
||||
Date.parse(b.rom_user.last_played ?? b.updated_at) -
|
||||
Date.parse(a.rom_user.last_played ?? a.updated_at),
|
||||
)
|
||||
.map(
|
||||
(g) =>
|
||||
({
|
||||
id: g.id,
|
||||
focusKey: g.slug ?? `game-${g.id}`,
|
||||
title: g.name ?? "",
|
||||
subtitle: g.platform_display_name ?? "",
|
||||
previewUrl: `${RPC_URL(__HOST__)}/api/romm${g.path_cover_large}`,
|
||||
}) satisfies GameMetaExtra,
|
||||
)}
|
||||
onGameFocus={handleFocus}
|
||||
onSelectGame={id => data.onGameSelect ? data.onGameSelect(id) : handleDefaultSelect(id)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export default function GamepadIcon({
|
||||
platform,
|
||||
variant,
|
||||
button,
|
||||
text,
|
||||
}: {
|
||||
platform: "xbox" | "playstation" | "nintendo";
|
||||
variant: string;
|
||||
button: string;
|
||||
text?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="gamepad-button-wrapper">
|
||||
<i
|
||||
className={`gamepad-button gamepad-button-${platform} gamepad-button-${platform}--${button} gamepad-button-${platform}--variant-${variant}`}
|
||||
>
|
||||
{text}
|
||||
</i>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
src/mainview/components/Header.tsx
Normal file
191
src/mainview/components/Header.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import
|
||||
{
|
||||
FocusContext,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import
|
||||
{
|
||||
BatteryFull,
|
||||
Bell,
|
||||
Bluetooth,
|
||||
Clock,
|
||||
Lock,
|
||||
Power,
|
||||
ShieldAlert,
|
||||
Sun,
|
||||
User,
|
||||
Wifi,
|
||||
} from "lucide-react";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
|
||||
import { RPC_URL } from "../../shared/constants";
|
||||
import { JSX } from "react";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
|
||||
function HeaderAvatar (data: {
|
||||
id: string;
|
||||
imageSrc?: string | string[];
|
||||
className?: string;
|
||||
active?: boolean;
|
||||
status?: HeaderAccount['status'];
|
||||
locked?: boolean;
|
||||
type?: HeaderAccount['type'];
|
||||
onSelect?: () => void;
|
||||
})
|
||||
{
|
||||
const { ref, focused } = useFocusable({ focusKey: data.id, onEnterPress: data.onSelect });
|
||||
const bgColors = {
|
||||
primary: " text-primary-content",
|
||||
secondary: " text-secondary-content",
|
||||
accent: " text-accent-content",
|
||||
base: "bg-base-100",
|
||||
none: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={data.id}
|
||||
ref={ref}
|
||||
onClick={data.onSelect}
|
||||
style={{ viewTransitionName: data.id }}
|
||||
className={classNames(
|
||||
`avatar indicator ring-base-100 ring-offset-base-100 size-14 rounded-full flex items-center justify-center`,
|
||||
bgColors[data.type ?? "none"],
|
||||
"text-base-content cursor-pointer transition-all drop-shadow-md",
|
||||
"hover:ring-primary hover:ring-7",
|
||||
{
|
||||
"ring-5 hover:ring-offset-5": data.active,
|
||||
"ring-7 ring-primary ring-offset-base-100": focused,
|
||||
"ring-offset-5": focused && data.active,
|
||||
},
|
||||
data.className,
|
||||
)}
|
||||
>
|
||||
{data.imageSrc ? (
|
||||
<div className="overflow rounded-full w-full h-full">
|
||||
<picture>
|
||||
{typeof data.imageSrc === 'string' && <img key={"og-image"} src={data.imageSrc}></img>}
|
||||
{Array.isArray(data.imageSrc) && data.imageSrc.map((s, i) =>
|
||||
{
|
||||
if (i === (data.imageSrc!.length - 1))
|
||||
{
|
||||
return <img key={'fallback-image'} src={s}></img>;
|
||||
}
|
||||
return <source key={`alt-img-${i}`} srcSet={s}></source>;
|
||||
})}
|
||||
</picture>
|
||||
</div>
|
||||
) : (
|
||||
<User />
|
||||
)}
|
||||
<span className={classNames("indicator-item status left-1 top-1 ring-3 ring-base-100 z-1", data.status)}></span>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface HeaderButton
|
||||
{
|
||||
id: string;
|
||||
icon: JSX.Element;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export interface HeaderAccount
|
||||
{
|
||||
id: string;
|
||||
previewUrl?: string | string[];
|
||||
status?: "status-error" | "status-success" | "status-neutral";
|
||||
type?: "base" | "primary" | "secondary" | "accent";
|
||||
locked?: boolean;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[], buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "header-elements" });
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const rommOnline = useQuery({
|
||||
...statsApiStatsGetOptions(),
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
});
|
||||
const user = useQuery({
|
||||
...getCurrentUserApiUsersMeGetOptions(),
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1
|
||||
});
|
||||
|
||||
let indicator = "status-neutral";
|
||||
if (user.isError)
|
||||
{
|
||||
indicator = "status-error";
|
||||
} else if (!user.isPending && rommOnline.isSuccess)
|
||||
{
|
||||
indicator = "status-success";
|
||||
}
|
||||
|
||||
const accounts: HeaderAccount[] = [{
|
||||
id: 'romm', previewUrl: [
|
||||
`${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`,
|
||||
],
|
||||
action: () =>
|
||||
{
|
||||
SaveSource('settings', location.pathname);
|
||||
navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } });
|
||||
},
|
||||
status: user.data ? "status-success" : 'status-error',
|
||||
type: 'secondary'
|
||||
}, ...data.accounts ?? []];
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<header
|
||||
ref={ref}
|
||||
className="h-14 mt-2 flex items-center justify-between text-white"
|
||||
>
|
||||
<div className="flex items-center gap-2 drop-shadow-sm">
|
||||
{accounts?.map(a => <HeaderAvatar
|
||||
key={`header-avatar-${a.id}`}
|
||||
type={a.type}
|
||||
id={`account-${a.id}`}
|
||||
status={a.status}
|
||||
locked={a.locked}
|
||||
imageSrc={a.previewUrl}
|
||||
onSelect={a.action}
|
||||
/>)}
|
||||
{data.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text drop-shadow-sm">
|
||||
<div className="flex gap-5">
|
||||
<Clock />
|
||||
<Wifi className="w-6 h-6" />
|
||||
<Bluetooth className="w-6 h-6" />
|
||||
<div className="indicator">
|
||||
<span className="indicator-item status status-error"></span>
|
||||
<Bell className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<BatteryFull className="w-6 h-6" />
|
||||
<span className="font-semibold">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
||||
<div className="flex gap-2">
|
||||
{data.buttonElements ?? data.buttons?.map(b => <RoundButton
|
||||
key={b.id}
|
||||
className="header-icon size-16"
|
||||
id={b.id}
|
||||
icon={b.icon}
|
||||
external={b.external}
|
||||
/>)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
24
src/mainview/components/LoadingCardList.tsx
Normal file
24
src/mainview/components/LoadingCardList.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import classNames from 'classnames';
|
||||
import { GameCardSkeleton } from './GameCard';
|
||||
|
||||
export default function LoadingCardList (data: { placeholderCount: number, grid?: boolean; })
|
||||
{
|
||||
return (
|
||||
<ul
|
||||
title="Games"
|
||||
id={`card-list-placeholder`}
|
||||
save-child-focus="session"
|
||||
className={classNames("my-6 items-center justify-center-safe h-(--game-card-height) ",
|
||||
data.grid ? "card-grid gap-5" : 'card-list gap-6'
|
||||
)}
|
||||
onKeyDown={(e) =>
|
||||
{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
{new Array(data.placeholderCount).fill(1).map(p => <GameCardSkeleton />)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
39
src/mainview/components/RoundButton.tsx
Normal file
39
src/mainview/components/RoundButton.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { JSX } from "react";
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function RoundButton (data: {
|
||||
id: string;
|
||||
icon: JSX.Element;
|
||||
className?: string;
|
||||
external?: boolean;
|
||||
action?: () => void;
|
||||
})
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: data.id,
|
||||
onEnterPress: data.action,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
id={data.id}
|
||||
ref={ref}
|
||||
onClick={data.action}
|
||||
className={classNames(twMerge(
|
||||
"rounded-full size-14 flex items-center justify-center bg-base-100 text-base-content cursor-pointer transition-all drop-shadow-sm",
|
||||
data.className, classNames(data.external === true
|
||||
? {
|
||||
"hover:ring-7 hover:ring-primary hover:bg-base-content hover:text-base-300": true,
|
||||
"ring-7 ring-primary bg-base-content text-base-100": focused,
|
||||
}
|
||||
: {
|
||||
"hover:bg-primary hover:text-primary-content": true,
|
||||
"bg-primary text-primary-content": focused,
|
||||
},)),
|
||||
)}
|
||||
>
|
||||
{data.icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/mainview/components/SaveScroll.tsx
Normal file
7
src/mainview/components/SaveScroll.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { ScrollSaveParams, useScrollSave } from "../scripts/utils";
|
||||
|
||||
export default function ScrollSave (data: ScrollSaveParams)
|
||||
{
|
||||
useScrollSave(data);
|
||||
return <></>;
|
||||
}
|
||||
29
src/mainview/components/ShortcutPrompt.tsx
Normal file
29
src/mainview/components/ShortcutPrompt.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React, { MouseEventHandler } from "react";
|
||||
import SvgIcon, { IconType } from "./SvgIcon";
|
||||
import classNames from "classnames";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export default function ShortcutPrompt (data: {
|
||||
icon: IconType;
|
||||
label?: string;
|
||||
className?: string;
|
||||
onClick?: MouseEventHandler;
|
||||
})
|
||||
{
|
||||
return (
|
||||
<span
|
||||
onClick={data.onClick}
|
||||
className={twMerge(
|
||||
"flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm",
|
||||
"sm:text-sm",
|
||||
data.className,
|
||||
classNames({
|
||||
"hover:bg-base-300 cursor-pointer": !!data.onClick,
|
||||
})
|
||||
)}
|
||||
>
|
||||
<SvgIcon className="md:size-8 sm:size-6" icon={data.icon} />
|
||||
{data.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
14
src/mainview/components/Shortcuts.tsx
Normal file
14
src/mainview/components/Shortcuts.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
import ShortcutPrompt from './ShortcutPrompt';
|
||||
|
||||
export default function Shortcuts ()
|
||||
{
|
||||
return (
|
||||
<div style={{ viewTransitionName: 'shortcuts' }} className="flex gap-2">
|
||||
<ShortcutPrompt icon="steamdeck_button_a" label="Continue" />
|
||||
<ShortcutPrompt icon="steamdeck_button_b" label="Back" />
|
||||
<ShortcutPrompt icon="steamdeck_button_x" label="Close" />
|
||||
<ShortcutPrompt icon="steamdeck_button_y" label="Options" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/mainview/components/SvgIcon.tsx
Normal file
32
src/mainview/components/SvgIcon.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import "virtual:svg-icons/register";
|
||||
import { StaticAssetPath } from "../gen/static-icon-assets.gen";
|
||||
|
||||
type OnlySvgIcon<T extends string> = T extends `${infer Rest}.svg`
|
||||
? Rest
|
||||
: never;
|
||||
type StripSvg<T extends string> = T extends `${infer Rest}.svg` ? Rest : T;
|
||||
type ReplaceSlash<T extends string> = T extends `${infer Left}/${infer Right}`
|
||||
? `${Left}-${ReplaceSlash<Right>}`
|
||||
: T;
|
||||
type IconName<T extends string> = ReplaceSlash<StripSvg<OnlySvgIcon<T>>>;
|
||||
export type IconType = IconName<StaticAssetPath>;
|
||||
|
||||
export default function SvgIcon ({
|
||||
icon,
|
||||
prefix = "icon",
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
icon: IconType;
|
||||
prefix?: string;
|
||||
className?: string;
|
||||
})
|
||||
{
|
||||
const symbolId = `#${prefix}-${icon}`;
|
||||
|
||||
return (
|
||||
<svg className={className} {...props} aria-hidden="true">
|
||||
<use href={symbolId} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue