feat: Implemented download page for downloading roms from various sources using plugins. Added support for internet archive external plugin. feat: Added tasks page to track running tasks/downloads feat: Added tanstack caching feat: Added quick play action Fixes #6 feat: Added quick emulator launch action fix: Made task queue only support 1 task per group and task ID should now be unique
132 lines
5.1 KiB
TypeScript
132 lines
5.1 KiB
TypeScript
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
import { Gamepad2, HardDrive } from 'lucide-react';
|
|
import { JSX, useEffect } from 'react';
|
|
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
|
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
|
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
|
import { storeGamesInfiniteQuery } from '@queries/store';
|
|
import InvalidStoreError from '@/mainview/components/store/InvalidStoreError';
|
|
import { CardList, GameMetaExtra } from '@/mainview/components/CardList';
|
|
import { RPC_URL } from '@/shared/constants';
|
|
import { GameListFilterType, FrontEndGameType } from '@simeonradivoev/gameflow-sdk/shared';
|
|
import { useSessionStorage } from 'usehooks-ts';
|
|
import { zodValidator } from '@tanstack/zod-adapter';
|
|
import z from 'zod';
|
|
import SideFilters from '@/mainview/components/SideFilters';
|
|
import { gameFiltersQuery } from '@/mainview/scripts/queries/romm';
|
|
import { isUrl } from '@/shared/utils';
|
|
|
|
export const Route = createFileRoute('/store/tab/games')({
|
|
component: RouteComponent,
|
|
errorComponent: InvalidStoreError,
|
|
validateSearch: zodValidator(z.object({
|
|
search: z.string().optional()
|
|
}))
|
|
});
|
|
|
|
function RouteComponent ()
|
|
{
|
|
const { focus } = Route.useSearch();
|
|
const [search] = useSessionStorage<string | undefined>(`${Route.to}-search`, undefined);
|
|
const navigator = useNavigate();
|
|
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus ?? 'store-games' });
|
|
const [filter, setFilter] = useSessionStorage<GameListFilterType>('store-games-filters', {});
|
|
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery(filter));
|
|
const { data: gameFilters } = useQuery(gameFiltersQuery({ source: 'store' }));
|
|
|
|
useEffect(() =>
|
|
{
|
|
setFilter(v => ({ ...v, search }));
|
|
}, [search]);
|
|
|
|
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' });
|
|
};
|
|
|
|
function handleDefaultSelect (g: FrontEndGameType)
|
|
{
|
|
navigator({ to: '/game/$source/$id', params: { id: g.id.id, source: g.id.source } });
|
|
};
|
|
|
|
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="ml-12">
|
|
<CardList grid finalElement={<LoadMoreButton
|
|
hidden
|
|
lastId={data?.pages.at(-1)?.data.at(-1)?.id}
|
|
onFocus={handleFocus}
|
|
isFetching={isFetchingNextPage || isFetching}
|
|
onAction={() =>
|
|
{
|
|
if (isFetchingNextPage || isFetching)
|
|
return;
|
|
fetchNextPage();
|
|
}} />}
|
|
games={data?.pages.flatMap((page) => page.data.map((g) =>
|
|
{
|
|
const badges: JSX.Element[] = [];
|
|
if (g.id.source === 'local')
|
|
{
|
|
badges.push(<HardDrive className="sm:size-4 md:size-8 md:p-1 m-1" />);
|
|
}
|
|
|
|
const previewUrls = g.path_covers.map(c =>
|
|
{
|
|
const url = isUrl(c) ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`);
|
|
url.searchParams.delete('ts');
|
|
return url;
|
|
});
|
|
|
|
|
|
let subtitle: string | JSX.Element | undefined = undefined;
|
|
if (g.path_platform_cover)
|
|
{
|
|
const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
|
|
platformUrl.searchParams.set('width', "64");
|
|
subtitle = <div className="flex gap-1 items-center">
|
|
{!!g.path_platform_cover && <img className="sm:hidden md:inline size-4" src={platformUrl.href} />}
|
|
<p className="opacity-80">{g.platform_display_name}</p>
|
|
</div>;
|
|
}
|
|
|
|
|
|
return {
|
|
id: `${g.id.source}@${g.id.id}`,
|
|
focusKey: `${g.id.source}@${g.id.id}`,
|
|
title: g.name ?? "",
|
|
subtitle,
|
|
previewUrls,
|
|
badges: badges,
|
|
onSelect: () => handleDefaultSelect(g),
|
|
onFocus: (k, n, d) => handleFocus(k, n, d)
|
|
} satisfies GameMetaExtra as GameMetaExtra;
|
|
})
|
|
) ?? []}
|
|
id={'store-games'} />
|
|
</div>
|
|
<div className='fixed left-2 top-52 bottom-0 sm:w-10 md:w-14 z-10'>
|
|
<SideFilters id='filter-btns' localFilter={filter} setLocalFilter={setFilter} filterValues={gameFilters} filters={{ source: 'store' }} />
|
|
</div>
|
|
</FocusContext>
|
|
</section>
|
|
</>;
|
|
}
|