feat: Implemented public plugin system accessible from the store.
feat: Implemented external ryujinx integration plugin refactor: moved sdk types and schemas to own workspace package fix: Fixed emulator launch with no game
This commit is contained in:
parent
9051834ace
commit
38cb752552
124 changed files with 1918 additions and 1067 deletions
|
|
@ -29,7 +29,7 @@ import FocusTooltip from "@/mainview/components/FocusTooltip";
|
|||
import { AutoFocus } from "@/mainview/components/AutoFocus";
|
||||
import { FilterUI } from "@/mainview/components/Filters";
|
||||
import Markdown from "react-markdown";
|
||||
import { FrontEndEmulatorDetailed } from "@/shared/types";
|
||||
import { FrontEndEmulatorDetailed } from "@simeonradivoev/gameflow-sdk/shared";
|
||||
|
||||
export const Route = createFileRoute('/store/details/emulator/$id')({
|
||||
component: RouteComponent,
|
||||
|
|
|
|||
161
src/mainview/routes/store/details.plugin.$id.tsx
Normal file
161
src/mainview/routes/store/details.plugin.$id.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { AutoFocus } from '@/mainview/components/AutoFocus';
|
||||
import DotsLoading from '@/mainview/components/backgrounds/dots';
|
||||
import { StickyHeaderUI } from '@/mainview/components/Header';
|
||||
import LoadingScreen from '@/mainview/components/LoadingScreen';
|
||||
import { Button } from '@/mainview/components/options/Button';
|
||||
import { FloatingShortcuts } from '@/mainview/components/Shortcuts';
|
||||
import StatList, { StatEntry } from '@/mainview/components/StatList';
|
||||
import { installPluginMutation, pluginFilter, uninstallPluginMutation, updatePluginMutation } from '@/mainview/scripts/queries/plugins';
|
||||
import { pluginDetailsQuery } from '@/mainview/scripts/queries/store';
|
||||
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||
import { HandleGoBack } from '@/mainview/scripts/utils';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { QueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router';
|
||||
import { ArrowRight, CircleFadingArrowUp, Download, Settings, Trash } from 'lucide-react';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/store/details/plugin/$id')({
|
||||
component: RouteComponent,
|
||||
pendingComponent: Loading,
|
||||
async loader (ctx)
|
||||
{
|
||||
const id = decodeURIComponent(ctx.params.id);
|
||||
const data = await ctx.context.queryClient.fetchQuery(pluginDetailsQuery(id));
|
||||
return { data };
|
||||
},
|
||||
});
|
||||
|
||||
function Loading ()
|
||||
{
|
||||
const { ref, focusSelf } = useFocusable({ focusKey: 'plugin-details' });
|
||||
return <>
|
||||
<DotsLoading ref={ref} />
|
||||
<AutoFocus focus={focusSelf} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function Details ()
|
||||
{
|
||||
const { id } = Route.useParams();
|
||||
const plugin = decodeURIComponent(id);
|
||||
const { data } = Route.useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
const handleRefresh = (client: QueryClient) =>
|
||||
{
|
||||
client.invalidateQueries(pluginFilter(plugin));
|
||||
navigate({ to: '/store/details/plugin/$id', params: { id: encodeURIComponent(id) }, replace: true });
|
||||
};
|
||||
const update = useMutation({
|
||||
...updatePluginMutation(plugin),
|
||||
onSuccess (data, variables, onMutateResult, context)
|
||||
{
|
||||
handleRefresh(context.client);
|
||||
},
|
||||
});
|
||||
const install = useMutation({
|
||||
...installPluginMutation(plugin),
|
||||
onSuccess (data, variables, onMutateResult, context)
|
||||
{
|
||||
handleRefresh(context.client);
|
||||
},
|
||||
});
|
||||
const uninstall = useMutation({
|
||||
...uninstallPluginMutation(plugin),
|
||||
onSuccess (data, variables, onMutateResult, context)
|
||||
{
|
||||
handleRefresh(context.client);
|
||||
},
|
||||
});
|
||||
|
||||
const stats: StatEntry[] = [];
|
||||
if (data.devDependencies)
|
||||
{
|
||||
stats.push({ content: Object.keys(data.devDependencies), label: "Dev Dependecies" });
|
||||
}
|
||||
if (data.dependencies)
|
||||
{
|
||||
stats.push({ content: Object.keys(data.dependencies), label: "Dependecies" });
|
||||
}
|
||||
if (data.maintainers)
|
||||
{
|
||||
stats.push({ content: data.maintainers.map(m => m.name), label: "Maintainers" });
|
||||
}
|
||||
if (data.dist)
|
||||
{
|
||||
stats.push({ content: prettyBytes(data.dist.unpackedSize), label: "Size" });
|
||||
}
|
||||
if (data.license)
|
||||
{
|
||||
stats.push({ content: data.license, label: "License" });
|
||||
}
|
||||
return <>
|
||||
|
||||
<div className='flex justify-between p-8'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='text-3xl font-bold'>{data.name}</div>
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex gap-1'>
|
||||
{data.update ? <>
|
||||
<div className='bg-base-300 px-2 rounded-full'>{data.update.from}</div>
|
||||
<ArrowRight />
|
||||
<div className='bg-warning text-warning-content px-2 rounded-full'>{data.version}</div>
|
||||
</> :
|
||||
<div className='bg-base-300 px-2 rounded-full'>{data.version}</div>}
|
||||
|
||||
</div>
|
||||
by {data.author?.name ?? data._npmUser?.name}</div>
|
||||
</div>
|
||||
<div className='flex gap-2 items-center'>
|
||||
{data.installed && <>
|
||||
{!!data.update && <Button onAction={e => update.mutate()} className='gap-2' style='warning' id='install-btn' >
|
||||
{update.isPending ? <span className="loading loading-spinner loading-lg"></span> : <CircleFadingArrowUp />} Update
|
||||
</Button>}
|
||||
<Button onAction={e => uninstall.mutate()} className='gap-2' style='accent' id='install-btn' >
|
||||
{uninstall.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Trash />} Uninstall
|
||||
</Button>
|
||||
<Button external onAction={e => { navigate({ to: '/settings/plugin/$source', params: { source: encodeURIComponent(plugin) } }); }} className='gap-2' style='info' id='install-btn' >
|
||||
<Settings /> Settings
|
||||
</Button>
|
||||
|
||||
</>}
|
||||
{!data.installed && <Button onAction={e => install.mutate()} className='gap-2' style='accent' id='install-btn' >
|
||||
{install.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Download />} Install
|
||||
</Button>}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider">Details</div>
|
||||
<div className='px-8'>
|
||||
<div className='p-4 bg-base-200 rounded-2xl'>{data.description}</div>
|
||||
<StatList id={'plugin-stats'} stats={stats} />
|
||||
</div>
|
||||
<div className="divider">Keywords</div>
|
||||
<div className='flex gap-2 px-8'>
|
||||
{data.keywords.map(k => <li className='flex px-2 bg-base-300 rounded-full'>{k}</li>)}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const router = useRouter();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugin-details' });
|
||||
useShortcuts(focusKey, () => [{
|
||||
label: "Return", button: GamePadButtonCode.B, action (e)
|
||||
{
|
||||
HandleGoBack(router, e);
|
||||
},
|
||||
}]);
|
||||
return <div ref={ref} className='absolute w-full h-full overflow-y-scroll overflow-x-hidden'>
|
||||
<FocusContext value={focusKey}>
|
||||
<StickyHeaderUI ref={ref} />
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Details />
|
||||
</Suspense>
|
||||
<FloatingShortcuts />
|
||||
</FocusContext>
|
||||
<AutoFocus focus={focusSelf} />
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -8,13 +8,13 @@ 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 { GameListFilterType, RPC_URL } from '@/shared/constants';
|
||||
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 { FrontEndGameType } from '@/shared/types';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/games')({
|
||||
component: RouteComponent,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||
import { autoEmulatorsQuery } from '@queries/settings';
|
||||
import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store';
|
||||
import ImageWithFallbacks from '@/mainview/components/ImageWithFallbacks';
|
||||
import { FrontEndGameTypeDetailed } from '@/shared/types';
|
||||
import { FrontEndGameTypeDetailed } from '@simeonradivoev/gameflow-sdk/shared';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/')({
|
||||
component: RouteComponent
|
||||
|
|
|
|||
151
src/mainview/routes/store/tab/plugins.tsx
Normal file
151
src/mainview/routes/store/tab/plugins.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { allPluginsFilter, installPluginMutation, uninstallPluginMutation, updatePluginMutation } from '@/mainview/scripts/queries/plugins';
|
||||
import { pluginsQuery } from '@/mainview/scripts/queries/store';
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||
import { FOCUS_KEYS } from '@/mainview/scripts/types';
|
||||
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()
|
||||
}))
|
||||
});
|
||||
|
||||
function PluginCard (data: { plugin: PluginEntryType; })
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const onAction = () =>
|
||||
{
|
||||
navigate({ to: '/store/details/plugin/$id', params: { id: decodeURIComponent(data.plugin.package.name) } });
|
||||
};
|
||||
const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.PLUGIN_ENTRY(data.plugin.package.sanitized_name), onEnterPress: onAction });
|
||||
const handleRefresh = (client: QueryClient) =>
|
||||
{
|
||||
client.invalidateQueries(allPluginsFilter);
|
||||
navigate({ to: '/store/tab/plugins', replace: true });
|
||||
};
|
||||
const update = useMutation({
|
||||
...updatePluginMutation(data.plugin.package.name),
|
||||
onSuccess (data, variables, onMutateResult, context)
|
||||
{
|
||||
handleRefresh(context.client);
|
||||
},
|
||||
});
|
||||
const install = useMutation({
|
||||
...installPluginMutation(data.plugin.package.name),
|
||||
onSuccess (f, variables, onMutateResult, context)
|
||||
{
|
||||
handleRefresh(context.client);
|
||||
}
|
||||
});
|
||||
const uninstall = useMutation({
|
||||
...uninstallPluginMutation(data.plugin.package.name),
|
||||
onSuccess (f, variables, onMutateResult, context)
|
||||
{
|
||||
handleRefresh(context.client);
|
||||
}
|
||||
});
|
||||
useShortcuts(focusKey, () =>
|
||||
{
|
||||
const shortcuts: Shortcut[] = [{
|
||||
label: "Details", button: GamePadButtonCode.A, action (e)
|
||||
{
|
||||
onAction();
|
||||
},
|
||||
}];
|
||||
|
||||
if (data.plugin.installed)
|
||||
{
|
||||
shortcuts.push({
|
||||
label: "Uninstall",
|
||||
button: GamePadButtonCode.X,
|
||||
action (e)
|
||||
{
|
||||
uninstall.mutate();
|
||||
},
|
||||
});
|
||||
|
||||
if (data.plugin.update)
|
||||
{
|
||||
shortcuts.push({
|
||||
label: "Update",
|
||||
button: GamePadButtonCode.Y,
|
||||
action (e)
|
||||
{
|
||||
update.mutate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
} else
|
||||
{
|
||||
shortcuts.push({
|
||||
label: "Install",
|
||||
button: GamePadButtonCode.X,
|
||||
action (e)
|
||||
{
|
||||
install.mutate();
|
||||
},
|
||||
});
|
||||
}
|
||||
return shortcuts;
|
||||
}, [data.plugin.installed, install.isPending, uninstall.isPending]);
|
||||
return <div ref={ref} onClick={onAction} data-installed={data.plugin.installed} className='flex flex-wrap bg-base-100 p-4 rounded-2xl focusable focusable-secondary focusable-hover justify-between cursor-pointer'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex gap-2 font-bold text-xl in-data-[installed=true]:text-info'>
|
||||
{data.plugin.installed && <HardDrive className='p-1 bg-base-300 rounded-full size-8 text-base-content' />}
|
||||
{data.plugin.update && <CircleFadingArrowUp className='p-1 bg-warning text-warning-content rounded-full size-8' />}
|
||||
{data.plugin.package.name}
|
||||
{(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'>
|
||||
<li>{data.plugin.package.publisher.username}</li>
|
||||
<Dot />
|
||||
<li>{data.plugin.package.version}</li>
|
||||
<Dot />
|
||||
<li>{prettyMilliseconds(new Date().getTime() - data.plugin.package.date.getTime(), { hideSeconds: true })}</li>
|
||||
<Dot />
|
||||
<li>{data.plugin.package.license}</li>
|
||||
{install.isPending && <>
|
||||
<Dot />
|
||||
<li><span className="loading loading-spinner loading-md"></span>installing</li>
|
||||
</>}
|
||||
{uninstall.isPending && <>
|
||||
<Dot />
|
||||
<li><span className="loading loading-spinner loading-md"></span>uninstalling</li>
|
||||
</>}
|
||||
</ul>
|
||||
</div>
|
||||
<div className='flex justify-center items-center'>
|
||||
<div className='flex gap-2 bg-base-300 rounded-3xl px-3 py-2'>
|
||||
<Download />
|
||||
{data.plugin.downloads.monthly}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const [search] = useSessionStorage<string | undefined>(`${Route.to}-search`, undefined);
|
||||
const { data: plugins } = useQuery(pluginsQuery(search));
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "plugins-store" });
|
||||
return <div ref={ref}>
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="divider"><Puzzle className='size-12' /> {plugins?.total} Plugins</div>
|
||||
<div className='flex flex-col gap-2 p-8'>
|
||||
{plugins?.objects.map((p, i) => <PluginCard key={i} plugin={p} />)}
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -14,6 +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 { useRef } from 'react';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
import z from 'zod';
|
||||
|
|
@ -93,9 +94,10 @@ function RouteComponent ()
|
|||
const headerRef = useRef(null);
|
||||
const sentinelRef = useRef(null);
|
||||
const filters: Record<string, FilterOption> = {
|
||||
home: { label: "Home", selected: useIsSettings(''), },
|
||||
emulators: { label: "Emulators", selected: useIsSettings('emulators') },
|
||||
games: { label: "Games", selected: useIsSettings('games') }
|
||||
home: { label: "Home", icon: <Home />, selected: useIsSettings(''), },
|
||||
emulators: { label: "Emulators", icon: <Joystick />, selected: useIsSettings('emulators') },
|
||||
games: { label: "Games", icon: <Gamepad2 />, selected: useIsSettings('games') },
|
||||
plugins: { label: "Plugins", icon: <Puzzle />, selected: useIsSettings('plugins') }
|
||||
};
|
||||
const [search, setSearch] = useSessionStorage<string | undefined>(`${router.history.location.pathname}-search`, undefined);
|
||||
const [, setGamesSearch] = useSessionStorage<string | undefined>(`/store/tab/games-search`, undefined);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue