feat: Bundled NW.js with appimages

feat: Implemented self update
feat: Added rclone saves for emulators
fix: Fixed auto focus in builds
feat: Added helper cards on empty library
This commit is contained in:
Simeon Radivoev 2026-04-26 03:26:15 +03:00
parent 587956c792
commit 813785f4f3
Signed by: simeonradivoev
GPG key ID: C16C2132A7660C8E
59 changed files with 1210 additions and 480 deletions

View file

@ -25,17 +25,13 @@ export function AnimatedBackground (data: {
)
: useState<string | undefined>();
const [lastBackgroundUrl, setLastBackgroundUrl] = useState<string | undefined>(undefined);
const backgroundElementRef = useRef<HTMLDivElement>(null);
useEffect(() =>
{
const lastBg = backgroundUrl;
if (data.backgroundUrl != backgroundUrl)
{
setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined);
setLastBackgroundUrl(lastBg);
}
}, [data.backgroundUrl]);
@ -44,13 +40,6 @@ export function AnimatedBackground (data: {
{
finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined;
} catch { }
let finalLastBackgroundUrl: URL | undefined;
try
{
finalLastBackgroundUrl = lastBackgroundUrl ? new URL(lastBackgroundUrl) : undefined;
} catch { }
const blur = useLocalSetting('backgroundBlur');
if (blur)
{
@ -59,13 +48,7 @@ export function AnimatedBackground (data: {
finalBackgroundUrl?.searchParams.set('blur', String(24));
}
if (!finalLastBackgroundUrl?.searchParams.has('blur'))
{
finalLastBackgroundUrl?.searchParams.set('blur', String(24));
}
finalBackgroundUrl?.searchParams.set('height', String(320));
finalLastBackgroundUrl?.searchParams.set('height', String(320));
}
useEffect(() =>
@ -90,8 +73,6 @@ export function AnimatedBackground (data: {
function handleSetBackground (url: string)
{
setLastBackgroundUrl(backgroundUrl);
setBackgroundUrl(url);
}
@ -120,7 +101,7 @@ export function AnimatedBackground (data: {
>
{!data.scrolling && <div className='absolute top-0 left-0 right-0 bottom-0 overflow-hidden'>
<div className='absolute w-full h-full bg-radial from-base-100 to-base-300 -z-5'></div>
{blur && finalLastBackgroundUrl && <img className='absolute w-full h-full object-cover object-center -z-4 mask-radial-at-center mask-radial-from-0 mask-radial-farthest-corner' src={finalLastBackgroundUrl.href}></img>}
{finalBackgroundUrl ? <img
decoding="async"
key={finalBackgroundUrl?.href}

View file

@ -37,6 +37,10 @@ export default function AppCommunication (data: { children: any; })
});
document.documentElement.dataset.loaded = "true";
return () =>
{
sub.close();
};
}, []);
return <SystemInfoContext value={systemInfo}>

View file

@ -1,5 +1,5 @@
import { doesFocusableExist, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { useLayoutEffect } from "react";
import { useEffect, useLayoutEffect } from "react";
export function AutoFocus (data: {
parentKey?: string;
@ -8,11 +8,15 @@ export function AutoFocus (data: {
delay?: number;
})
{
useLayoutEffect(() =>
useEffect(() =>
{
let delayTimeout: number | undefined;
if (data.force || !getCurrentFocusKey() || getCurrentFocusKey() === data.parentKey || !doesFocusableExist(getCurrentFocusKey()))
const focusDoesntExist = !doesFocusableExist(getCurrentFocusKey());
const parentFocus = getCurrentFocusKey() === data.parentKey;
const noFocus = !getCurrentFocusKey();
if (data.force || noFocus || parentFocus || focusDoesntExist)
{
if (data.delay)
{
@ -21,8 +25,8 @@ export function AutoFocus (data: {
{
data.focus({ instant: true });
}
}
return () =>
{
if (delayTimeout)

View file

@ -32,7 +32,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
oneShot('click');
};
useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]);
useShortcuts(data.game.focusKey, () => [{ label: "Details", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]);
return (
<CardElement
@ -69,7 +69,7 @@ export function CardList (data: {
{
const { ref, focusKey } = useFocusable({
focusKey: data.id,
focusable: data.games.length > 0,
focusable: data.games.length > 0 || (!!data.finalElement && (Array.isArray(data.finalElement) ? data.finalElement.length > 0 : !!data.finalElement)),
preferredChildFocusKey: data.focus
});

View file

@ -30,7 +30,7 @@ import { TwitchIcon } from "../scripts/brandIcons";
import { rommLoggedInQuery } from "../scripts/queries/romm";
import { twitchLoginVerificationQuery } from "../scripts/queries/settings";
import { SystemInfoContext } from "../scripts/contexts";
import { useRouter } from "@tanstack/react-router";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { oneShot } from "../scripts/audio/audio";
import { hasUpdateQuery } from "../scripts/queries/system";
@ -87,16 +87,24 @@ export interface HeaderAccount
function UpdateStatus ()
{
const handleSelect = () =>
{
navigate({ to: '/settings/about' });
};
const hasUnread = false;
return <div className={classNames("tooltip tooltip-bottom tooltip-warning p-2 rounded-full", { "bg-warning text-warning-content": hasUnread })} data-tip="Update Available">
<CircleFadingArrowUp className="sm:size-4 md:size-8 text-warning" />
const navigate = useNavigate();
const { ref } = useFocusable({
focusKey: 'update-bt', onEnterPress: handleSelect
});
return <div onClick={handleSelect} ref={ref} className={classNames("tooltip tooltip-bottom tooltip-warning p-2 rounded-full focusable focusable-primary focusable-hover focused:bg-warning cursor-pointer", { "bg-warning text-warning-content ": hasUnread })} data-tip="Update Available">
<CircleFadingArrowUp className="sm:size-4 md:size-8 text-warning in-focused:text-warning-content" />
</div>;
}
function NotificationStatus ()
{
const hasUnread = false;
return <div className={classNames("p-2 rounded-full", { "bg-warning text-warning-content": hasUnread })}>
return <div className={classNames("p-2 rounded-full focused:bg-base-300", { "bg-warning text-warning-content": hasUnread })}>
<Bell className="sm:size-4 md:size-8" />
</div>;
}
@ -219,14 +227,17 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
router.navigate({ to: '/settings/accounts' });
oneShot('click');
};
const { ref } = useFocusable({
focusKey: 'accounts', onEnterPress: handleSelect
});
const accounts: HeaderAccount[] = [];
if (data.accounts) accounts.push(...data.accounts);
const router = useRouter();
const { ref } = useFocusable({
focusKey: 'accounts',
onEnterPress: handleSelect,
focusable: accounts.length > 0
});
if (rommUser.data?.hasLogin || rommUser.isError)
{
accounts.push({
@ -259,7 +270,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; })
{
const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' });
const { data: hasUpdate } = useQuery(hasUpdateQuery);
const { data: update } = useQuery(hasUpdateQuery);
return <div ref={ref} className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
<FocusContext value={focusKey}>
<div className="flex gap-2 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
@ -267,7 +278,7 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
<WiFiStatus />
<BluetoothStatus />
<NotificationStatus />
{!!hasUpdate && hasUpdate >= 1 && <UpdateStatus />}
{!!update && update.hasUpdate >= 1 && <UpdateStatus />}
<BatteryStatus />
</div>
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}

View file

@ -13,7 +13,20 @@ export default function ImageWithFallbacks (data: {
{
img.dataset.index = String(nextIndex);
img.src = data.src[nextIndex].href;
}
};
return <img draggable={data.draggable} className={data.className} src={data.src[0].href} data-index={0} onError={handleError}></img>;
return <img
draggable={data.draggable}
className={data.className}
src={data.src[0].href}
data-index={0}
onError={handleError}
onLoad={e =>
{
e.currentTarget.dataset.loaded = "true";
}}
>
</img>;
}

View file

@ -1,8 +1,9 @@
import { Ref, RefObject } from 'react';
import './dots.css';
export default function DotsLoading ()
export default function DotsLoading (data: { ref?: Ref<any>; })
{
return <div className="flex gap-3 justify-center animation_alternate items-center pt-8">
return <div ref={data.ref} className="flex gap-3 justify-center animation_alternate items-center pt-8">
<div className="ball size-6"></div>
<div className="ball size-6"></div>
<div className="ball size-6"></div>

View file

@ -109,7 +109,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
if (!cmd) return;
if (cmd.emulator === 'EMULATORJS')
{
const params = new URLSearchParams(cmd.command);
const params = new URLSearchParams(Array.isArray(cmd.command) ? cmd.command[0] : cmd.command);
router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()) });
} else
{
@ -120,14 +120,15 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
let mainButton: any | undefined = undefined;
if (status === 'installed')
{
mainButton = <div className="flex gap-2"><ActionButton onAction={() => handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details}
key="primary"
type='primary'
id="mainAction"
>
<Play />
mainButton = <div className="flex gap-2">
<ActionButton onAction={() => handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details}
key="primary"
type='primary'
id="mainAction"
>
<Play />
</ActionButton>
</ActionButton>
{validCommands.length > 1 &&
<ActionButton className="size-11! header-icon-small" tooltip={"All Commands"} type="base" id="allActionsBtn" onAction={() => showAllCommands(true, 'allActionsBtn')}>

View file

@ -464,7 +464,7 @@ const assets = new Set<string>([
]);
// Store basePath resolved from Vite config
const BASE_PATH = "./";
const BASE_PATH = "/";
/**

View file

@ -7,7 +7,7 @@
@theme {
--breakpoint-sm: 0px;
--breakpoint-md: 1280px;
--breakpoint-md: 1024px;
--page-scroll-bg: transparent;
--animation-size: 1;

View file

@ -166,7 +166,6 @@ function RouteComponent ()
return (
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage} scrolling>
<AutoFocus focus={focusSelf} />
<GameDetailsContext value={{
update: () => setUpdate(v => v + 1)
}} >
@ -214,6 +213,7 @@ function RouteComponent ()
</div>
<FloatingShortcuts />
</GameDetailsContext>
<AutoFocus focus={focusSelf} />
</AnimatedBackground>
);
}

View file

@ -13,10 +13,13 @@ import
LayoutGrid,
PlusCircle,
Plus,
LucideIcon,
} from "lucide-react";
import
{
createFileRoute,
PathParamOptions,
ToPathOption,
useRouter,
} from "@tanstack/react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@ -52,6 +55,7 @@ import { FloatingShortcuts } from "../components/Shortcuts";
import SelectMenu from "../components/SelectMenu";
import HeaderSearchField from "../components/HeaderSearchField";
import CardElement from "../components/CardElement";
import { Router } from "..";
export const Route = createFileRoute("/")({
component: ConsoleHomeUI,
@ -114,24 +118,30 @@ function Preview (data: { index: number; children?: any; })
</div>;
}
function GetStoreGamesCard ()
function AdditionalCard (data: {
id: string,
route: keyof typeof Router.routesByPath,
title: string,
subTitle: string,
index: number,
actionLabel: string;
icon: LucideIcon | string;
badgeIcon?: LucideIcon;
})
{
const router = useRouter();
const handleNavigate = () =>
{
router.navigate({ to: '/store/tab/games' });
};
return <CardElement onFocus={scrollIntoViewHandler({ behavior: "smooth", inline: "center" })} badges={[<Search className="size-8" />]} onAction={handleNavigate} title="Gameflow Store" subtitle="Get Free Games" preview={<Preview index={43} ><Store className="not-mobile:drop-shadow-md in-focus:animate-rotate size-32" /></Preview>} focusKey='store-games-btn' index={0} id="store-games-btn" />;
}
function ShowAllGamesCard ()
{
const router = useRouter();
const handleNavigate = () =>
{
router.navigate({ to: '/games' });
router.navigate({ to: data.route as any });
};
return <CardElement onFocus={scrollIntoViewHandler({ behavior: "smooth", inline: "center" })} onAction={handleNavigate} title="All Games" preview={<Preview index={17} ><LayoutGrid className="not-mobile:drop-shadow-md in-focus:animate-rotate size-32" /></Preview>} focusKey='all-games-btn' index={0} id="all-games-btn" />;
useShortcuts(data.id, () => [{ label: data.actionLabel, button: GamePadButtonCode.A, action: handleNavigate }]);
return <CardElement onFocus={scrollIntoViewHandler({ behavior: "smooth", inline: "center" })} badges={data.badgeIcon ? [<data.badgeIcon className="size-8" />] : undefined} onAction={handleNavigate} title={data.title} subtitle={data.subTitle} preview={<Preview index={data.index} >
{typeof data.icon === 'string' ?
<img className="not-mobile:drop-shadow-md" src={data.icon} /> :
<data.icon className="not-mobile:drop-shadow-md in-focus:animate-rotate size-32" />
}
</Preview>} focusKey={data.id} index={0} id={data.id} />;
}
function HomeList (data: {
@ -197,8 +207,13 @@ function HomeList (data: {
id="games-list"
setBackground={bg.setBackground}
filters={{ limit: 12, orderBy: 'activity' }}
finalElement={[<GetStoreGamesCard />, <ShowAllGamesCard />]}
emptyElement={[]}
finalElement={[
<AdditionalCard key='store-games-btn' icon={Store} badgeIcon={Search} route='/store/tab/games' id='store-games-btn' title="Gameflow Store" subTitle="Get Free Games" index={43} actionLabel="Go To Store" />,
<AdditionalCard key='all-games-btn' icon={LayoutGrid} route='/games' id='all-games-btn' title="All Games" subTitle="All Owned Games" index={17} actionLabel="All Games" />
]}
emptyElement={[
<AdditionalCard key='romm-setup-btn' icon={'https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg'} route='/settings/accounts' id='romm-setup-btn' title="Setup Romm" subTitle="To Import Games" index={18} actionLabel="Setup Romm" />
]}
/>
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
</>;

View file

@ -5,7 +5,8 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import Shortcuts, { FloatingShortcuts } from '../components/Shortcuts';
import { useJobStatus } from '../scripts/utils';
import { useRef } from 'react';
import { useEffect, useRef } from 'react';
import { rommApi } from '../scripts/clientApi';
export const Route = createFileRoute('/launcher/$source/$id')({
component: RouteComponent,
@ -39,7 +40,7 @@ function RouteComponent ()
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { data, state } = useJobStatus('launch-game', {
const { state, data } = useJobStatus('launch-game', {
onProgress (process, data)
{
if (progressRef.current)
@ -55,6 +56,7 @@ function RouteComponent ()
},
}, [progressRef.current, HandleGoBack]);
useBlocker({ shouldBlockFn: () => !!data });
return <AnimatedBackground ref={ref} backgroundKey='game-details'>

View file

@ -1,8 +1,11 @@
import { systemInfoQuery } from '@queries/system';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/mainview/components/options/Button';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { checkUpdateMutation, hasUpdateQuery, systemInfoQuery, updateMutation } from '@queries/system';
import { useMutation, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { ArrowUpCircle, CircleFadingArrowUp, RefreshCcw } from 'lucide-react';
import prettyBytes from 'pretty-bytes';
export const Route = createFileRoute('/settings/about')({
@ -12,58 +15,87 @@ export const Route = createFileRoute('/settings/about')({
function RouteComponent ()
{
const { data: systemInfo } = useQuery(systemInfoQuery);
return <table className="table">
<tbody>
<tr>
<th>Agent</th>
<td>{navigator.userAgent}</td>
</tr>
{/* row 2 */}
<tr>
<th>Platform</th>
<td>{navigator.platform}</td>
</tr>
<tr>
<th>Resolution</th>
<td>{screen.width}x{screen.height}</td>
</tr>
<tr>
<th>Window</th>
<td>{window.innerWidth}x{window.innerHeight}</td>
</tr>
{/* row 3 */}
<tr>
<th>User</th>
<td>{systemInfo?.data?.user}</td>
</tr>
<tr>
<th>Architecture</th>
<td>{systemInfo?.data?.arch}</td>
</tr>
<tr>
<th>System</th>
<td>{systemInfo?.data?.platform}</td>
</tr>
<tr>
<th>Hostname</th>
<td>{systemInfo?.data?.hostname}</td>
</tr>
<tr>
<th>Machine</th>
<td>{systemInfo?.data?.machine}</td>
</tr>
<tr>
<th>Sizes</th>
<td>Cache: {prettyBytes(systemInfo?.data?.cacheSize ?? 0)}, Store: {prettyBytes(systemInfo?.data?.storeSize ?? 0)}</td>
</tr>
<tr>
<th>Source</th>
<td>{systemInfo?.data?.source}</td>
</tr>
<tr>
<th>Steam Deck</th>
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
</tr>
</tbody>
const { ref, focusKey } = useFocusable({ focusKey: 'about-section' });
const { data: hasUpdate, refetch: refetchHasUpdate } = useQuery(hasUpdateQuery);
const update = useMutation(updateMutation);
const forceCheckUpdate = useMutation({
...checkUpdateMutation,
onSuccess (data, variables, onMutateResult, context)
{
refetchHasUpdate();
},
});
return <table ref={ref} className="table">
<FocusContext value={focusKey}>
<tbody>
<tr>
<th>Version</th>
<td>{systemInfo?.data?.version}</td>
</tr>
<tr>
<th>Update</th>
<td className='flex flex-flex-wrap gap-2'>
{
hasUpdate && hasUpdate.hasUpdate > 0 ?
<Button className='gap-3' style='warning' id='update-btn' onAction={() => update.mutate()}><CircleFadingArrowUp /> Update to {hasUpdate?.version}</Button> :
<Button className='gap-3' id='update-btn' onAction={() => forceCheckUpdate.mutate()}>{forceCheckUpdate.isPending ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcw />}Check for Update</Button>
}
{<Button className='gap-3' id='force-update-btn' onAction={() => update.mutate()}><CircleFadingArrowUp /> Force Update</Button>}
</td>
</tr>
<tr>
<th>Agent</th>
<td>{navigator.userAgent}</td>
</tr>
{/* row 2 */}
<tr>
<th>Platform</th>
<td>{navigator.platform}</td>
</tr>
<tr>
<th>Resolution</th>
<td>{screen.width}x{screen.height}</td>
</tr>
<tr>
<th>Window</th>
<td>{window.innerWidth}x{window.innerHeight}</td>
</tr>
{/* row 3 */}
<tr>
<th>User</th>
<td>{systemInfo?.data?.user}</td>
</tr>
<tr>
<th>Architecture</th>
<td>{systemInfo?.data?.arch}</td>
</tr>
<tr>
<th>System</th>
<td>{systemInfo?.data?.platform}</td>
</tr>
<tr>
<th>Hostname</th>
<td>{systemInfo?.data?.hostname}</td>
</tr>
<tr>
<th>Machine</th>
<td>{systemInfo?.data?.machine}</td>
</tr>
<tr>
<th>Sizes</th>
<td>Cache: {prettyBytes(systemInfo?.data?.cacheSize ?? 0)}, Store: {prettyBytes(systemInfo?.data?.storeSize ?? 0)}</td>
</tr>
<tr>
<th>Source</th>
<td>{systemInfo?.data?.source}</td>
</tr>
<tr>
<th>Steam Deck</th>
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
</tr>
</tbody>
</FocusContext>
</table>;
}

View file

@ -1,4 +1,5 @@
import { AutoFocus } from '@/mainview/components/AutoFocus';
import DotsLoading from '@/mainview/components/backgrounds/dots';
import { Button } from '@/mainview/components/options/Button';
import { OptionDropdown } from '@/mainview/components/options/OptionDropdown';
import { OptionInput } from '@/mainview/components/options/OptionInput';
@ -7,16 +8,34 @@ import { RoundButton } from '@/mainview/components/RoundButton';
import { getAllPluginsQuery, getPluginDetailsQuery } from '@/mainview/scripts/queries/plugins';
import { getPluginActionsQuery, getPluginSettingQuery, getPluginSettingsDefinitionQuery, pluginActionMutation, setPluginSettingMutation } from '@/mainview/scripts/queries/settings';
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { JSONSchema7 } from 'json-schema';
import { ArrowLeft, CirclePlay, Play, Settings2, SettingsIcon } from 'lucide-react';
import toast from 'react-hot-toast';
export const Route = createFileRoute('/settings/plugin/$source')({
component: RouteComponent,
pendingComponent: Loading,
async loader (ctx)
{
const definitions = await ctx.context.queryClient.fetchQuery(getPluginSettingsDefinitionQuery(ctx.params.source));
const actions = await ctx.context.queryClient.fetchQuery(getPluginActionsQuery(ctx.params.source));
await new Promise(resolve => setTimeout(resolve, 1000));
return { definitions, actions };
},
});
function Loading ()
{
const { ref, focusSelf } = useFocusable({ focusKey: 'plugins' });
return <>
<DotsLoading ref={ref} />
<AutoFocus focus={focusSelf} />
</>;
}
function PluginAction (data: { id: string, title: string | undefined, description: string | undefined; action: string; reload: () => void; })
{
const { source } = Route.useParams();
@ -91,15 +110,19 @@ function PluginOption (data: { name: string, title?: string, prop: JSONSchema7;
function Settings ()
{
const { definitions, actions } = Route.useLoaderData();
const { source } = Route.useParams();
const { data: definitions, refetch: refetchDefinitions } = useQuery(getPluginSettingsDefinitionQuery(source));
const { data: actions, refetch: referchActions } = useQuery(getPluginActionsQuery(source));
const queryClient = useQueryClient();
const handleReload = () =>
{
referchActions();
refetchDefinitions();
queryClient.refetchQueries(getPluginSettingsDefinitionQuery(source));
queryClient.refetchQueries(getPluginActionsQuery(source));
};
const { ref, focusKey } = useFocusable({ focusKey: 'plugin-settings' });
const { ref, focusKey } = useFocusable({
focusKey: 'plugin-settings',
focusable: (definitions?.properties && Object.keys(definitions?.properties).length > 0) || actions.length > 0
});
return <div ref={ref}>
<FocusContext value={focusKey}>
{!!definitions?.properties && Object.entries(Object.groupBy(Object.entries(definitions?.properties)
@ -142,16 +165,19 @@ function RouteComponent ()
return <div ref={ref}>
<FocusContext value={focusKey}>
<RoundButton className='absolute' id='return-to-plugins' onAction={handleReturn}><ArrowLeft /></RoundButton>
<div className='flex flex-col gap-4'>
<div className='flex text-2xl font-bold gap-2 grow items-center justify-center'>
<RoundButton onFocus={scrollIntoViewHandler({ inline: 'end' })} id='return-to-plugins' onAction={handleReturn}><ArrowLeft /></RoundButton>
<img className='h-12' src={data?.icon}></img>
{data?.displayName}
</div>
<ul className='flex gap-2 justify-center'>{data?.keywords?.map((k, i) => <li key={i} className='bg-base-200 rounded-full p-2 px-4'>{k}</li>)}</ul>
<div className='bg-base-200 p-4 rounded-2xl'>{data?.description}</div>
</div>
<Settings />
</FocusContext>
<AutoFocus focus={focusSelf} />
</div>;

View file

@ -1,3 +1,4 @@
import { AutoFocus } from '@/mainview/components/AutoFocus';
import { pluginCategoryIcons, pluginCategoryPriorities } from '@/mainview/components/Constants';
import { Button } from '@/mainview/components/options/Button';
import { OptionInput } from '@/mainview/components/options/OptionInput';
@ -62,7 +63,7 @@ function Plugin (data: {
function RouteComponent ()
{
const { data: plugins, refetch: refetchPlugins } = useQuery(getAllPluginsQuery);
const { ref, focusKey } = useFocusable({ focusKey: 'plugins' });
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugins' });
const pluginMutation = useMutation({
...enablePluginMutation, onSuccess (data, variables, onMutateResult, context)
{
@ -84,6 +85,7 @@ function RouteComponent ()
</div>
</div>;
})}
<AutoFocus focus={focusSelf} />
</FocusContext>
</div>;
}

View file

@ -30,7 +30,7 @@ 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 });
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' }));
@ -80,7 +80,8 @@ function RouteComponent ()
if (isFetchingNextPage || isFetching)
return;
fetchNextPage();
}} />} games={data?.pages.flatMap((page) => page.data.map((g) =>
}} />}
games={data?.pages.flatMap((page) => page.data.map((g) =>
{
const badges: JSX.Element[] = [];
if (g.id.source === 'local')
@ -119,7 +120,8 @@ function RouteComponent ()
onFocus: (k, n, d) => handleFocus(k, n, d)
} satisfies GameMetaExtra as GameMetaExtra;
})
) ?? []} id={'store-games'} />
) ?? []}
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' }} />

View file

@ -15,6 +15,7 @@ import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
import { useQuery } from '@tanstack/react-query';
import { autoEmulatorsQuery } from '@queries/settings';
import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store';
import ImageWithFallbacks from '@/mainview/components/ImageWithFallbacks';
export const Route = createFileRoute('/store/tab/')({
component: RouteComponent
@ -64,16 +65,7 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
{game ? <div key={selectedGame} className="flex transition-all duration-500 flex-col rounded-3xl overflow-hidden shadow-black/5 shadow-md w-full ring-6 ring-base-200 border-6 border-base-200">
<div className='flex relative h-full overflow-hidden'>
<div className='absolute w-full h-full z-0 bg-base-200'>
<picture key={selectedGame}
className='w-full h-full object-cover transition-all duration-500 ease-out scale-110 opacity-0 light:data-loaded:opacity-40 dark:data-loaded:opacity-80 z-0'
onLoad={(e) =>
{
e.currentTarget.dataset.loaded = "true";
e.currentTarget.classList.toggle('scale-110', false);
}}
>
{previewUrls?.map((u, i) => <source key={i} src={u.href} />)}
</picture>
<ImageWithFallbacks src={previewUrls ?? []} className='w-full h-full transition-all duration-500 ease-out scale-110 opacity-0 light:data-loaded:opacity-40 dark:data-loaded:opacity-40 z-0 object-cover' />
</div>
<div key={selectedGame} className='flex sm:flex-wrap md:flex-nowrap grow z-1 p-8 opacity-0 animate-fade-in h-full items-end gap-4 sm:justify-end md:justify-between'>
<div className='flex gap-4 max-h-full z-1 grow md:h-full'>
@ -89,7 +81,7 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
</div>
</div>
<Button onAction={() => storeContext.showDetails('game', game.id.source, game.id.id, focusKey)} className='px-6 py-3 text-2xl! z-1 gap-2 focusable focusable-primary' id={'play-featured-btn'}> <Search /> Details</Button>
<Button onAction={() => storeContext.showDetails('game', game.id.source, game.id.id, focusKey)} className='px-6 py-3 text-2xl! z-1 gap-2 drop-shadow-md focusable focusable-primary' id={'play-featured-btn'}> <Search /> Details</Button>
</div>
</div>
</div> : <div className='skeleton w-full rounded-3xl grow sm:h-64 z-15' />}

View file

@ -56,4 +56,24 @@ export const hasUpdateQuery = queryOptions({
return data;
},
staleTime: 1000 * 60 * 30
});
export const checkUpdateMutation = mutationOptions({
mutationKey: ['update', 'check'],
mutationFn: async () =>
{
const { data, error } = await systemApi.api.system.update.check.post();
if (error) throw error;
return data;
},
});
export const updateMutation = mutationOptions({
mutationKey: ['update'],
mutationFn: async () =>
{
const { data, error } = await systemApi.api.system.update.post();
if (error) throw error;
return data;
},
});

View file

@ -272,6 +272,7 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
onEnded?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
onCompleted?: (data: ExtractField<JobResponse<JOB>, "completed" | "ended", 'data'>) => void;
onError?: (error: string) => void;
onClosed?: () => void;
},
deps?: DependencyList
)
@ -289,6 +290,7 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
const sub = jobsApi.api.jobs[id].subscribe({ query: init?.query });
ref.current = sub as any;
sub.on('close', () => init?.onClosed?.());
sub.subscribe(({ data }) =>
{
switch (data.type)
@ -326,7 +328,7 @@ export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api
sub.close();
ref.current = null;
};
}, [id, init?.query, init?.onEnded, init?.onCompleted, init?.onProgress, init?.onError, ...(deps ?? [])]);
}, [id, init?.query]);
return { data, state, error, wsRef: ref };
}

View file

@ -1,5 +1,6 @@
declare const __HOST__: string;
declare const __PUBLIC__: boolean;
declare const __FLATPAK__: boolean;
declare const __EMULATORS__: Record<string, string>;
declare module "@emulators" {
const data: Record<string, string>;