feat: Implemented launching and downloading of roms

This is just an initial implementation lots of kings to iron out
This commit is contained in:
Simeon Radivoev 2026-02-19 16:10:29 +02:00
parent ef08fa6114
commit f15bf9a1e0
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
117 changed files with 37776 additions and 1073 deletions

View file

@ -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>

View file

@ -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) =>
{

View file

@ -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>

View 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>;
}

View file

@ -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,
},
)}
>

View file

@ -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 >
);
}

View file

@ -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;
},
) ?? []}
/>
</>
);

View file

@ -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>

View 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) =>
{
}}
/>
);
}

View file

@ -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({

View file

@ -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>
);
}

View 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);
}
}

View 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>;
}

View 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>;
}

View file

@ -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,
)}
/>

View file

@ -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"));

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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)

View file

@ -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;
}

View file

@ -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>,
);
}

View file

@ -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) }} />

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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,
})
)}

View 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>;
}

View 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>
);
}

View file

@ -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>
);
}

View 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>;
}

View file

@ -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) =>
{

View 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>;
}

View file

@ -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"
/>

View 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',
}
});

View file

@ -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);

View file

@ -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);

View file

@ -56,3 +56,8 @@ export function useScrollSave (data: ScrollSaveParams)
return { ref: data.ref };
}
export function serverOp ()
{
}

View file

@ -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;

View file

@ -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
{