feat: Implemented launching and downloading of roms
This is just an initial implementation lots of kings to iron out
This commit is contained in:
parent
ef08fa6114
commit
f15bf9a1e0
117 changed files with 37776 additions and 1073 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import React, { createContext, Ref, useContext, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import React, { createContext, JSX, Ref, useContext, useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
|
||||
|
|
@ -13,15 +14,23 @@ export function AnimatedBackground (data: {
|
|||
animated?: boolean,
|
||||
})
|
||||
{
|
||||
const [lastBackgroundUrl, setLastBackgroundUrl] = data.backgroundUrl ? useSessionStorage<string | undefined>(
|
||||
const blurBackground = true;
|
||||
const animateBackground = true;
|
||||
|
||||
const [lastBackgroundUrl, setLastBackgroundUrl] = data.backgroundKey ? useSessionStorage<string | undefined>(
|
||||
`${data.backgroundKey!}-last`,
|
||||
data.backgroundUrl,
|
||||
) : useState<string | undefined>();
|
||||
|
||||
const [backgroundUrl, setBackgroundUrl] = data.backgroundUrl ? useSessionStorage<string | undefined>(
|
||||
const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ? useSessionStorage<string | undefined>(
|
||||
data.backgroundKey!,
|
||||
data.backgroundUrl,
|
||||
) : useState(data.backgroundUrl);
|
||||
) : useState<string | undefined>();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setBackgroundUrl(data.backgroundUrl);
|
||||
}, [data.backgroundUrl]);
|
||||
|
||||
function handleSetBackground (url: string)
|
||||
{
|
||||
|
|
@ -36,6 +45,20 @@ export function AnimatedBackground (data: {
|
|||
color-mix(in srgb, var(--color-base-100) 80%, transparent)
|
||||
), url('${url}') center / cover`;
|
||||
|
||||
let backgroundElements: JSX.Element | undefined = undefined;
|
||||
if (true)
|
||||
{
|
||||
backgroundElements = <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>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedBackgroundContext value={{ setBackground: handleSetBackground }}>
|
||||
<div ref={data.ref}
|
||||
|
|
@ -43,17 +66,9 @@ export function AnimatedBackground (data: {
|
|||
>
|
||||
{!!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>
|
||||
{blurBackground && <div className={"absolute w-full h-full backdrop-blur-3xl"} style={{ zIndex: -2 }}></div>}
|
||||
{data.animated && animateBackground && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}>
|
||||
{backgroundElements}
|
||||
</div>}
|
||||
{data.children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,17 +3,16 @@ 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 { FrontEndId, GameMeta } from "../../shared/constants";
|
||||
import GameCard, { GameCardParams } from "./GameCard";
|
||||
import { JSX, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export interface GameMetaExtra extends GameMeta
|
||||
{
|
||||
preview?: JSX.Element;
|
||||
badge?: JSX.Element;
|
||||
preview?: GameCardParams['preview'];
|
||||
badges?: JSX.Element[];
|
||||
focusKey: string;
|
||||
}
|
||||
|
||||
|
|
@ -22,8 +21,9 @@ export function CardList (data: {
|
|||
type?: string;
|
||||
games: GameMetaExtra[];
|
||||
grid?: boolean;
|
||||
onSelectGame?: (id: number) => void;
|
||||
onGameFocus?: (id: number) => void;
|
||||
onSelectGame?: (id: string) => void;
|
||||
onGameFocus?: (id: string) => void;
|
||||
className?: string;
|
||||
})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
|
|
@ -32,7 +32,7 @@ export function CardList (data: {
|
|||
|
||||
function BuildGame (g: GameMetaExtra, i: number)
|
||||
{
|
||||
let preview: JSX.Element | string | undefined = g.preview;
|
||||
let preview: GameCardParams['preview'] = g.preview;
|
||||
if (!preview && g.previewUrl)
|
||||
{
|
||||
preview = g.previewUrl;
|
||||
|
|
@ -48,11 +48,17 @@ export function CardList (data: {
|
|||
subtitle={g.subtitle ?? ""}
|
||||
onFocus={() =>
|
||||
{
|
||||
g.onFocus?.();
|
||||
data.onGameFocus?.(g.id);
|
||||
(document.querySelector(":root") as HTMLElement).style.setProperty('--selected-card-offset', `${i}s`);
|
||||
}}
|
||||
onAction={() =>
|
||||
{
|
||||
g.onSelect?.();
|
||||
data.onSelectGame?.(g.id);
|
||||
}}
|
||||
onAction={() => data.onSelectGame?.(g.id)}
|
||||
preview={preview}
|
||||
badge={g.badge}
|
||||
badges={g.badges}
|
||||
id={g.id}
|
||||
/>
|
||||
);
|
||||
|
|
@ -64,8 +70,9 @@ export function CardList (data: {
|
|||
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'
|
||||
className={twMerge("my-6 items-center justify-center-safe h-(--game-card-height) ",
|
||||
data.grid ? "card-grid h-fit gap-5" : 'card-list gap-6',
|
||||
data.className
|
||||
)}
|
||||
onKeyDown={(e) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga
|
|||
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 { JSX, Suspense } from 'react';
|
||||
import Shortcuts from './Shortcuts';
|
||||
import { AutoFocus } from './AutoFocus';
|
||||
|
||||
|
|
@ -21,7 +19,7 @@ export interface CollectionsDetailParams
|
|||
|
||||
export function CollectionsDetail (data: CollectionsDetailParams)
|
||||
{
|
||||
const focusKey = `game-list-${data.id}-${data.filters.platformIds?.join()}-${data.filters.collectionId}`;
|
||||
const focusKey = `game-list-${data.id}-${data.filters.platformId}-${data.filters.collectionId}`;
|
||||
const { ref, focusSelf } = useFocusable({
|
||||
focusKey,
|
||||
preferredChildFocusKey: `${focusKey}-list`,
|
||||
|
|
@ -46,7 +44,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
<div>
|
||||
{data.footer}
|
||||
</div>
|
||||
<Shortcuts />
|
||||
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_b', label: 'Back' }]} />
|
||||
</footer>
|
||||
</AnimatedBackground>
|
||||
</FocusContext>
|
||||
|
|
|
|||
107
src/mainview/components/ContextDialog.tsx
Normal file
107
src/mainview/components/ContextDialog.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { createContext, JSX, useContext, useEffect } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
const ContextDialogContext = createContext({} as {
|
||||
close: () => void,
|
||||
id: string;
|
||||
});
|
||||
|
||||
export function ContextList (data: { options: DialogEntry[]; className?: string; showCloseButton?: boolean; })
|
||||
{
|
||||
const context = useContext(ContextDialogContext);
|
||||
return <ul className={twMerge("list max-h-[70vh] overflow-y-auto", data.className)}>
|
||||
{data.options.map(o => <OptionElement className="list-row" key={o.id} {...o} />)}
|
||||
{data.showCloseButton !== false && <OptionElement className="list-row" type='accent' icon={<X />} action={context.close} id="close" content="Close" />}
|
||||
</ul>;
|
||||
}
|
||||
|
||||
export function OptionElement (data: DialogEntry & { onFocus?: () => void; className?: string; })
|
||||
{
|
||||
const context = useContext(ContextDialogContext);
|
||||
const handleFocus = () =>
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
data.onFocus?.();
|
||||
};
|
||||
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: `${context.id}-list-option-${data.id}`,
|
||||
onEnterPress: handleAction,
|
||||
onFocus: handleFocus
|
||||
});
|
||||
const colors = {
|
||||
primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused }),
|
||||
secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused }),
|
||||
accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused }),
|
||||
info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused }),
|
||||
warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused }),
|
||||
error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused })
|
||||
};
|
||||
return <li ref={ref}
|
||||
onClick={handleAction}
|
||||
className={
|
||||
twMerge("flex cursor-pointer")}>
|
||||
<p className={twMerge("flex w-full h-14 items-center px-5 rounded-2xl transition-all gap-2",
|
||||
colors[data.type],
|
||||
classNames({ "font-semibold": focused }),
|
||||
data.className)}>
|
||||
{data.icon}
|
||||
{data.content}
|
||||
</p>
|
||||
</li>;
|
||||
}
|
||||
|
||||
export interface DialogEntry
|
||||
{
|
||||
id: string,
|
||||
content: string | JSX.Element;
|
||||
icon?: string | JSX.Element;
|
||||
type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error';
|
||||
action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void;
|
||||
}
|
||||
|
||||
export function ContextDialog (data: { id: string, children: any | any[], open: boolean, close: () => void; })
|
||||
{
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusable: data.open, focusKey: `${data.id}-context-dialog`, isFocusBoundary: true });
|
||||
useEffect(() =>
|
||||
{
|
||||
if (data.open)
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
}, [data.open]);
|
||||
|
||||
useEventListener('cancel', (e) =>
|
||||
{
|
||||
if (data.open)
|
||||
{
|
||||
e.stopPropagation();
|
||||
data.close();
|
||||
}
|
||||
}, ref);
|
||||
|
||||
return <dialog ref={ref} open={data.open} closedby="any" className={
|
||||
twMerge("absolute modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
||||
classNames({ "opacity-0": !data.open }))
|
||||
}
|
||||
onClick={() =>
|
||||
{
|
||||
if (data.open) data.close();
|
||||
}}>
|
||||
<FocusContext value={focusKey}>
|
||||
<ContextDialogContext value={{ id: data.id, close: data.close }} >
|
||||
<div
|
||||
className={twMerge("bg-base-100/80 delay-200 rounded-4xl p-6 min-w-[30vw] cursor-auto", data.open ? "animate-scale-delayed" : "opacity-0")}
|
||||
style={{ backdropFilter: 'blur(24px)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{data.children}
|
||||
</div>
|
||||
</ContextDialogContext>
|
||||
</FocusContext>
|
||||
</dialog>;
|
||||
}
|
||||
|
|
@ -27,10 +27,10 @@ function FilterCat (
|
|||
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":
|
||||
"bg-base-content px-3 text-base-300 drop-shadow cursor-default":
|
||||
focused || data.active,
|
||||
"ring-base-content ring-7": focused,
|
||||
"hover:bg-base-300 cursor-pointer": !focused,
|
||||
"ring-primary ring-7": focused,
|
||||
"hover:bg-base-content/40 cursor-pointer": !focused,
|
||||
},
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -16,23 +16,30 @@ export function GameCardSkeleton ()
|
|||
);
|
||||
}
|
||||
|
||||
export default function GameCard (data: {
|
||||
export interface GameCardParams
|
||||
{
|
||||
title: string;
|
||||
type?: string;
|
||||
subtitle: string;
|
||||
preview?: string | JSX.Element;
|
||||
subtitle: string | JSX.Element;
|
||||
preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element);
|
||||
focusKey: string;
|
||||
index: number;
|
||||
id: number;
|
||||
badge?: JSX.Element;
|
||||
onFocus?: (id: number) => void;
|
||||
id: string;
|
||||
badges?: JSX.Element[];
|
||||
className?: string;
|
||||
onFocus?: (id: string) => void;
|
||||
onBlur?: (id: string) => void;
|
||||
onAction?: () => void;
|
||||
})
|
||||
clickFocuses?: boolean;
|
||||
}
|
||||
|
||||
export default function GameCard (data: GameCardParams)
|
||||
{
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: data.focusKey,
|
||||
onFocus: () => data.onFocus?.(data.id),
|
||||
onEnterPress: () => data.onAction?.(),
|
||||
onBlur: () => data.onBlur?.(data.id)
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
|
|
@ -59,33 +66,48 @@ export default function GameCard (data: {
|
|||
}}
|
||||
onFocus={focusSelf}
|
||||
onDoubleClick={data.onAction}
|
||||
onClick={focused ? data.onAction : focusSelf}
|
||||
onClick={() =>
|
||||
{
|
||||
focusSelf();
|
||||
data.onAction?.();
|
||||
}}
|
||||
className={twMerge(
|
||||
`game-card game-card-height flex flex-col justify-end`,
|
||||
`game-card game-card-height flex flex-col justify-end z-5`,
|
||||
'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",
|
||||
`focused animate-wiggle ring-7 bg-base-content text-base-300 ring-primary drop-shadow-xl drop-shadow-black/30 scale-102 z-10` :
|
||||
"bg-base-300 hover:bg-base-100 hover:scale-102 text-base-content",
|
||||
classNames({
|
||||
"h-(--game-card-height)": typeof data.preview === "string"
|
||||
})
|
||||
}),
|
||||
data.className
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<img width={5192} height={5192} className={classNames({ "animate-rotate-small": focused })} src={data.preview} ></img>
|
||||
) : (
|
||||
data.preview
|
||||
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
|
||||
)}</div>
|
||||
|
||||
<div className="h-0 flex pr-2 justify-end items-center">{data.badge}</div>
|
||||
<div className="h-0 flex pr-2 justify-end items-center">
|
||||
{data.badges?.map(b =>
|
||||
<div
|
||||
className={
|
||||
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 mr-4 transition-colors",
|
||||
classNames({ "bg-primary text-primary-content": focused }))}
|
||||
>
|
||||
{b}
|
||||
</div>)
|
||||
}
|
||||
</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>
|
||||
</li >
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { keepPreviousData, useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { getRomsApiRomsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { GameMetaExtra, CardList } from "./CardList";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||
import { FrontEndId, 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";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { JSX } from "react";
|
||||
|
||||
export interface GameListFilter
|
||||
{
|
||||
platformIds?: number[];
|
||||
platformId?: number;
|
||||
collectionId?: number;
|
||||
}
|
||||
|
||||
|
|
@ -19,30 +19,39 @@ export interface GameListParams
|
|||
filters?: GameListFilter,
|
||||
grid?: boolean,
|
||||
setBackground?: (url: string) => void;
|
||||
onGameSelect?: (id: number) => void;
|
||||
onGameSelect?: (id: FrontEndId) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GameList (data: GameListParams)
|
||||
{
|
||||
const games = useSuspenseQuery(gamesQueryOptions(data.filters));
|
||||
const games = useSuspenseQuery({
|
||||
queryKey: ['games', data.filters ?? 'all'],
|
||||
queryFn: () => rommApi.api.romm.games.get({
|
||||
query: {
|
||||
platform_id: data.filters?.platformId,
|
||||
collection_id: data.filters?.collectionId
|
||||
}
|
||||
}).then(d => d.data)
|
||||
});
|
||||
const navigator = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleFocus = (id: number) =>
|
||||
const handleFocus = (id: FrontEndId) =>
|
||||
{
|
||||
const game = games.data?.items.find((g) => g.id === id);
|
||||
const game = games.data?.games.find((g) => g.id === id);
|
||||
if (game)
|
||||
{
|
||||
data.setBackground?.(
|
||||
`${RPC_URL(__HOST__)}/api/romm${game.path_cover_small}`,
|
||||
`${RPC_URL(__HOST__)}${game.path_cover}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function handleDefaultSelect (id: number)
|
||||
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: number | null)
|
||||
{
|
||||
SaveSource('details', location.pathname);
|
||||
navigator({ to: '/game/$id', params: { id: String(id) }, viewTransition: { types: ['zoom-in'] } });
|
||||
SaveSource('details');
|
||||
navigator({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -51,23 +60,34 @@ export function GameList (data: GameListParams)
|
|||
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),
|
||||
)
|
||||
className={data.className}
|
||||
games={games.data?.games
|
||||
.map(
|
||||
(g) =>
|
||||
({
|
||||
id: g.id,
|
||||
{
|
||||
const badges: JSX.Element[] = [];
|
||||
if (g.id.source === 'local')
|
||||
{
|
||||
badges.push(<HardDrive className="size-8 m-1" />);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `game-${g.id.source}-${g.id.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)}
|
||||
subtitle: (
|
||||
<div className="flex gap-1 items-center">
|
||||
{!!g.path_platform_cover && <img className="size-4" src={`${RPC_URL(__HOST__)}${g.path_platform_cover}`} />}
|
||||
<p className="opacity-80">{g.platform_display_name}</p>
|
||||
</div>
|
||||
),
|
||||
previewUrl: `${RPC_URL(__HOST__)}${g.path_cover}`,
|
||||
badges: badges,
|
||||
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id) : handleDefaultSelect(g.id, g.source, g.source_id),
|
||||
onFocus: () => handleFocus(g.id)
|
||||
} satisfies GameMetaExtra;
|
||||
},
|
||||
) ?? []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ function HeaderAvatar (data: {
|
|||
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"],
|
||||
|
|
@ -92,6 +91,7 @@ export interface HeaderButton
|
|||
id: string;
|
||||
icon: JSX.Element;
|
||||
external?: boolean;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export interface HeaderAccount
|
||||
|
|
@ -135,7 +135,7 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
|
|||
],
|
||||
action: () =>
|
||||
{
|
||||
SaveSource('settings', location.pathname);
|
||||
SaveSource('settings');
|
||||
navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } });
|
||||
},
|
||||
status: user.data ? "status-success" : 'status-error',
|
||||
|
|
@ -182,6 +182,7 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
|
|||
id={b.id}
|
||||
icon={b.icon}
|
||||
external={b.external}
|
||||
action={b.action}
|
||||
/>)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
72
src/mainview/components/PlatformsList.tsx
Normal file
72
src/mainview/components/PlatformsList.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { getPlatformsApiPlatformsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
|
||||
import { DefaultRommStaleTime, GameMeta, RPC_URL } from "../../shared/constants";
|
||||
import { CardList, GameMetaExtra } from "./CardList";
|
||||
import classNames from "classnames";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
|
||||
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; })
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { data: platforms } = useSuspenseQuery(
|
||||
{
|
||||
queryKey: ['platform', 'all'],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.platforms.get();
|
||||
if (error) throw error;
|
||||
return data.platforms;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: DefaultRommStaleTime,
|
||||
});
|
||||
|
||||
return (
|
||||
<CardList
|
||||
type="platform"
|
||||
id={data.id}
|
||||
className={data.className}
|
||||
games={platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
|
||||
.map((g) => ({
|
||||
id: g.slug,
|
||||
focusKey: g.slug,
|
||||
title: g.name,
|
||||
subtitle: g.family_name ?? "",
|
||||
previewUrl: "",
|
||||
badges: [(<span className="text-lg font-bold p-2 rounded-full">
|
||||
{g.game_count}
|
||||
</span>)],
|
||||
onFocus: () => data.setBackground(
|
||||
`https://picsum.photos/id/${10 + g.slug.length}/1920/1080.webp`,
|
||||
),
|
||||
onSelect: () =>
|
||||
{
|
||||
navigate({ to: `/platform/${g.source ?? g.id.source}/${g.source_id ?? g.id.id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
},
|
||||
preview:
|
||||
({ focused }) => <div
|
||||
className="flex h-60 p-6 bg-base-100 justify-center"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
color-mix(in srgb, var(--color-base-content) 60%, transparent),
|
||||
color-mix(in srgb, var(--color-base-300) 60%, transparent)
|
||||
), url(https://picsum.photos/id/${8 + g.slug.length}/300/300.webp?blur=10) center / cover`,
|
||||
|
||||
backgroundBlendMode: "screen",
|
||||
boxShadow: 'inset 0 0 32px rgba(0,0,0,0.6)'
|
||||
}}
|
||||
>
|
||||
<img className={classNames("drop-shadow-2xl", { "animate-rotate": focused })}
|
||||
src={`${RPC_URL(__HOST__)}${g.path_cover}`}
|
||||
></img>
|
||||
</div>
|
||||
,
|
||||
} satisfies GameMetaExtra))}
|
||||
onSelectGame={(id) =>
|
||||
{
|
||||
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ export default function ShortcutPrompt (data: {
|
|||
<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",
|
||||
"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 ring-[1px] ring-base-content/10 drop-shadow-black/30",
|
||||
"sm:text-sm",
|
||||
data.className,
|
||||
classNames({
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import React from 'react';
|
||||
import ShortcutPrompt from './ShortcutPrompt';
|
||||
import { IconType } from './SvgIcon';
|
||||
|
||||
export default function Shortcuts ()
|
||||
export interface Shortcut
|
||||
{
|
||||
icon: IconType;
|
||||
label: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export default function Shortcuts (data: { shortcuts: Shortcut[]; })
|
||||
{
|
||||
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" />
|
||||
{data.shortcuts.map((s, i) => <ShortcutPrompt key={i} onClick={s.action} icon={s.icon} label={s.label} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
29
src/mainview/components/backgrounds/dots.css
Normal file
29
src/mainview/components/backgrounds/dots.css
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
.ball {
|
||||
border-radius: 50%;
|
||||
animation: bounce 0.6s 32 alternate;
|
||||
}
|
||||
|
||||
.ball:nth-child(1) {
|
||||
background: var(--color-accent);
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.ball:nth-child(2) {
|
||||
background: var(--color-secondary);
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.ball:nth-child(3) {
|
||||
background: var(--color-primary);
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
}
|
||||
10
src/mainview/components/backgrounds/dots.tsx
Normal file
10
src/mainview/components/backgrounds/dots.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import './dots.css';
|
||||
|
||||
export default function DotsLoading ()
|
||||
{
|
||||
return <div className="flex gap-3 justify-center animation_alternate items-center pt-8">
|
||||
<div className="ball size-6"></div>
|
||||
<div className="ball size-6"></div>
|
||||
<div className="ball size-6"></div>
|
||||
</div>;
|
||||
}
|
||||
28
src/mainview/components/options/Button.tsx
Normal file
28
src/mainview/components/options/Button.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import
|
||||
{
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
|
||||
export function Button (data: { id: string, children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: data.id,
|
||||
onEnterPress: data.onAction,
|
||||
onFocus: data.onFocus,
|
||||
focusable: !data.disabled
|
||||
});
|
||||
return <button
|
||||
ref={ref}
|
||||
onClick={data.onAction}
|
||||
disabled={data.disabled}
|
||||
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg", classNames({
|
||||
"btn-accent": focused
|
||||
}, data.className))}
|
||||
type={data.type}
|
||||
>
|
||||
{data.children}
|
||||
</button>;
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import classNames from "classnames";
|
||||
import { ChangeEventHandler, FocusEventHandler, HTMLInputTypeAttribute, JSX, useRef } from "react";
|
||||
import { ChangeEventHandler, FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useOptionContext } from "./OptionSpace";
|
||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { systemApi } from "../../scripts/clientApi";
|
||||
|
||||
export function OptionInput (data: {
|
||||
name: string;
|
||||
|
|
@ -11,10 +13,18 @@ export function OptionInput (data: {
|
|||
icon?: JSX.Element;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
autocomplete?: HTMLInputAutoCompleteAttribute;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
})
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: data.name, onEnterPress: () =>
|
||||
{
|
||||
inputRef.current?.focus();
|
||||
systemApi.api.system.show_keyboard.post();
|
||||
}
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const option = useOptionContext({
|
||||
onOptionEnterPress ()
|
||||
|
|
@ -24,10 +34,11 @@ export function OptionInput (data: {
|
|||
});
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent">
|
||||
<span className={twMerge("text-base-content/80", classNames({
|
||||
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
|
||||
classNames({ "[&_input]:not-focus:ring-7 [&_input]:not-focus:ring-accent": focused }))}>
|
||||
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
|
||||
"text-primary-content": option.focused
|
||||
}))}>{data.icon}</span>
|
||||
}))}>{data.icon}</span>}
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={data.name}
|
||||
|
|
@ -35,12 +46,13 @@ export function OptionInput (data: {
|
|||
value={data.value}
|
||||
defaultValue={data.defaultValue}
|
||||
type={data.type}
|
||||
autoComplete={data.autocomplete}
|
||||
onFocus={() => option.focus()}
|
||||
placeholder={data.placeholder}
|
||||
onChange={data.onChange}
|
||||
onBlur={data.onBlur}
|
||||
className={twMerge(
|
||||
"input grow rounded-full ring-primary-content focus:ring-3",
|
||||
"input grow rounded-full ring-primary-content focus:ring-7",
|
||||
data.className,
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -42,8 +42,9 @@ export function OptionSpace (data: {
|
|||
id?: string;
|
||||
className?: string;
|
||||
focusable?: boolean;
|
||||
children: JSX.Element;
|
||||
children?: any | any[];
|
||||
label?: string | JSX.Element;
|
||||
saveLastFocusedChild?: boolean;
|
||||
})
|
||||
{
|
||||
const eventTarget = useMemo(() => new EventTarget(), []);
|
||||
|
|
@ -51,6 +52,11 @@ export function OptionSpace (data: {
|
|||
focusKey: data.id,
|
||||
focusable: data.focusable !== false,
|
||||
trackChildren: true,
|
||||
saveLastFocusedChild: data.saveLastFocusedChild ?? false,
|
||||
onFocus ()
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
},
|
||||
onEnterPress ()
|
||||
{
|
||||
eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));
|
||||
|
|
|
|||
72
src/mainview/components/options/SettingsOption.tsx
Normal file
72
src/mainview/components/options/SettingsOption.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { HTMLInputTypeAttribute, JSX, useCallback, useState } from "react";
|
||||
import { SettingsType } from "../../../shared/constants";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { OptionSpace } from "./OptionSpace";
|
||||
import { OptionInput } from "./OptionInput";
|
||||
import { settingsApi } from "../../scripts/clientApi";
|
||||
|
||||
type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
export function SettingsOption (data: {
|
||||
label: string;
|
||||
id: KeysWithValueAssignableTo<SettingsType, string>;
|
||||
type: HTMLInputTypeAttribute;
|
||||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
})
|
||||
{
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
useQuery({
|
||||
enabled: !!data.id,
|
||||
queryKey: ["setting", data.id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data: value, error } = await settingsApi.api.settings({ id: data.id! }).get();
|
||||
if (error) throw error;
|
||||
if (!dirty)
|
||||
{
|
||||
setLocalValue(String(value.value));
|
||||
}
|
||||
return value.value;
|
||||
},
|
||||
});
|
||||
const setSettingMutation = useMutation({
|
||||
mutationKey: ["setting", data.id],
|
||||
mutationFn: async (value: any) =>
|
||||
{
|
||||
const response = await settingsApi.api.settings({ id: data.id! }).post({ value });
|
||||
if (response.error) throw response.error;
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
if (dirty)
|
||||
{
|
||||
setDirty(false);
|
||||
setSettingMutation.mutate(localValue);
|
||||
}
|
||||
}, [dirty, setDirty, localValue]);
|
||||
|
||||
return (
|
||||
<OptionSpace label={data.label}>
|
||||
<OptionInput
|
||||
icon={data.icon}
|
||||
name={data.id ?? ""}
|
||||
type={data.type}
|
||||
placeholder={data.placeholder}
|
||||
onBlur={handleSave}
|
||||
onChange={(e) =>
|
||||
{
|
||||
setLocalValue(e.currentTarget.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
value={localValue}
|
||||
/>
|
||||
</OptionSpace>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import classNames from "classnames";
|
||||
import { CircleX, Cross, X } from "lucide-react";
|
||||
import { createContext, JSX, useContext, useEffect, useState } from "react";
|
||||
import toast, { ToastBar, Toaster } from "react-hot-toast";
|
||||
|
||||
let toasterGlobalId = 0;
|
||||
|
||||
const ToastersContext = createContext(
|
||||
{} as {
|
||||
showToaster: (data: Toast) => void;
|
||||
},
|
||||
);
|
||||
|
||||
export function useToasters ()
|
||||
{
|
||||
const toasters = useContext(ToastersContext);
|
||||
return { ...toasters };
|
||||
}
|
||||
|
||||
interface Toast
|
||||
{
|
||||
message: string | JSX.Element;
|
||||
type: "success" | "info" | "error" | "warning";
|
||||
duration?: number;
|
||||
icon?: JSX.Element;
|
||||
}
|
||||
|
||||
interface ToastExtra extends Toast
|
||||
{
|
||||
timeout?: NodeJS.Timeout;
|
||||
id: number;
|
||||
}
|
||||
|
||||
function ToastComponent (data: { toast: Toast; })
|
||||
{
|
||||
return (
|
||||
<div className={classNames(`alert alert-${data.toast.type} `)}>
|
||||
<span>
|
||||
{data.toast.icon}
|
||||
{data.toast.message}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toasters ()
|
||||
{
|
||||
const [visibleToasters, setVisible] = useState<ToastExtra[]>([]);
|
||||
useEffect(() =>
|
||||
{
|
||||
return () =>
|
||||
{
|
||||
visibleToasters.filter((t) => t.timeout).forEach((t) => clearTimeout(t.timeout));
|
||||
};
|
||||
}, [setVisible]);
|
||||
|
||||
return (
|
||||
<Toaster toastOptions={{
|
||||
className: "bg-base-300 text-base-content", success: {
|
||||
className: 'bg-success'
|
||||
}
|
||||
}}>
|
||||
{(t) => <ToastBar toast={t} >
|
||||
{({ icon, message }) => (
|
||||
<>
|
||||
{icon}
|
||||
{message}
|
||||
{t.type !== 'loading' && (
|
||||
<button className="size-6 p-0 rounded-full cursor-pointer text-base-100" onClick={() => toast.dismiss(t.id)}><CircleX className="size-5" /></button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ToastBar>}
|
||||
</Toaster>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,10 +11,13 @@
|
|||
import { Route as rootRouteImport } from './../routes/__root'
|
||||
import { Route as SettingsRouteRouteImport } from './../routes/settings/route'
|
||||
import { Route as IndexRouteImport } from './../routes/index'
|
||||
import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories'
|
||||
import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts'
|
||||
import { Route as PlatformIdRouteImport } from './../routes/platform/$id'
|
||||
import { Route as GameIdRouteImport } from './../routes/game/$id'
|
||||
import { Route as CollectionIdRouteImport } from './../routes/collection/$id'
|
||||
import { Route as SettingsAboutRouteImport } from './../routes/settings/about'
|
||||
import { Route as CollectionIdRouteImport } from './../routes/collection.$id'
|
||||
import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$source.$id'
|
||||
import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id'
|
||||
import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id'
|
||||
|
||||
const SettingsRouteRoute = SettingsRouteRouteImport.update({
|
||||
id: '/settings',
|
||||
|
|
@ -26,51 +29,75 @@ const IndexRoute = IndexRouteImport.update({
|
|||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsDirectoriesRoute = SettingsDirectoriesRouteImport.update({
|
||||
id: '/directories',
|
||||
path: '/directories',
|
||||
getParentRoute: () => SettingsRouteRoute,
|
||||
} as any)
|
||||
const SettingsAccountsRoute = SettingsAccountsRouteImport.update({
|
||||
id: '/accounts',
|
||||
path: '/accounts',
|
||||
getParentRoute: () => SettingsRouteRoute,
|
||||
} as any)
|
||||
const PlatformIdRoute = PlatformIdRouteImport.update({
|
||||
id: '/platform/$id',
|
||||
path: '/platform/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const GameIdRoute = GameIdRouteImport.update({
|
||||
id: '/game/$id',
|
||||
path: '/game/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
const SettingsAboutRoute = SettingsAboutRouteImport.update({
|
||||
id: '/about',
|
||||
path: '/about',
|
||||
getParentRoute: () => SettingsRouteRoute,
|
||||
} as any)
|
||||
const CollectionIdRoute = CollectionIdRouteImport.update({
|
||||
id: '/collection/$id',
|
||||
path: '/collection/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlatformSourceIdRoute = PlatformSourceIdRouteImport.update({
|
||||
id: '/platform/$source/$id',
|
||||
path: '/platform/$source/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LauncherSourceIdRoute = LauncherSourceIdRouteImport.update({
|
||||
id: '/launcher/$source/$id',
|
||||
path: '/launcher/$source/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const GameSourceIdRoute = GameSourceIdRouteImport.update({
|
||||
id: '/game/$source/$id',
|
||||
path: '/game/$source/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRouteRouteWithChildren
|
||||
'/collection/$id': typeof CollectionIdRoute
|
||||
'/game/$id': typeof GameIdRoute
|
||||
'/platform/$id': typeof PlatformIdRoute
|
||||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRouteRouteWithChildren
|
||||
'/collection/$id': typeof CollectionIdRoute
|
||||
'/game/$id': typeof GameIdRoute
|
||||
'/platform/$id': typeof PlatformIdRoute
|
||||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRouteRouteWithChildren
|
||||
'/collection/$id': typeof CollectionIdRoute
|
||||
'/game/$id': typeof GameIdRoute
|
||||
'/platform/$id': typeof PlatformIdRoute
|
||||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
|
|
@ -78,33 +105,43 @@ export interface FileRouteTypes {
|
|||
| '/'
|
||||
| '/settings'
|
||||
| '/collection/$id'
|
||||
| '/game/$id'
|
||||
| '/platform/$id'
|
||||
| '/settings/about'
|
||||
| '/settings/accounts'
|
||||
| '/settings/directories'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/collection/$id'
|
||||
| '/game/$id'
|
||||
| '/platform/$id'
|
||||
| '/settings/about'
|
||||
| '/settings/accounts'
|
||||
| '/settings/directories'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/collection/$id'
|
||||
| '/game/$id'
|
||||
| '/platform/$id'
|
||||
| '/settings/about'
|
||||
| '/settings/accounts'
|
||||
| '/settings/directories'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
SettingsRouteRoute: typeof SettingsRouteRouteWithChildren
|
||||
CollectionIdRoute: typeof CollectionIdRoute
|
||||
GameIdRoute: typeof GameIdRoute
|
||||
PlatformIdRoute: typeof PlatformIdRoute
|
||||
GameSourceIdRoute: typeof GameSourceIdRoute
|
||||
LauncherSourceIdRoute: typeof LauncherSourceIdRoute
|
||||
PlatformSourceIdRoute: typeof PlatformSourceIdRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
|
@ -123,6 +160,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings/directories': {
|
||||
id: '/settings/directories'
|
||||
path: '/directories'
|
||||
fullPath: '/settings/directories'
|
||||
preLoaderRoute: typeof SettingsDirectoriesRouteImport
|
||||
parentRoute: typeof SettingsRouteRoute
|
||||
}
|
||||
'/settings/accounts': {
|
||||
id: '/settings/accounts'
|
||||
path: '/accounts'
|
||||
|
|
@ -130,19 +174,12 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof SettingsAccountsRouteImport
|
||||
parentRoute: typeof SettingsRouteRoute
|
||||
}
|
||||
'/platform/$id': {
|
||||
id: '/platform/$id'
|
||||
path: '/platform/$id'
|
||||
fullPath: '/platform/$id'
|
||||
preLoaderRoute: typeof PlatformIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/game/$id': {
|
||||
id: '/game/$id'
|
||||
path: '/game/$id'
|
||||
fullPath: '/game/$id'
|
||||
preLoaderRoute: typeof GameIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
'/settings/about': {
|
||||
id: '/settings/about'
|
||||
path: '/about'
|
||||
fullPath: '/settings/about'
|
||||
preLoaderRoute: typeof SettingsAboutRouteImport
|
||||
parentRoute: typeof SettingsRouteRoute
|
||||
}
|
||||
'/collection/$id': {
|
||||
id: '/collection/$id'
|
||||
|
|
@ -151,15 +188,40 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof CollectionIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/platform/$source/$id': {
|
||||
id: '/platform/$source/$id'
|
||||
path: '/platform/$source/$id'
|
||||
fullPath: '/platform/$source/$id'
|
||||
preLoaderRoute: typeof PlatformSourceIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/launcher/$source/$id': {
|
||||
id: '/launcher/$source/$id'
|
||||
path: '/launcher/$source/$id'
|
||||
fullPath: '/launcher/$source/$id'
|
||||
preLoaderRoute: typeof LauncherSourceIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/game/$source/$id': {
|
||||
id: '/game/$source/$id'
|
||||
path: '/game/$source/$id'
|
||||
fullPath: '/game/$source/$id'
|
||||
preLoaderRoute: typeof GameSourceIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SettingsRouteRouteChildren {
|
||||
SettingsAboutRoute: typeof SettingsAboutRoute
|
||||
SettingsAccountsRoute: typeof SettingsAccountsRoute
|
||||
SettingsDirectoriesRoute: typeof SettingsDirectoriesRoute
|
||||
}
|
||||
|
||||
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
|
||||
SettingsAboutRoute: SettingsAboutRoute,
|
||||
SettingsAccountsRoute: SettingsAccountsRoute,
|
||||
SettingsDirectoriesRoute: SettingsDirectoriesRoute,
|
||||
}
|
||||
|
||||
const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
|
||||
|
|
@ -170,8 +232,9 @@ const rootRouteChildren: RootRouteChildren = {
|
|||
IndexRoute: IndexRoute,
|
||||
SettingsRouteRoute: SettingsRouteRouteWithChildren,
|
||||
CollectionIdRoute: CollectionIdRoute,
|
||||
GameIdRoute: GameIdRoute,
|
||||
PlatformIdRoute: PlatformIdRoute,
|
||||
GameSourceIdRoute: GameSourceIdRoute,
|
||||
LauncherSourceIdRoute: LauncherSourceIdRoute,
|
||||
PlatformSourceIdRoute: PlatformSourceIdRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
|
|
|||
|
|
@ -3,17 +3,96 @@
|
|||
@plugin "daisyui";
|
||||
|
||||
@theme {
|
||||
--color-dark: #333333;
|
||||
--color-light: #464646;
|
||||
--color-light: #828282;
|
||||
--color-light2: #bcbcbc;
|
||||
--color-primary: #E5FF00;
|
||||
--color-alt: #4656E6;
|
||||
--color-alert: #E60012;
|
||||
--game-card-height: calc(var(--spacing) * 100);
|
||||
--game-card-width: calc(var(--spacing) * 64);
|
||||
|
||||
--animate-wiggle: wiggle 0.3s ease-in-out 1;
|
||||
--animate-rotate: rotate 0.3s ease-in-out 1 0.2s;
|
||||
--animate-rotate-small: rotate-small 0.3s ease-in-out 1 0.2s;
|
||||
--animate-scale: scale 0.3s ease-in-out 1;
|
||||
--animate-slide-up: slide-up 0.2s ease-in-out 1;
|
||||
--animate-scale-delayed: scale 0.3s ease-in-out 1 100ms;
|
||||
--animate-scale-small: scale-small 0.3s ease-in-out 1;
|
||||
--animate-fade-out: fade-out 0.3s ease-out 1;
|
||||
|
||||
@keyframes slide-up {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(0, 10px)
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-small {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(100%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(102%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(100%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(105%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate-small {
|
||||
|
||||
0%,
|
||||
50%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: rotate(0.2deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: rotate(-0.2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
|
||||
0%,
|
||||
50%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: rotate(1deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: rotate(-1deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
|
||||
|
|
@ -39,11 +118,27 @@ html {
|
|||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
.background {
|
||||
-webkit-backface-visibility: hidden;
|
||||
-webkit-perspective: 1000;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
-webkit-transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000;
|
||||
transform: translate3d(0, 0, 0);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.game-card {
|
||||
@apply rounded-2xl;
|
||||
}
|
||||
|
||||
.menu-icon svg {
|
||||
@apply sm:size-7 md:size-9 transition-all;
|
||||
}
|
||||
|
||||
.menu-icon.focus svg {
|
||||
@apply sm:size-8 md:size-10;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,28 +10,15 @@ import
|
|||
} from "@tanstack/react-router";
|
||||
import { routeTree } from "./gen/routeTree.gen";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { AppType } from "../bun/api/rpc";
|
||||
import { RPC_URL } from "../shared/constants";
|
||||
import "./scripts/gamepads";
|
||||
import "./scripts/windowEvents";
|
||||
import { Toasters } from "./contexts/ToasterContext";
|
||||
import { client as rommClient } from "../clients/romm/client.gen";
|
||||
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
||||
import "./scripts/spatialNavigation";
|
||||
import
|
||||
{
|
||||
treaty
|
||||
} from '@elysiajs/eden';
|
||||
|
||||
const hashHistory = createHashHistory({});
|
||||
|
||||
export const client = treaty<AppType>(RPC_URL(__HOST__), {
|
||||
keepDomain: true,
|
||||
fetch: {
|
||||
credentials: 'include',
|
||||
}
|
||||
});
|
||||
|
||||
rommClient.setConfig({
|
||||
baseUrl: `${RPC_URL(__HOST__)}/api/romm`,
|
||||
credentials: "include",
|
||||
|
|
@ -51,8 +38,7 @@ export const Router = createRouter({
|
|||
history: hashHistory,
|
||||
defaultPreload: "intent",
|
||||
context: { queryClient },
|
||||
scrollRestoration: true,
|
||||
scrollToTopSelectors: ["[save-scroll]"],
|
||||
scrollRestoration: false,
|
||||
defaultNotFoundComponent: () =>
|
||||
{
|
||||
return (
|
||||
|
|
@ -86,7 +72,6 @@ if (!rootElement.innerHTML)
|
|||
root.render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={Router} />
|
||||
<Toasters />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { useEventListener, useSessionStorage } from 'usehooks-ts';
|
||||
import { CollectionsDetail } from '../../components/CollectionsDetail';
|
||||
import { getRomsApiRomsGetOptions } from '../../../clients/romm/@tanstack/react-query.gen';
|
||||
import { DefaultRommStaleTime } from '../../../shared/constants';
|
||||
import { CollectionsDetail } from '../components/CollectionsDetail';
|
||||
import { getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen';
|
||||
import { DefaultRommStaleTime } from '../../shared/constants';
|
||||
|
||||
export const Route = createFileRoute('/collection/$id')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -20,7 +20,7 @@ function RouteComponent ()
|
|||
undefined,
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ['zoom-out'] } }));
|
||||
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ["zoom-out"] } }));
|
||||
|
||||
return (
|
||||
<CollectionsDetail setBackground={setBackground} filters={{ collectionId: Number(id) }} />
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { getRomApiRomsIdGetOptions } from "../../../clients/romm/@tanstack/react-query.gen";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../../shared/constants";
|
||||
import { twJoin, twMerge } from "tailwind-merge";
|
||||
import { JSX, Ref, RefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FocusContext, getCurrentFocusKey, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { Clock, HardDrive, Image, Play, Settings, Trophy } from "lucide-react";
|
||||
import ShortcutPrompt from "../../components/ShortcutPrompt";
|
||||
import { HeaderUI } from "../../components/Header";
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import { DetailedRomSchema } from "../../../clients/romm";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import { PopSource } from "../../scripts/spatialNavigation";
|
||||
import { gameQueryOptions } from "../../query-options";
|
||||
import { AnimatedBackground } from "../../components/AnimatedBackground";
|
||||
|
||||
export const Route = createFileRoute("/game/$id")({
|
||||
loader: ({ params, context }) => context.queryClient.fetchQuery(gameQueryOptions(Number(params.id))),
|
||||
component: GameDetailsUI,
|
||||
pendingComponent: GameDetailsUIPending
|
||||
});
|
||||
|
||||
function GameDetailsUIPending ()
|
||||
{
|
||||
return <AnimatedBackground>
|
||||
<div className="flex flex-col p-2 px-3 w-full h-full">
|
||||
<HeaderUI />
|
||||
<div className="flex flex-col justify-center items-center grow">
|
||||
<span className="loading loading-dots loading-xl"></span>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedBackground>;
|
||||
}
|
||||
|
||||
export function GameDetailsUI ()
|
||||
{
|
||||
// In a component!
|
||||
const { id } = Route.useParams();
|
||||
const data = Route.useLoaderData();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
|
||||
const backgroundImage = `${RPC_URL(__HOST__)}/api/romm${data.path_cover_small}`;
|
||||
const mainAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
focusSelf();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnimatedBackground backgroundUrl={backgroundImage}>
|
||||
<div className="z-0">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="px-3 py-2" ref={mainAreaRef}>
|
||||
<HeaderUI />
|
||||
<Details mainAreaRef={mainAreaRef} game={data} />
|
||||
</div>
|
||||
<div className="divider"><Image className="size-16" />Screenshots</div>
|
||||
<Screenshots screenshots={data.merged_screenshots} />
|
||||
<footer className="absolute left-0 bottom-0 w-full p-2 flex items-center justify-between z-10">
|
||||
<div className="flex gap-2 text-sm">
|
||||
</div>
|
||||
<Shortcuts />
|
||||
</footer>
|
||||
</FocusContext>
|
||||
</div>
|
||||
</AnimatedBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game: DetailedRomSchema; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'main-details', onFocus: () =>
|
||||
{
|
||||
data.mainAreaRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
},
|
||||
preferredChildFocusKey: "play-btn",
|
||||
saveLastFocusedChild: false
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const platformCoverImg = `${RPC_URL(__HOST__)}/api/romm/assets/platforms/${data.game.platform_slug}.svg`;
|
||||
const gameCoverImg = `${RPC_URL(__HOST__)}/api/romm${data.game.path_cover_large}`;
|
||||
useEventListener("cancel", () =>
|
||||
{
|
||||
navigate({ to: PopSource('details') ?? '/', viewTransition: { types: ['zoom-out'] } });
|
||||
});
|
||||
|
||||
return <main ref={ref} className="flex p-3 flex-col h-[75vh]">
|
||||
<FocusContext value={focusKey}>
|
||||
<section className="flex my-4 p-12 pt-4 gap-12 h-full rounded-4xl z-0">
|
||||
<div className="flex flex-col gap-6">
|
||||
<img className="h-full w-auto rounded-3xl drop-shadow-2xl drop-shadow-base-300/40 object-contain" src={gameCoverImg}></img>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col gap-6 pt-16">
|
||||
<div className="flex gap-6">
|
||||
<Detail icon={<Clock />} >{data.game.rom_user.last_played ? new Date(data.game.rom_user.last_played).toDateString() : "Never"}</Detail>
|
||||
<Detail icon={<HardDrive />} >{prettyBytes(data.game.fs_size_bytes)}</Detail>
|
||||
<Detail icon={<img className="size-6" src={platformCoverImg}></img>} >{data.game.platform_display_name}</Detail>
|
||||
</div>
|
||||
<p className="text-base-content/80 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden">
|
||||
{data.game.summary}
|
||||
</p>
|
||||
<ActionButtons game={data.game} />
|
||||
</div>
|
||||
</section>
|
||||
</FocusContext>
|
||||
</main>;
|
||||
}
|
||||
|
||||
function Screenshot (data: { url: string; index: number; setFocused?: (index: number) => void; })
|
||||
{
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: `screenshot-${data.index}`,
|
||||
onFocus: () =>
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ inline: 'center', behavior: 'smooth' });
|
||||
data.setFocused?.(data.index);
|
||||
}
|
||||
});
|
||||
return <img onClick={focusSelf} ref={ref} className={twJoin("h-[60vh] rounded-3xl", classNames({
|
||||
"ring-7 ring-primary": focused,
|
||||
"cursor-pointer": !focused
|
||||
}))} src={`${RPC_URL(__HOST__)}/api/romm${data.url}`}></img>;
|
||||
}
|
||||
|
||||
function Screenshots (data: { screenshots: string[]; })
|
||||
{
|
||||
const [focusedScreenshot, setFocusedScreenshot] = useState(-1);
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'screenshot-list',
|
||||
onFocus: () => (ref.current as HTMLElement).scrollIntoView({ block: 'center', behavior: 'smooth' }),
|
||||
onBlur: () => setFocusedScreenshot(-1)
|
||||
});
|
||||
|
||||
return <div ref={ref} className="flex flex-col p-16 pt-2 w-full z-0">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="flex gap-6 px-16 py-2 overflow-hidden">
|
||||
{data.screenshots.map((s, i) => <Screenshot setFocused={setFocusedScreenshot} index={i} url={s} />)}
|
||||
</div>
|
||||
<div className="flex gap-2 py-6 justify-center items-center h-3">{data.screenshots.map((s, i) =>
|
||||
{
|
||||
const focused = i === focusedScreenshot;
|
||||
return <button onClick={() => setFocus(`screenshot-${i}`)} className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
|
||||
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
|
||||
}))}></button>;
|
||||
})}</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PlayButton ()
|
||||
{
|
||||
const { focused, ref } = useFocusable({
|
||||
focusKey: "play-btn"
|
||||
});
|
||||
return (
|
||||
<div ref={ref} className="flex gap-3 items-center font-semibold">
|
||||
<button className={twMerge("bg-primary p-6 rounded-full cursor-pointer",
|
||||
"hover:bg-base-content hover:text-base-200 hover:ring-7 hover:ring-primary",
|
||||
classNames({
|
||||
"bg-base-content text-base-200 ring-7 ring-primary": focused
|
||||
}))}><Play className="size-8" /></button>
|
||||
<p className="text-4xl">Play</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
//<PlayButton />
|
||||
|
||||
function ActionButtons (data: { game: DetailedRomSchema; })
|
||||
{
|
||||
const [hoverText, setHoverText] = useState<string | undefined>(undefined);
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) });
|
||||
|
||||
return <div ref={ref} className="flex gap-4 items-center">
|
||||
<FocusContext value={focusKey}>
|
||||
<ActionButton onFocus={() => setHoverText("")} type='primary' id="play"><Play /></ActionButton>
|
||||
<div className="divider divider-horizontal m-0"></div>
|
||||
{!!data.game.merged_ra_metadata?.achievements && <ActionButton onFocus={() => setHoverText("Achievements")} type="base" id="achievements" >
|
||||
<div className="flex flex-col gap-2 items-center text-2xl">
|
||||
<div className="flex flex-row">
|
||||
<Trophy />
|
||||
{`${data.game.merged_ra_metadata.achievements.filter(a => a.type).length}/${data.game.merged_ra_metadata.achievements.length}`}
|
||||
</div>
|
||||
<progress className="progress progress-secondary w-full" value={50} max="100"></progress>
|
||||
</div>
|
||||
</ActionButton>}
|
||||
<ActionButton onFocus={() => setHoverText("Settings")} type="base" id="settings" icon={<Settings />} />
|
||||
{!!hoverText && <p className="py-2 px-4 bg-accent text-accent-content rounded-full">{hoverText}</p>}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Shortcuts ()
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "action-buttons" });
|
||||
return <div ref={ref} className="flex gap-2" style={{ viewTransitionName: 'shortcuts' }}>
|
||||
<FocusContext value={focusKey}>
|
||||
<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" />
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Detail (data: { icon: JSX.Element; children?: any | any[]; })
|
||||
{
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{data.icon}
|
||||
{data.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton (data: { id: string, icon?: JSX.Element, children?: any | any[]; className?: string; type: "primary" | 'base' | "accent"; onFocus?: () => void; })
|
||||
{
|
||||
const { ref, focused } = useFocusable({ focusKey: data.id, onFocus: data.onFocus });
|
||||
const styles = {
|
||||
primary: twMerge("bg-primary text-primary-content rounded-full size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary",
|
||||
classNames({
|
||||
"bg-base-content text-base-300 ring-7 ring-primary": focused
|
||||
})),
|
||||
base: twMerge(" text-base-content border-dashed border-base-content/20 border-2", classNames({
|
||||
"bg-base-content text-base-300 ring-7 ring-primary": focused
|
||||
})),
|
||||
accent: twMerge("bg-primary text-primary-content ", classNames({
|
||||
"bg-base-content text-base-300 ring-7 ring-primary": focused
|
||||
}))
|
||||
};
|
||||
return (
|
||||
<button ref={ref} className={twMerge("header-icon flex flex-col gap-2 px-5 py-4 rounded-3xl text-2xl justify-center items-center cursor-pointer",
|
||||
"hover:ring-7 hover:ring-primary", styles[data.type], data.className)}>
|
||||
{data.icon}
|
||||
{data.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
490
src/mainview/routes/game/$source.$id.tsx
Normal file
490
src/mainview/routes/game/$source.$id.tsx
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants";
|
||||
import { twJoin, twMerge } from "tailwind-merge";
|
||||
import { JSX, RefObject, useEffect, useRef, useState } from "react";
|
||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { Clock, CloudDownload, Download, Folder, HardDrive, Image, PackageOpen, Play, Settings, Store, Trash, TriangleAlert, Trophy } from "lucide-react";
|
||||
import { HeaderUI } from "../../components/Header";
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import { PopSource, SaveSource, useFocusEventListener } from "../../scripts/spatialNavigation";
|
||||
import { AnimatedBackground } from "../../components/AnimatedBackground";
|
||||
import { rommApi } from "../../scripts/clientApi";
|
||||
import toast from "react-hot-toast";
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Router } from "../..";
|
||||
import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog";
|
||||
import Shortcuts from "../../components/Shortcuts";
|
||||
|
||||
const placeholderText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam eleifend ante magna, id euismod quam tempus sit amet. Maecenas sem lectus, euismod imperdiet volutpat ac, posuere in turpis. Vestibulum commodo lacinia lectus sit amet ultricies. Integer euismod consequat elit, sit amet dapibus libero fermentum nec. Aliquam accumsan placerat dui a maximus. Nunc lectus urna, scelerisque a magna non, imperdiet lobortis turpis. Aliquam magna dui, porttitor in nisl vitae, pretium fringilla sem. ";
|
||||
|
||||
const gameQuery = (source: string, id: number) => queryOptions({
|
||||
queryKey: ['game', source, id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.game({ source })({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/game/$source/$id")({
|
||||
loader: ({ params, context }) =>
|
||||
{
|
||||
context.queryClient.prefetchQuery(gameQuery(params.source, Number(params.id)));
|
||||
},
|
||||
component: GameDetailsUI,
|
||||
pendingComponent: GameDetailsUIPending,
|
||||
});
|
||||
|
||||
function GameDetailsUIPending ()
|
||||
{
|
||||
return <AnimatedBackground>
|
||||
<div className="flex flex-col p-2 px-3 w-full h-full">
|
||||
<HeaderUI />
|
||||
<div className="flex flex-col justify-center items-center grow">
|
||||
<span className="loading loading-dots loading-xl"></span>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedBackground>;
|
||||
}
|
||||
|
||||
export function GameDetailsUI ()
|
||||
{
|
||||
const { source, id } = Route.useParams();
|
||||
const { data, isSuccess } = useQuery(gameQuery(source, Number(id)));
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
|
||||
const backgroundImage = data?.path_cover ? `${RPC_URL(__HOST__)}${data?.path_cover}` : undefined;
|
||||
const mainAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEventListener("cancel", (e) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
HandleGoBack();
|
||||
}, ref);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (isSuccess)
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
|
||||
}, [isSuccess]);
|
||||
|
||||
return (
|
||||
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage}>
|
||||
<div className="z-0 overflow-y-scroll">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="px-3 py-2" ref={mainAreaRef}>
|
||||
<HeaderUI />
|
||||
<Details mainAreaRef={mainAreaRef} game={data} />
|
||||
</div>
|
||||
<div className="divider"><div className="flex items-center gap-3 opacity-60"><Image className="size-6" />Screenshots</div></div>
|
||||
{!!data && <Screenshots screenshots={data.paths_screenshots} />}
|
||||
<footer className="absolute left-0 bottom-0 w-full p-2 flex items-center justify-between z-10">
|
||||
<div className="flex gap-2 text-sm">
|
||||
</div>
|
||||
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_a', label: "Play" }]} />
|
||||
</footer>
|
||||
</FocusContext>
|
||||
</div>
|
||||
</AnimatedBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function HandleGoBack ()
|
||||
{
|
||||
Router.navigate({ to: PopSource('details') ?? '/', viewTransition: { types: ['zoom-out'] } });
|
||||
}
|
||||
|
||||
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?: FrontEndGameTypeDetailed; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'main-details', onFocus: () =>
|
||||
{
|
||||
data.mainAreaRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
},
|
||||
preferredChildFocusKey: "play-btn",
|
||||
saveLastFocusedChild: false
|
||||
});
|
||||
|
||||
const platformCoverImg = `${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`;
|
||||
const gameCoverImg = data.game?.path_cover ? `${RPC_URL(__HOST__)}${data.game?.path_cover}` : undefined;
|
||||
|
||||
let fileSizeIcon: JSX.Element | undefined;
|
||||
if (!data.game)
|
||||
{
|
||||
fileSizeIcon = <span className="loading loading-spinner loading-lg"></span>;
|
||||
} else if (data.game.missing)
|
||||
{
|
||||
fileSizeIcon = <TriangleAlert />;
|
||||
} else if (data.game.local)
|
||||
{
|
||||
fileSizeIcon = <HardDrive />;
|
||||
} else
|
||||
{
|
||||
fileSizeIcon = <CloudDownload />;
|
||||
}
|
||||
|
||||
return <main ref={ref} className="flex p-3 flex-col h-[75vh]">
|
||||
<FocusContext value={focusKey}>
|
||||
<section className="flex my-4 p-12 pt-4 gap-12 h-full rounded-4xl z-0">
|
||||
<div className="flex gap-6 overflow-hidden bg-base-300 justify-end h-full rounded-3xl aspect-3/4">
|
||||
{gameCoverImg ?
|
||||
<img className="drop-shadow-2xl drop-shadow-base-300/40 h-full" src={gameCoverImg}></img> :
|
||||
<div className="skeleton w-full h-full"></div>
|
||||
}
|
||||
</div>
|
||||
<div className="flex-2 flex flex-col gap-6 pt-16">
|
||||
<div className="flex gap-6">
|
||||
<Detail icon={<Clock />} >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"}</Detail>
|
||||
{!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) &&
|
||||
<div className={classNames({ "text-error": data.game.missing })}>
|
||||
<div className="tooltip" data-tip={data.game.path_fs}>
|
||||
<Detail icon={fileSizeIcon} >{data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}</Detail>
|
||||
</div>
|
||||
</div>}
|
||||
<Detail icon={<img className="size-6" src={platformCoverImg}></img>} >{data.game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</Detail>
|
||||
<Detail icon={
|
||||
<Store />
|
||||
} >
|
||||
{data.game?.source ?? data.game?.id.source}
|
||||
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
|
||||
</div>
|
||||
<p className="text-base-content/80 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden">
|
||||
{data.game?.summary ?? <div className="flex flex-col gap-4 w-full">
|
||||
<div className="skeleton h-4 w-[30%]"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
<div className="skeleton h-4 w-[60%]"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
</div>}
|
||||
</p>
|
||||
{!!data.game && <ActionButtons key="actions" game={data.game} />}
|
||||
</div>
|
||||
</section>
|
||||
</FocusContext>
|
||||
</main>;
|
||||
}
|
||||
|
||||
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; })
|
||||
{
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: `screenshot-${data.index}`,
|
||||
onFocus: () =>
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ inline: 'center', behavior: 'smooth' });
|
||||
data.setFocused?.(data.index);
|
||||
}
|
||||
}); 4096;
|
||||
return <img className={twJoin("h-[60vh] rounded-3xl", classNames({
|
||||
"ring-7 ring-primary": focused,
|
||||
"cursor-pointer": !focused
|
||||
}))} onClick={focusSelf} ref={ref} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" />;
|
||||
}
|
||||
|
||||
function Screenshots (data: { screenshots: string[]; })
|
||||
{
|
||||
const scrollRef = useRef(null);
|
||||
const [focusedScreenshot, setFocusedScreenshot] = useState(-1);
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'screenshot-list',
|
||||
onFocus: () => (ref.current as HTMLElement).scrollIntoView({ block: 'center', behavior: 'smooth' }),
|
||||
onBlur: () => setFocusedScreenshot(-1)
|
||||
});
|
||||
|
||||
return <div ref={ref} className="flex flex-col p-16 pt-2 w-full z-0">
|
||||
<FocusContext value={focusKey}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-6 px-16 py-2 overflow-hidden justify-center-safe"
|
||||
>
|
||||
{data.screenshots.map((s, i) => <Screenshot key={s} setFocused={setFocusedScreenshot} index={i} path={s} />)}
|
||||
</div>
|
||||
<div className="flex gap-2 py-6 justify-center items-center h-3">{data.screenshots.map((s, i) =>
|
||||
{
|
||||
const focused = i === focusedScreenshot;
|
||||
return <button key={i} onClick={() => setFocus(`screenshot-${i}`)} className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
|
||||
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
|
||||
}))}></button>;
|
||||
})}</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; })
|
||||
{
|
||||
if (!data.game.achievements)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return <ActionButton key="achievements" square tooltip="Achievements" type="base" id="achievements" >
|
||||
<div className="flex flex-col gap-2 items-center text-2xl">
|
||||
<div className="flex flex-row">
|
||||
<Trophy />
|
||||
{`${data.game.achievements.unlocked}/${data.game.achievements.total}`}
|
||||
</div>
|
||||
<progress className="progress progress-secondary w-full" value={50} max="100"></progress>
|
||||
</div>
|
||||
</ActionButton>;
|
||||
}
|
||||
|
||||
function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
||||
{
|
||||
const { source, id } = Route.useParams();
|
||||
const installMutation = useMutation({
|
||||
mutationKey: ['install'],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).install.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
const playMutation = useMutation({
|
||||
mutationKey: ['play'],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).play.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
const [progress, setProgress] = useState<number | undefined>(undefined);
|
||||
const [status, setStatus] = useState<GameStatusType | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [details, setDetails] = useState<string | undefined>(undefined);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const es = new EventSource(`${RPC_URL(__HOST__)}/api/romm/status/${data.game.id.source}/${data.game.id.id}`);
|
||||
|
||||
es.onmessage = ({ data }) =>
|
||||
{
|
||||
const stats = JSON.parse(data) as GameInstallProgress;
|
||||
setProgress(stats.progress);
|
||||
setStatus(stats.status);
|
||||
setDetails(stats.details);
|
||||
setError(stats.error);
|
||||
};
|
||||
|
||||
es.addEventListener('refresh', () =>
|
||||
{
|
||||
queryClient.invalidateQueries({ queryKey: ['game', data.game.id] });
|
||||
location.reload();
|
||||
});
|
||||
|
||||
es.onerror = (event) =>
|
||||
{
|
||||
const error = (event as any).data?.error;
|
||||
if (error)
|
||||
{
|
||||
toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return () => es.close();
|
||||
}, [data.game.id]);
|
||||
|
||||
let progressIcon: JSX.Element | undefined = undefined;
|
||||
switch (status)
|
||||
{
|
||||
case 'download':
|
||||
progressIcon = <Download />;
|
||||
break;
|
||||
case 'extract':
|
||||
progressIcon = <PackageOpen />;
|
||||
break;
|
||||
}
|
||||
|
||||
let mainButton: JSX.Element | undefined = undefined;
|
||||
if (status === 'installed')
|
||||
{
|
||||
mainButton = <ActionButton onAction={() =>
|
||||
{
|
||||
playMutation.mutate();
|
||||
SaveSource('launch');
|
||||
Router.navigate({ to: '/launcher/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id } });
|
||||
}} tooltip={details} key="primary" type='primary' id="mainAction"><Play /></ActionButton>;
|
||||
}
|
||||
else if (error)
|
||||
{
|
||||
mainButton = <ActionButton
|
||||
key="error"
|
||||
tooltip={error}
|
||||
tooltip_type="error"
|
||||
type='error'
|
||||
onAction={() =>
|
||||
{
|
||||
if (status === 'missing-emulator')
|
||||
{
|
||||
SaveSource('settings');
|
||||
Router.navigate({ to: '/settings/directories', viewTransition: { types: ['zoom-in'] } });
|
||||
}
|
||||
}}
|
||||
id="mainAction">
|
||||
<TriangleAlert />
|
||||
</ActionButton>;
|
||||
}
|
||||
else
|
||||
{
|
||||
mainButton = <ActionButton
|
||||
key={status ?? 'unknown'}
|
||||
disabled={installMutation.isPending}
|
||||
onAction={() =>
|
||||
{
|
||||
if (status === 'install')
|
||||
{
|
||||
installMutation.mutate();
|
||||
}
|
||||
}}
|
||||
tooltip={details ?? status}
|
||||
type='primary'
|
||||
id="mainAction">
|
||||
{status === 'install' ? <Download /> : <span className="loading loading-spinner loading-lg"></span>}
|
||||
</ActionButton>;
|
||||
}
|
||||
|
||||
return <div className="flex gap-2">
|
||||
{mainButton}
|
||||
<div className="divider divider-horizontal m-0"></div>
|
||||
{progress !== null && !!progressIcon && <ActionButton key="progress" square tooltip={details} type="base" id="progress" >
|
||||
<div key={`install-${status}`} data-tooltip={details ?? status} className="flex flex-col gap-2 w-16 items-center text-2xl">
|
||||
<div className="flex flex-row">
|
||||
{progressIcon}
|
||||
</div>
|
||||
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
|
||||
</div>
|
||||
</ActionButton>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
|
||||
{
|
||||
const [hoverText, setHoverText] = useState<string | undefined>(undefined);
|
||||
const [hoverTextType, setHoverTextType] = useState<string>('accent');
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) });
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: ['delete', data.game.id],
|
||||
mutationFn: () => rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).delete(),
|
||||
onSuccess: () =>
|
||||
{
|
||||
location.reload();
|
||||
console.log("Deleted");
|
||||
}
|
||||
});
|
||||
|
||||
const contextOptions: DialogEntry[] = [];
|
||||
if (data.game.local)
|
||||
{
|
||||
contextOptions.push({
|
||||
id: 'delete',
|
||||
action: () =>
|
||||
{
|
||||
deleteMutation.mutate();
|
||||
},
|
||||
icon: <Trash />,
|
||||
content: "Delete",
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
const handleTooltipSet = (e: HTMLElement) =>
|
||||
{
|
||||
const dataTooltip = e.getAttribute('data-tooltip');
|
||||
setHoverText(dataTooltip ?? undefined);
|
||||
setHoverTextType(e.getAttribute('data-tooltip_type') ?? 'accent');
|
||||
};
|
||||
|
||||
useFocusEventListener('focuschanged', (e) =>
|
||||
{
|
||||
if (e.target instanceof HTMLElement)
|
||||
{
|
||||
handleTooltipSet(e.target);
|
||||
}
|
||||
|
||||
}, ref);
|
||||
|
||||
const tooltipStyles = {
|
||||
base: 'bg-base-100 text-base-content',
|
||||
accent: 'bg-accent text-accent-content',
|
||||
error: 'bg-error text-error-content'
|
||||
};
|
||||
|
||||
return <div ref={ref} className="flex overflow-hidden p-2 gap-4 h-32 items-center">
|
||||
<FocusContext value={focusKey}>
|
||||
<MainActions game={data.game} />
|
||||
<AchievementsInfo game={data.game} />
|
||||
<ActionButton tooltip="Settings" onAction={() => setOpen(true)} type="base" id="settings" icon={<Settings />} >
|
||||
|
||||
</ActionButton >
|
||||
<ContextDialog id="settings-context" open={open} close={() =>
|
||||
{
|
||||
setOpen(false);
|
||||
setFocus("settings");
|
||||
}}>
|
||||
<ContextList options={contextOptions} />
|
||||
</ContextDialog>
|
||||
{!!hoverText && <p className={twMerge("flex py-2 px-4 rounded-4xl text-wrap wrap-anywhere", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Detail (data: { icon: JSX.Element; children?: any | any[]; })
|
||||
{
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{data.icon}
|
||||
{data.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton (data: {
|
||||
id: string,
|
||||
icon?: JSX.Element,
|
||||
children?: any | any[];
|
||||
className?: string;
|
||||
type: "primary" | 'base' | "accent" | 'error';
|
||||
square?: boolean,
|
||||
onFocus?: () => void;
|
||||
tooltip?: string,
|
||||
tooltip_type?: 'accent' | 'error';
|
||||
onAction?: () => void;
|
||||
disabled?: boolean;
|
||||
})
|
||||
{
|
||||
const { ref, focused } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true });
|
||||
const styles = {
|
||||
primary: twMerge("bg-primary text-primary-content",
|
||||
classNames({
|
||||
"bg-base-content text-base-300 ring-7 ring-primary": focused
|
||||
})),
|
||||
base: twMerge(" text-base-content border-dashed border-base-content/20 border-2", classNames({
|
||||
"bg-base-content text-base-300 ring-7 ring-primary": focused
|
||||
})),
|
||||
accent: twMerge("bg-primary text-primary-content ", classNames({
|
||||
"bg-base-content text-base-300 ring-7 ring-primary": focused
|
||||
})),
|
||||
error: twMerge("bg-error text-error-content ", classNames({
|
||||
"bg-error text-error-content ring-7 ring-primary": focused
|
||||
})),
|
||||
};
|
||||
return (
|
||||
<button
|
||||
disabled={data.disabled}
|
||||
ref={ref}
|
||||
onClick={data.onAction}
|
||||
data-tooltip={data.tooltip}
|
||||
data-tooltip_type={data.tooltip_type}
|
||||
className={twMerge("header-icon flex flex-col gap-2 px-5 py-4 rounded-3xl text-2xl justify-center items-center cursor-pointer disabled:opacity-30",
|
||||
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}>
|
||||
{data.icon}
|
||||
{data.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { JSX, Suspense, useContext } from "react";
|
||||
import { JSX, Suspense, useContext, useState } from "react";
|
||||
import
|
||||
{
|
||||
Gamepad2,
|
||||
|
|
@ -16,7 +16,7 @@ import
|
|||
useLocation,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import
|
||||
{
|
||||
FocusContext,
|
||||
|
|
@ -24,13 +24,12 @@ import
|
|||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||
import { useLocalStorage, useSessionStorage } from "usehooks-ts";
|
||||
import { useEventListener, useLocalStorage } from "usehooks-ts";
|
||||
import
|
||||
{
|
||||
getCollectionsApiCollectionsGetOptions,
|
||||
getPlatformsApiPlatformsGetOptions,
|
||||
} from "../../clients/romm/@tanstack/react-query.gen";
|
||||
import { CardList } from "../components/CardList";
|
||||
import { CardList, GameMetaExtra } from "../components/CardList";
|
||||
import { HeaderUI } from "../components/Header";
|
||||
import { FilterUI } from "../components/Filters";
|
||||
import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground";
|
||||
|
|
@ -42,6 +41,8 @@ import SaveScroll from "../components/SaveScroll";
|
|||
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Shortcuts from "../components/Shortcuts";
|
||||
import { PlatformsList } from "../components/PlatformsList";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: ConsoleHomeUI,
|
||||
|
|
@ -60,64 +61,7 @@ const filters = {
|
|||
},
|
||||
};
|
||||
|
||||
function PlatformList (data: { id: string, setBackground: (url: string) => void; })
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { data: platforms } = useSuspenseQuery({
|
||||
...getPlatformsApiPlatformsGetOptions(),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: DefaultRommStaleTime,
|
||||
});
|
||||
|
||||
return (
|
||||
<CardList
|
||||
type="platform"
|
||||
id={data.id}
|
||||
games={platforms.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at))
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
focusKey: g.slug,
|
||||
title: g.display_name,
|
||||
subtitle: g.family_name ?? "",
|
||||
previewUrl: g.url_logo ?? "",
|
||||
badge: (
|
||||
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
||||
{g.rom_count}
|
||||
</span>
|
||||
),
|
||||
preview: (
|
||||
<div
|
||||
className="flex h-60 p-6 bg-base-100 justify-center items-center"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
color-mix(in srgb, var(--color-base-content) 60%, transparent),
|
||||
color-mix(in srgb, var(--color-base-300) 60%, transparent)
|
||||
), url(https://picsum.photos/id/${10 + g.id}/300/300.webp?blur=10) center / cover`,
|
||||
|
||||
backgroundBlendMode: "screen",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${g.slug.toLocaleLowerCase()}.svg`}
|
||||
></img>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
onSelectGame={(id) =>
|
||||
{
|
||||
navigate({ to: `/platform/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
}}
|
||||
onGameFocus={(id) =>
|
||||
{
|
||||
data.setBackground(
|
||||
`https://picsum.photos/id/${10 + (id ?? 0)}/1920/1080.webp`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionList (data: { id: string, setBackground: (url: string) => void; })
|
||||
function CollectionList (data: { id: string, setBackground: (url: string) => void; className?: string; })
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { data: collections } = useSuspenseQuery({
|
||||
|
|
@ -130,19 +74,20 @@ function CollectionList (data: { id: string, setBackground: (url: string) => voi
|
|||
<CardList
|
||||
type="collection"
|
||||
id={data.id}
|
||||
className={data.className}
|
||||
games={collections.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at))
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
id: String(g.id),
|
||||
title: g.name,
|
||||
focusKey: `collection-${g.id}`,
|
||||
subtitle: g.user__username,
|
||||
previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`,
|
||||
badge: (
|
||||
badges: [
|
||||
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
||||
{g.rom_count}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
],
|
||||
} satisfies GameMetaExtra))}
|
||||
onSelectGame={(id) =>
|
||||
{
|
||||
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
|
|
@ -171,21 +116,45 @@ function HomeList (data: {
|
|||
})
|
||||
{
|
||||
const bg = useContext(AnimatedBackgroundContext);
|
||||
|
||||
const { ref, focused, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "home-list",
|
||||
preferredChildFocusKey: `${data.selectedFilter}-list`
|
||||
});
|
||||
|
||||
const lists = {
|
||||
consoles: <PlatformList id={"consoles-list"} setBackground={bg.setBackground} />,
|
||||
games: <GameList id="games-list" setBackground={bg.setBackground} />,
|
||||
collections: <CollectionList id={"collections-list"} setBackground={bg.setBackground} />,
|
||||
consoles: <PlatformsList className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />,
|
||||
games: <GameList className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />,
|
||||
collections: <CollectionList className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />,
|
||||
};
|
||||
|
||||
useEventListener('wheel', e =>
|
||||
{
|
||||
const deltaY = e.deltaY;
|
||||
const deltaYSign = Math.sign(e.deltaY);
|
||||
|
||||
if (deltaYSign == -1)
|
||||
{
|
||||
(ref.current as HTMLElement)?.scrollBy({
|
||||
top: 0,
|
||||
left: deltaY,
|
||||
behavior: 'auto'
|
||||
});
|
||||
|
||||
} else
|
||||
{
|
||||
(ref.current as HTMLElement)?.scrollBy({
|
||||
top: 0,
|
||||
left: deltaY,
|
||||
behavior: 'auto'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<FocusContext value={focusKey}>
|
||||
<div ref={ref} className="flex overflow-x-scroll no-scrollbar pb-3 mb-1 justify-center-safe">
|
||||
<div ref={ref} className="flex overflow-x-scroll no-scrollbar pb-3 mb-1 justify-center-safe" style={{
|
||||
mask: `linear-gradient(to right, rgba(0,0,0,0.8) 0%, black 10%, black 90%, rgba(0,0,0,0.8) 100%)`
|
||||
}}>
|
||||
<div className="flex px-16">
|
||||
<ErrorBoundary fallback={<HomeListError focused={focused} />}>
|
||||
<Suspense key={data.selectedFilter} fallback={<LoadingCardList placeholderCount={8} />}>
|
||||
|
|
@ -206,6 +175,14 @@ export default function ConsoleHomeUI ()
|
|||
keyof typeof filters
|
||||
>("home-filter-selected", "games");
|
||||
|
||||
const closeMutation = useMutation({
|
||||
mutationKey: ['close'], mutationFn: async () =>
|
||||
{
|
||||
const { error } = await systemApi.api.system.exit.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
forceFocus: true,
|
||||
autoRestoreFocus: false,
|
||||
|
|
@ -220,7 +197,7 @@ export default function ConsoleHomeUI ()
|
|||
<div className="px-3 w-full pt-2">
|
||||
<HeaderUI buttons={[
|
||||
{ id: "search", icon: <Search /> },
|
||||
{ id: "power-button", icon: <Power />, external: true }
|
||||
{ id: "power-button", icon: <Power />, external: true, action: () => closeMutation.mutate() }
|
||||
]} />
|
||||
</div>
|
||||
<div className="flex w-full flex-col grow justify-evenly">
|
||||
|
|
@ -243,7 +220,7 @@ export default function ConsoleHomeUI ()
|
|||
<footer className="px-2 pb-2 flex items-center justify-between">
|
||||
<div className="flex gap-2 text-sm">
|
||||
</div>
|
||||
<Shortcuts />
|
||||
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_a', label: 'Select' }]} />
|
||||
</footer>
|
||||
</FocusContext.Provider>
|
||||
</AnimatedBackground>
|
||||
|
|
@ -282,7 +259,7 @@ function MainMenu (data: {})
|
|||
<CircleIcon
|
||||
action={() =>
|
||||
{
|
||||
SaveSource('settings', location.pathname);
|
||||
SaveSource('settings');
|
||||
navigate({ to: "/settings/accounts", viewTransition: { types: ['zoom-in'] } });
|
||||
}}
|
||||
icon={<Settings />}
|
||||
|
|
@ -319,7 +296,7 @@ function CircleIcon (data: {
|
|||
'sm:w-14 sm:h-14',
|
||||
typeClasses[data.type ?? "none"], classNames(
|
||||
{
|
||||
"ring-7 ring-primary drop-shadow-2xl": focused,
|
||||
"focus ring-7 ring-primary drop-shadow-2xl animate-scale": focused,
|
||||
"hover:ring-7 hover:ring-primary": true,
|
||||
})
|
||||
)}
|
||||
|
|
|
|||
58
src/mainview/routes/launcher.$source.$id.tsx
Normal file
58
src/mainview/routes/launcher.$source.$id.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { AnimatedBackground } from '@/mainview/components/AnimatedBackground';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { GameInstallProgress, RPC_URL } from '@/shared/constants';
|
||||
import DotsLoading from '../components/backgrounds/dots';
|
||||
import { useEventListener } from 'usehooks-ts';
|
||||
import { Router } from '..';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { rommApi } from '../scripts/clientApi';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const Route = createFileRoute('/launcher/$source/$id')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
function HandleGoBack ()
|
||||
{
|
||||
Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id } });
|
||||
}
|
||||
|
||||
const { source, id } = Route.useParams();
|
||||
const { data } = useQuery({ queryKey: ['romm', 'game'], queryFn: () => rommApi.api.romm.game({ source })({ id }).get() });
|
||||
|
||||
useEventListener("cancel", (e) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
HandleGoBack();
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const es = new EventSource(`${RPC_URL(__HOST__)}/api/romm/status/${source}/${id}`);
|
||||
|
||||
es.onmessage = ({ data }) =>
|
||||
{
|
||||
const stats = JSON.parse(data) as GameInstallProgress;
|
||||
if (stats.status !== 'playing')
|
||||
{
|
||||
HandleGoBack();
|
||||
}
|
||||
};
|
||||
|
||||
es.addEventListener('refresh', HandleGoBack);
|
||||
|
||||
es.onerror = HandleGoBack;
|
||||
|
||||
return () => es.close();
|
||||
}, []);
|
||||
|
||||
|
||||
return <AnimatedBackground backgroundKey='game-details'>
|
||||
<div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'>
|
||||
<DotsLoading />
|
||||
<h1 className='font-semibold'>Launching {data?.data?.name} ...</h1>
|
||||
</div>
|
||||
</AnimatedBackground>;
|
||||
}
|
||||
54
src/mainview/routes/platform.$source.$id.tsx
Normal file
54
src/mainview/routes/platform.$source.$id.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEventListener, useSessionStorage } from "usehooks-ts";
|
||||
import { CollectionsDetail } from "../components/CollectionsDetail";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||
import { Suspense } from "react";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
|
||||
export const Route = createFileRoute("/platform/$source/$id")({
|
||||
component: RouteComponent
|
||||
});
|
||||
|
||||
function PlatformTitle ()
|
||||
{
|
||||
const { source, id } = Route.useParams();
|
||||
const { data: platform } = useSuspenseQuery({
|
||||
queryKey: ['platform', source, id], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}, staleTime: DefaultRommStaleTime
|
||||
});
|
||||
|
||||
return <div className="flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
|
||||
|
||||
<div className="divider mb-6 mt-0">
|
||||
<img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${platform.slug.toLocaleLowerCase()}.svg`} ></img>
|
||||
{platform.display_name}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { id } = Route.useParams();
|
||||
|
||||
const [, setBackground] = useSessionStorage<string | undefined>(
|
||||
"home-background",
|
||||
undefined,
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ['zoom-out'] } }));
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<CollectionsDetail
|
||||
title={<Suspense><PlatformTitle /></Suspense>}
|
||||
setBackground={setBackground}
|
||||
filters={{ platformId: Number(id) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import
|
||||
{
|
||||
getPlatformApiPlatformsIdGetOptions,
|
||||
getRomsApiRomsGetOptions,
|
||||
} from "../../../clients/romm/@tanstack/react-query.gen";
|
||||
import { useEventListener, useSessionStorage } from "usehooks-ts";
|
||||
import { CollectionsDetail } from "../../components/CollectionsDetail";
|
||||
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { DefaultRommStaleTime, RPC_PORT, RPC_URL } from "../../../shared/constants";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export const Route = createFileRoute("/platform/$id")({
|
||||
component: RouteComponent
|
||||
});
|
||||
|
||||
function PlatformSlug ()
|
||||
{
|
||||
const { id } = Route.useParams();
|
||||
const { data: platform } = useSuspenseQuery({ ...getPlatformApiPlatformsIdGetOptions({ path: { id: Number(id) } }), staleTime: DefaultRommStaleTime });
|
||||
|
||||
return <div className="flex gap-2 pr-4 pl-2 text-2xl font-semibold text-base-content items-center justify-center drop-shadow drop-shadow-base-300/10 ">
|
||||
<img className="size-10 rounded-full bg-base-100 p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${platform.slug.toLocaleLowerCase()}.svg`} ></img>
|
||||
{platform.display_name}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PlatformTitle ()
|
||||
{
|
||||
const { id } = Route.useParams();
|
||||
const { data: platform } = useSuspenseQuery({ ...getPlatformApiPlatformsIdGetOptions({ path: { id: Number(id) } }), staleTime: DefaultRommStaleTime });
|
||||
|
||||
return <div className="flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
|
||||
|
||||
<div className="divider mb-6 mt-0">
|
||||
<img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${platform.slug.toLocaleLowerCase()}.svg`} ></img>
|
||||
{platform.display_name}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { id } = Route.useParams();
|
||||
|
||||
const [, setBackground] = useSessionStorage<string | undefined>(
|
||||
"home-background",
|
||||
undefined,
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ['zoom-out'] } }));
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<CollectionsDetail
|
||||
title={<Suspense><PlatformTitle /></Suspense>}
|
||||
setBackground={setBackground}
|
||||
filters={{ platformIds: [Number(id)] }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/mainview/routes/settings/about.tsx
Normal file
60
src/mainview/routes/settings/about.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { rommApi, systemApi } from '@/mainview/scripts/clientApi';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/settings/about')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { data: systemInfo } = useQuery({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() });
|
||||
return <div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<td>{navigator.userAgent}</td>
|
||||
</tr>
|
||||
{/* row 2 */}
|
||||
<tr>
|
||||
<th>Platform</th>
|
||||
<td>{navigator.platform}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Resolution</th>
|
||||
<td>{screen.width}x{screen.height}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Window</th>
|
||||
<td>{window.innerWidth}x{window.innerHeight}</td>
|
||||
</tr>
|
||||
{/* row 3 */}
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<td>{systemInfo?.data?.user}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Architecture</th>
|
||||
<td>{systemInfo?.data?.arch}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>System</th>
|
||||
<td>{systemInfo?.data?.platform}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<td>{systemInfo?.data?.hostname}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Machine</th>
|
||||
<td>{systemInfo?.data?.machine}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Steam Deck</th>
|
||||
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -3,119 +3,31 @@ import
|
|||
FocusContext,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { useIsMutating, useMutation, useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import classNames from "classnames";
|
||||
import { Cross, Delete, Key, Link, Lock, Save, Trash, User, X } from "lucide-react";
|
||||
import { Key, Link, Lock, Save, Trash, User, X } from "lucide-react";
|
||||
import
|
||||
{
|
||||
HTMLInputTypeAttribute,
|
||||
JSX,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { client } from "../..";
|
||||
import { RPC_URL, SettingsType } from "../../../shared/constants";
|
||||
import { RPC_URL } from "../../../shared/constants";
|
||||
import
|
||||
{
|
||||
getCurrentUserApiUsersMeGetOptions,
|
||||
statsApiStatsGetOptions,
|
||||
} from "../../../clients/romm/@tanstack/react-query.gen";
|
||||
import { UserSchema } from "../../../clients/romm";
|
||||
import toast from "react-hot-toast";
|
||||
import z from "zod";
|
||||
import { OptionSpace } from "../../components/options/OptionSpace";
|
||||
import { OptionInput } from "../../components/options/OptionInput";
|
||||
import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { rommApi, settingsApi } from "../../scripts/clientApi";
|
||||
import { Button } from "../../components/options/Button";
|
||||
|
||||
export const Route = createFileRoute("/settings/accounts")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
function Option (data: {
|
||||
label: string;
|
||||
id: KeysWithValueAssignableTo<SettingsType, string>;
|
||||
type: HTMLInputTypeAttribute;
|
||||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
})
|
||||
{
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
useQuery({
|
||||
enabled: !!data.id,
|
||||
queryKey: ["setting", data.id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const value = await client.api.settings({ id: data.id! }).get().then(d => d.data?.value);
|
||||
if (!dirty)
|
||||
{
|
||||
setLocalValue(String(value));
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
const setSettingMultation = useMutation({
|
||||
mutationKey: ["setting", data.id],
|
||||
mutationFn: (value: any) =>
|
||||
client.api.settings({ id: data.id! }).post({ value }).then(d => d.status)
|
||||
});
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
if (dirty)
|
||||
{
|
||||
setDirty(false);
|
||||
setSettingMultation.mutate(localValue);
|
||||
}
|
||||
}, [dirty, setDirty, localValue]);
|
||||
|
||||
return (
|
||||
<OptionSpace label={data.label}>
|
||||
<OptionInput
|
||||
icon={data.icon}
|
||||
name={data.id ?? ""}
|
||||
type={data.type}
|
||||
placeholder={data.placeholder}
|
||||
onBlur={handleSave}
|
||||
onChange={(e) =>
|
||||
{
|
||||
setLocalValue(e.currentTarget.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
value={localValue}
|
||||
/>
|
||||
</OptionSpace>
|
||||
);
|
||||
}
|
||||
|
||||
function Button (data: { children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: data.type,
|
||||
onEnterPress: data.onAction,
|
||||
onFocus: data.onFocus,
|
||||
focusable: !data.disabled
|
||||
});
|
||||
return <button
|
||||
ref={ref}
|
||||
onClick={data.onAction}
|
||||
disabled={data.disabled}
|
||||
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg", classNames({
|
||||
"btn-accent": focused
|
||||
}, data.className))}
|
||||
type={data.type}
|
||||
>
|
||||
{data.children}
|
||||
</button>;
|
||||
}
|
||||
|
||||
function LoginControls (data: { hasPassword: boolean; })
|
||||
{
|
||||
const user = useQuery({
|
||||
|
|
@ -128,7 +40,7 @@ function LoginControls (data: { hasPassword: boolean; })
|
|||
context.state.canSubmit;
|
||||
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
|
||||
const logoutMutation = useMutation({
|
||||
mutationKey: ["romm", "auth", "logout"], mutationFn: () => client.api.romm.logout.post(),
|
||||
mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post(),
|
||||
onSuccess: async (d, v, r, c) =>
|
||||
{
|
||||
c.client.invalidateQueries({ queryKey: ["romm", "auth"] });
|
||||
|
|
@ -167,10 +79,9 @@ function RouteComponent ()
|
|||
preferredChildFocusKey: focus
|
||||
});
|
||||
|
||||
const { data: hasPassword } = useQuery({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => client.api.romm.login.get().then(d => d.data?.hasPassword as boolean) });
|
||||
const { data: hostname } = useQuery({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => client.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) });
|
||||
const { data: username } = useQuery({ queryKey: ['romm', 'auth', 'username'], queryFn: () => client.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) });
|
||||
|
||||
const { data: hasPassword } = useQuery({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) });
|
||||
const { data: hostname } = useQuery({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) });
|
||||
const { data: username } = useQuery({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) });
|
||||
|
||||
const loginForm = useSettingsForm({
|
||||
defaultValues: {
|
||||
|
|
@ -210,7 +121,7 @@ function RouteComponent ()
|
|||
mutationKey: ["romm", "login"],
|
||||
mutationFn: (data: z.infer<typeof dataSchema>) =>
|
||||
{
|
||||
return client.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
|
||||
return rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
|
||||
},
|
||||
onSuccess: (d, v, r, c) =>
|
||||
{
|
||||
|
|
|
|||
242
src/mainview/routes/settings/directories.tsx
Normal file
242
src/mainview/routes/settings/directories.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { SettingsOption } from '../../components/options/SettingsOption';
|
||||
import { OptionSpace } from '../../components/options/OptionSpace';
|
||||
import { OptionInput } from '../../components/options/OptionInput';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { settingsApi } from '../../scripts/clientApi';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '../../components/options/Button';
|
||||
import { Check, ChevronDown, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
|
||||
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
|
||||
import classNames from 'classnames';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { RPC_URL } from '../../../shared/constants';
|
||||
import emulators from '@emulators';
|
||||
|
||||
export const Route = createFileRoute('/settings/directories')({
|
||||
component: RouteComponent,
|
||||
pendingComponent: EmulatorsPending,
|
||||
});
|
||||
|
||||
function EmulatorsPending ()
|
||||
{
|
||||
return <div className="flex flex-col p-2 px-3 w-full h-full">
|
||||
<div className="flex flex-col justify-center items-center grow">
|
||||
<span className="loading loading-dots loading-xl"></span>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function EmulatorListCat (data: { selected: string, set: (c: string) => void; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'categories' });
|
||||
return <ul className='flex gap-1' ref={ref}>
|
||||
<FocusContext value={focusKey}>
|
||||
{[..."ABCDEFGHIJKLMNOPQRSTVWXYZ"].map(c =>
|
||||
<OptionElement key={c} className={classNames('p-2 justify-center size-8 text-base-content bg-base-300 text-lg', { "ring-4 ring-primary": data.selected === c })} onFocus={() => data.set(c)} content={c} id={c} action={(ctx) => ctx.focus()} type="primary" />
|
||||
)}
|
||||
</FocusContext>
|
||||
</ul>;
|
||||
}
|
||||
|
||||
function EmulatorListType (data: { category: string, action: (e: string) => void, })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'list-section' });
|
||||
return <div ref={ref} className='grow'>
|
||||
<FocusContext value={focusKey}>
|
||||
<ContextList className='h-[60vh]' options={Object.keys(emulators).filter(e => e.startsWith(data.category)).map(e => ({
|
||||
id: e,
|
||||
action: (ctx) =>
|
||||
{
|
||||
data.action(e);
|
||||
ctx.close();
|
||||
},
|
||||
type: 'primary',
|
||||
content: e
|
||||
} satisfies DialogEntry))} />
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function NewEmulatorPath (data: {})
|
||||
{
|
||||
const [newEmulatorTypeOpen, setNewEmulatorTypeOpen] = useState(false);
|
||||
const [newEmulatorContextCat, setNewEmulatorContextCat] = useState('A');
|
||||
const handleCloseContext = () =>
|
||||
{
|
||||
setNewEmulatorTypeOpen(false);
|
||||
setFocus('emulator');
|
||||
};
|
||||
const addOverrideMutation = useMutation({
|
||||
mutationKey: ['emulator', 'custom', 'add'],
|
||||
mutationFn: async (id: string) =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
|
||||
});
|
||||
|
||||
return <OptionSpace label={"Custom Emulator Path"}>
|
||||
<Button disabled={addOverrideMutation.isPending} id='emulator' type='button' onAction={() => setNewEmulatorTypeOpen(true)} >
|
||||
Emulator
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
<ContextDialog open={newEmulatorTypeOpen} id='new-emulator-type-context' close={handleCloseContext}>
|
||||
<div className='flex flex-col'>
|
||||
<EmulatorListCat selected={newEmulatorContextCat} set={setNewEmulatorContextCat} />
|
||||
<div className="divider mb-1 mt-2"></div>
|
||||
<EmulatorListType category={newEmulatorContextCat} action={e =>
|
||||
{
|
||||
addOverrideMutation.mutate(e);
|
||||
}} />
|
||||
</div>
|
||||
</ContextDialog>
|
||||
</OptionSpace>;
|
||||
}
|
||||
|
||||
function EmulatorPath (data: { id: string; })
|
||||
{
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const { data: remoteValue } = useQuery({
|
||||
enabled: !!data.id,
|
||||
queryKey: ["emulator", data.id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).get();
|
||||
if (error) throw error;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
const setSettingMutation = useMutation({
|
||||
mutationKey: ["emulator", data.id, 'set'],
|
||||
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: data.id }).put({ value }),
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
{
|
||||
ctx.client.invalidateQueries({ queryKey: ["emulator", data.id] });
|
||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
}
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: ["emulator", data.id, 'delete'],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).delete();
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
{
|
||||
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
|
||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
if (dirty)
|
||||
{
|
||||
setDirty(false);
|
||||
setSettingMutation.mutate(localValue ?? '');
|
||||
}
|
||||
}, [dirty, setDirty, localValue]);
|
||||
|
||||
return (
|
||||
<OptionSpace label={<><p className='font-semibold'>{data.id}</p><small className='text-base-content/40'>{emulators[data.id]}</small></>}>
|
||||
<div className='flex gap-2'>
|
||||
<OptionInput
|
||||
name={data.id ?? ""}
|
||||
type="text"
|
||||
onBlur={handleSave}
|
||||
autocomplete="off"
|
||||
defaultValue={remoteValue}
|
||||
onChange={(e) =>
|
||||
{
|
||||
setLocalValue(e.currentTarget.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
value={localValue}
|
||||
/>
|
||||
<Button id={`delete-${data.id}`} className='p-2' onAction={() => deleteMutation.mutate()} type='button' >
|
||||
<Trash />
|
||||
</Button>
|
||||
</div>
|
||||
</OptionSpace>
|
||||
);
|
||||
}
|
||||
|
||||
function EmulatorBadge (data: { path?: string, exists: boolean, emulator: string; pathCover?: string; })
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: `badge-${data.emulator}`, onFocus: () =>
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
return <div className={classNames("tooltip tooltip-primary", { "tooltip-open": focused })} data-tip={`${emulators[data.emulator]}`}>
|
||||
<div ref={ref} className={
|
||||
twMerge('flex flex-col rounded-3xl bg-base-300 w-64 h-16 justify-center items-center p-4 overflow-hidden',
|
||||
classNames({
|
||||
"bg-base-200/50": !data.path,
|
||||
"border-dashed border-base-content/40 border-2": focused
|
||||
|
||||
}))
|
||||
}>
|
||||
<p className='flex gap-2 font-semibold'>
|
||||
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className='text-warning' />}
|
||||
{!!data.pathCover && <img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${data.pathCover}`}></img>}
|
||||
{data.emulator}
|
||||
</p>
|
||||
{data.path ? <small className={classNames('opacity-60 max-w-full overflow-clip text-nowrap text-ellipsis', { 'text-error': !data.exists })}>{data.path}</small> : ""}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function EmulatorBadges (data: { path?: string; })
|
||||
{
|
||||
const { data: autoEmulators } = useQuery({ queryKey: ['auto-emulators'], queryFn: async () => settingsApi.api.settings.emulators.automatic.get() });
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators?.data && autoEmulators.data.length > 0 });
|
||||
return <div ref={ref} className='flex flex-wrap gap-2 justify-center-safe'>
|
||||
<FocusContext value={focusKey}>
|
||||
{autoEmulators?.data?.map(e => <EmulatorBadge pathCover={e.path_cover ?? undefined} path={e.path} exists={e.exists} emulator={e.emulator} />)}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = Route.useSearch();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
preferredChildFocusKey: focus
|
||||
});
|
||||
const { data: customEmulators } = useQuery({
|
||||
queryKey: ['custom-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
return <FocusContext value={focusKey}>
|
||||
<ul ref={ref} className="list rounded-box gap-2">
|
||||
<div className="divider text-2xl mt-0 md:mt-4">
|
||||
<div className="flex flex-col">
|
||||
<h3>Romm</h3>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsOption label="Download Path" id="downloadPath" type="text" />
|
||||
<div className="divider text-2xl mt-0 md:mt-4">
|
||||
<div className="flex flex-col">
|
||||
<h3>Emulatos</h3>
|
||||
</div>
|
||||
</div>
|
||||
<EmulatorBadges />
|
||||
<div className="divider text-base-content/40">Overrides</div>
|
||||
<NewEmulatorPath />
|
||||
{!!customEmulators && customEmulators.map((key) => <EmulatorPath key={key} id={key} />)}
|
||||
</ul>
|
||||
</FocusContext>;
|
||||
}
|
||||
|
|
@ -17,16 +17,18 @@ import
|
|||
{
|
||||
ArrowBigLeft,
|
||||
FingerprintPattern,
|
||||
HardDrive,
|
||||
Info,
|
||||
MonitorCog,
|
||||
} from "lucide-react";
|
||||
import { JSX, useEffect } from "react";
|
||||
import { JSX, useEffect, useRef } from "react";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import ShortcutPrompt from "../../components/ShortcutPrompt";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import z from "zod";
|
||||
import { SettingsSchema } from "../../../shared/constants";
|
||||
import { PopSource } from "../../scripts/spatialNavigation";
|
||||
import { Router } from "../..";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: SettingsUI,
|
||||
|
|
@ -78,8 +80,9 @@ function MenuItem (data: {
|
|||
className={twMerge(
|
||||
"group rounded-full p-3 pl-5 text-base-content/80",
|
||||
classNames({
|
||||
"bg-primary/40 text-primary-content": !focused && acitve,
|
||||
"bg-primary text-primary-content font-semibold": focused,
|
||||
"bg-primary text-primary-content": acitve,
|
||||
"font-semibold ring-7 ring-primary-content": focused,
|
||||
"bg-secondary text-secondary-content ring-primary": data.return && focused,
|
||||
}),
|
||||
data.linkClassName,
|
||||
)}
|
||||
|
|
@ -100,7 +103,7 @@ function SettingsMenu (data: {})
|
|||
const { ref, focusKey } = useFocusable({
|
||||
focusable: true,
|
||||
focusKey: 'settings-menu',
|
||||
preferredChildFocusKey: "/settings/accounts"
|
||||
preferredChildFocusKey: location.hash.replace("#", '')
|
||||
});
|
||||
|
||||
return <ul
|
||||
|
|
@ -120,6 +123,12 @@ function SettingsMenu (data: {})
|
|||
label="Visual"
|
||||
icon={<MonitorCog />}
|
||||
/>
|
||||
<MenuItem
|
||||
focusSelect
|
||||
route="/settings/directories"
|
||||
label="Directories"
|
||||
icon={<HardDrive />}
|
||||
/>
|
||||
<MenuItem
|
||||
focusSelect
|
||||
route="/settings/about"
|
||||
|
|
@ -138,15 +147,32 @@ function SettingsMenu (data: {})
|
|||
</ul>;
|
||||
}
|
||||
|
||||
function HandleGoBack ()
|
||||
{
|
||||
|
||||
if (document.activeElement && document.activeElement !== document.body && document.activeElement instanceof HTMLElement)
|
||||
{
|
||||
document.activeElement.blur();
|
||||
} else
|
||||
{
|
||||
const source = PopSource('settings');
|
||||
if (source)
|
||||
{
|
||||
console.log("Found source ", source, " to go back to");
|
||||
}
|
||||
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function SettingsUI ()
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "settings-page-layout",
|
||||
preferredChildFocusKey: 'settings-menu'
|
||||
});
|
||||
|
||||
useEventListener("cancel", () => navigate({ to: PopSource('settings') ?? "/", viewTransition: { types: ['zoom-out'] } }));
|
||||
useEventListener("cancel", HandleGoBack, ref);
|
||||
useEffect(() =>
|
||||
{
|
||||
focusSelf();
|
||||
|
|
@ -166,7 +192,7 @@ export function SettingsUI ()
|
|||
</div>
|
||||
<div className="divider divider-end">
|
||||
<ShortcutPrompt
|
||||
onClick={() => navigate({ to: "/" })}
|
||||
onClick={HandleGoBack}
|
||||
icon="steamdeck_button_b"
|
||||
label="Back"
|
||||
/>
|
||||
|
|
|
|||
22
src/mainview/scripts/clientApi.ts
Normal file
22
src/mainview/scripts/clientApi.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { treaty } from "@elysiajs/eden";
|
||||
import { RommAPIType, SettingsAPIType, SystemAPIType } from "../../bun/api/rpc";
|
||||
import { RPC_URL } from "../../shared/constants";
|
||||
|
||||
export const rommApi = treaty<RommAPIType>(RPC_URL(__HOST__), {
|
||||
keepDomain: true,
|
||||
fetch: {
|
||||
credentials: 'include',
|
||||
}
|
||||
});
|
||||
export const settingsApi = treaty<SettingsAPIType>(RPC_URL(__HOST__), {
|
||||
keepDomain: true,
|
||||
fetch: {
|
||||
credentials: 'include',
|
||||
}
|
||||
});
|
||||
export const systemApi = treaty<SystemAPIType>(RPC_URL(__HOST__), {
|
||||
keepDomain: true,
|
||||
fetch: {
|
||||
credentials: 'include',
|
||||
}
|
||||
});
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
import { navigateByDirection } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { getCurrentFocusKey, navigateByDirection, SpatialNavigation } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { dispatchFocusedEvent, GetFocusedElement } from "./spatialNavigation";
|
||||
|
||||
let loopStarted = false;
|
||||
|
||||
window.addEventListener("gamepadconnected", (evt) => {
|
||||
if (!loopStarted) {
|
||||
requestAnimationFrame(updateStatus);
|
||||
loopStarted = true;
|
||||
}
|
||||
window.addEventListener("gamepadconnected", (evt) =>
|
||||
{
|
||||
if (!loopStarted)
|
||||
{
|
||||
requestAnimationFrame(updateStatus);
|
||||
loopStarted = true;
|
||||
}
|
||||
});
|
||||
window.addEventListener("gamepaddisconnected", (evt) => {
|
||||
|
||||
window.addEventListener("gamepaddisconnected", (evt) =>
|
||||
{
|
||||
|
||||
});
|
||||
|
||||
const throttleMap = new Map<string, number>();
|
||||
|
|
@ -21,10 +25,10 @@ function throttleNav (key: string, dir: string, event: Event)
|
|||
const currentDate = new Date();
|
||||
const lastTime = throttleMap.get(key);
|
||||
const acceleration = throttleAcceleration.get(key) ?? 0;
|
||||
const speed = Math.max(maxSpeed - (maxSpeed - minSpeed) * (acceleration / 6),minSpeed);
|
||||
const speed = Math.max(maxSpeed - (maxSpeed - minSpeed) * (acceleration / 6), minSpeed);
|
||||
if ((currentDate.getTime() - (lastTime ?? 0) > speed))
|
||||
{
|
||||
navigateByDirection(dir, { event })
|
||||
navigateByDirection(dir, { event });
|
||||
throttleMap.set(key, currentDate.getTime());
|
||||
throttleAcceleration.set(key, acceleration + 1);
|
||||
}
|
||||
|
|
@ -34,11 +38,17 @@ window.addEventListener('keydown', e =>
|
|||
{
|
||||
if (e.key === 'Escape')
|
||||
{
|
||||
window.dispatchEvent(new Event('cancel'));
|
||||
const focusedElement = GetFocusedElement(getCurrentFocusKey());
|
||||
const finalTarget = focusedElement ?? window;
|
||||
const evn = new Event('cancel', { bubbles: true, cancelable: true });
|
||||
finalTarget.dispatchEvent(evn);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
function updateStatus () {
|
||||
|
||||
|
||||
function updateStatus ()
|
||||
{
|
||||
for (const gamepad of navigator.getGamepads().filter(g => !!g))
|
||||
{
|
||||
const gamepadEvent = new GamepadEvent('gamepad-navigation', { gamepad, });
|
||||
|
|
@ -47,14 +57,14 @@ function updateStatus () {
|
|||
{
|
||||
if (!throttleMap.has('enter'))
|
||||
{
|
||||
window.dispatchEvent(new KeyboardEvent('keydown',{key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true}));
|
||||
dispatchFocusedEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true }), window);
|
||||
throttleMap.set('enter', 0);
|
||||
}
|
||||
} else
|
||||
{
|
||||
if (throttleMap.delete('enter'))
|
||||
{
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', {key: 'Enter'}));
|
||||
dispatchFocusedEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +72,8 @@ function updateStatus () {
|
|||
{
|
||||
if (!throttleMap.has('cancel'))
|
||||
{
|
||||
window.dispatchEvent(new Event('cancel'));
|
||||
const evn = new Event('cancel', { bubbles: true, cancelable: true });
|
||||
dispatchFocusedEvent(evn);
|
||||
throttleMap.set('cancel', 0);
|
||||
}
|
||||
} else
|
||||
|
|
@ -70,79 +81,87 @@ function updateStatus () {
|
|||
throttleMap.delete('cancel');
|
||||
}
|
||||
|
||||
if (gamepad.buttons[12].pressed)
|
||||
const activeFocus = GetFocusedElement(getCurrentFocusKey());
|
||||
if (activeFocus instanceof HTMLInputElement)
|
||||
{
|
||||
throttleNav('gp-up', "up", gamepadEvent);
|
||||
} else
|
||||
{
|
||||
throttleAcceleration.delete('gp-up');
|
||||
throttleMap.delete('gp-up');
|
||||
}
|
||||
if (gamepad.buttons[13].pressed)
|
||||
{
|
||||
throttleNav('gp-down', "down", gamepadEvent);
|
||||
} else
|
||||
{
|
||||
throttleAcceleration.delete('gp-down');
|
||||
throttleMap.delete('gp-down');
|
||||
}
|
||||
if (gamepad.buttons[14].pressed)
|
||||
{
|
||||
throttleNav('gp-left', "left", gamepadEvent);
|
||||
} else
|
||||
{
|
||||
throttleAcceleration.delete('gp-left');
|
||||
throttleMap.delete('gp-left');
|
||||
}
|
||||
if (gamepad.buttons[15].pressed)
|
||||
{
|
||||
throttleNav('gp-right', "right", gamepadEvent);
|
||||
} else
|
||||
{
|
||||
throttleAcceleration.delete('gp-right');
|
||||
throttleMap.delete('gp-right');
|
||||
}
|
||||
|
||||
const deadzone = 0.5;
|
||||
const cancelDeadzone = 0.3;
|
||||
|
||||
function AxisControls ()
|
||||
} else
|
||||
{
|
||||
if (gamepad.axes[0] > deadzone)
|
||||
if (gamepad.buttons[12].pressed)
|
||||
{
|
||||
throttleNav('gpa-right', "right", gamepadEvent);
|
||||
return;
|
||||
}
|
||||
else if (gamepad.axes[0] < -deadzone)
|
||||
{
|
||||
throttleNav('gpa-left', "left", gamepadEvent);
|
||||
return;
|
||||
}
|
||||
else if ((throttleMap.has('gpa-left') || throttleMap.has('gpa-left')) && gamepad.axes[0] < cancelDeadzone && gamepad.axes[0] > -cancelDeadzone)
|
||||
{
|
||||
throttleAcceleration.delete('gpa-right');
|
||||
throttleAcceleration.delete('gpa-left');
|
||||
throttleMap.delete('gpa-left');
|
||||
throttleMap.delete('gpa-left');
|
||||
}
|
||||
|
||||
if (gamepad.axes[1] > deadzone)
|
||||
{
|
||||
throttleNav('gpa-down', "down", gamepadEvent);
|
||||
}
|
||||
else if (gamepad.axes[1] < -deadzone)
|
||||
{
|
||||
throttleNav('gpa-up', "up", gamepadEvent);
|
||||
throttleNav('gp-up', "up", gamepadEvent);
|
||||
} else
|
||||
{
|
||||
throttleAcceleration.delete('gpa-up');
|
||||
throttleAcceleration.delete('gpa-down');
|
||||
throttleMap.delete('gpa-up');
|
||||
throttleMap.delete('gpa-down');
|
||||
throttleAcceleration.delete('gp-up');
|
||||
throttleMap.delete('gp-up');
|
||||
}
|
||||
if (gamepad.buttons[13].pressed)
|
||||
{
|
||||
throttleNav('gp-down', "down", gamepadEvent);
|
||||
} else
|
||||
{
|
||||
throttleAcceleration.delete('gp-down');
|
||||
throttleMap.delete('gp-down');
|
||||
}
|
||||
if (gamepad.buttons[14].pressed)
|
||||
{
|
||||
throttleNav('gp-left', "left", gamepadEvent);
|
||||
} else
|
||||
{
|
||||
throttleAcceleration.delete('gp-left');
|
||||
throttleMap.delete('gp-left');
|
||||
}
|
||||
if (gamepad.buttons[15].pressed)
|
||||
{
|
||||
throttleNav('gp-right', "right", gamepadEvent);
|
||||
} else
|
||||
{
|
||||
throttleAcceleration.delete('gp-right');
|
||||
throttleMap.delete('gp-right');
|
||||
}
|
||||
|
||||
const deadzone = 0.5;
|
||||
const cancelDeadzone = 0.3;
|
||||
|
||||
function AxisControls ()
|
||||
{
|
||||
if (gamepad.axes[0] > deadzone)
|
||||
{
|
||||
throttleNav('gpa-right', "right", gamepadEvent);
|
||||
return;
|
||||
}
|
||||
else if (gamepad.axes[0] < -deadzone)
|
||||
{
|
||||
throttleNav('gpa-left', "left", gamepadEvent);
|
||||
return;
|
||||
}
|
||||
else if ((throttleMap.has('gpa-left') || throttleMap.has('gpa-left')) && gamepad.axes[0] < cancelDeadzone && gamepad.axes[0] > -cancelDeadzone)
|
||||
{
|
||||
throttleAcceleration.delete('gpa-right');
|
||||
throttleAcceleration.delete('gpa-left');
|
||||
throttleMap.delete('gpa-left');
|
||||
throttleMap.delete('gpa-left');
|
||||
}
|
||||
|
||||
if (gamepad.axes[1] > deadzone)
|
||||
{
|
||||
throttleNav('gpa-down', "down", gamepadEvent);
|
||||
}
|
||||
else if (gamepad.axes[1] < -deadzone)
|
||||
{
|
||||
throttleNav('gpa-up', "up", gamepadEvent);
|
||||
} else
|
||||
{
|
||||
throttleAcceleration.delete('gpa-up');
|
||||
throttleAcceleration.delete('gpa-down');
|
||||
throttleMap.delete('gpa-up');
|
||||
throttleMap.delete('gpa-down');
|
||||
}
|
||||
}
|
||||
|
||||
AxisControls();
|
||||
}
|
||||
|
||||
AxisControls();
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateStatus);
|
||||
|
|
|
|||
|
|
@ -1,25 +1,28 @@
|
|||
import
|
||||
{
|
||||
getCurrentFocusKey,
|
||||
init,
|
||||
SpatialNavigation,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { RefObject, useEffect } from "react";
|
||||
|
||||
init({
|
||||
shouldFocusDOMNode: false,
|
||||
throttle: 200,
|
||||
throttle: 200
|
||||
});
|
||||
|
||||
let addFocusable = SpatialNavigation.addFocusable.bind(SpatialNavigation);
|
||||
let removeFocusable = SpatialNavigation.removeFocusable.bind(SpatialNavigation);
|
||||
let setCurrentFocusedKey = SpatialNavigation.setCurrentFocusedKey.bind(SpatialNavigation);
|
||||
|
||||
type SaveFocusType = "session" | "local";
|
||||
|
||||
type HistorySourceType = "settings" | 'details';
|
||||
type HistorySourceType = "settings" | 'details' | 'launch';
|
||||
const historySourceMap = new Map<string, string>();
|
||||
|
||||
export function SaveSource (id: HistorySourceType, url: string)
|
||||
export function SaveSource (id: HistorySourceType, url?: string)
|
||||
{
|
||||
historySourceMap.set(id, url);
|
||||
historySourceMap.set(id, url ?? location.hash.replace("#", ''));
|
||||
}
|
||||
|
||||
export function HasSource (id: HistorySourceType)
|
||||
|
|
@ -29,11 +32,49 @@ export function HasSource (id: HistorySourceType)
|
|||
|
||||
export function PopSource (id: HistorySourceType)
|
||||
{
|
||||
if (!historySourceMap.has(id))
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
const source = historySourceMap.get(id);
|
||||
historySourceMap.delete(id);
|
||||
return source;
|
||||
}
|
||||
|
||||
export function GetFocusedElement (focusKey: string)
|
||||
{
|
||||
return (SpatialNavigation as any).focusableComponents[focusKey]?.node as HTMLElement;
|
||||
}
|
||||
|
||||
export function dispatchFocusedEvent (event: Event, override?: Element | Window)
|
||||
{
|
||||
const focusedElement = GetFocusedElement(getCurrentFocusKey());
|
||||
const finalTarget = override ?? focusedElement ?? window;
|
||||
return finalTarget.dispatchEvent(event);
|
||||
}
|
||||
|
||||
export interface FocusEventMap
|
||||
{
|
||||
'focuschanged': Event;
|
||||
}
|
||||
|
||||
export function useFocusEventListener<K extends keyof FocusEventMap, O extends HTMLElement> (eventName: K, handler: (event: FocusEventMap[K]) => void, element?: RefObject<O | null | undefined>): void
|
||||
{
|
||||
useEffect(() =>
|
||||
{
|
||||
const finalElement = element ? element.current : window;
|
||||
finalElement?.addEventListener(eventName, handler);
|
||||
|
||||
return () => finalElement?.removeEventListener(eventName, handler);
|
||||
}, [eventName, handler, element?.current]);
|
||||
}
|
||||
|
||||
SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) =>
|
||||
{
|
||||
setCurrentFocusedKey(newFocusKey, focusDetails);
|
||||
dispatchFocusedEvent(new Event('focuschanged', { bubbles: true }));
|
||||
};
|
||||
|
||||
SpatialNavigation.addFocusable = (toAdd) =>
|
||||
{
|
||||
addFocusable(toAdd);
|
||||
|
|
|
|||
|
|
@ -56,3 +56,8 @@ export function useScrollSave (data: ScrollSaveParams)
|
|||
|
||||
return { ref: data.ref };
|
||||
}
|
||||
|
||||
export function serverOp ()
|
||||
{
|
||||
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { client } from "../index";
|
||||
import { settingsApi } from "./clientApi";
|
||||
|
||||
window.addEventListener("resize", () =>
|
||||
{
|
||||
client.api.settings({ id: 'windowSize' }).post({ value: { width: window.innerWidth, height: window.innerHeight } });
|
||||
settingsApi.api.settings({ id: 'windowSize' }).post({ value: { width: window.innerWidth, height: window.innerHeight } });
|
||||
});
|
||||
|
||||
let lastWindowPosX: number = window.screenX;
|
||||
|
|
@ -11,7 +11,7 @@ var screenPositionInternal: NodeJS.Timeout = setInterval(() =>
|
|||
{
|
||||
if (lastWindowPosX != window.screenX || lastWindowPosY != window.screenY)
|
||||
{
|
||||
client.api.settings({ id: 'windowPosition' }).post({ value: { x: window.screenX, y: window.screenY } });
|
||||
settingsApi.api.settings({ id: 'windowPosition' }).post({ value: { x: window.screenX, y: window.screenY } });
|
||||
}
|
||||
|
||||
lastWindowPosX = window.screenX;
|
||||
|
|
|
|||
5
src/mainview/types.d.ts
vendored
5
src/mainview/types.d.ts
vendored
|
|
@ -1,4 +1,9 @@
|
|||
declare const __HOST__: string;
|
||||
declare const __EMULATORS__: Record<string, string>;
|
||||
declare module "@emulators" {
|
||||
const data: Record<string, string>;
|
||||
export default data;
|
||||
}
|
||||
|
||||
global
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue