feat: Implemented filtering and searching
This commit is contained in:
parent
4806f3487a
commit
444d8c4c27
49 changed files with 841 additions and 290 deletions
|
|
@ -10,6 +10,7 @@ import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { storeEmulatorsQuery } from '@queries/store';
|
||||
import InvalidStoreError from '@/mainview/components/store/InvalidStoreError';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/emulators')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -18,13 +19,14 @@ export const Route = createFileRoute('/store/tab/emulators')({
|
|||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = useSearch({ from: '/store/tab' });
|
||||
const { focus } = Route.useSearch();
|
||||
const [search] = useSessionStorage<string | undefined>(`${Route.to}-search`, undefined);
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "main-area",
|
||||
preferredChildFocusKey: focus
|
||||
});
|
||||
const storeContext = useContext(StoreContext);
|
||||
const { data: emulators } = useQuery({ ...storeEmulatorsQuery, retry: false, throwOnError: true });
|
||||
const { data: emulators } = useQuery({ ...storeEmulatorsQuery({ search }), retry: false, throwOnError: true });
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,27 +1,43 @@
|
|||
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router';
|
||||
import { Gamepad2 } from 'lucide-react';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router';
|
||||
import { Gamepad2, HardDrive } from 'lucide-react';
|
||||
import { JSX, useContext, useEffect, useState } from 'react';
|
||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
|
||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
||||
import { storeGamesInfiniteQuery } from '@queries/store';
|
||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||
import InvalidStoreError from '@/mainview/components/store/InvalidStoreError';
|
||||
import { CardList, GameMetaExtra } from '@/mainview/components/CardList';
|
||||
import { GameListFilterType, RPC_URL } from '@/shared/constants';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import z from 'zod';
|
||||
import SideFilters from '@/mainview/components/SideFilters';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/games')({
|
||||
component: RouteComponent,
|
||||
errorComponent: InvalidStoreError
|
||||
errorComponent: InvalidStoreError,
|
||||
validateSearch: zodValidator(z.object({
|
||||
search: z.string().optional()
|
||||
}))
|
||||
});
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = useSearch({ from: '/store/tab' });
|
||||
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 });
|
||||
const [filter, setFilter] = useSessionStorage<GameListFilterType>('store-games-filters', {});
|
||||
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery(filter));
|
||||
const [filterValues, setFilterValues] = useState<FrontEndFilterLists>();
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery);
|
||||
const storeContext = useContext(StoreContext);
|
||||
useEffect(() =>
|
||||
{
|
||||
setFilter(v => ({ ...v, search }));
|
||||
}, [search]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
|
@ -38,6 +54,11 @@ function RouteComponent ()
|
|||
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}>
|
||||
|
|
@ -47,19 +68,8 @@ function RouteComponent ()
|
|||
Games
|
||||
</h2>
|
||||
</div>
|
||||
<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={(k, n, d) =>
|
||||
{
|
||||
storeContext.prefetchDetails('game', g.id.source, g.id.id);
|
||||
handleFocus(k, n, d);
|
||||
}} 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
|
||||
<div className="pl-12">
|
||||
<CardList grid finalElement={<LoadMoreButton
|
||||
lastId={data?.pages.at(-1)?.data.at(-1)?.id}
|
||||
onFocus={handleFocus}
|
||||
isFetching={isFetchingNextPage || isFetching}
|
||||
|
|
@ -68,7 +78,40 @@ function RouteComponent ()
|
|||
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 previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
|
||||
previewUrl.searchParams.delete('ts');
|
||||
|
||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
|
||||
platformUrl.searchParams.set('width', "64");
|
||||
|
||||
return {
|
||||
id: `${g.id.source}@${g.id.id}`,
|
||||
focusKey: `${g.id.source}@${g.id.id}`,
|
||||
title: g.name ?? "",
|
||||
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>
|
||||
),
|
||||
previewUrl: previewUrl.href,
|
||||
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={filterValues} filters={{ source: 'store' }} />
|
||||
</div>
|
||||
</FocusContext>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { AutoFocus } from '@/mainview/components/AutoFocus';
|
||||
import { FilterUI } from '@/mainview/components/Filters';
|
||||
import { HeaderUI } from '@/mainview/components/Header';
|
||||
import HeaderSearchField from '@/mainview/components/HeaderSearchField';
|
||||
import SelectMenu from '@/mainview/components/SelectMenu';
|
||||
import Shortcuts, { FloatingShortcuts } from '@/mainview/components/Shortcuts';
|
||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||
|
|
@ -13,7 +14,8 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||
import { useMatchRoute, useRouter } from '@tanstack/react-router';
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import { useRef } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
import z from 'zod';
|
||||
|
||||
export const Route = createFileRoute('/store/tab')({
|
||||
|
|
@ -95,6 +97,8 @@ function RouteComponent ()
|
|||
emulators: { label: "Emulators", selected: useIsSettings('emulators') },
|
||||
games: { label: "Games", selected: useIsSettings('games') }
|
||||
};
|
||||
const [search, setSearch] = useSessionStorage<string | undefined>(`${router.history.location.pathname}-search`, undefined);
|
||||
const [, setGamesSearch] = useSessionStorage<string | undefined>(`/store/tab/games-search`, undefined);
|
||||
|
||||
const handleDetails = (type: string, source: string, id: string, focus: string) =>
|
||||
{
|
||||
|
|
@ -120,6 +124,19 @@ function RouteComponent ()
|
|||
}
|
||||
};
|
||||
|
||||
const handleSearch = (search: string | undefined) =>
|
||||
{
|
||||
if (filters['home'].selected)
|
||||
{
|
||||
setGamesSearch(search);
|
||||
router.navigate({ to: '/store/tab/games', replace: true, viewTransition: { types: ['slide-up'] } });
|
||||
} else
|
||||
{
|
||||
setSearch(search);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const isMobile = mobileCheck();
|
||||
useStickyDataAttr(headerRef, sentinelRef, ref);
|
||||
|
||||
|
|
@ -129,7 +146,7 @@ function RouteComponent ()
|
|||
<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 />
|
||||
<HeaderUI buttonElements={<HeaderSearchField compact={useIsSettings('')} id={'store-search'} search={search} onSubmit={handleSearch} />} />
|
||||
</div>
|
||||
<TopArea filters={filters} />
|
||||
<StoreOutlet />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue