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

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