refactor: moved queries to their own file

This commit is contained in:
Simeon Radivoev 2026-03-17 12:57:11 +02:00
parent 364bc9d0be
commit cf6fff6fac
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
83 changed files with 1107 additions and 852 deletions

View file

@ -1,5 +1,5 @@
import { storeEmulatorsQuery } from '@/mainview/scripts/queries';
import { createFileRoute, useSearch } from '@tanstack/react-router';
import { Joystick } from 'lucide-react';
import { useContext, useEffect } from 'react';
@ -7,33 +7,13 @@ import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/no
import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard';
import { StoreContext } from '@/mainview/scripts/contexts';
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
import { useQuery } from '@tanstack/react-query';
import queries from '@/mainview/scripts/queries';
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' });
@ -42,7 +22,7 @@ function RouteComponent ()
preferredChildFocusKey: focus
});
const storeContext = useContext(StoreContext);
const { emulators } = Route.useLoaderData();
const { data: emulators } = useQuery(queries.store.storeEmulatorsQuery);
useEffect(() =>
{
@ -64,7 +44,7 @@ function RouteComponent ()
</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) => (
{emulators?.map((data) => (
<StoreEmulatorCard
id={data.name}
key={data.name}
@ -72,7 +52,7 @@ function RouteComponent ()
onFocus={({ node, details }) => { node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' }); }}
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
/>
))}
)) ?? Array.from({ length: 10 }).map((_, i) => <div key={i} className="skeleton rounded-3xl" />)}
</div>
</FocusContext>
</section>

View file

@ -1,95 +1,23 @@
import { StoreGameCard } from '@/mainview/components/store/GamesSection';
import { FocusContext, getCurrentFocusKey, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { FocusContext, getCurrentFocusKey, 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 { Gamepad2 } from 'lucide-react';
import { useEffect } 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;
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
import queries from '@/mainview/scripts/queries';
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 };*/
},
component: RouteComponent
});
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 };
}
});
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(queries.store.storeGamesInfiniteQuery);
useEffect(() =>
{
@ -115,17 +43,21 @@ function RouteComponent ()
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">
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[21rem] 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} />))
)}
) ?? Array.from({ length: 20 }).map((_, i) => <div key={i} className="flex flex-col gap-4">
<div className="skeleton grow w-full"></div>
<div className="skeleton h-4 w-[80%]"></div>
<div className="skeleton h-4 w-[40%]"></div>
</div>)}
<LoadMoreButton
lastId={data?.pages.at(-1)?.data.at(-1)?.id.id}
onFocus={handleFocus}
isFetching={isFetchingNextPage}
isFetching={isFetchingNextPage || isFetching}
onAction={() =>
{
if (isFetchingNextPage)
if (isFetchingNextPage || isFetching)
return;
fetchNextPage();
}} />

View file

@ -1,11 +1,11 @@
import { createFileRoute, ErrorComponentProps, useSearch } from '@tanstack/react-router';
import { createFileRoute, 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 queries from '@/mainview/scripts/queries';
import { useContext, useEffect, useRef, useState } from 'react';
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
import { StoreContext } from '@/mainview/scripts/contexts';
@ -13,66 +13,34 @@ import { useInterval } from 'usehooks-ts';
import { Button } from '@/mainview/components/options/Button';
import { HardDrive, Search } from 'lucide-react';
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
import { useQuery } from '@tanstack/react-query';
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 };
}
component: RouteComponent
});
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 ()
function Main (data: { games?: FrontEndGameTypeDetailed[]; })
{
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 [selectedGame, setSelectedGame] = useState(0);
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];
const game = data.games ? data.games[selectedGame] : undefined;
useInterval(() =>
{
setSelectedGame(current => (current + 1) % data.games.length);
if (!data.games) return;
setSelectedGame(current => (current + 1) % data.games!.length);
setNextSwitch(new Date().getTime() + 10000);
}, 10000);
useEffect(() =>
{
if (!data.games) return;
setSelectedGame(new Date().getSeconds() % data.games.length);
}, [data.games]);
useInterval(() =>
{
var time = (nextSwitch - new Date().getTime()) / 10000;
@ -81,18 +49,18 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
}, 10);
const storeContext = useContext(StoreContext);
const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`);
previewUrl.searchParams.set('blur', '16');
const previewUrl = data.games ? new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`) : undefined;
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">
{game ? <div key={selectedGame} className="flex transition-all duration-500 flex-col rounded-3xl overflow-hidden shadow-black/5 shadow-xl w-full">
<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}
src={previewUrl?.href}
onLoad={(e) =>
{
e.currentTarget.classList.toggle('opacity-0', false);
@ -101,11 +69,11 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
/>
</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 gap-4 max-h-full z-1 grow md:h-full'>
<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='relative rounded-3xl max-w-xs h-48 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}`} />
{!!data.games && <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>
@ -117,21 +85,19 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
<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> : <div className='skeleton w-full rounded-3xl grow sm:h-64 z-15' />}
<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) =>
{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>
<img className='size-6' src={`${RPC_URL(__HOST__)}${g.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>) ?? Array.from({ length: 3 }).map((_, i) => <div key={i} className="skeleton rounded-3xl"></div>)}
</div>
</FocusContext>
</div>;
@ -140,7 +106,9 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
export function RouteComponent ()
{
const { focus } = useSearch({ from: '/store/tab' });
const { crutialEmulators, recommendedEmulators, featuredGames } = Route.useLoaderData();
const { data: crucialEmulators, isSuccess } = useQuery({ ...queries.settings.autoEmulatorsQuery, select: (data) => data.filter(e => !e.exists && e.isCritical) });
const { data: featuredGames } = useQuery(queries.store.storeFeaturedGamesQuery);
const { data: recommendedEmulators } = useQuery(queries.store.storeEmulatorsRecommendedQuery);
const { focusKey, ref, focusSelf } = useFocusable({ focusKey: 'main-area', preferredChildFocusKey: focus ?? "recommended-emulators" });
const storeContext = useContext(StoreContext);
@ -152,15 +120,15 @@ export function RouteComponent ()
focusSelf({ instant: true });
}
}, [focus]);
}, [focus, isSuccess]);
return (
<div className='animate-slide-up' ref={ref}>
<FocusContext value={focusKey}>
{!!featuredGames && <Main games={featuredGames} />}
{crutialEmulators.length > 0 && <MissingEmulatorsSection
{<Main games={featuredGames} />}
{!!crucialEmulators && crucialEmulators?.length > 0 && <MissingEmulatorsSection
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
emulators={crutialEmulators} />}
emulators={crucialEmulators} />}
<div className='pt-4'>
<EmulatorsSection
id="recommended-emulators"
@ -177,7 +145,7 @@ export function RouteComponent ()
<StatsSection
romCount={1240}
missingCount={crutialEmulators.length}
missingCount={crucialEmulators?.length ?? 0}
/>
</FocusContext>
</div>