feat: Implemented link game importing
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
This commit is contained in:
parent
9a3e605625
commit
9141fb35d4
70 changed files with 1922 additions and 560 deletions
129
src/mainview/routes/store/details.download.$source.$id.tsx
Normal file
129
src/mainview/routes/store/details.download.$source.$id.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { AutoFocus } from '@/mainview/components/AutoFocus';
|
||||
import DotsLoading from '@/mainview/components/backgrounds/dots';
|
||||
import { ContextList, DialogEntry } from '@/mainview/components/ContextDialog';
|
||||
import { StickyHeaderUI } from '@/mainview/components/Header';
|
||||
import { Button } from '@/mainview/components/options/Button';
|
||||
import Screenshots from '@/mainview/components/Screenshots';
|
||||
import SelectMenu from '@/mainview/components/SelectMenu';
|
||||
import { FloatingShortcuts } from '@/mainview/components/Shortcuts';
|
||||
import { GlobalDialogContext } from '@/mainview/scripts/contexts';
|
||||
import { downloadLookupQuery } from '@/mainview/scripts/queries/romm';
|
||||
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||
import { HandleGoBack } from '@/mainview/scripts/utils';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router';
|
||||
import { Download } from 'lucide-react';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import { useContext } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/store/details/download/$source/$id')({
|
||||
component: RouteComponent,
|
||||
pendingComponent: Loading,
|
||||
async loader (ctx)
|
||||
{
|
||||
const data = await ctx.context.queryClient.fetchQuery(downloadLookupQuery(decodeURIComponent(ctx.params.source), decodeURIComponent(ctx.params.id)));
|
||||
return { data };
|
||||
}
|
||||
});
|
||||
|
||||
function Loading ()
|
||||
{
|
||||
const { ref, focusSelf } = useFocusable({ focusKey: 'download-details' });
|
||||
return <>
|
||||
<DotsLoading ref={ref} />
|
||||
<AutoFocus focus={focusSelf} />
|
||||
</>;
|
||||
}
|
||||
|
||||
const imagesMap = new Set(['JPEG', 'PNG', 'Motion JPEG', 'Item Image']);
|
||||
const videoFormat = new Set(['h.264']);
|
||||
const downloadsBlacklist = new Set(['JPEG Thumb', 'Metadata', 'Thumbnail', 'Item Tile', 'Archive BitTorrent', ...videoFormat, ...imagesMap]);
|
||||
|
||||
function Details (data: { onDownload: (focusKey: string) => void; })
|
||||
{
|
||||
const { data: download } = Route.useLoaderData();
|
||||
const screenshots = download.files.filter(f => f.format && imagesMap.has(f.format)).map(f => f.download_url);
|
||||
if (screenshots.length <= 0 && download.cover_url) screenshots.push(download.cover_url);
|
||||
return <div className='flex flex-col'>
|
||||
<Screenshots className='bg-base-300 h-64' screenshots={screenshots} />
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex bg-base-200 p-16 justify-between'>
|
||||
<div className=' flex gap-2'>
|
||||
{!!download.cover_url && <img className='w-32 object-cover rounded-2xl' src={download.cover_url} />}
|
||||
<div className='flex flex-col grow'>
|
||||
<div className='font-bold text-2xl'>{download.name}</div>
|
||||
<div className='flex gap-1'>
|
||||
<div>{download.date?.toDateString()}</div>
|
||||
<div className="divider divider-horizontal m-0"></div>
|
||||
<div>{download.source}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<Button external id='download-btn' className='gap-2 font-semibold text-2xl' style='accent' onAction={(ctx) => data.onDownload(ctx.focusKey!)} ><Download />Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-4 px-16 py-4 justify-center'>
|
||||
{!!download.summary && <div className='flex prose grow'>
|
||||
<div dangerouslySetInnerHTML={{ __html: download.summary }}></div>
|
||||
</div>}
|
||||
<div>
|
||||
<div className="divider"><Download size={64} /> Downloads</div>
|
||||
<ul className='flex flex-col gap-2'>
|
||||
{download.files.filter(f => f.format && !downloadsBlacklist.has(f.format)).map(f => <li className='flex bg-base-300 gap-2 px-4 py-2 rounded-2xl justify-between'>
|
||||
{f.id}
|
||||
<span className='font-semibold'>{!!f.size && prettyBytes(f.size)}</span>
|
||||
</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'download-details', preferredChildFocusKey: 'download-btn' });
|
||||
const { data } = Route.useLoaderData();
|
||||
const globalDialog = useContext(GlobalDialogContext);
|
||||
|
||||
useShortcuts(focusKey, () => [{
|
||||
label: "Return",
|
||||
action: (e) => HandleGoBack(router, e),
|
||||
button: GamePadButtonCode.B
|
||||
}], [router]);
|
||||
|
||||
return <div ref={ref} className='absolute w-full h-full overflow-y-scroll overflow-x-hidden'>
|
||||
<FocusContext value={focusKey}>
|
||||
<StickyHeaderUI ref={ref} />
|
||||
<Details onDownload={(focusKey) => globalDialog.openContext({
|
||||
content: <ContextList options={data.files.filter(f => f.format && !downloadsBlacklist.has(f.format)).map(f =>
|
||||
{
|
||||
const option: DialogEntry = {
|
||||
id: f.id,
|
||||
content: f.id,
|
||||
type: 'primary',
|
||||
action (ctx)
|
||||
{
|
||||
navigate({
|
||||
to: '/game/add', search: {
|
||||
gameLocation: f.download_url,
|
||||
search: data.name,
|
||||
step: 1
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return option;
|
||||
})} />
|
||||
}, focusKey)} />
|
||||
<FloatingShortcuts />
|
||||
</FocusContext>
|
||||
<SelectMenu rootFocusKey={focusKey} />
|
||||
<AutoFocus focus={focusSelf} />
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef, useState } from "react";
|
||||
import { useContext, useRef, useState } from "react";
|
||||
import
|
||||
{
|
||||
useFocusable,
|
||||
|
|
@ -11,7 +11,7 @@ import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
|
|||
import { rommApi, systemApi } from "@/mainview/scripts/clientApi";
|
||||
import { Button } from "@/mainview/components/options/Button";
|
||||
import { ChevronDown, CircleFadingArrowUp, CloudUpload, Cpu, Download, Fullscreen, Gamepad2, Info, Monitor, Puzzle, Settings, Settings2, Terminal, Trash2, TriangleAlert, WandSparkles } from "lucide-react";
|
||||
import { ContextList, DialogEntry, useContextDialog } from "@/mainview/components/ContextDialog";
|
||||
import { ContextList, DialogEntry } from "@/mainview/components/ContextDialog";
|
||||
import { RPC_URL } from "@/shared/constants";
|
||||
import Screenshots from "@/mainview/components/Screenshots";
|
||||
import { StickyHeaderUI } from "@/mainview/components/Header";
|
||||
|
|
@ -30,6 +30,7 @@ import { AutoFocus } from "@/mainview/components/AutoFocus";
|
|||
import { FilterUI } from "@/mainview/components/Filters";
|
||||
import Markdown from "react-markdown";
|
||||
import { FrontEndEmulatorDetailed } from "@simeonradivoev/gameflow-sdk/shared";
|
||||
import { GlobalDialogContext } from "@/mainview/scripts/contexts";
|
||||
|
||||
export const Route = createFileRoute('/store/details/emulator/$id')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -65,6 +66,7 @@ function TitleArea (data: {
|
|||
onUpdate: (source: string) => void;
|
||||
})
|
||||
{
|
||||
const globalDialog = useContext(GlobalDialogContext);
|
||||
const navigation = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
|
|
@ -253,14 +255,12 @@ function TitleArea (data: {
|
|||
installButtonContent = <><TriangleAlert />Unsupported</>;
|
||||
}
|
||||
|
||||
const { dialog: installOptionsDialog, setOpen } = useContextDialog("install-context-menu", {
|
||||
content: <ContextList options={options} />
|
||||
});
|
||||
const openOptionsDialog = (focusKey: string) => globalDialog.openContext({ content: <ContextList options={options} /> }, focusKey);
|
||||
|
||||
const handleOptionsOpen = () =>
|
||||
{
|
||||
if (isInstalling || !data.emulator) return false;
|
||||
setOpen(true, 'install-btn');
|
||||
openOptionsDialog('install-btn');
|
||||
};
|
||||
|
||||
return <div ref={ref} className="flex flex-wrap gap-4 sm:portrait:justify-center md:justify-normal items-center">
|
||||
|
|
@ -294,10 +294,10 @@ function TitleArea (data: {
|
|||
<div className="flex relative sm:portrait:grow md:grow-0 justify-center gap-4 items-center">
|
||||
<FocusTooltip visible={hasFocusedChild} parentRef={ref} />
|
||||
{(data.emulator?.storeDownloadInfo?.hasUpdate || !data.emulator?.storeDownloadInfo) && installedFromStore && !!updateToVersion && <div className="tooltip tooltip-warning" data-tip="Update Available">
|
||||
<Button id="update-warning-bt" tooltipType="warning" tooltip="Update Available" style="warning" className="rounded-full size-14 focusable focusable-warning shadow-lg" onAction={() => setOpen(true, 'update-warning-bt')}><CircleFadingArrowUp /></Button>
|
||||
<Button id="update-warning-bt" tooltipType="warning" tooltip="Update Available" style="warning" className="rounded-full size-14 focusable focusable-warning shadow-lg" onAction={() => openOptionsDialog('update-warning-bt')}><CircleFadingArrowUp /></Button>
|
||||
</div>}
|
||||
{(!data.emulator?.bios || data.emulator.bios.length <= 0) && (data.emulator?.biosRequirement === 'required') && installedFromStore && <div className="tooltip tooltip-error" data-tip="Missing BIOS">
|
||||
<Button id="bios-warning-bt" tooltipType="error" tooltip="Missing BIOS" style="error" className="rounded-full size-14 focusable focusable-error shadow-lg" onAction={() => setOpen(true, 'bios-warning-bt')}><TriangleAlert /></Button>
|
||||
<Button id="bios-warning-bt" tooltipType="error" tooltip="Missing BIOS" style="error" className="rounded-full size-14 focusable focusable-error shadow-lg" onAction={() => openOptionsDialog('bios-warning-bt')}><TriangleAlert /></Button>
|
||||
</div>}
|
||||
<Button style="accent" id="install-btn" className="px-8 py-3 rounded-4xl focusable focusable-accent sm:portrait:grow flex-col gap-2 light:ring-offset-7 light:ring-offset-base-100 light:focused:ring-offset-0 shadow-lg" onAction={handleOptionsOpen} >
|
||||
<div className="flex gap-4">
|
||||
|
|
@ -309,7 +309,6 @@ function TitleArea (data: {
|
|||
{isInstalling && <progress ref={installProgressRef} className="progress" value={0} max="100"></progress>}
|
||||
</Button>
|
||||
</div>
|
||||
{installOptionsDialog}
|
||||
</FocusContext >
|
||||
</div >;
|
||||
}
|
||||
|
|
|
|||
109
src/mainview/routes/store/tab/download.tsx
Normal file
109
src/mainview/routes/store/tab/download.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import DotsLoading from '@/mainview/components/backgrounds/dots';
|
||||
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
||||
import { SideDownloadFilters } from '@/mainview/components/SideFilters';
|
||||
import { downloadLookupFiltersQuery, downloadsLookupQuery } from '@/mainview/scripts/queries/romm';
|
||||
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { DownloadLookupEntry, DownloadsLookupFilter } from '@simeonradivoev/gameflow-sdk/shared';
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { DownloadIcon, Eye, MessageCircle, Save, Star } from 'lucide-react';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/download')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function Download (data: { focusKey: string, match: DownloadLookupEntry; })
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const handleAction = () => navigate({
|
||||
to: '/store/details/download/$source/$id', params: {
|
||||
source: encodeURIComponent(data.match.source),
|
||||
id: encodeURIComponent(data.match.id)
|
||||
}
|
||||
});
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: data.focusKey,
|
||||
onFocus: (l, p, d) => scrollIntoViewHandler({ behavior: "smooth", block: "center", inline: "center" })(focusKey, ref.current, d),
|
||||
onEnterPress: handleAction
|
||||
});
|
||||
return <li onClick={handleAction} ref={ref} className='flex gap-4 bg-base-100 not-mobile:shadow-xl rounded-3xl p-2 focusable focusable-accent focusable-hover cursor-pointer overflow-hidden'>
|
||||
{!!data.match.cover_url && <img className='min-w-32 w-32 rounded-2xl object-cover' src={data.match.cover_url} />}
|
||||
<div className='flex flex-col gap-2 justify-center grow w-[calc(100%-16rem)]'>
|
||||
<div className='font-semibold overflow-hidden text-xl text-shadow-md truncate'>{data.match.name}</div>
|
||||
<div className='text-base-content/60 overflow-hidden truncate'>{data.match.date?.toDateString()}</div>
|
||||
<ul className='flex flex-wrap gap-2'>
|
||||
{!!data.match.size && <li className='flex gap-1 text-base-content bg-base-300 rounded-full px-2 py-1'><Save />{prettyBytes(data.match.size)}</li>}
|
||||
{!!data.match.download_count && <li className='flex gap-1 text-base-content bg-base-300 rounded-full px-2 py-1'><DownloadIcon />{data.match.download_count}</li>}
|
||||
{!!data.match.view_count && <li className='flex gap-1 text-base-content bg-base-300 rounded-full px-2 py-1'><Eye />{data.match.view_count}</li>}
|
||||
{!!data.match.comment_count && <li className='flex gap-1 text-base-content bg-base-300 rounded-full px-2 py-1'><MessageCircle />{data.match.comment_count}</li>}
|
||||
{!!data.match.rating && <li className='flex gap-1 text-base-content bg-base-300 rounded-full px-2 py-1'><Star />{data.match.rating}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</li>;
|
||||
}
|
||||
|
||||
function Downloads (data: {
|
||||
pages: {
|
||||
data: DownloadLookupEntry[];
|
||||
totalCount: number;
|
||||
nextPage: number;
|
||||
}[];
|
||||
hasNextPage: boolean,
|
||||
isFetchingNextPage: boolean,
|
||||
isFetching: boolean,
|
||||
fetchNextPage: () => void,
|
||||
error: string | undefined;
|
||||
})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'downloads-list' });
|
||||
return <ul ref={ref} className='grid ml-12 h-fit sm:gap-2 md:gap-5 auto-rows-[10rem] grid-cols-1 md:grid-cols-2 lg:grid-cols-3'>
|
||||
<FocusContext value={focusKey}>
|
||||
{data.pages.flatMap((page, p) => page.data.map((match, i) => <Download focusKey={`dl-${p}-${i}`} key={match.id} match={match} />))}
|
||||
{data.hasNextPage && <LoadMoreButton
|
||||
isFetching={data.isFetchingNextPage || data.isFetching}
|
||||
onAction={() =>
|
||||
{
|
||||
if (data.isFetchingNextPage || data.isFetching)
|
||||
return;
|
||||
data.fetchNextPage();
|
||||
}} />}
|
||||
{!!data.error}
|
||||
</FocusContext>
|
||||
</ul>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const [search] = useSessionStorage<string | undefined>(`${Route.to}-search`, undefined);
|
||||
const [filter, setFilter] = useSessionStorage<DownloadsLookupFilter>('store-download-lookup-filters', {});
|
||||
const { data, error, isPending, isFetching, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
...downloadsLookupQuery({ ...filter, search }),
|
||||
maxPages: 10,
|
||||
refetchOnMount: false
|
||||
});
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: "main-area",
|
||||
preferredChildFocusKey: "downloads-list"
|
||||
});
|
||||
|
||||
const { data: lookupFilters } = useQuery(downloadLookupFiltersQuery);
|
||||
|
||||
return <div ref={ref} className='px-6 py-4 animate-slide-up'>
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="divider font-bold uppercase tracking-widest">
|
||||
{isFetching && <span className="loading loading-xl loading-spinner"></span>}
|
||||
Results
|
||||
{isPending ? <span className="loading loading-spinner"></span> : <span className='bg-base-100 px-2 rounded-full'>{data?.pages[0].totalCount}</span>}
|
||||
</div>
|
||||
{isPending && <DotsLoading />}
|
||||
{data && <Downloads hasNextPage={hasNextPage} isFetching={isFetching} isFetchingNextPage={isFetchingNextPage} error={error?.message} pages={data.pages} fetchNextPage={fetchNextPage} />}
|
||||
<div className='fixed left-2 top-52 bottom-0 sm:w-10 md:w-14 z-10'>
|
||||
<SideDownloadFilters id={'downloads-lookup-filter'} setLocalFilter={setFilter} localFilter={filter} filterValues={lookupFilters} />
|
||||
</div>
|
||||
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -11,10 +11,15 @@ import { useQuery } from '@tanstack/react-query';
|
|||
import { storeEmulatorsQuery } from '@queries/store';
|
||||
import InvalidStoreError from '@/mainview/components/store/InvalidStoreError';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import z from 'zod';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/emulators')({
|
||||
component: RouteComponent,
|
||||
errorComponent: InvalidStoreError
|
||||
errorComponent: InvalidStoreError,
|
||||
validateSearch: zodValidator(z.object({
|
||||
search: z.string().optional()
|
||||
}))
|
||||
});
|
||||
|
||||
function RouteComponent ()
|
||||
|
|
@ -26,7 +31,11 @@ function RouteComponent ()
|
|||
preferredChildFocusKey: focus
|
||||
});
|
||||
const storeContext = useContext(StoreContext);
|
||||
const { data: emulators } = useQuery({ ...storeEmulatorsQuery({ search }), retry: false, throwOnError: true });
|
||||
const { data: emulators } = useQuery({
|
||||
...storeEmulatorsQuery({ search }),
|
||||
retry: false,
|
||||
throwOnError: true
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
|
@ -62,6 +71,7 @@ function RouteComponent ()
|
|||
/>
|
||||
)) ?? Array.from({ length: 10 }).map((_, i) => <div key={i} className="skeleton rounded-3xl" />)}
|
||||
</div>
|
||||
|
||||
</FocusContext>
|
||||
</section>
|
||||
</>;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ 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,
|
||||
|
|
@ -68,7 +69,7 @@ function RouteComponent ()
|
|||
Games
|
||||
</h2>
|
||||
</div>
|
||||
<div className="pl-12">
|
||||
<div className="ml-12">
|
||||
<CardList grid finalElement={<LoadMoreButton
|
||||
hidden
|
||||
lastId={data?.pages.at(-1)?.data.at(-1)?.id}
|
||||
|
|
@ -90,7 +91,7 @@ function RouteComponent ()
|
|||
|
||||
const previewUrls = g.path_covers.map(c =>
|
||||
{
|
||||
const url = c.startsWith('http') ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`);
|
||||
const url = isUrl(c) ? new URL(c) : new URL(`${RPC_URL(__HOST__)}${c}`);
|
||||
url.searchParams.delete('ts');
|
||||
return url;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,17 +6,12 @@ import { PluginEntryType } from '@simeonradivoev/gameflow-sdk/shared';
|
|||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { QueryClient, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import { CircleFadingArrowUp, Dot, Download, HardDrive, Puzzle } from 'lucide-react';
|
||||
import prettyMilliseconds from 'pretty-ms';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
import z from 'zod';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/plugins')({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(z.object({
|
||||
search: z.string().optional()
|
||||
}))
|
||||
component: RouteComponent
|
||||
});
|
||||
|
||||
function PluginCard (data: { plugin: PluginEntryType; })
|
||||
|
|
@ -107,7 +102,7 @@ function PluginCard (data: { plugin: PluginEntryType; })
|
|||
{(install.isPending || uninstall.isPending) && <span className="loading loading-spinner loading-lg"></span>}
|
||||
</div>
|
||||
<div className='text-base-content/40'>{data.plugin.package.description}</div>
|
||||
<ul className='flex flex-wrap gap-2'>{data.plugin.package.keywords.concat(...data.plugin.installed ? ["installed"] : []).map(k => <li className='bg-base-300 px-2 rounded-full'>{k}</li>)}</ul>
|
||||
<ul className='flex flex-wrap gap-2'>{data.plugin.package.keywords.concat(...data.plugin.installed ? ["installed"] : []).map((k, i) => <li key={i} className='bg-base-300 px-2 rounded-full'>{k}</li>)}</ul>
|
||||
<ul className='flex flex-wrap gap-2'>
|
||||
<li>{data.plugin.package.publisher.username}</li>
|
||||
<Dot />
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ 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 { Gamepad2, Home, Joystick, Puzzle } from 'lucide-react';
|
||||
import { DownloadCloud, Gamepad2, Home, Joystick, Puzzle } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
import z from 'zod';
|
||||
|
|
@ -97,6 +97,7 @@ function RouteComponent ()
|
|||
home: { label: "Home", icon: <Home />, selected: useIsSettings(''), },
|
||||
emulators: { label: "Emulators", icon: <Joystick />, selected: useIsSettings('emulators') },
|
||||
games: { label: "Games", icon: <Gamepad2 />, selected: useIsSettings('games') },
|
||||
download: { label: "Download", icon: <DownloadCloud />, selected: useIsSettings('download') },
|
||||
plugins: { label: "Plugins", icon: <Puzzle />, selected: useIsSettings('plugins') }
|
||||
};
|
||||
const [search, setSearch] = useSessionStorage<string | undefined>(`${router.history.location.pathname}-search`, undefined);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue