feat: implemented a basic store and emulatorjs

This commit is contained in:
Simeon Radivoev 2026-03-14 02:15:57 +02:00
parent 2f32cbc730
commit 7286541822
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
121 changed files with 5900 additions and 1092 deletions

View file

@ -1,10 +1,9 @@
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { RouterContext } from "..";
import Notifications from "../components/Notifications";
import { Toaster } from "react-hot-toast";
import { mobileCheck, useLocalSetting } from "../scripts/utils";
import useActiveControl from "../scripts/gamepads";
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
@ -14,18 +13,19 @@ function RootComponent ()
{
const isMobile = mobileCheck();
const theme = useLocalSetting('theme');
const { control } = useActiveControl();
return (
<div data-theme={theme === 'auto' ? undefined : theme} className="w-screen h-screen overflow-hidden">
<div data-theme={theme === 'auto' ? undefined : theme} data-device={isMobile ? 'mobile' : ''} data-active-control={control} className="w-screen h-screen overflow-hidden">
<Outlet />
<Notifications />
<Toaster containerStyle={{ viewTimelineName: 'toasters' }} />
{import.meta.env.DEV && !isMobile &&
{/*import.meta.env.DEV && !isMobile &&
<>
<TanStackRouterDevtools position="top-left" />
<ReactQueryDevtools buttonPosition="top-right" />
</>
}
*/}
</div >
);
}

View file

@ -1,9 +1,10 @@
import { createFileRoute } from '@tanstack/react-router';
import { useSessionStorage } from 'usehooks-ts';
import { CollectionsDetail } from '../components/CollectionsDetail';
import { getCollectionApiCollectionsIdGetOptions, getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen';
import { DefaultRommStaleTime } from '../../shared/constants';
import { getCollectionApiCollectionsIdGetOptions, getRomsApiRomsGetOptions } from '@clients/romm/@tanstack/react-query.gen';
import { DefaultRommStaleTime } from '@shared/constants';
import { useQuery } from '@tanstack/react-query';
import { useContext } from 'react';
import { AnimatedBackgroundContext } from '../scripts/contexts';
export const Route = createFileRoute('/collection/$id')({
component: RouteComponent,
@ -17,12 +18,9 @@ function RouteComponent ()
{
const { id } = Route.useParams();
const { data: collection } = useQuery({ ...getCollectionApiCollectionsIdGetOptions({ path: { id: Number(id) } }) });
const [, setBackground] = useSessionStorage<string | undefined>(
"home-background",
undefined,
);
const animatedBgContext = useContext(AnimatedBackgroundContext);
return (
<CollectionsDetail setBackground={setBackground} title={<div className="divider font-semibold text-2xl">{collection?.name}</div>} filters={{ collection_id: Number(id) }} />
<CollectionsDetail setBackground={animatedBgContext.setBackground} title={<div className="divider font-semibold text-2xl">{collection?.name}</div>} filters={{ collection_id: Number(id) }} />
);
}

View file

@ -0,0 +1,185 @@
import { EMULATORJS_URL, RPC_URL, SERVER_URL } from '@/shared/constants';
import { createFileRoute } from '@tanstack/react-router';
import { gameQuery } from '../scripts/queries';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
import { RefObject, useEffect, useRef, useState } from 'react';
import { Router } from '..';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { Button, ButtonStyle } from '../components/options/Button';
import { DoorOpen, Home, RefreshCw, Undo } from 'lucide-react';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import Shortcuts from '../components/Shortcuts';
import { useEventListener, useTimeout } from 'usehooks-ts';
import { GetFocusedElement, useGlobalFocus } from '../scripts/spatialNavigation';
import useActiveControl from '../scripts/gamepads';
import { twMerge } from 'tailwind-merge';
import { HeaderAccounts, HeaderStatusBar } from '../components/Header';
import { RoundButton } from '../components/RoundButton';
export const Route = createFileRoute('/embedded/$source/$id')({
component: RouteComponent,
loader: async (ctx) =>
{
const data = await ctx.context.queryClient.fetchQuery(gameQuery(ctx.params.source, ctx.params.id));
return { data };
},
validateSearch: zodValidator(z.record(z.string(), z.string().optional().nullable()))
});
function OverlayButton (data: {
id: string,
style: ButtonStyle,
tooltip: string, setTooltip: (tooltip: string) => void,
className?: string;
children?: any;
} & InteractParams)
{
return <div className="tooltip tooltip-bottom" data-tip={data.tooltip}>
<RoundButton external onFocus={() => data.setTooltip(data.tooltip)} style={data.style} className={twMerge("", data.className)} id={data.id} onAction={data.onAction} >
{data.children}
</RoundButton>
</div>;
}
function Overlay (data: {
open: boolean;
iframeRef: RefObject<HTMLIFrameElement | null>;
close: () => void;
goBack: () => void;
})
{
const { ref, focusSelf, focusKey } = useFocusable({ focusable: data.open, focusKey: 'overlay', forceFocus: true, isFocusBoundary: true });
const [tooltip, setTooltip] = useState<string | undefined>(undefined);
useShortcuts(focusKey, () => data.open ? [{ label: 'Return', button: GamePadButtonCode.B, action: data.close }] : [], [data.open, data.close]);
useEffect(() =>
{
if (data.open)
{
focusSelf();
}
}, [data.open]);
const { isPointer } = useActiveControl();
const handleEvent = (type: string, value?: any) => data.iframeRef.current?.contentWindow?.postMessage({ type, data: value });
return <div data-open={data.open} className='flex group w-full flex-col gap-2 transition-opacity p-4 not-data-[open=true]:pointer-events-none not-data-[open=true]:opacity-0'>
<div className='grid grid-cols-3 justify-between items-start'>
<div className='flex justify-start'>
<HeaderAccounts />
</div>
<div className='flex justify-center'>
<ul ref={ref} className='flex rounded-4xl bg-base-100 justify-end gap-2 p-4 group-data-[open=true]:animate-scale'>
<FocusContext value={focusKey}>
<OverlayButton id="return" style='primary' tooltip='Return' setTooltip={setTooltip} onAction={data.close} ><Undo /></OverlayButton>
<OverlayButton id="restart" style='secondary' tooltip='Restart' setTooltip={setTooltip} onAction={() =>
{
data.close();
handleEvent('restart');
}} ><RefreshCw /></OverlayButton>
<OverlayButton id="exit" style='warning' tooltip='Exit' setTooltip={setTooltip} onAction={data.goBack} ><DoorOpen /></OverlayButton>
</FocusContext>
</ul>
</div>
<div className='flex justify-end'>
<HeaderStatusBar />
</div>
</div>
<div className='flex justify-center'>
{!!tooltip && data.open && !isPointer && <div className='bg-accent text-accent-content rounded-full font-semibold py-1 px-4'>{tooltip}</div>}
</div>
</div>;
}
function Frame (data: { ref: RefObject<HTMLIFrameElement | null>; })
{
const { ref } = useFocusable({ focusKey: 'frame' });
const { data: game } = Route.useLoaderData();
const search = Route.useSearch();
search['gameName'] = game.name;
search['backgroundImage'] = `${RPC_URL(__HOST__)}${game.path_cover}`;
search['backgroundBlur'] = "true";
if (!__PUBLIC__)
{
search['threads'] = "true";
}
const params = Object.entries(search)
.filter(kvp => kvp[1] !== null && kvp[1] !== undefined)
.map(kvp => `${kvp[0]}=${encodeURIComponent(kvp[1]!)}`).join('&');
return <iframe ref={r =>
{
ref.current = r;
data.ref.current = r;
}}
allow='fullscreen; cross-origin-isolated'
className='absolute w-full h-full transition-[padding]' src={
__PUBLIC__ ? `${SERVER_URL(__HOST__)}/emulatorjs/?${params}` : `${EMULATORJS_URL(__HOST__)}/?${params}`
}></iframe>;
}
function RouteComponent ()
{
const { ref, focusSelf, focusKey } = useFocusable({
focusKey: 'emulatorjs',
preferredChildFocusKey: 'frame',
forceFocus: true
});
const iframeRef = useRef<HTMLIFrameElement>(null);
const [overlayOpen, setOverlayOpen] = useState(false);
const { source, id } = Route.useParams();
function HandleGoBack ()
{
Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id } });
}
useEventListener('message', e =>
{
if (e.data.type === 'exit')
{
HandleGoBack();
}
});
useShortcuts(focusKey, () => [{
button: GamePadButtonCode.Steam, action: () =>
{
setOverlayOpen(!overlayOpen);
}
}], [overlayOpen, setOverlayOpen]);
const setPaused = (paused: boolean) =>
{
if (paused) iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: true });
else
{
// we want to prevent input from closing the overlay spilling
setTimeout(() => iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: false }), 100);
}
};
useEffect(() => setPaused(overlayOpen), [overlayOpen]);
const { shortcuts } = useShortcutContext();
useEffect(() => { if (!overlayOpen) focusSelf(); }, [overlayOpen]);
function handleClose ()
{
setOverlayOpen(false);
}
return <div ref={ref} className='absolute w-full h-full'>
<FocusContext value={focusKey}>
<Frame ref={iframeRef} />
<div className='flex fixed left-0 right-0 top-0'>
<Overlay iframeRef={iframeRef} goBack={HandleGoBack} open={overlayOpen} close={handleClose} />
</div>
<div className='flex justify-end fixed bottom-4 right-4 left-4 z-10'>
<Shortcuts shortcuts={shortcuts} />
</div>
</FocusContext>
</div>;
}

View file

@ -1,5 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import { FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants";
import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router";
import { CommandEntry, 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";
@ -17,32 +17,124 @@ import { ContextDialog, ContextList, DialogEntry } from "../../components/Contex
import Shortcuts from "../../components/Shortcuts";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import { gameQuery } from "@/mainview/scripts/queries";
import Screenshots from "@/mainview/components/Screenshots";
import { delay, useSticky, useStickyDataAttr } from "@/mainview/scripts/utils";
import useActiveControl from "@/mainview/scripts/gamepads";
export const Route = createFileRoute("/game/$source/$id")({
loader: ({ params, context }) =>
loader: async ({ params, context }) =>
{
context.queryClient.prefetchQuery(gameQuery(params.source, Number(params.id)));
const data = await context.queryClient.fetchQuery(gameQuery(params.source, params.id));
return { data };
},
component: GameDetailsUI,
pendingComponent: GameDetailsUIPending,
errorComponent: Error
});
function Error (data: ErrorComponentProps)
{
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
useEffect(() =>
{
focusSelf();
}, []);
return <AnimatedBackground ref={ref} backgroundKey="game-details">
<div className="relative z-10 h-full">
<FocusContext value={focusKey}>
<div className="h-0" />
<div className="fixed group top-0 left-0 right-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
<HeaderUI />
</div>
<div className="absolute w-full flex flex-col justify-center items-center h-full overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
<div className="flex gap-2 items-center text-4xl text-error"><TriangleAlert className="size-12" /> {data.error.message}</div>
</div>
<div className="bg-base-200">
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
<Shortcuts shortcuts={shortcuts} />
</footer>
</div>
</FocusContext>
</div>
</AnimatedBackground>;
}
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>
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
useEffect(() =>
{
focusSelf();
}, []);
return <AnimatedBackground ref={ref} backgroundKey="game-details">
<div className="z-10">
<FocusContext value={focusKey}>
<div className="h-0" />
<div className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
<HeaderUI />
</div>
<div className="flex flex-col h-[80vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
<main ref={ref} className="flex p-3 flex-col flex-1 min-h-0">
<section className="flex portrait:flex-col my-4 sm:p-0 md:px-12 md:pb-8 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
<div className="flex gap-6 overflow-hidden bg-base-100 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24 p-4">
<div className="skeleton w-full h-full"></div>
</div>
<div className="flex-2 flex flex-col sm:gap-1 md:gap-6 sm:pt-2 md:pt-16 min-h-0">
<div className="flex flex-wrap sm:gap-4 md:gap-6 shrink-0">
<Detail icon={<Clock />} ></Detail>
<Detail icon={<div className="skeleton size-6" />} ><div className="skeleton h-4 w-32"></div></Detail>
<Detail icon={
<Store />
} >
</Detail>
</div>
<div className="md:hidden divider divider-vertical m-0"></div>
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden text-lg">
<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>
</div>
</div>
</section>
</main>
</div>
<div className="bg-base-200">
<div className="divider m-0 pb-12"><div className="flex items-center gap-3 opacity-60"><Image className="sm:size-4 md:size-6" />Screenshots</div></div>
<div className="flex flex-col w-full z-0 min-h-0">
<div
className="flex gap-6 px-16 py-2 sm:overflow-scroll md:overflow-hidden no-scrollbar justify-center-safe"
>
{Array.from({ length: 5 }).map((s, i) => <div key={i} className="skeleton h-64 w-lg"></div>)}
</div>
</div>
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
<Shortcuts shortcuts={shortcuts} />
</footer>
</div>
</FocusContext>
</div>
</AnimatedBackground>;
}
function HandleGoBack ()
{
const source = PopSource('details');
Router.navigate({ to: source ?? '/', viewTransition: { types: ['zoom-out'] } });
const { to, search } = PopSource('details');
Router.navigate({ to: to ?? '/', viewTransition: { types: ['zoom-out'] }, search });
}
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?: FrontEndGameTypeDetailed; })
@ -50,7 +142,7 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
const { ref, focusKey } = useFocusable({
focusKey: 'main-details', onFocus: () =>
{
data.mainAreaRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
data.mainAreaRef.current?.scrollIntoView({ block: 'end', behavior: 'smooth' });
},
preferredChildFocusKey: "play-btn",
saveLastFocusedChild: false
@ -77,10 +169,10 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
return <main ref={ref} className="flex p-3 flex-col flex-1 min-h-0">
<FocusContext value={focusKey}>
<section className="flex portrait:flex-col my-4 sm:p-0 md:p-12 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
<div className="flex gap-6 overflow-hidden bg-base-300 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24">
<section className="flex portrait:flex-col my-4 sm:p-0 md:px-12 md:pb-8 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
<div className="flex gap-6 overflow-hidden bg-base-100 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24 p-4">
{gameCoverImg ?
<img className="drop-shadow-2xl drop-shadow-base-300/40 w-full object-cover" src={gameCoverImg}></img> :
<img className="drop-shadow-2xl drop-shadow-base-300/40 w-full object-cover rounded-2xl" src={gameCoverImg}></img> :
<div className="skeleton w-full h-full"></div>
}
</div>
@ -101,7 +193,7 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
</div>
<div className="md:hidden divider divider-vertical m-0"></div>
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden ">
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden text-lg">
{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>
@ -118,60 +210,6 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
</main>;
}
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; })
{
const { ref, focused, focusSelf } = useFocusable({
focusKey: `screenshot-${data.index}`,
onFocus: (e, p, details) =>
{
data.setFocused?.(data.index);
(ref.current as HTMLElement).scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
}
}); 4096;
return <img className={twJoin("max-h-[60vh] rounded-3xl", classNames({
"sm:ring-4 md:ring-7 ring-primary": focused,
"cursor-pointer": !focused
}))} onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} 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: (e, p, details) =>
{
if (!(details.nativeEvent instanceof TouchEvent))
{
(ref.current as HTMLElement).scrollIntoView({ block: 'center', behavior: 'smooth' });
}
},
onBlur: () => setFocusedScreenshot(-1)
});
return <div ref={ref} className="flex flex-col w-full z-0">
<FocusContext value={focusKey}>
<div
ref={scrollRef}
className="flex gap-6 px-16 py-2 sm:overflow-scroll md:overflow-hidden no-scrollbar 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={(e) => setFocus(`screenshot-${i}`, { nativeEvent: e.nativeEvent })}
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)
@ -221,6 +259,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
const [status, setStatus] = useState<GameStatusType | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const [details, setDetails] = useState<string | undefined>(undefined);
const [commands, setCommands] = useState<CommandEntry[] | undefined>(undefined);
const queryClient = useQueryClient();
useEffect(() =>
@ -233,13 +272,14 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
setProgress(stats.progress);
setStatus(stats.status);
setDetails(stats.details);
setCommands(stats.commands);
setError(stats.error);
};
es.addEventListener('refresh', () =>
{
queryClient.invalidateQueries({ queryKey: ['game', data.game.id] });
location.reload();
Router.navigate({ to: '/game/$source/$id', params: { id, source } });
});
es.addEventListener('error', (e) =>
@ -248,6 +288,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
{
const stats = JSON.parse((e as any).data) as GameInstallProgress;
toast.error(stats.error);
setError(stats.error);
}
});
@ -257,6 +298,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
if (error)
{
toast.error(error);
setError(error);
}
};
@ -279,9 +321,18 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
{
mainButton = <ActionButton onAction={() =>
{
playMutation.mutate();
SaveSource('launch');
Router.navigate({ to: '/launcher/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id } });
const firstValid = commands?.find(c => c.valid);
if (firstValid?.emulator === 'emulatorjs')
{
const params = new URLSearchParams(firstValid.command);
Router.navigate({ to: '/embedded/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id }, search: Object.fromEntries(params.entries()) });
} else
{
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)
@ -383,6 +434,8 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
}, ref);
const { isPointer } = useActiveControl();
const tooltipStyles = {
base: 'bg-base-100 text-base-content',
accent: 'bg-accent text-accent-content',
@ -403,7 +456,7 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
}}>
<ContextList options={contextOptions} />
</ContextDialog>
{!!hoverText && <p className={twMerge("flex sm:hidden md:inline py-1 md:py-2 md:px-4 rounded-4xl text-wrap wrap-anywhere text-base", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>}
{!!hoverText && !isPointer && <p className={twMerge("flex sm:hidden md:inline py-1 md:py-2 md:px-4 rounded-4xl text-wrap wrap-anywhere text-base", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>}
</FocusContext>
</div>;
}
@ -434,41 +487,35 @@ function ActionButton (data: {
{
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 sm:ring-4 md: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 sm:ring-4 md:ring-7 ring-primary": focused
})),
accent: twMerge("bg-primary text-primary-content ", classNames({
"bg-base-content text-base-300 sm:ring-4 md:ring-7 ring-primary": focused
})),
error: twMerge("bg-error text-error-content ", classNames({
"bg-error text-error-content sm:ring-4 md:ring-7 ring-primary": focused
})),
primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary",
base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary",
accent: "bg-primary text-primary-content focusable focusable-primary focusable:bg-base-content focusable:text-base-300",
error: "bg-error text-error-content focused:bg-error focused:text-error-content",
};
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 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30",
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md: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>
<div className="tooltip tooltip-accent tooltip-right" data-tip={data.tooltip}>
<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 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content",
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md: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>
</div>
);
}
export default function GameDetailsUI ()
{
const { source, id } = Route.useParams();
const { data, isSuccess } = useQuery(gameQuery(source, Number(id)));
const { data } = Route.useLoaderData();
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
const backgroundImage = data?.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined;
const headerRef = useRef(null);
const sentinelRef = useRef(null);
const backgroundImage = data.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined;
const mainAreaRef = useRef<HTMLDivElement>(null);
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
@ -476,27 +523,26 @@ export default function GameDetailsUI ()
useEffect(() =>
{
if (isSuccess)
{
focusSelf();
}
focusSelf();
}, []);
}, [isSuccess]);
useStickyDataAttr(headerRef, sentinelRef, ref);
return (
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage} scrolling>
<div className="z-0">
<div className="z-10">
<FocusContext value={focusKey}>
<div className="flex flex-col px-3 py-2 h-[90vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
<div ref={sentinelRef} className="h-0" />
<div ref={headerRef} className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
<HeaderUI />
</div>
<div className="flex flex-col h-[80vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
<Details mainAreaRef={mainAreaRef} game={data} />
</div>
<div className="bg-base-200">
<div className="divider m-0 pb-12"><div className="flex items-center gap-3 opacity-60"><Image className="sm:size-4 md: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>
{!!data && <Screenshots screenshots={data.paths_screenshots} onFocus={(_, node) => node.scrollIntoView({ behavior: 'smooth', block: 'center' })} />}
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
<Shortcuts shortcuts={shortcuts} />
</footer>
</div>

View file

@ -4,12 +4,12 @@ import
Gamepad2,
Settings,
MessageSquare,
ShoppingBag,
Image,
Search,
Power,
OctagonAlert,
Maximize,
Store,
} from "lucide-react";
import
{
@ -21,13 +21,14 @@ import
{
FocusContext,
FocusDetails,
getCurrentFocusKey,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { useEventListener } from "usehooks-ts";
import { HeaderAccounts, HeaderStatusBar, HeaderUI } from "../components/Header";
import { HeaderAccounts, HeaderStatusBar } from "../components/Header";
import { FilterUI } from "../components/Filters";
import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground";
import { AnimatedBackground } from "../components/AnimatedBackground";
import { GameList } from "../components/GameList";
import { SaveSource } from "../scripts/spatialNavigation";
import LoadingCardList from "../components/LoadingCardList";
@ -43,7 +44,9 @@ import z from "zod";
import { Router } from "..";
import CollectionList from "../components/CollectionList";
import { zodValidator } from '@tanstack/zod-adapter';
import { mobileCheck } from "../scripts/utils";
import { mobileCheck, useDragScroll } from "../scripts/utils";
import { AnimatedBackgroundContext } from "../scripts/contexts";
import { FrontEndId } from "@/shared/constants";
export const Route = createFileRoute("/")({
component: ConsoleHomeUI,
@ -93,6 +96,7 @@ function HomeList (data: {
{
const [initFocus, setInitFocus] = useState(false);
const bg = useContext(AnimatedBackgroundContext);
const { } = Route.useSearch;
const { ref, focused, focusKey, focusSelf } = useFocusable({
focusKey: "home-list",
preferredChildFocusKey: `${data.selectedFilter}-list`
@ -103,18 +107,54 @@ function HomeList (data: {
const isMounseEvent = details.nativeEvent instanceof MouseEvent;
if (!isMounseEvent)
{
node?.scrollIntoView({ inline: 'center', behavior: initFocus ? 'smooth' : 'instant' });
node?.scrollIntoView({ inline: 'center', block: 'center', behavior: initFocus ? 'smooth' : 'instant' });
}
setInitFocus(true);
};
const lists: Record<string, JSX.Element> = {
consoles: <PlatformsList onFocus={handleNodeFocus} className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />,
games: <GameList onFocus={handleNodeFocus} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />,
collections: <CollectionList onFocus={handleNodeFocus} className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />,
function handleGameSelect (id: FrontEndId, source: string | null, sourceId: string | null)
{
SaveSource('details', { search: { filter: data.selectedFilter } });
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
};
const handleCollectionSelect = (id: string) =>
{
SaveSource('game-list', { search: { filter: data.selectedFilter } });
Router.navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
};
const handlePlatformSelect = (source: string, id: string) =>
{
SaveSource('game-list', { search: { filter: data.selectedFilter } });
Router.navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
};
let activeList: JSX.Element;
switch (data.selectedFilter)
{
case 'consoles':
activeList = <>
<PlatformsList onSelect={handlePlatformSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
</>;
break;
case 'collections':
activeList = <>
<CollectionList onSelect={handleCollectionSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
</>;
break;
default:
activeList = <>
<GameList onGameSelect={handleGameSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
</>;
break;
}
useEventListener('wheel', e =>
{
const deltaY = e.deltaY;
@ -138,17 +178,18 @@ function HomeList (data: {
}
});
useDragScroll(ref);
return (
<FocusContext value={focusKey}>
<div ref={ref} className="flex h-full w-full landscape:overflow-x-scroll portrait:overflow-y-scroll overflow-hidden no-scrollbar justify-center-safe sm:pt-2 md:py-6 md:pb-3 md:mb-1" style={{
<div ref={ref} className="flex h-full w-full landscape:overflow-x-scroll portrait:overflow-y-scroll overflow-hidden no-scrollbar justify-center-safe sm:py-2 md:py-6 md:pb-6 md:mb-1 not-mobile:sm:pb-4" 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="landscape:px-16 portrait:min-h-fit portrait:h-fit portrait:pb-32 portrait:w-full landscape:h-full">
<div className="landscape:flex landscape:px-16 portrait:min-h-fit portrait:h-fit portrait:pb-32 portrait:w-full landscape:h-full landscape:items-center">
<ErrorBoundary fallback={<HomeListError focused={focused} />}>
<Suspense key={data.selectedFilter} fallback={<LoadingCardList placeholderCount={8} />}>
{lists[data.selectedFilter]}
{activeList}
<SaveScroll id={`card-list-${data.selectedFilter}`} ref={ref} />
<AutoFocus focus={focusSelf} delay={10} />
</Suspense>
</ErrorBoundary>
</div>
@ -179,7 +220,7 @@ function MainMenu (data: {})
type="secondary"
/>
<CircleIcon icon={<MessageSquare />} label="News" />
<CircleIcon icon={<ShoppingBag />} label="Shop" />
<CircleIcon type="info" icon={<Store />} action={() => navigate({ to: "/store/tab", viewTransition: { types: ['zoom-in'] } })} label="Shop" />
<CircleIcon icon={<Image />} label="Album" />
<CircleIcon
icon={<Gamepad2 />}
@ -202,7 +243,7 @@ function MainMenu (data: {})
function CircleIcon (data: {
action?: () => void;
type?: "secondary" | "accent";
type?: "secondary" | "accent" | "info";
label?: string;
icon?: JSX.Element;
})
@ -215,6 +256,7 @@ function CircleIcon (data: {
const typeClasses = {
secondary: "bg-secondary text-secondary-content",
accent: "bg-accent text-accent-content",
info: "bg-info text-info-content",
none: "bg-base-content",
};
return (
@ -222,15 +264,9 @@ function CircleIcon (data: {
ref={ref}
onClick={data.action}
className={twMerge(
`portrait:sm:size-12 sm:w-14 sm:h-10 menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all`,
typeClasses[data.type ?? "none"], classNames(
{
"focus ring-7 ring-primary drop-shadow-2xl animate-scale": focused,
"hover:ring-7 hover:ring-primary": true,
})
)}
`portrait:sm:size-12 sm:w-14 sm:h-10 menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all focusable focusable-primary focused:drop-shadow-2xl focused:animate-scale focusable-hover bg-base-content border-6 md:border-12 border-base-content focused:border-0 hover:border-0 z-1 active:border-0 active:bg-base-300 active:text-base-content active:transition-none`, typeClasses[data.type ?? 'none'])}
>
{data.icon}
<div className="in-focused:animate-rotate-instant animation-size-5">{data.icon}</div>
</li>
);
}
@ -291,11 +327,11 @@ export default function ConsoleHomeUI ()
<div className="sm:landscape:hidden md:landscape:inline sm:portrait:col-start-1 md:inline flex col-span-1 md:pl-2 md:pt-2">
<HeaderAccounts />
</div>
<div className="sm:portrait:*:justify-center sm:portrait:col-span-3 sm:landscape:*:justify-start sm:px-2 sm:pt-2 md:row-start-2 md:col-start-1 sm:landscape:col-span-1 md:landscape:col-span-3 flex items-center md:*:justify-center! md:ml-0 gap-2 *:w-full *:flex">
<div className=" sm:portrait:col-span-3 sm:px-2 sm:pt-2 md:row-start-2 md:col-start-1 sm:landscape:col-span-1 md:landscape:col-span-3 flex items-center md:ml-0 gap-2">
<FilterUI
id="home"
options={filters}
selected={filter ? filter : 'games'}
containerClassName="flex w-full sm:landscape:justify-start sm:portrait:justify-center md:justify-center!"
options={Object.fromEntries(Object.entries(filters).map(([key, value]) => [key, { ...value, selected: key === filter }]))}
setSelected={setFilter}
/>
</div>
@ -311,7 +347,7 @@ export default function ConsoleHomeUI ()
<MainMenu />
</div>
<footer className={twMerge(
"sm:portrait:hidden sm:col-span-1 md:col-start-2 md:col-span-2 md:relative px-2 pb-2 flex items-end justify-end",
"fixed bottom-4 left-4 right-4 sm:portrait:hidden sm:col-span-1 md:col-start-2 md:col-span-2 flex items-end justify-end",
)}>
<Shortcuts shortcuts={shortcuts} />
</footer>

View file

@ -2,9 +2,8 @@ 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 { useEffect } from 'react';
import { rommApi } from '../scripts/clientApi';
import { useQuery } from '@tanstack/react-query';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';

View file

@ -1,21 +1,21 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEventListener, useSessionStorage } from "usehooks-ts";
import { createFileRoute } from "@tanstack/react-router";
import { CollectionsDetail } from "../components/CollectionsDetail";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { Suspense } from "react";
import { useContext } from "react";
import { rommApi } from "../scripts/clientApi";
import { AnimatedBackgroundContext } from "../scripts/contexts";
export const Route = createFileRoute("/platform/$source/$id")({
component: RouteComponent
});
function PlatformTitle (data: { platformSlug?: string, platformName?: string; })
function PlatformTitle (data: { pathCover: string | null, platformName?: string; })
{
return <div className="sm:landscape:hidden 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">
{!!data.platformSlug && <img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${data.platformSlug.toLocaleLowerCase()}.svg`} ></img>}
{!!data.pathCover && <img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}${data.pathCover}`} ></img>}
{data.platformName}
</div>
</div>;
@ -33,16 +33,13 @@ function RouteComponent ()
}, staleTime: DefaultRommStaleTime
});
const [, setBackground] = useSessionStorage<string | undefined>(
"home-background",
undefined,
);
const animatedBgContext = useContext(AnimatedBackgroundContext);
return (
<div className="w-full h-full">
{!!platform && <CollectionsDetail
title={<PlatformTitle platformSlug={platform.slug} platformName={platform.name} />}
setBackground={setBackground}
title={<PlatformTitle pathCover={platform.path_cover} platformName={platform.name} />}
setBackground={animatedBgContext.setBackground}
filters={{ platform_id: Number(id), platform_slug: platform.slug, platform_source: source }}
/>}
</div>

View file

@ -1,6 +1,7 @@
import { systemApi } from '@/mainview/scripts/clientApi';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import prettyBytes from 'pretty-bytes';
export const Route = createFileRoute('/settings/about')({
component: RouteComponent,
@ -9,56 +10,58 @@ export const Route = createFileRoute('/settings/about')({
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>Source</th>
<td>{systemInfo?.data?.source}</td>
</tr>
<tr>
<th>Steam Deck</th>
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
</tr>
</tbody>
</table>
</div>;
return <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>Sizes</th>
<td>Cache: {prettyBytes(systemInfo?.data?.cacheSize ?? 0)}, Store: {prettyBytes(systemInfo?.data?.storeSize ?? 0)}</td>
</tr>
<tr>
<th>Source</th>
<td>{systemInfo?.data?.source}</td>
</tr>
<tr>
<th>Steam Deck</th>
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
</tr>
</tbody>
</table>;
}

View file

@ -7,17 +7,18 @@ import
import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import classNames from "classnames";
import { Key, Link, Lock, Save, ScanQrCode, Trash, User, X } from "lucide-react";
import { Key, Link, Lock, LogOut, Save, ScanQrCode, Trash, User, X } from "lucide-react";
import
{
useEffect,
useRef,
} from "react";
import { RPC_URL } from "../../../shared/constants";
import { RPC_URL } from "@shared/constants";
import
{
getCurrentUserApiUsersMeGetOptions,
statsApiStatsGetOptions,
} from "../../../clients/romm/@tanstack/react-query.gen";
} from "@clients/romm/@tanstack/react-query.gen";
import toast from "react-hot-toast";
import z from "zod";
import { OptionSpace } from "../../components/options/OptionSpace";
@ -26,20 +27,95 @@ import { rommApi, settingsApi } from "../../scripts/clientApi";
import { Button } from "../../components/options/Button";
import { ContextDialog } from "@/mainview/components/ContextDialog";
import QRCode from "react-qr-code";
import { useAsyncGenerator } from "@/mainview/scripts/utils";
import { useJobStatus } from "@/mainview/scripts/utils";
import { useInterval } from "usehooks-ts";
import { TwitchIcon } from "@/mainview/scripts/brandIcons";
export const Route = createFileRoute("/settings/accounts")({
component: RouteComponent,
});
function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: string; endsAt: Date; })
function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: string; endsAt: Date; startedAt: Date; code?: string; })
{
const progressRef = useRef<HTMLProgressElement>(null);
useInterval(() =>
{
if (progressRef.current)
{
const time = data.endsAt.getTime() - data.startedAt.getTime();
progressRef.current.value = ((data.endsAt.getTime() - new Date().getTime()) / time) * 100;
}
}, 1000);
return <ContextDialog id={data.id} open={data.isOpen} close={() => data.cancel()} className="flex flex-col justify-center items-center gap-2">
<QRCode value={data.url} />
<progress ref={progressRef} className="progress w-56" max="100"></progress>
{!!data.code && <p> Code: {data.code} </p>}
<Button id="qr-login-cancel" focusClassName="btn-warning" type="button" onAction={() => data.cancel()}><X /> Cancel</Button>
</ContextDialog>;
}
function TwitchLogin (data: {})
{
const loginStatus = useQuery({
queryKey: ['twitch', 'login', 'status'],
retry (failureCount, error)
{
if (error.status === 404)
{
return false;
}
return failureCount < 3;
},
queryFn: async () =>
{
const { data, error, status } = await rommApi.api.romm.login.twitch.get();
if (error) throw { ...error, status };
return data;
}
});
const loginMutation = useMutation({
mutationKey: ['twitch', 'login'],
mutationFn: (openInBrowser: boolean) =>
{
return rommApi.api.romm.login.twitch.post({ openInBrowser });
},
onSuccess: () => loginStatus.refetch()
});
const logoutMutation = useMutation({
mutationKey: ['twitch', 'logout'],
mutationFn: () =>
{
return rommApi.api.romm.logout.twitch.post();
},
onSuccess: () => loginStatus.refetch()
});
const { data: loginData, wsRef } = useJobStatus('twitch-login-job', { onEnded: () => loginStatus.refetch() });
return <div className="flex flex-wrap gap-1 items-center justify-center-safe">
{loginStatus.isSuccess ?
<div className="badge badge-success badge-lg rounded-full gap-2"><b>{loginStatus.data.login}</b></div> :
<div className={classNames("badge gap-2 tooltip", { "badge-error": loginStatus.error })} data-tip={loginStatus.error?.message}>
{loginStatus.isError || loginStatus.isRefetchError ? <Lock className="size-4" /> : <span className="loading loading-spinner loading-sm"></span>}
</div>
}
<Button id="twitch-login-btn-qr" disabled={loginMutation.isPending} onAction={() => loginMutation.mutate(false)} >
<ScanQrCode />
</Button>
<Button id="twitch-login-btn" disabled={loginMutation.isPending} onAction={() => loginMutation.mutate(true)} >
{TwitchIcon}
Login
</Button>
{loginStatus.isSuccess && <Button id="twitch-logout-btn" onAction={() => logoutMutation.mutate()} ><LogOut /> Logout</Button>}
{!!loginData && <LoginQR code={loginData.user_code} url={loginData.url} cancel={() => wsRef.current?.send({ type: 'cancel' })} id='twitch-login-qr' isOpen={true} endsAt={loginData.expires_at} startedAt={loginData.started_at} />}
</div>;
}
function LoginControls (data: { hasPassword: boolean; })
{
const user = useQuery({
@ -48,42 +124,30 @@ function LoginControls (data: { hasPassword: boolean; })
refetchOnWindowFocus: false,
retry: 0
});
const { data: qrLoginStatusGen, refetch } = useQuery({
queryKey: ['login', 'qr'], queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.login.remote.status.get();
if (error) throw error;
return data;
}
});
const statusValue = useAsyncGenerator(qrLoginStatusGen, [qrLoginStatusGen]);
const cancelQrMutation = useMutation({
const loginMutation = useMutation({
mutationKey: ['login', 'qr', 'cancel'],
mutationFn: () => rommApi.api.romm.login.remote.cancel.post(),
onSuccess: () => refetch()
});
const requestQrLoginMutation = useMutation({
mutationKey: ['login', 'qr'],
mutationFn: () => rommApi.api.romm.login.remote.start.post(),
onSuccess: () => refetch()
mutationFn: () => rommApi.api.romm.login.romm.post()
});
const { data: statusValue, error: loginError, wsRef } = useJobStatus('login-job');
const context = useSettingsFormContext({});
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
const logoutMutation = useMutation({
mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post(),
onSuccess: async (d, v, r, c) =>
{
user.refetch();
c.client.invalidateQueries({ queryKey: ["romm", "auth"] });
}
});
return <div className="flex gap-2 items-center flex-wrap">
{user.isError && <div className="badge badge-error gap-2 tooltip" data-tip={(user.error as any)?.detail ?? ''}>
<Lock className="size-4" /></div>}
{user.isSuccess && <>
<div className="badge badge-success badge-lg rounded-full gap-2"> <p className="sm:hidden md:inline">Logged In As:</p> <img className="size-6 rounded-full" src={`${RPC_URL(__HOST__)}/api/romm/assets/romm/assets/${user.data?.avatar_path}`} /><b>{user.data?.username}</b></div>
</>}
<Button id="qr-login" type="button" onAction={() => requestQrLoginMutation.mutate()}><ScanQrCode /> </Button>
return <div className="flex gap-2 items-center flex-wrap justify-center-safe">
{user.isSuccess ?
<div className="badge badge-success badge-lg rounded-full gap-2"> <p className="sm:hidden md:inline">Logged In As:</p> <img className="size-6 rounded-full" src={`${RPC_URL(__HOST__)}/api/romm/assets/romm/assets/${user.data?.avatar_path}`} /><b>{user.data?.username}</b></div> :
<div className={classNames("badge gap-2 tooltip", { "badge-error": user.error })} data-tip={user.error?.message}>
{user.isError ? <Lock className="size-4" /> : <span className="loading loading-spinner loading-sm"></span>}
</div>
}
<Button id="qr-login" type="button" disabled={loginMutation.isPending} onAction={() => loginMutation.mutate()}><ScanQrCode /> </Button>
<Button id="can-submit" disabled={!context.state.canSubmit || !context.state.isDirty} type="submit" onAction={() => context.handleSubmit()} >
<Save /> Save
</Button>
@ -99,11 +163,11 @@ function LoginControls (data: { hasPassword: boolean; })
<Button id="cancel" disabled={context.state.isDefaultValue} type="reset" onAction={() => context.reset()}>
<X /> Cancel
</Button>
{statusValue?.data?.endsAt && <LoginQR id="qr-login-context" endsAt={statusValue.data.endsAt} isOpen={true} cancel={() =>
{!!statusValue && <LoginQR startedAt={statusValue.startedAt} id="qr-login-context" endsAt={statusValue.endsAt} isOpen={true} cancel={() =>
{
setFocus(`qr-login`);
cancelQrMutation.mutate();
}} url={statusValue?.data?.url ?? ''} />}
wsRef.current?.send({ type: 'cancel' });
}} url={statusValue?.url ?? ''} />}
</div>;
}
@ -183,7 +247,7 @@ function RouteComponent ()
return (
<FocusContext.Provider value={focusKey}>
<ul ref={ref} className="list rounded-box gap-2">
<ul ref={ref} className="list relative rounded-box gap-2">
<div className="divider text-2xl mt-0 md:mt-4">
<div className="flex flex-col">
<h3>Romm</h3>
@ -218,12 +282,24 @@ function RouteComponent ()
<loginForm.AppField name="password" children={(field) =>
<field.FormOption label={"Romm Password"} icon={<Key />} type="password" placeholder={hasPassword ? '*****' : "Password"} />} />
<loginForm.Subscribe children={(form) =>
<OptionSpace className="justify-end">
<OptionSpace id="login-controls-space" className="justify-end border-0">
<LoginControls hasPassword={hasPassword === true} />
</OptionSpace>} />
</form>
</loginForm.AppForm>
<div className="divider text-2xl mt-0 md:mt-4">
<div className="flex gap-2 items-center">
{TwitchIcon}
<h3> Twitch</h3>
</div>
</div>
</ul>
<OptionSpace label={<div className="flex flex-col">
Twitch Login
<small className="text-base-content/40">for IGDB Metadata</small>
</div>} id="twitch-login-space" className="justify-end border-0">
<TwitchLogin />
</OptionSpace>
</FocusContext.Provider>
);
}

View file

@ -15,6 +15,7 @@ import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spat
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
import FilePicker from '@/mainview/components/FilePicker';
import { dirname } from 'pathe';
import { autoEmulatorsQuery } from '@/mainview/scripts/queries';
export const Route = createFileRoute('/settings/emulators')({
component: RouteComponent,
@ -75,7 +76,7 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd
};
return <OptionSpace label={"Custom Emulator Path"}>
return <OptionSpace id={'custom-emulator-path-option'} label={"Custom Emulator Path"}>
<Button disabled={data.isAddingOverride} id='emulator' type='button' onAction={() => setNewEmulatorTypeOpen(true)} >
Emulator
<ChevronDown />
@ -155,7 +156,7 @@ function EmulatorPath (data: { id: string; })
};
return (
<OptionSpace label={
<OptionSpace id={`${data.id}-space`} label={
focus => <>
<p className='font-semibold'>{data.id}</p>
<small className='opacity-40'>{emulators[data.id]}</small>
@ -211,6 +212,7 @@ function EmulatorBadge (data: {
path?: string,
exists: boolean,
emulator: string;
isCritical: boolean;
pathCover?: string;
addOverride: (emulator: string) => void;
})
@ -229,16 +231,16 @@ function EmulatorBadge (data: {
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',
twMerge('flex flex-col rounded-3xl bg-base-300 justify-center items-center p-4 overflow-hidden h-full',
classNames({
"bg-base-200": !data.path,
"border-dashed border-base-content/40 border-2": !data.path && !focused,
"border-dashed border-base-content/40 border-2": !data.path && data.isCritical && !focused,
"border-dashed border-accent border-4": focused
}))
}>
<p className='flex gap-2 font-semibold'>
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className='text-warning' />}
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className={data.isCritical ? 'text-warning' : 'text-base-content/40'} />}
{!!data.pathCover && <img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${data.pathCover}`}></img>}
{data.emulator}
</p>
@ -249,11 +251,11 @@ function EmulatorBadge (data: {
function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; })
{
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'>
const { data: autoEmulators } = useQuery(autoEmulatorsQuery);
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators && autoEmulators.length > 0 });
return <div ref={ref} className='grid grid-cols-[repeat(auto-fit,14rem)] auto-rows-[4rem] gap-2 justify-center-safe'>
<FocusContext value={focusKey}>
{autoEmulators?.data?.map(e => <EmulatorBadge key={e.emulator} addOverride={data.addOverride} pathCover={e.path_cover ?? undefined} path={e.path} exists={e.exists} emulator={e.emulator} />)}
{autoEmulators?.map(e => <EmulatorBadge key={e.emulator} isCritical={e.isCritical} addOverride={data.addOverride} pathCover={e.path_cover ?? undefined} path={e.path?.path} exists={e.exists} emulator={e.emulator} />)}
</FocusContext>
</div>;
}

View file

@ -17,6 +17,7 @@ function RouteComponent ()
return <ul ref={ref} className="list rounded-box gap-2">
<FocusContext value={focusKey}>
<LocalOption id="backgroundBlur" label="Background Blur" type='checkbox'></LocalOption>
<LocalOption id="backgroundAnimation" label="Background Animation" type='checkbox'></LocalOption>
<LocalOption id="theme" label="Theme" type='dropdown' values={['dark', 'light', 'auto']}></LocalOption>
</FocusContext>
</ul>;

View file

@ -7,7 +7,7 @@ import
{
Outlet,
createFileRoute,
useMatchRoute,
useMatch,
useNavigate,
} from "@tanstack/react-router";
import { ViewTransitionOptions } from "@tanstack/router-core";
@ -29,7 +29,6 @@ import { PopSource } from "../../scripts/spatialNavigation";
import { Router } from "../..";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import Shortcuts from "@/mainview/components/Shortcuts";
import useActiveControl from "@/mainview/scripts/gamepads";
export const Route = createFileRoute("/settings")({
component: SettingsUI,
@ -49,10 +48,13 @@ function MenuItem (data: {
label: string;
})
{
const matchRoute = useMatchRoute();
const navigate = useNavigate();
const acitve = matchRoute({ to: data.route });
const handleNonFocusSelect = () => navigate({ to: data.return ? PopSource('settings') ?? data.route : data.route, viewTransition: data.viewTransition });
const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });;
const handleNonFocusSelect = () =>
{
const { to, search } = PopSource('settings');
navigate({ to: data.return ? to ?? data.route : data.route, viewTransition: data.viewTransition, search: data.return ? search : undefined });
};
const { ref, focusSelf, focused } = useFocusable({
focusKey: `menu-item-${data.route}`,
forceFocus: !!acitve,
@ -69,29 +71,26 @@ function MenuItem (data: {
? handleNonFocusSelect
: undefined,
});
const { isPointer } = useActiveControl();
return (
<li
ref={ref}
key={data.route}
onClick={data.focusSelect ? focusSelf : handleNonFocusSelect}
onFocus={focusSelf}
className={data.className}
className={twMerge("flex group-focusable cursor-pointer", data.className)}
>
<div
aria-selected={!!acitve}
className={twMerge(
"group rounded-full p-3 md:pl-5 text-base-content/80",
"rounded-full p-3 md:pl-5 text-base-content/80 focusable focusable-accent in-focused:font-semibold aria-selected:bg-primary aria-selected:text-primary-content w-full hover:bg-primary/40 active:bg-base-content active:text-base-100",
classNames({
"bg-primary text-primary-content": acitve,
"font-semibold sm:ring-4 md:ring-7 ring-accent": focused && !isPointer,
"bg-secondary text-secondary-content ring-primary": data.return && focused,
"in-focused:bg-secondary in-focused:text-secondary-content in-focused:ring-primary": data.return,
}),
data.linkClassName,
)}
>
<div className={twMerge("flex gap-2 items-center transition-all", classNames({
"scale-110": focused || acitve
}))}>
<div className="flex gap-2 items-center transition-all in-focused:scale-110">
{data.icon}
<div className="sm:hidden md:inline">{data.label}</div>
</div>
@ -110,7 +109,7 @@ function SettingsMenu (data: {})
return <ul
ref={ref}
className="menu portrait:menu-horizontal md:menu-xl landscape:flex-nowrap bg-base-200 sm:p-2 md:p-4 sm:portrait:gap-0 sm:landscape:gap-0 md:landscape:w-128 md:gap-2! rounded-4xl overflow-auto portrait:w-full"
className="flex flex-col portrait:flex-row md:text-2xl landscape:flex-nowrap bg-base-200 sm:p-2 md:p-4 sm:portrait:gap-0 sm:landscape:gap-0 md:landscape:w-128 md:gap-2! rounded-4xl overflow-auto portrait:w-full"
>
<FocusContext value={focusKey}>
<MenuItem
@ -158,12 +157,12 @@ function SettingsMenu (data: {})
function HandleGoBack ()
{
const source = PopSource('settings');
if (source)
const { to, search } = PopSource('settings');
if (to)
{
console.log("Found source ", source, " to go back to");
console.log("Found source ", to, " to go back to");
}
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
Router.navigate({ to: to ?? "/", viewTransition: { types: ['zoom-out'] }, search });
}
@ -184,7 +183,7 @@ export function SettingsUI ()
return (
<FocusContext.Provider value={focusKey}>
<div ref={ref} className="bg-base-100 flex flex-col w-full h-full md:p-4">
<div ref={ref} className="bg-base-100 flex flex-col w-full h-full sm:p-2 md:p-4">
<div className="flex landscape:flex-row portrait:flex-col-reverse grow overflow-hidden">
<div id="Menu" className="flex flex-row landscape:h-full md:landscape:w-56">
<SettingsMenu />

View file

@ -0,0 +1,198 @@
import { useEffect, useRef, useState } from "react";
import
{
useFocusable,
FocusContext,
setFocus,
} from "@noriginmedia/norigin-spatial-navigation";
import { createFileRoute } from "@tanstack/react-router";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import { Router } from "@/mainview";
import Shortcuts from "@/mainview/components/Shortcuts";
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
import { PopSource } from "@/mainview/scripts/spatialNavigation";
import { systemApi } from "@/mainview/scripts/clientApi";
import { storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@/mainview/scripts/queries";
import { Button } from "@/mainview/components/options/Button";
import { ChevronDown, Download, Info, Settings } from "lucide-react";
import { ContextDialog, ContextList, DialogEntry } from "@/mainview/components/ContextDialog";
import { FrontEndEmulator, RPC_URL } from "@/shared/constants";
import Screenshots from "@/mainview/components/Screenshots";
import { HeaderUI } from "@/mainview/components/Header";
import { useQuery } from "@tanstack/react-query";
import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection";
import { scrollIntoViewHandler, useStickyDataAttr } from "@/mainview/scripts/utils";
export const Route = createFileRoute('/store/details/emulator/$id')({
component: RouteComponent,
async loader (ctx)
{
const emulator = await ctx.context.queryClient.fetchQuery(storeEmulatorDetailsQuery(ctx.params.id));
return { emulator };
}
});
function HomePageLink (data: { homepage: string; })
{
const { ref } = useFocusable({ focusKey: 'homepage-link' });
return <a ref={ref} className="text-lg text-info cursor-pointer focusable focusable-accent focusable-hover bg-base-200 rounded-full px-4 py-1" onClick={() => systemApi.api.system.open.post({ url: data.homepage })}>{data.homepage}</a>;
}
function TitleArea (data: { emulator: FrontEndEmulator; })
{
const [installOpen, setInstallOpen] = useState(false);
const installOptions: DialogEntry[] = [];
const { ref, focusKey } = useFocusable({
focusKey: 'title-area',
preferredChildFocusKey: "install-btn",
onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ behavior: "smooth", block: 'end' }); }
});
return <div ref={ref} className="flex flex-wrap gap-4 items-center">
<FocusContext value={focusKey}>
<img className="size-32" src={data.emulator.logo}></img>
<div className="flex flex-col grow justify-start gap-1">
<h1 className="text-4xl font-semibold">{data.emulator.name}</h1>
<p className="flex gap-2">
{data.emulator.systems.map(({ id, name, icon }) =>
{
return <div key={id} className="flex gap-1 items-center text-base-content/35 mt-0.5">
{!!icon && <img className="size-6 p-1 bg-base-200 rounded-full" src={`${RPC_URL(__HOST__)}${icon}`} />}
<p className="text-nowrap text-ellipsis overflow-hidden">{name}</p>
</div>;
})}
</p>
<div className="flex pt-2 gap-1">
<HomePageLink homepage={data.emulator.homepage} />
</div>
</div>
<Button style="accent" id="install-btn" className="px-8 py-3 gap-4 rounded-4xl focusable focusable-accent" onAction={() => setInstallOpen(true)} >{
data.emulator.exists ?
<><Settings /> Options</> :
<><Download />Install</>
}
<div className="divider divider-horizontal divider-neutral m-0 opacity-20"></div>
<ChevronDown />
</Button>
<ContextDialog id="install-context-menu" open={installOpen} close={() =>
{
setInstallOpen(false);
setFocus("install-btn");
}}>
<ContextList options={installOptions}>
</ContextList>
</ContextDialog>
</FocusContext>
</div>;
}
function Description (data: { emulator: FrontEndEmulator; })
{
return <div className="flex-col sm:px-8 md:px-16 pt-8 sm:pb-8 md:pb-12 bg-base-100">
<p>{data.emulator.description}</p>
</div>;
}
export function RouteComponent ()
{
const { id } = Route.useParams();
const headerRef = useRef(null);
const sentinelRef = useRef(null);
const { ref, focusKey, focusSelf } = useFocusable({
focusKey: `GAME_DETAIL_${id}`,
trackChildren: true,
preferredChildFocusKey: 'title-area'
});
const { emulator } = Route.useLoaderData();
const { data: recommended } = useQuery(storeEmulatorsRecommendedQuery);
useShortcuts(focusKey, () => [{
label: "Return",
action: () =>
{
const { to, search } = PopSource('store-details');
Router.navigate({ to: to ?? '/store/tab', viewTransition: { types: ['zoom-out'] }, search: search ?? { focus: id } });
},
button: GamePadButtonCode.B
}]);
useEffect(() =>
{
focusSelf();
}, []);
const { shortcuts } = useShortcutContext();
useStickyDataAttr(headerRef, sentinelRef, ref);
return (
<AnimatedBackground ref={ref} className="bg-base-100" scrolling>
<FocusContext.Provider value={focusKey}>
<div className="flex flex-col min-h-full z-10">
<div ref={sentinelRef} className="h-0" />
<div ref={headerRef} className='sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15'>
<HeaderUI />
</div>
<div className=" w-full sm:px-8 md:px-16 pb-8 pt-12">
<TitleArea emulator={emulator} />
</div>
<div className="flex flex-col bg-base-200 pt-4 min-h-0 grow text-lg">
<Screenshots screenshots={emulator.screenshots} onFocus={scrollIntoViewHandler({ block: 'end' })} />
<Description emulator={emulator} />
</div>
<div className='mobile:hidden bg-gradient'></div>
<div className='mobile:hidden bg-noise'></div>
</div>
<div className="flex flex-col bg-base-100 py-4">
<div className="divider"> <Info className="size-12" /> Stats</div>
<ul className="flex flex-col table table-lg sm:px-8 md:px-16">
{!!emulator.keywords &&
<li className="flex flex-wrap gap-2 items-center">
<div className="font-semibold">Tags:</div>
<div className="flex flex-wrap gap-2">{emulator.keywords?.map(k => <span className="rounded-full bg-base-200 px-3 py-1">{k}</span>)}</div>
</li>
}
{!!emulator.status.source &&
<li>
<div>Source</div>
<div>{emulator.status.source}</div>
</li>
}
{!!emulator.status.location &&
<li>
<div>Location</div>
<div>{emulator.status.location}</div>
</li>
}
</ul>
<div className="relative mt-16 bg-base-200">
{recommended && <EmulatorsSection
id={`${id}-recommended`}
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
<h2 className="font-bold uppercase tracking-widest">
More Emulators
</h2></>}
onFocus={scrollIntoViewHandler({ block: 'center' })}
onSelect={(id, focus) =>
{
setFocus("title-area");
Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } });
}}
emulators={recommended.map(em => ({
name: em.name,
id: em.name,
installed: em.exists,
logo: em.logo,
systems: em.systems
} satisfies ShopFrontEndEmulator))} />}
</div>
</div>
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-10'>
<Shortcuts shortcuts={shortcuts} />
</div>
</FocusContext.Provider>
</AnimatedBackground >
);
}

View file

@ -0,0 +1,80 @@
import { storeEmulatorsQuery } from '@/mainview/scripts/queries';
import { createFileRoute, useSearch } from '@tanstack/react-router';
import { Joystick } from 'lucide-react';
import { useContext, useEffect } from 'react';
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard';
import { StoreContext } from '@/mainview/scripts/contexts';
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
export const Route = createFileRoute('/store/tab/emulators')({
component: RouteComponent,
pendingComponent: PendingComponent,
async loader ({ context })
{
const emulators = await context.queryClient.fetchQuery(storeEmulatorsQuery);
return { emulators };
},
});
function PendingComponent ()
{
return <section className="px-6 py-4">
<div className="divider text-info">
<Joystick className='size-12' />
<h2 className="font-bold uppercase tracking-widest">
Emulators
</h2>
</div>
{/* Cards */}
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[12rem] py-2 px-4 gap-4 justify-center-safe">
{[1, 2, 3, 4, 5, 6].map(i => <div key={i} className="skeleton h-36 rounded-2xl" />)}
</div>
</section>;
}
function RouteComponent ()
{
const { focus } = useSearch({ from: '/store/tab' });
const { ref, focusKey, focusSelf } = useFocusable({
focusKey: "main-area",
preferredChildFocusKey: focus
});
const storeContext = useContext(StoreContext);
const { emulators } = Route.useLoaderData();
useEffect(() =>
{
if (focus && !GetFocusedElement(getCurrentFocusKey()))
{
focusSelf({ instant: true });
}
}, [focus]);
return <>
<section ref={ref} className="px-6 py-4 animate-slide-up">
<FocusContext value={focusKey}>
<div className="divider text-info">
<Joystick className='size-12' />
<h2 className="font-bold uppercase tracking-widest">
Emulators
</h2>
</div>
{/* Cards */}
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[12rem] py-2 md:px-4 gap-4 justify-center-safe">
{emulators && emulators.map((data) => (
<StoreEmulatorCard
id={data.name}
key={data.name}
emulator={data}
onFocus={({ node, details }) => { node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' }); }}
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
/>
))}
</div>
</FocusContext>
</section>
</>;
}

View file

@ -0,0 +1,136 @@
import { StoreGameCard } from '@/mainview/components/store/GamesSection';
import { FocusContext, getCurrentFocusKey, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { createFileRoute, useSearch } from '@tanstack/react-router';
import { Gamepad, Gamepad2, HardDrive, Save } from 'lucide-react';
import { JSX, useContext, useEffect, useRef, useState } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { StoreContext } from '@/mainview/scripts/contexts';
import { basename, dirname, extname } from 'pathe';
import { rommApi } from '@/mainview/scripts/clientApi';
import { FrontEndGameType, RPC_URL } from '@/shared/constants';
import CardElement from '@/mainview/components/CardElement';
import { FOCUS_KEYS } from '@/mainview/scripts/types';
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
import { useIntersectionObserver } from 'usehooks-ts';
const staleTime = 24 * 60 * 60 * 1000;
export const Route = createFileRoute('/store/tab/games')({
component: RouteComponent,
async loader (ctx)
{
/*const gamesManifest = await ctx.context.queryClient.fetchQuery({
queryKey: ['store-games-manifest'], queryFn: async () =>
{
const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json());
return store.tree.filter((e: any) =>
{
if (e.type === 'blob' && e.path !== "featured.json")
{
return true;
}
return false;
}) as [];
}, staleTime
});
return { gamesManifest };*/
},
});
function LoadMoreButton (data: { isFetching: boolean; lastId?: string; } & FocusParams & InteractParams)
{
const handleAction = (e?: Event) =>
{
data.onAction?.(e);
if (data.lastId && focused)
setFocus(FOCUS_KEYS.GAME_CARD(data.lastId));
};
const { ref, focusKey, focused } = useFocusable({
focusKey: 'load-more-btn',
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details),
onEnterPress: handleAction
});
const { ref: intersct } = useIntersectionObserver({
onChange: (isIntersecting, entry) =>
{
if (isIntersecting)
{
handleAction();
}
}
});
return <div ref={(r) =>
{
ref.current = r;
intersct(r);
}} className='flex bg-base-100 game-card focusable focusable-accent focusable-hover text-2xl justify-center items-center cursor-pointer' onClick={handleAction} id='load-more-btn'>{data.isFetching ? <span className="loading loading-spinner loading-xl"></span> : "Load More"}</div>;
}
function RouteComponent ()
{
const { focus } = useSearch({ from: '/store/tab' });
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus });
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery<{ data: FrontEndGameType[], nextPage: number; }>({
initialPageParam: 0,
queryKey: ['store-games'],
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
queryFn: async (data) =>
{
const pageParam = data.pageParam as number;
const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } });
if (error) throw error;
return { data: games.games, nextPage: pageParam + 1 };
}
});
useEffect(() =>
{
if (focus && !GetFocusedElement(getCurrentFocusKey()))
{
console.log(focus);
focusSelf({ instant: true });
}
}, [focus]);
const handleFocus = (focusKey: string, node: HTMLElement, details: Record<string, any>) =>
{
node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' });
};
return <>
<section ref={ref} className="px-6 py-4 animate-slide-up">
<FocusContext value={focusKey}>
<div className="divider text-accent">
<Gamepad2 className='size-12' />
<h2 className="font-bold uppercase tracking-widest">
Games
</h2>
</div>
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[minmax(18rem,min-content)] py-2 md:px-4 gap-4 justify-center-safe">
{data?.pages.flatMap((page) => (
page.data.map((g, i) => <FrontEndGameCard onFocus={handleFocus} key={g.id.id} game={g} index={i} />))
)}
<LoadMoreButton
lastId={data?.pages.at(-1)?.data.at(-1)?.id.id}
onFocus={handleFocus}
isFetching={isFetchingNextPage}
onAction={() =>
{
if (isFetchingNextPage)
return;
fetchNextPage();
}} />
</div>
</FocusContext>
</section>
</>;
}

View file

@ -0,0 +1,185 @@
import { createFileRoute, ErrorComponentProps, useSearch } from '@tanstack/react-router';
import { useFocusable, FocusContext, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { MissingEmulatorsSection } from "../../../components/store/MissingEmulatorsSection";
import { EmulatorsSection } from "../../../components/store/EmulatorsSection";
import { GamesSection } from "../../../components/store/GamesSection";
import { StatsSection } from "../../../components/store/StatsSection";
import { FrontEndGameTypeDetailed, RPC_URL } from '@/shared/constants';
import { autoEmulatorsQuery, storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@/mainview/scripts/queries';
import { useContext, useEffect, useRef, useState } from 'react';
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
import { StoreContext } from '@/mainview/scripts/contexts';
import { useInterval } from 'usehooks-ts';
import { Button } from '@/mainview/components/options/Button';
import { HardDrive, Search } from 'lucide-react';
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
export const Route = createFileRoute('/store/tab/')({
component: RouteComponent,
pendingComponent: LoadingSkeleton,
errorComponent: ErrorComponent,
loader: async ({ context }) =>
{
const autoEmulators = await context.queryClient.fetchQuery(autoEmulatorsQuery);
const crutialEmulators = autoEmulators?.filter(e => !e.exists && e.isCritical);
const featuredGames = await await context.queryClient.fetchQuery(storeFeaturedGamesQuery);
const recommendedEmulators = await context.queryClient.fetchQuery(storeEmulatorsRecommendedQuery);
return { crutialEmulators, recommendedEmulators, featuredGames };
}
});
function ErrorComponent (data: ErrorComponentProps)
{
return <div className="flex items-center justify-center h-64">
<div role="alert" className="alert alert-error alert-soft max-w-sm">
<span>Failed to load store data.</span>
<p>{data.error.message}</p>
</div>
</div>;
}
// ── Loading skeleton ───────────────────────────────────────────────────────
function LoadingSkeleton ()
{
return (
<div className="flex flex-col gap-6 px-6 py-4 animate-pulse">
{/* Missing section */}
<div className="grid grid-cols-3 gap-3">
{[1, 2, 3].map((i) => <div key={i} className="skeleton h-40 rounded-2xl" />)}
</div>
{/* Emulators */}
<div className="grid grid-cols-6 gap-3">
{[1, 2, 3, 4, 5, 6].map((i) => <div key={i} className="skeleton h-36 rounded-2xl" />)}
</div>
{/* Games */}
<div className="grid grid-cols-4 gap-3">
{[1, 2, 3, 4].map((i) => <div key={i} className="skeleton h-44 rounded-2xl" />)}
</div>
</div>
);
}
function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
{
const [selectedGame, setSelectedGame] = useState(new Date().getSeconds() % data.games.length);
const [nextSwitch, setNextSwitch] = useState(new Date().getTime() + 10000);
const progressRef = useRef<HTMLProgressElement>(null);
const { ref, focusKey } = useFocusable({ focusKey: 'main-featured-area' });
const game = data.games[selectedGame];
useInterval(() =>
{
setSelectedGame(current => (current + 1) % data.games.length);
setNextSwitch(new Date().getTime() + 10000);
}, 10000);
useInterval(() =>
{
var time = (nextSwitch - new Date().getTime()) / 10000;
if (progressRef.current)
progressRef.current.value = time;
}, 10);
const storeContext = useContext(StoreContext);
const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`);
previewUrl.searchParams.set('blur', '16');
return <div ref={ref} className='flex sm:flex-wrap md:flex-nowrap group-focusable p-4 mt-4 gap-4'>
<FocusContext value={focusKey}>
<div key={selectedGame} className="flex transition-all duration-500 flex-col sm:32 md:h-64 rounded-3xl overflow-hidden shadow-black/5 shadow-xl grow">
<div className='flex relative h-full overflow-hidden'>
<div className='absolute w-full h-full z-0 bg-base-200'>
<img key={selectedGame}
className='w-full h-full object-cover transition-all duration-500 ease-out scale-110 opacity-0 z-0 mask-l-from-0'
src={previewUrl.href}
onLoad={(e) =>
{
e.currentTarget.classList.toggle('opacity-0', false);
e.currentTarget.classList.toggle('scale-110', false);
}}
/>
</div>
<div key={selectedGame} className='flex sm:flex-wrap md:flex-nowrap grow z-1 p-8 opacity-0 animate-fade-in h-full items-end gap-4 sm:justify-end md:justify-between'>
<div className='flex gap-4 max-h-full z-1 grow'>
<div className='flex sm:portrait:flex-wrap sm:portrait:grow gap-4 max-h-full justify-center'>
<div className='relative rounded-3xl max-w-xs overflow-hidden'>
<div className='flex absolute bottom-4 left-4 size-8 bg-base-content text-base-100 rounded-full items-center justify-center shadow-lg'><HardDrive /></div>
<img className='object-cover w-full h-full' src={`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`} />
</div>
<div className='flex flex-col gap-2 py-3 max-w-md'>
<h1 className='font-semibold text-3xl'>{game.name}</h1>
<p className='overflow-hidden text-wrap text-ellipsis text-base-content/60'>{game.summary}</p>
</div>
</div>
</div>
<Button onAction={() => storeContext.showDetails('game', game.id.source, game.id.id, focusKey)} className='px-6 py-3 text-2xl! z-1 gap-2 focusable focusable-primary' id={'play-featured-btn'}> <Search /> Details</Button>
</div>
</div>
{data.children}
</div>
<div className='sm:flex sm:flex-wrap grow justify-stretch md:grid sm:landscape:grid-flow-col sm:auto-cols-[minmax(8rem,1fr)] md:grid-flow-row! auto-rows-fr landscape:min-w-xs gap-4'>
{data.games.map((g, i) =>
<div key={i} data-active={i === selectedGame} className='flex grow flex-col gap-1 transition-opacity duration-500 data-[active=true]:opacity-50 rounded-3xl bg-base-100 p-4 justify-center shadow-md'>
<div className='flex gap-2'>
<img className='size-6' src={`${RPC_URL(__HOST__)}${game.path_platform_cover}`}></img>
<div className='flex gap-2 items-center grow'>
{g.name}
</div>
</div>
{i === selectedGame && <progress ref={progressRef} className="progress progress-accent w-full" style={{ animationName: '' }} value={0} max="1"></progress>}
</div>)}
</div>
</FocusContext>
</div>;
}
export function RouteComponent ()
{
const { focus } = useSearch({ from: '/store/tab' });
const { crutialEmulators, recommendedEmulators, featuredGames } = Route.useLoaderData();
const { focusKey, ref, focusSelf } = useFocusable({ focusKey: 'main-area', preferredChildFocusKey: focus ?? "recommended-emulators" });
const storeContext = useContext(StoreContext);
useEffect(() =>
{
if (focus && !GetFocusedElement(getCurrentFocusKey()))
{
focusSelf({ instant: true });
}
}, [focus]);
return (
<div className='animate-slide-up' ref={ref}>
<FocusContext value={focusKey}>
{!!featuredGames && <Main games={featuredGames} />}
{crutialEmulators.length > 0 && <MissingEmulatorsSection
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
emulators={crutialEmulators} />}
<div className='pt-4'>
<EmulatorsSection
id="recommended-emulators"
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
onFocus={scrollIntoViewHandler({ block: 'end' })}
emulators={recommendedEmulators} />
</div>
<GamesSection
onSelect={(id, focus) => storeContext.showDetails('game', id.source, id.id, focus)}
onFocus={scrollIntoViewHandler({ block: 'center' })}
games={featuredGames}
/>
<StatsSection
romCount={1240}
missingCount={crutialEmulators.length}
/>
</FocusContext>
</div>
);
}

View file

@ -0,0 +1,156 @@
import { Router } from '@/mainview';
import { FilterUI } from '@/mainview/components/Filters';
import { HeaderUI } from '@/mainview/components/Header';
import Shortcuts from '@/mainview/components/Shortcuts';
import { StoreContext } from '@/mainview/scripts/contexts';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '@/mainview/scripts/shortcuts';
import { SaveSource } from '@/mainview/scripts/spatialNavigation';
import { mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { useMatchRoute } from '@tanstack/react-router';
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import { Settings } from 'lucide-react';
import { useEffect, useRef } from 'react';
import z from 'zod';
export const Route = createFileRoute('/store/tab')({
component: RouteComponent,
validateSearch: zodValidator(z.object({ focus: z.string().optional() }))
});
function useIsSettings (subPath: string)
{
"use no memo";
const matchRoute = useMatchRoute();
const isSettings = !!matchRoute({
to: `/store/tab/${subPath}` as any
});
return isSettings;
}
function TopArea (data: { filters: Record<string, FilterOption>; })
{
const { ref, focusKey } = useFocusable({
focusKey: 'top-area',
preferredChildFocusKey: 'store-tabs',
onFocus: () =>
{
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'end' });
}
});
return <div ref={ref}>
<FocusContext value={focusKey}>
<div className='w-full'>
<FilterUI containerClassName='flex w-full justify-center' id="store-tabs" options={data.filters} setSelected={(s) => Router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}` })} />
</div>
</FocusContext>
</div>;
}
function RouteComponent ()
{
// Root spatial nav container
const { ref, focusKey, focusSelf } = useFocusable({
focusKey: "STORE_ROOT",
trackChildren: true,
preferredChildFocusKey: 'top-area'
});
const headerRef = useRef(null);
const sentinelRef = useRef(null);
const filters: Record<string, FilterOption> = {
home: { label: "Home", selected: useIsSettings(''), },
emulators: { label: "Emulators", selected: useIsSettings('emulators') },
games: { label: "Games", selected: useIsSettings('games') }
};
useShortcuts(focusKey, () => [{
label: "Return",
action: () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } }),
button: GamePadButtonCode.B
},
{
action: () =>
{
const filterKeys = Object.keys(filters);
const filterIndex = Math.max(0, filterKeys.findIndex(f => filters[f].selected));
const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1);
const newFilter = filterKeys[selectedFilterIndex];
Router.navigate({ to: `/store/tab/${newFilter === 'home' ? '' : newFilter}` });
},
button: GamePadButtonCode.R1
},
{
action: () =>
{
const filterKeys = Object.keys(filters);
const filterIndex = Math.max(0, filterKeys.findIndex(f => filters[f as any].selected));
const selectedFilterIndex = Math.max(0, filterIndex - 1,);
const newFilter = filterKeys[selectedFilterIndex];
Router.navigate({ to: `/store/tab/${newFilter === 'home' ? '' : newFilter}` });
},
button: GamePadButtonCode.L1
}], [filters]);
const { shortcuts } = useShortcutContext();
const { focus } = Route.useSearch();
useEffect(() =>
{
if (!focus)
{
focusSelf();
}
}, []);
const handleDetails = (type: string, source: string, id: string, focus: string) =>
{
if (type === 'emulator')
{
SaveSource('store-details', { url: location.hash.replaceAll(/#|(\?.+)/g, ''), search: { focus } });
Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } });
}
else if (type === 'game')
{
console.log(source, id);
SaveSource('details', { url: location.hash.replaceAll(/#|(\?.+)/g, ''), search: { focus } });
Router.navigate({ to: '/game/$source/$id', params: { source: source, id: id }, viewTransition: { types: ['zoom-in'] } });
}
};
const match = Route.useMatch();
const goToSettings = () =>
{
SaveSource('settings', { url: match.pathname, search: { focus: "settings" } });
Router.navigate({ to: '/settings', viewTransition: { types: ['zoom-in'] } });
};
const isMobile = mobileCheck();
useStickyDataAttr(headerRef, sentinelRef, ref);
return <div ref={ref} className='overflow-y-scroll w-screen h-screen' >
<StoreContext value={{ showDetails: handleDetails }} >
<FocusContext.Provider value={focusKey}>
<div className="relative flex flex-col min-h-screen text-base-content z-10" >
<div ref={sentinelRef} className="h-0" />
<div ref={headerRef} className='sticky p-2 group top-0 not-mobile:data-stuck:backdrop-blur-xl z-15 mobile:data-stuck:bg-base-300'>
<HeaderUI buttons={[{ icon: <Settings />, id: "settings", action: goToSettings, external: true }]} />
</div>
<TopArea filters={filters} />
<Outlet />
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-15'>
<Shortcuts shortcuts={shortcuts} />
</div>
{!isMobile && <>
<div className='bg-gradient'></div>
<div className='bg-noise'></div>
</>}
</div>
</FocusContext.Provider>
</StoreContext>
</div >;
}