feat: Implemented filtering and searching

This commit is contained in:
Simeon Radivoev 2026-04-12 22:19:24 +03:00
parent 4806f3487a
commit 444d8c4c27
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
49 changed files with 841 additions and 290 deletions

View file

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