feat: implemented a basic store and emulatorjs
This commit is contained in:
parent
2f32cbc730
commit
7286541822
121 changed files with 5900 additions and 1092 deletions
80
src/mainview/routes/store/tab/emulators.tsx
Normal file
80
src/mainview/routes/store/tab/emulators.tsx
Normal 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>
|
||||
</>;
|
||||
}
|
||||
136
src/mainview/routes/store/tab/games.tsx
Normal file
136
src/mainview/routes/store/tab/games.tsx
Normal 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>
|
||||
</>;
|
||||
}
|
||||
185
src/mainview/routes/store/tab/index.tsx
Normal file
185
src/mainview/routes/store/tab/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
src/mainview/routes/store/tab/route.tsx
Normal file
156
src/mainview/routes/store/tab/route.tsx
Normal 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 >;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue