fix: Fixed tests
feat: Added RClone integration feat: Implemented plugin settings feat: Updated minimal store version test: Fixed tests feat: Moved store and igdb and es-de to their own plugins
This commit is contained in:
parent
444d8c4c27
commit
c09fbd3dc8
115 changed files with 4139 additions and 1502 deletions
|
|
@ -9,7 +9,6 @@ export const focusQueue: string[] = [];
|
|||
|
||||
export default function App (data: { children: any; })
|
||||
{
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const focusMap = new Map<number, string>();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SystemInfoContext } from "../scripts/contexts";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { SystemInfoType } from "@/shared/constants";
|
||||
import LoadingScreen from "./LoadingScreen";
|
||||
|
||||
export default function AppCommunication (data: { children: any; })
|
||||
{
|
||||
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfoType | undefined>();
|
||||
const [loadingInfo, setLoadingInfo] = useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const loadingProgressBarRef = useRef<HTMLProgressElement>(null);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const sub = systemApi.api.system.info.system.subscribe();
|
||||
|
|
@ -20,14 +24,32 @@ export default function AppCommunication (data: { children: any; })
|
|||
case "focus":
|
||||
window.focus();
|
||||
break;
|
||||
case "loading":
|
||||
setLoadingInfo(data.state);
|
||||
if (loadingProgressBarRef.current)
|
||||
loadingProgressBarRef.current.value = data.progress;
|
||||
setLoading(true);
|
||||
break;
|
||||
case "loaded":
|
||||
setLoading(false);
|
||||
break;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
document.documentElement.dataset.loaded = "true";
|
||||
}, []);
|
||||
|
||||
return <SystemInfoContext value={systemInfo}>
|
||||
{data.children}
|
||||
{loading ?
|
||||
<LoadingScreen>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex gap-2">
|
||||
<span className="loading loading-spinner loading-xl"></span>
|
||||
{loadingInfo}
|
||||
</div>
|
||||
<progress ref={loadingProgressBarRef} className="progress w-[20vw]" value={0} max="100"></progress>
|
||||
</div>
|
||||
</LoadingScreen>
|
||||
: data.children}
|
||||
</SystemInfoContext>;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { JSX } from "react";
|
|||
import { twMerge } from "tailwind-merge";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
import { oneShot } from "../scripts/audio/audio";
|
||||
import ImageWithFallbacks from "./ImageWithFallbacks";
|
||||
|
||||
export function GameCardSkeleton ()
|
||||
{
|
||||
|
|
@ -21,8 +22,8 @@ export function GameCardSkeleton ()
|
|||
export interface GameCardParams extends FocusParams
|
||||
{
|
||||
title: string;
|
||||
subtitle: string | JSX.Element;
|
||||
preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element);
|
||||
subtitle?: string | JSX.Element;
|
||||
preview?: string | JSX.Element | URL[] | ((p: { focused: boolean; }) => JSX.Element);
|
||||
srcset?: string;
|
||||
focusKey: string;
|
||||
index: number;
|
||||
|
|
@ -49,6 +50,21 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
|||
});
|
||||
const { isPointer } = useActiveControl();
|
||||
|
||||
let preview: any = undefined;
|
||||
if (typeof data.preview === "string")
|
||||
{
|
||||
preview = <img draggable={false} srcSet={data.srcset} className={classNames("object-cover aspect-3/4", data.previewClassName, { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>;
|
||||
} else if (Array.isArray(data.preview))
|
||||
{
|
||||
preview = <ImageWithFallbacks src={data.preview} draggable={false} className={classNames("object-cover aspect-3/4 w-full h-full", data.previewClassName, { "animate-rotate-small": focused && !isPointer })} />;
|
||||
} else if (typeof data.preview === 'function')
|
||||
{
|
||||
preview = data.preview({ focused });
|
||||
} else
|
||||
{
|
||||
preview = data.preview;
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
id={`game-entry-${data.id}`}
|
||||
|
|
@ -76,11 +92,7 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
|||
focused ? "md:mt-2 md:mx-2" : "md:mt-2 md:mx-2",
|
||||
classNames({ "h-full": typeof data.preview === "string" })
|
||||
)}>
|
||||
{typeof data.preview === "string" ? (
|
||||
<img draggable={false} srcSet={data.srcset} className={classNames("object-cover aspect-3/4", data.previewClassName, { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
|
||||
) : (
|
||||
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
|
||||
)}
|
||||
{preview}
|
||||
</div>
|
||||
|
||||
<div className="h-0 flex pr-2 justify-end items-center sm:gap-1 md:gap-2 z-2">
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ export interface GameMetaExtra extends GameMeta
|
|||
function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusParams & InteractParams)
|
||||
{
|
||||
let preview: GameCardParams['preview'] = data.game.preview;
|
||||
if (!preview && data.game.previewUrl)
|
||||
if (!preview && data.game.previewUrls)
|
||||
{
|
||||
preview = data.game.previewUrl;
|
||||
preview = data.game.previewUrls;
|
||||
}
|
||||
|
||||
const handleAction = (ctx: InteractParamsArgs) =>
|
||||
|
|
@ -40,7 +40,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
|
|||
focusKey={data.game.focusKey}
|
||||
data-index={data.i}
|
||||
title={data.game.title}
|
||||
subtitle={data.game.subtitle ?? ""}
|
||||
subtitle={data.game.subtitle}
|
||||
srcset={data.game.previewSrcset}
|
||||
onFocus={(focusKey, node, details) =>
|
||||
{
|
||||
|
|
@ -69,8 +69,6 @@ export function CardList (data: {
|
|||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: data.id,
|
||||
forceFocus: true,
|
||||
autoRestoreFocus: true,
|
||||
focusable: data.games.length > 0,
|
||||
preferredChildFocusKey: data.focus
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ export default function CollectionList (data: {
|
|||
id: `${g.id.source}@${g.id.id}`,
|
||||
title: g.name,
|
||||
focusKey: `collection-${g.id}`,
|
||||
subtitle: "",
|
||||
previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`,
|
||||
badges: [
|
||||
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
||||
|
|
@ -46,7 +45,7 @@ export default function CollectionList (data: {
|
|||
],
|
||||
} satisfies GameMetaExtra))}
|
||||
onSelectGame={data.onSelect ? data.onSelect : handleDefaultSelect}
|
||||
onGameFocus={(id, node, details) =>
|
||||
onFocus={(id, node, details) =>
|
||||
{
|
||||
data.setBackground(
|
||||
`https://picsum.photos/id/${10 + (id ?? 0)}/100/100.webp?blur=10`,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { GameListFilterSchema, GameListFilterType } from '@/shared/constants';
|
|||
import { HandleGoBack } from '../scripts/utils';
|
||||
import LoadingCardList from './LoadingCardList';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { gameQuery } from '../scripts/queries/romm';
|
||||
import { gameFiltersQuery, gameQuery } from '../scripts/queries/romm';
|
||||
import { useNavigate, useRouter } from '@tanstack/react-router';
|
||||
import SelectMenu from './SelectMenu';
|
||||
import { RoundButton } from './RoundButton';
|
||||
|
|
@ -41,7 +41,6 @@ export interface CollectionsDetailParams
|
|||
export function CollectionsDetail (data: CollectionsDetailParams)
|
||||
{
|
||||
const router = useRouter();
|
||||
const [filterValues, setFilterValues] = useState<FrontEndFilterLists>();
|
||||
const queryClient = useQueryClient();
|
||||
const finalFilter = { ...data.localFilter, ...data.filters };
|
||||
const focusKey = `game-list-${data.id}`;
|
||||
|
|
@ -50,6 +49,8 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
preferredChildFocusKey: `${focusKey}-list`
|
||||
});
|
||||
|
||||
const { data: filterValues } = useQuery(gameFiltersQuery({ source: data.filters?.source }));
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]);
|
||||
|
||||
const handleScroll: FocusParams['onFocus'] = (cardId, node, details) =>
|
||||
|
|
@ -79,7 +80,6 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
<GameList
|
||||
key={`${data.id}-${JSON.stringify(finalFilter)}`}
|
||||
grid
|
||||
setFilterValues={setFilterValues}
|
||||
filters={finalFilter}
|
||||
onFocus={handleScroll}
|
||||
focus={data.focus}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,21 @@
|
|||
import { Gamepad2, HardDrive, Store } from "lucide-react";
|
||||
import { CloudSync, Gamepad2, HardDrive, MonitorPlay, Store, Terminal } from "lucide-react";
|
||||
|
||||
export const sourceIconMap: Record<string, any> = {
|
||||
store: <Store />,
|
||||
local: <HardDrive />,
|
||||
romm: <Gamepad2 />
|
||||
};
|
||||
|
||||
export const pluginCategoryIcons: Record<string, any> = {
|
||||
saves: <CloudSync />,
|
||||
sources: <Gamepad2 />,
|
||||
launchers: <Terminal />,
|
||||
emulators: <MonitorPlay />
|
||||
};
|
||||
|
||||
export const pluginCategoryPriorities: Record<string, number> = {
|
||||
saves: 100,
|
||||
sources: 90,
|
||||
launchers: 80,
|
||||
emulators: 60
|
||||
};
|
||||
|
|
@ -13,16 +13,24 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG
|
|||
router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } });
|
||||
};
|
||||
|
||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`);
|
||||
platformUrl.searchParams.set('width', "64");
|
||||
const subtitle = <div className="flex gap-1 items-center">
|
||||
{!!data.game.path_platform_cover && <img className="sm:hidden md:inline size-4" src={platformUrl.href} />}
|
||||
<p className="opacity-80">{data.game.platform_display_name}</p>
|
||||
</div>;
|
||||
let subtitle: any = undefined;
|
||||
if (data.game.path_platform_cover)
|
||||
{
|
||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`);
|
||||
platformUrl.searchParams.set('width', "64");
|
||||
subtitle = <div className="flex gap-1 items-center">
|
||||
{!!data.game.path_platform_cover && <img className="sm:hidden md:inline size-4" src={platformUrl.href} />}
|
||||
<p className="opacity-80">{data.game.platform_display_name}</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_cover}`);
|
||||
previewUrl.searchParams.delete('ts');
|
||||
previewUrl.searchParams.set('width', "640");
|
||||
const previewUrls = data.game.path_covers.map(c =>
|
||||
{
|
||||
const url = new URL(`${RPC_URL(__HOST__)}${c}`);
|
||||
url.searchParams.delete('ts');
|
||||
url.searchParams.set('width', "640");
|
||||
return url;
|
||||
});
|
||||
|
||||
const badges: JSX.Element[] = [];
|
||||
|
||||
|
|
@ -53,7 +61,7 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG
|
|||
badges={badges}
|
||||
onFocus={data.onFocus}
|
||||
onAction={(e) => data.onAction ? data.onAction(e) : handleDefaultSelect(data.game.id, data.game.source, data.game.source_id)}
|
||||
preview={previewUrl.href}
|
||||
preview={previewUrls}
|
||||
title={data.game.name ?? ""}
|
||||
subtitle={subtitle}
|
||||
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { GameMetaExtra, CardList } from "./CardList";
|
||||
import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
|
@ -19,7 +19,6 @@ export interface GameListParams extends FocusParams
|
|||
className?: string;
|
||||
finalElement?: JSX.Element;
|
||||
saveChildFocus?: "session" | "local";
|
||||
setFilterValues?: (filters: FrontEndFilterLists) => void;
|
||||
}
|
||||
|
||||
export function GameList (data: GameListParams)
|
||||
|
|
@ -37,7 +36,7 @@ export function GameList (data: GameListParams)
|
|||
try
|
||||
{
|
||||
const screenshotUrl = game.paths_screenshots && game.paths_screenshots.length > 0 ? new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`) : undefined;
|
||||
const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_cover}`);
|
||||
const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_covers[0]}`);
|
||||
const previewUrl = blur ? coverUrl : (screenshotUrl ?? coverUrl);
|
||||
previewUrl.searchParams.delete('ts');
|
||||
data.setBackground?.(previewUrl.href) ?? backgroundContext.setBackground(previewUrl.href);
|
||||
|
|
@ -48,11 +47,6 @@ export function GameList (data: GameListParams)
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
data.setFilterValues?.(games.data.filters);
|
||||
}, [games.data.filters]);
|
||||
|
||||
function handleDefaultSelect (g: FrontEndGameType)
|
||||
{
|
||||
navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } });
|
||||
|
|
@ -79,23 +73,31 @@ export function GameList (data: GameListParams)
|
|||
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 previewUrls = g.path_covers.map(c =>
|
||||
{
|
||||
const url = new URL(`${RPC_URL(__HOST__)}${c}`);
|
||||
url.searchParams.delete('ts');
|
||||
return url;
|
||||
});
|
||||
|
||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
|
||||
platformUrl.searchParams.set('width', "64");
|
||||
let platformUrl: URL | undefined = undefined;
|
||||
if (g.path_platform_cover)
|
||||
{
|
||||
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.slug ?? `game-${g.id}`,
|
||||
focusKey: `${data.id}-${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} />}
|
||||
<img className="sm:hidden md:inline size-4" src={platformUrl?.href} />
|
||||
<p className="opacity-80">{g.platform_display_name}</p>
|
||||
</div>
|
||||
),
|
||||
previewUrl: previewUrl.href,
|
||||
previewUrls: previewUrls,
|
||||
badges: badges,
|
||||
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g),
|
||||
onFocus: () => handleFocus(g.id, g.source, g.source_id)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import
|
|||
BatteryWarning,
|
||||
Bell,
|
||||
Bluetooth,
|
||||
CircleFadingArrowUp,
|
||||
Clock,
|
||||
Settings,
|
||||
Wifi,
|
||||
|
|
@ -31,6 +32,7 @@ import { twitchLoginVerificationQuery } from "../scripts/queries/settings";
|
|||
import { SystemInfoContext } from "../scripts/contexts";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { oneShot } from "../scripts/audio/audio";
|
||||
import { hasUpdateQuery } from "../scripts/queries/system";
|
||||
|
||||
function HeaderAvatar (data: {
|
||||
id: string;
|
||||
|
|
@ -83,6 +85,14 @@ export interface HeaderAccount
|
|||
action?: () => void;
|
||||
}
|
||||
|
||||
function UpdateStatus ()
|
||||
{
|
||||
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" />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function NotificationStatus ()
|
||||
{
|
||||
const hasUnread = false;
|
||||
|
|
@ -249,13 +259,15 @@ 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);
|
||||
return <div ref={ref} className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="flex sm:gap-2 md:gap-5 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
|
||||
<div className="flex gap-2 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
|
||||
<ClockStatus />
|
||||
<WiFiStatus />
|
||||
<BluetoothStatus />
|
||||
<NotificationStatus />
|
||||
{!!hasUpdate && hasUpdate >= 1 && <UpdateStatus />}
|
||||
<BatteryStatus />
|
||||
</div>
|
||||
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
||||
|
|
|
|||
19
src/mainview/components/ImageWithFallbacks.tsx
Normal file
19
src/mainview/components/ImageWithFallbacks.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export default function ImageWithFallbacks (data: {
|
||||
src: URL[];
|
||||
draggable?: boolean;
|
||||
className?: string;
|
||||
})
|
||||
{
|
||||
const handleError = (e: React.SyntheticEvent<HTMLImageElement>) =>
|
||||
{
|
||||
const img = e.currentTarget;
|
||||
const nextIndex = Number(img.dataset.index) + 1;
|
||||
|
||||
if (nextIndex < data.src.length)
|
||||
{
|
||||
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>;
|
||||
}
|
||||
|
|
@ -17,7 +17,6 @@ export default function LoadingCardList (data: { id: string, placeholderCount: n
|
|||
ref={ref}
|
||||
title="Games"
|
||||
id={`card-list-placeholder`}
|
||||
save-child-focus="session"
|
||||
className={twMerge("items-center justify-center-safe h-full",
|
||||
data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-min grid-cols-[repeat(auto-fill,var(--game-card-width))]" :
|
||||
'landscape:grid landscape:grid-flow-col landscape:auto-cols-min auto-rows-[1fr] sm:gap-2 md:gap-4 portrait:grid portrait:auto-rows-min portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))] *:portrait:aspect-8/10 *:landscape:aspect-8/12 sm:landscape:max-h-84 md:max-h-128!',
|
||||
|
|
|
|||
9
src/mainview/components/LoadingScreen.tsx
Normal file
9
src/mainview/components/LoadingScreen.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export default function LoadingScreen (data: { children?: any; })
|
||||
{
|
||||
return <div className="absolute flex items-center gap-2 justify-center bg-base-300 w-screen h-screen z-100 font-semibold text-2xl text-shadow-lg">
|
||||
<div className="absolute w-screen h-screen bg-radial from-base-100 to-base-300 -z-2"></div>
|
||||
<div className="bg-noise"></div>
|
||||
<div className="bg-dots"></div>
|
||||
{data.children}
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -3,10 +3,36 @@ import { useNavigate } from "@tanstack/react-router";
|
|||
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
|
||||
import { CardList, GameMetaExtra } from "./CardList";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
import { JSX, useMemo } from "react";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { JSX, useMemo, useState } from "react";
|
||||
import { Gamepad2, HardDrive } from "lucide-react";
|
||||
import { mobileCheck } from "../scripts/utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import placeholder from '../assets/256x256.png?url';
|
||||
|
||||
function Preview (data: { index: number, pathCover: string | null; })
|
||||
{
|
||||
const coverUrl = new URL(`${RPC_URL(__HOST__)}${data.pathCover}`);
|
||||
coverUrl.searchParams.set('width', "320");
|
||||
const isMobile = mobileCheck();
|
||||
return <div
|
||||
className="flex p-6 bg-base-100 justify-center items-center aspect-square"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
color-mix(in srgb, var(--color-base-content) 60%, transparent),
|
||||
color-mix(in srgb, var(--color-base-300) 60%, transparent)
|
||||
), url(https://picsum.photos/id/${10 + data.index}/100/100.webp?blur=10) center / cover`,
|
||||
|
||||
backgroundBlendMode: isMobile ? undefined : "screen",
|
||||
boxShadow: isMobile ? undefined : 'inset 0 0 32px rgba(0,0,0,0.6)'
|
||||
}}
|
||||
>
|
||||
<img draggable={false} className={"not-mobile:drop-shadow-2xl in-focus:animate-rotate"}
|
||||
onError={e => e.currentTarget.src = placeholder}
|
||||
src={coverUrl.href}
|
||||
>
|
||||
</img>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function PlatformsList (data: {
|
||||
id: string,
|
||||
|
|
@ -17,7 +43,7 @@ export function PlatformsList (data: {
|
|||
saveChildFocus?: "session" | "local";
|
||||
} & FocusParams)
|
||||
{
|
||||
const isMobile = mobileCheck();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { data: platforms } = useSuspenseQuery(
|
||||
{
|
||||
|
|
@ -44,37 +70,19 @@ export function PlatformsList (data: {
|
|||
badges.push(<span className="flex items-center justify-center sm:size-3 md:size-6 m-1 md:text-2xl font-semibold font-boldrounded-full">{g.game_count}</span>);
|
||||
if (g.hasLocal)
|
||||
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
|
||||
const coverUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
|
||||
coverUrl.searchParams.set('width', "320");
|
||||
|
||||
const entry: GameMetaExtra = {
|
||||
id: g.slug,
|
||||
focusKey: g.slug,
|
||||
title: g.name,
|
||||
subtitle: g.family_name ?? "",
|
||||
previewUrl: "",
|
||||
subtitle: g.family_name ?? undefined,
|
||||
previewUrls: "",
|
||||
badges,
|
||||
onFocus: () => data.setBackground(
|
||||
g.paths_screenshots.length > 0 ? `${RPC_URL(__HOST__)}${g.paths_screenshots[new Date().getMinutes() % g.paths_screenshots.length]}` : `${RPC_URL(__HOST__)}/api/romm/image?url=https://picsum.photos/id/${10 + i}/1280/720.webp`,
|
||||
),
|
||||
onSelect: () => data.onSelect ? data.onSelect(g.id.source, g.id.id) : handleDefaultSelect(g.id.source, g.id.id),
|
||||
preview:
|
||||
() => <div
|
||||
className="flex p-6 bg-base-100 justify-center"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
color-mix(in srgb, var(--color-base-content) 60%, transparent),
|
||||
color-mix(in srgb, var(--color-base-300) 60%, transparent)
|
||||
), url(https://picsum.photos/id/${10 + i}/100/100.webp?blur=10) center / cover`,
|
||||
|
||||
backgroundBlendMode: isMobile ? undefined : "screen",
|
||||
boxShadow: isMobile ? undefined : 'inset 0 0 32px rgba(0,0,0,0.6)'
|
||||
}}
|
||||
>
|
||||
<img draggable={false} className={"not-mobile:drop-shadow-2xl in-focus:animate-rotate"}
|
||||
src={coverUrl.href}
|
||||
></img>
|
||||
</div>
|
||||
,
|
||||
preview: () => <Preview index={i} pathCover={g.path_cover} />
|
||||
};
|
||||
return entry;
|
||||
}), [platforms]);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog";
|
|||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||
import { MatchRoute, useMatch, useMatchRoute, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { DoorOpen, Gamepad2, RefreshCcw, Settings, Store } from "lucide-react";
|
||||
import { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { FOCUS_KEYS } from "../scripts/types";
|
||||
|
||||
|
|
@ -54,12 +54,24 @@ export default function SelectMenu (data: { rootFocusKey: string; })
|
|||
action (ctx)
|
||||
{
|
||||
setOpen(false);
|
||||
navigate({ to: "/settings/accounts" });
|
||||
navigate({ to: "/settings/interface" });
|
||||
},
|
||||
selected: !!matchRoute({ to: '/settings/accounts' }),
|
||||
selected: !!matchRoute({ to: '/settings' }) && !matchRoute({ to: '/settings/plugins' }) && !matchRoute({ to: '/settings/plugin/$source' }),
|
||||
type: "accent",
|
||||
id: "settings-m"
|
||||
},
|
||||
{
|
||||
content: "Plugins",
|
||||
icon: <Puzzle />,
|
||||
action (ctx)
|
||||
{
|
||||
setOpen(false);
|
||||
navigate({ to: "/settings/plugins" });
|
||||
},
|
||||
selected: !!matchRoute({ to: '/settings/plugins' }) && !matchRoute({ to: '/settings/plugin/$source' }),
|
||||
type: "accent",
|
||||
id: "plugins-m"
|
||||
},
|
||||
{
|
||||
content: "Reload",
|
||||
icon: <RefreshCcw />,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default function ActionButton (data: {
|
|||
square?: boolean,
|
||||
onFocus?: () => void;
|
||||
tooltip?: string,
|
||||
tooltip_type?: 'accent' | 'error';
|
||||
tooltipType?: 'accent' | 'error';
|
||||
disabled?: boolean;
|
||||
} & InteractParams)
|
||||
{
|
||||
|
|
@ -30,7 +30,7 @@ export default function ActionButton (data: {
|
|||
ref={ref}
|
||||
onClick={e => data.onAction?.({ event: e.nativeEvent, focusKey })}
|
||||
data-tooltip={data.tooltip}
|
||||
data-tooltip-type={data.tooltip_type}
|
||||
data-tooltip-type={data.tooltipType}
|
||||
className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content",
|
||||
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}>
|
||||
{data.icon}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { deleteGameMutation, fixSourceMutation, gameInvalidationQuery, validateSourceQuery } from "@/mainview/scripts/queries/romm";
|
||||
import { deleteGameMutation, fixSourceMutation, gameInvalidationQuery, updateSourceMutation, validateSourceQuery } from "@/mainview/scripts/queries/romm";
|
||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
|
||||
import { getErrorMessage } from "react-error-boundary";
|
||||
import toast from "react-hot-toast";
|
||||
import { Hammer, Settings, Trash, Trophy } from "lucide-react";
|
||||
import { Hammer, RefreshCcw, Settings, Trash, Trophy } from "lucide-react";
|
||||
import MainActions from "./MainActions";
|
||||
import ActionButton from "./ActionButton";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
|
|
@ -34,7 +34,8 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots');
|
||||
|
||||
const fixMutation = useMutation({
|
||||
...fixSourceMutation, onSuccess (data, variables, onMutateResult, context)
|
||||
...fixSourceMutation,
|
||||
onSuccess (data, variables, onMutateResult, context)
|
||||
{
|
||||
if (onMutateResult) toast.success("Updated Source");
|
||||
context.client.invalidateQueries(gameInvalidationQuery(variables.id, variables.source)).then(() => router.history.back());
|
||||
|
|
@ -44,6 +45,18 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
toast.error(getErrorMessage(error) ?? "Error While Trying To Fix");
|
||||
}
|
||||
});
|
||||
const updateMutation = useMutation({
|
||||
...updateSourceMutation,
|
||||
onSuccess (data, variables, onMutateResult, context)
|
||||
{
|
||||
if (onMutateResult) toast.success("Updated Source");
|
||||
context.client.invalidateQueries(gameInvalidationQuery(variables.id, variables.source));
|
||||
},
|
||||
onError (error)
|
||||
{
|
||||
toast.error(getErrorMessage(error) ?? "Error While Trying To Update");
|
||||
}
|
||||
});
|
||||
const { data: validation } = useQuery(validateSourceQuery(data.source, data.id));
|
||||
const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', forceFocus: true, trackChildren: true, preferredChildFocusKey: 'mainAction' });
|
||||
const router = useRouter();
|
||||
|
|
@ -62,7 +75,7 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
useBlocker({
|
||||
shouldBlockFn: () =>
|
||||
{
|
||||
return deleteMutation.isPending || fixMutation.isPending;
|
||||
return deleteMutation.isPending || fixMutation.isPending || updateMutation.isPending;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -85,15 +98,34 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
{
|
||||
contextOptions.push({
|
||||
id: "fix_source",
|
||||
action (ctx)
|
||||
async action (ctx)
|
||||
{
|
||||
if (data.game)
|
||||
fixMutation.mutate({ source: data.game.id.source, id: data.game.id.id });
|
||||
if (!data.game) return;
|
||||
await fixMutation.mutateAsync({ source: data.game.id.source, id: data.game.id.id });
|
||||
ctx.close();
|
||||
router.navigate({ replace: true });
|
||||
},
|
||||
icon: fixMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Hammer />,
|
||||
content: "Try Fix Source",
|
||||
type: "warning"
|
||||
});
|
||||
} else if (data.game?.id.source === 'local')
|
||||
{
|
||||
contextOptions.push({
|
||||
id: 'update_source',
|
||||
async action (ctx)
|
||||
{
|
||||
if (data.game)
|
||||
{
|
||||
await updateMutation.mutateAsync({ source: data.game.id.source, id: data.game.id.id });
|
||||
ctx.close();
|
||||
router.navigate({ replace: true });
|
||||
}
|
||||
},
|
||||
icon: updateMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcw />,
|
||||
content: "Update Metadata",
|
||||
type: "primary"
|
||||
});
|
||||
}
|
||||
|
||||
const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: <ContextList disableCloseButton={deleteMutation.isPending} options={contextOptions} />, canClose: !deleteMutation.isPending });
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export default function Details (data: {
|
|||
const platformCoverImg = data.game?.path_platform_cover ? new URL(`${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`) : undefined;
|
||||
if (platformCoverImg)
|
||||
platformCoverImg.searchParams.set("width", "64");
|
||||
const gameCoverImg = data.game?.path_cover ? `${RPC_URL(__HOST__)}${data.game?.path_cover}` : undefined;
|
||||
const gameCoverImg = data.game?.path_covers ? `${RPC_URL(__HOST__)}${data.game?.path_covers[0]}` : undefined;
|
||||
|
||||
let fileSizeIcon: JSX.Element | undefined;
|
||||
if (!data.game)
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
{
|
||||
const errorMessage = getErrorMessage(e.data.error);
|
||||
if (!errorMessage) return;
|
||||
toast.error(errorMessage);
|
||||
setError(errorMessage);
|
||||
}
|
||||
});
|
||||
|
|
@ -137,7 +136,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
mainButton = <ActionButton
|
||||
key="error"
|
||||
tooltip={error}
|
||||
tooltip-type="error"
|
||||
tooltipType="error"
|
||||
type='error'
|
||||
onAction={() =>
|
||||
{
|
||||
|
|
@ -169,7 +168,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
{
|
||||
case 'present':
|
||||
case 'install':
|
||||
installMut.mutate();
|
||||
installMut.mutate({});
|
||||
break;
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export function OptionInput (data: {
|
|||
step?: number;
|
||||
defaultValue?: string | boolean | number;
|
||||
autocomplete?: HTMLInputAutoCompleteAttribute;
|
||||
compact?: boolean;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onChange?: (value: string | number | boolean) => void;
|
||||
})
|
||||
|
|
@ -121,7 +122,7 @@ export function OptionInput (data: {
|
|||
};
|
||||
|
||||
return (
|
||||
<label ref={ref} className={`flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent group-focusable`}>
|
||||
<label ref={ref} className={twMerge(`flex items-center gap-3 rounded-full divide-accent group-focusable`, data.compact !== true ? "sm:flex-2 md:flex-1" : "")}>
|
||||
{!!data.icon && <span className="text-base-content/80">{data.icon}</span>}
|
||||
{data.type !== 'checkbox' && <input
|
||||
ref={inputRef}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { OptionContext } from "@/mainview/scripts/contexts";
|
||||
import { Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { Direction, FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { JSX, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
|
@ -38,6 +39,8 @@ export function OptionSpace (data: {
|
|||
children?: any | any[];
|
||||
label?: string | JSX.Element | ((focused: boolean) => JSX.Element);
|
||||
saveLastFocusedChild?: boolean;
|
||||
preferredChildFocusKey?: string;
|
||||
shortcuts?: Shortcut[];
|
||||
})
|
||||
{
|
||||
const [focusBoundary, setFocusBoundary] = useState(false);
|
||||
|
|
@ -50,6 +53,7 @@ export function OptionSpace (data: {
|
|||
saveLastFocusedChild: data.saveLastFocusedChild ?? false,
|
||||
isFocusBoundary: focusBoundary,
|
||||
focusBoundaryDirections,
|
||||
preferredChildFocusKey: data.preferredChildFocusKey,
|
||||
onFocus ()
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
|
|
@ -59,6 +63,7 @@ export function OptionSpace (data: {
|
|||
eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));
|
||||
},
|
||||
});
|
||||
useShortcuts(focusKey, () => data.shortcuts ?? []);
|
||||
let labelElement: any = data.label;
|
||||
if (data.label instanceof Function)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (detail
|
|||
export function EmulatorsSection (data: {
|
||||
id: string;
|
||||
emulators?: FrontEndEmulator[];
|
||||
onSelect?: (id: string, focusKey: string) => void;
|
||||
onSelect?: (em: FrontEndEmulator, focusKey: string) => void;
|
||||
header?: any;
|
||||
} & FocusParams)
|
||||
{
|
||||
|
|
@ -64,7 +64,7 @@ export function EmulatorsSection (data: {
|
|||
|
||||
<Carousel scrollRef={containerRef} className="flex *:min-w-[18rem] overflow-y-hidden overflow-x-scroll scrollbar-none py-2 pb-4 px-4 gap-4 select-none">
|
||||
{data.emulators?.map((em) => (
|
||||
<StoreEmulatorCard id={`${data.id}-${em.name}`} key={em.name} emulator={em} onSelect={(id, focusKey) => data.onSelect?.(em.name, focusKey)} onFocus={({ node, details }) =>
|
||||
<StoreEmulatorCard id={`${data.id}-${em.name}`} key={em.name} emulator={em} onSelect={(id, focusKey) => data.onSelect?.(em, focusKey)} onFocus={({ node, details }) =>
|
||||
{
|
||||
scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' });
|
||||
}} />
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route'
|
|||
import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index'
|
||||
import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games'
|
||||
import { Route as StoreTabEmulatorsRouteImport } from './../routes/store/tab/emulators'
|
||||
import { Route as SettingsPluginSourceRouteImport } from './../routes/settings/plugin.$source'
|
||||
import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$source.$id'
|
||||
import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id'
|
||||
import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id'
|
||||
|
|
@ -94,6 +95,11 @@ const StoreTabEmulatorsRoute = StoreTabEmulatorsRouteImport.update({
|
|||
path: '/emulators',
|
||||
getParentRoute: () => StoreTabRouteRoute,
|
||||
} as any)
|
||||
const SettingsPluginSourceRoute = SettingsPluginSourceRouteImport.update({
|
||||
id: '/plugin/$source',
|
||||
path: '/plugin/$source',
|
||||
getParentRoute: () => SettingsRouteRoute,
|
||||
} as any)
|
||||
const PlatformSourceIdRoute = PlatformSourceIdRouteImport.update({
|
||||
id: '/platform/$source/$id',
|
||||
path: '/platform/$source/$id',
|
||||
|
|
@ -141,6 +147,7 @@ export interface FileRoutesByFullPath {
|
|||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
'/settings/plugin/$source': typeof SettingsPluginSourceRoute
|
||||
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
||||
'/store/tab/games': typeof StoreTabGamesRoute
|
||||
'/store/tab/': typeof StoreTabIndexRoute
|
||||
|
|
@ -161,6 +168,7 @@ export interface FileRoutesByTo {
|
|||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
'/settings/plugin/$source': typeof SettingsPluginSourceRoute
|
||||
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
||||
'/store/tab/games': typeof StoreTabGamesRoute
|
||||
'/store/tab': typeof StoreTabIndexRoute
|
||||
|
|
@ -183,6 +191,7 @@ export interface FileRoutesById {
|
|||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
'/settings/plugin/$source': typeof SettingsPluginSourceRoute
|
||||
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
||||
'/store/tab/games': typeof StoreTabGamesRoute
|
||||
'/store/tab/': typeof StoreTabIndexRoute
|
||||
|
|
@ -206,6 +215,7 @@ export interface FileRouteTypes {
|
|||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
| '/settings/plugin/$source'
|
||||
| '/store/tab/emulators'
|
||||
| '/store/tab/games'
|
||||
| '/store/tab/'
|
||||
|
|
@ -226,6 +236,7 @@ export interface FileRouteTypes {
|
|||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
| '/settings/plugin/$source'
|
||||
| '/store/tab/emulators'
|
||||
| '/store/tab/games'
|
||||
| '/store/tab'
|
||||
|
|
@ -247,6 +258,7 @@ export interface FileRouteTypes {
|
|||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
| '/settings/plugin/$source'
|
||||
| '/store/tab/emulators'
|
||||
| '/store/tab/games'
|
||||
| '/store/tab/'
|
||||
|
|
@ -359,6 +371,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof StoreTabEmulatorsRouteImport
|
||||
parentRoute: typeof StoreTabRouteRoute
|
||||
}
|
||||
'/settings/plugin/$source': {
|
||||
id: '/settings/plugin/$source'
|
||||
path: '/plugin/$source'
|
||||
fullPath: '/settings/plugin/$source'
|
||||
preLoaderRoute: typeof SettingsPluginSourceRouteImport
|
||||
parentRoute: typeof SettingsRouteRoute
|
||||
}
|
||||
'/platform/$source/$id': {
|
||||
id: '/platform/$source/$id'
|
||||
path: '/platform/$source/$id'
|
||||
|
|
@ -411,6 +430,7 @@ interface SettingsRouteRouteChildren {
|
|||
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
|
||||
SettingsInterfaceRoute: typeof SettingsInterfaceRoute
|
||||
SettingsPluginsRoute: typeof SettingsPluginsRoute
|
||||
SettingsPluginSourceRoute: typeof SettingsPluginSourceRoute
|
||||
}
|
||||
|
||||
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
|
||||
|
|
@ -420,6 +440,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
|
|||
SettingsEmulatorsRoute: SettingsEmulatorsRoute,
|
||||
SettingsInterfaceRoute: SettingsInterfaceRoute,
|
||||
SettingsPluginsRoute: SettingsPluginsRoute,
|
||||
SettingsPluginSourceRoute: SettingsPluginSourceRoute,
|
||||
}
|
||||
|
||||
const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import LoadingScreen from "./components/LoadingScreen";
|
||||
|
||||
const rootElement = document.getElementById("preload")!;
|
||||
|
||||
|
|
@ -9,13 +10,11 @@ if (!rootElement.innerHTML)
|
|||
const root = createRoot(rootElement);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<div className="in-data-[loaded=true]:hidden absolute flex items-center gap-2 justify-center bg-base-300 w-screen h-screen z-100 font-semibold text-2xl text-shadow-lg">
|
||||
<span className="loading loading-spinner loading-xl"></span>
|
||||
<div className="absolute w-screen h-screen bg-radial from-base-100 to-base-300 -z-2"></div>
|
||||
<div className="bg-noise"></div>
|
||||
<div className="bg-dots"></div>
|
||||
Loading Gameflow
|
||||
<div className="in-data-[loaded=true]:hidden absolute w-screen h-screen">
|
||||
<LoadingScreen >
|
||||
<span className="loading loading-spinner loading-xl"></span> Loading Gameflow
|
||||
</LoadingScreen>
|
||||
</div>
|
||||
</StrictMode>,
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ function Frame (data: { ref: RefObject<HTMLIFrameElement | null>; })
|
|||
|
||||
const search = Route.useSearch();
|
||||
search['gameName'] = game.name;
|
||||
search['backgroundImage'] = `${RPC_URL(__HOST__)}${game.path_cover}`;
|
||||
search['backgroundImage'] = `${RPC_URL(__HOST__)}${game.path_covers[0]}`;
|
||||
search['backgroundBlur'] = "true";
|
||||
|
||||
if (!__PUBLIC__)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { GamesSection } from "@/mainview/components/store/GamesSection";
|
|||
import Details from "@/mainview/components/game/Details";
|
||||
import { AutoFocus } from "@/mainview/components/AutoFocus";
|
||||
import SelectMenu from "@/mainview/components/SelectMenu";
|
||||
import { en } from "zod/v4/locales";
|
||||
|
||||
export const Route = createFileRoute("/game/$source/$id")({
|
||||
loader: async ({ params, context }) =>
|
||||
|
|
@ -145,7 +146,7 @@ function RouteComponent ()
|
|||
const [, setUpdate] = useState(0);
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details", forceFocus: true });
|
||||
const headerRef = useRef(null);
|
||||
const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined;
|
||||
const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_covers[0]}`) : undefined;
|
||||
const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible });
|
||||
|
||||
useShortcuts(focusKey, () => [{
|
||||
|
|
@ -185,9 +186,10 @@ function RouteComponent ()
|
|||
Related Emulators
|
||||
</h2></>}
|
||||
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
||||
onSelect={(id, focus) =>
|
||||
onSelect={(em, focus) =>
|
||||
{
|
||||
router.navigate({ to: '/store/details/emulator/$id', params: { id } });
|
||||
if (em.source === 'local') return;
|
||||
router.navigate({ to: '/store/details/emulator/$id', params: { id: em.name } });
|
||||
}}
|
||||
emulators={recommendedEmulators} />}
|
||||
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ function HomeList (data: {
|
|||
saveChildFocus="session"
|
||||
onFocus={(l, n, d) =>
|
||||
{
|
||||
const [source, id] = l.split('@');
|
||||
const [source, id] = l.split('@', 1);
|
||||
queryClient.prefetchQuery(gameQuery(source, id));
|
||||
handleNodeFocus(l, n, d);
|
||||
}}
|
||||
|
|
@ -238,17 +238,11 @@ function MainMenu ()
|
|||
label="Home"
|
||||
type="secondary"
|
||||
/>
|
||||
<CircleIcon icon={<MessageSquare />} label="News" />
|
||||
<CircleIcon type="info" icon={<Store />} onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.event?.type } })} label="Shop" />
|
||||
<CircleIcon icon={<Image />} label="Album" />
|
||||
<CircleIcon
|
||||
icon={<Gamepad2 />}
|
||||
label="Controllers"
|
||||
/>
|
||||
<CircleIcon
|
||||
onAction={(e) =>
|
||||
{
|
||||
router.navigate({ to: '/settings/accounts', state: { eventType: e?.event?.type } });
|
||||
router.navigate({ to: '/settings/interface', state: { eventType: e?.event?.type } });
|
||||
}}
|
||||
icon={<Settings />}
|
||||
label="Settings"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { CollectionsDetail } from "../components/CollectionsDetail";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { GameListFilterSchema, GameListFilterType, RPC_URL } from "../../shared/constants";
|
||||
import { platformQuery } from "@queries/romm";
|
||||
import { deletePlatformMutation, localPlatformFilter, platformQuery, updatePlatformMutation } from "@queries/romm";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import z from "zod";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import { RefreshCcw, Settings2 } from "lucide-react";
|
||||
import { ContextList, DialogEntry, useContextDialog } from "../components/ContextDialog";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export const Route = createFileRoute("/platform/$source/$id")({
|
||||
component: RouteComponent,
|
||||
|
|
@ -31,18 +34,77 @@ function PlatformTitle (data: {})
|
|||
function RouteComponent ()
|
||||
{
|
||||
const { source, id } = Route.useParams();
|
||||
const router = useRouter();
|
||||
const { countHint } = Route.useSearch();
|
||||
const [filter, setFilter] = useLocalStorage<GameListFilterType>("platforms-filters", {});
|
||||
const updatePlatform = useMutation({
|
||||
...updatePlatformMutation(id), onSuccess (data, variables, onMutateResult, context)
|
||||
{
|
||||
context.client.invalidateQueries(localPlatformFilter(id));
|
||||
},
|
||||
});
|
||||
const deletePlatform = useMutation({
|
||||
...deletePlatformMutation(id),
|
||||
onError (error, variables, onMutateResult, context)
|
||||
{
|
||||
toast.error(error.message);
|
||||
},
|
||||
onSuccess (data, variables, onMutateResult, context)
|
||||
{
|
||||
context.client.invalidateQueries(localPlatformFilter(id));
|
||||
router.history.back();
|
||||
},
|
||||
});
|
||||
const settingsOptions: DialogEntry[] = [];
|
||||
if (source === 'local')
|
||||
{
|
||||
settingsOptions.push({
|
||||
id: 'update-platform',
|
||||
type: "primary",
|
||||
content: "Update Platform",
|
||||
icon: updatePlatform.isPending ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcw />,
|
||||
async action (ctx)
|
||||
{
|
||||
await updatePlatform.mutateAsync();
|
||||
ctx.close();
|
||||
router.navigate({ replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
settingsOptions.push({
|
||||
id: 'update-platform',
|
||||
type: "error",
|
||||
content: "Delete",
|
||||
icon: deletePlatform.isPending ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcw />,
|
||||
action (ctx)
|
||||
{
|
||||
deletePlatform.mutateAsync();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { dialog: platformSettingsDialog, setOpen: setPlatformSettingsOpen } = useContextDialog('platform-settings-dialog', {
|
||||
content: <ContextList options={settingsOptions} />
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<CollectionsDetail
|
||||
localFilter={filter}
|
||||
setLocalFilter={setFilter}
|
||||
headerButtons={[{
|
||||
id: 'open-platform-settings-btn',
|
||||
icon: <Settings2 />,
|
||||
action ()
|
||||
{
|
||||
setPlatformSettingsOpen(true);
|
||||
},
|
||||
}]}
|
||||
countHint={countHint}
|
||||
title={<PlatformTitle />}
|
||||
filters={{ platform_id: Number(id), platform_source: source }}
|
||||
/>
|
||||
{platformSettingsDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import
|
|||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import classNames from "classnames";
|
||||
import { Key, Link, Lock, LogIn, LogOut, ScanQrCode, User, X } from "lucide-react";
|
||||
import
|
||||
|
|
@ -90,6 +90,7 @@ function TwitchLogin ()
|
|||
function LoginControls (data: {})
|
||||
{
|
||||
const user = useQuery(rommUserQuery);
|
||||
const router = useRouter();
|
||||
const loginMutation = useMutation(rommQrLoginMutation);
|
||||
const { data: statusValue, wsRef } = useJobStatus('login-job');
|
||||
const { data: loginStatusData } = useQuery(rommLoggedInQuery);
|
||||
|
|
@ -100,7 +101,8 @@ function LoginControls (data: {})
|
|||
onSuccess: async (d, v, r, c) =>
|
||||
{
|
||||
user.refetch();
|
||||
c.client.invalidateQueries({ queryKey: ["romm", "auth"] });
|
||||
await c.client.invalidateQueries({ queryKey: ["romm", "auth"] });
|
||||
await router.navigate({ replace: true });
|
||||
}
|
||||
});
|
||||
return <div className="flex gap-2 items-center flex-wrap justify-center-safe">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { OptionInput } from '../../components/options/OptionInput';
|
|||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '../../components/options/Button';
|
||||
import { Check, ChevronDown, FileQuestion, FolderSearch, Plug, SearchAlert, Store, Trash } from 'lucide-react';
|
||||
import { Check, ChevronDown, FileQuestion, FolderSearch, HardDrive, Plug, SearchAlert, Store, Trash } from 'lucide-react';
|
||||
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
|
||||
import classNames from 'classnames';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
|
@ -248,7 +248,7 @@ function EmulatorBadge (data: {
|
|||
{data.emulator.validSources.length > 0 && <div className="divider">
|
||||
<div className='flex p-2 gap-1'>{data.emulator.validSources.map(s =>
|
||||
{
|
||||
let icon = <FileQuestion />;
|
||||
let icon = <HardDrive />;
|
||||
let action: (() => void) | undefined = undefined;
|
||||
let className = "bg-warning text-warning-content";
|
||||
switch (s.type)
|
||||
|
|
|
|||
158
src/mainview/routes/settings/plugin.$source.tsx
Normal file
158
src/mainview/routes/settings/plugin.$source.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { AutoFocus } from '@/mainview/components/AutoFocus';
|
||||
import { Button } from '@/mainview/components/options/Button';
|
||||
import { OptionDropdown } from '@/mainview/components/options/OptionDropdown';
|
||||
import { OptionInput } from '@/mainview/components/options/OptionInput';
|
||||
import { OptionSpace } from '@/mainview/components/options/OptionSpace';
|
||||
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 { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { useMutation, useQuery } 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,
|
||||
});
|
||||
|
||||
function PluginAction (data: { id: string, title: string | undefined, description: string | undefined; action: string; reload: () => void; })
|
||||
{
|
||||
const { source } = Route.useParams();
|
||||
const action = useMutation({
|
||||
...pluginActionMutation(source, data.id),
|
||||
onSuccess (acitonData, variables, onMutateResult, context)
|
||||
{
|
||||
if (acitonData.data?.openTab)
|
||||
{
|
||||
window.open(acitonData.data?.openTab, "_blank");
|
||||
} else if (acitonData.data?.reload)
|
||||
{
|
||||
data.reload();
|
||||
}
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
return <OptionSpace
|
||||
id={`${data.id}-option`}
|
||||
label={
|
||||
<div className='flex flex-col'>
|
||||
<div>{data.title ?? data.id}</div>
|
||||
<div className='text-sm text-base-content/40 text-wrap'>{data.description}</div>
|
||||
</div>}>
|
||||
<Button id={`${data.id}-btn`} onAction={e => action.mutate()} >{data.action}</Button>
|
||||
</OptionSpace>;
|
||||
}
|
||||
|
||||
function PluginOption (data: { name: string, title?: string, prop: JSONSchema7; })
|
||||
{
|
||||
const { source } = Route.useParams();
|
||||
const { data: value, refetch: refetchValue } = useQuery(getPluginSettingQuery(source, data.name));
|
||||
const setValue = useMutation({
|
||||
...setPluginSettingMutation(source, data.name),
|
||||
onError (error, variables, onMutateResult, context)
|
||||
{
|
||||
toast.error(error.message);
|
||||
},
|
||||
onSuccess (data, variables, onMutateResult, context)
|
||||
{
|
||||
refetchValue();
|
||||
},
|
||||
});
|
||||
let input: any = undefined;
|
||||
switch (data.prop.type)
|
||||
{
|
||||
case "string":
|
||||
if (Array.isArray(data.prop.examples))
|
||||
{
|
||||
input = <OptionDropdown name={data.name} values={data.prop.examples.filter(e => !!e).map(e => e!.toString())} onChange={v => setValue.mutate(v)} value={value?.value as any} />;
|
||||
} else
|
||||
{
|
||||
input = <OptionInput value={value?.value as any} onChange={v => setValue.mutate(v)} type="text" name={data.name} />;
|
||||
}
|
||||
break;
|
||||
|
||||
case "boolean":
|
||||
input = <OptionInput value={value?.value as any} onChange={v => setValue.mutate(v)} type='checkbox' name={data.name} />;
|
||||
break;
|
||||
}
|
||||
return <OptionSpace
|
||||
id={`${data.name}-option`}
|
||||
label={
|
||||
<div className='flex flex-col'>
|
||||
<div>{data.title ?? data.name}</div>
|
||||
<div className='text-sm text-base-content/40 text-wrap'>{data.prop.description}</div>
|
||||
</div>}>
|
||||
{input}
|
||||
</OptionSpace>;
|
||||
}
|
||||
|
||||
function Settings ()
|
||||
{
|
||||
const { source } = Route.useParams();
|
||||
const { data: definitions, refetch: refetchDefinitions } = useQuery(getPluginSettingsDefinitionQuery(source));
|
||||
const { data: actions, refetch: referchActions } = useQuery(getPluginActionsQuery(source));
|
||||
const handleReload = () =>
|
||||
{
|
||||
referchActions();
|
||||
refetchDefinitions();
|
||||
};
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'plugin-settings' });
|
||||
return <div ref={ref}>
|
||||
<FocusContext value={focusKey}>
|
||||
{!!definitions?.properties && Object.entries(Object.groupBy(Object.entries(definitions?.properties)
|
||||
.filter(([key, prop]) => typeof prop === 'object'), ([key, prop]) =>
|
||||
{
|
||||
const schema = prop as JSONSchema7;
|
||||
if (schema.$comment)
|
||||
{
|
||||
const meta = JSON.parse(schema.$comment);
|
||||
return meta.category;
|
||||
}
|
||||
return "settings";
|
||||
})).map(([cat, data]) =>
|
||||
{
|
||||
return <div className='flex flex-col gap-1'>
|
||||
<div className="divider">{cat !== "settings" ? cat : <><Settings2 className='size-14' /> Settings</>}</div>
|
||||
{data?.map(([key, prop]) =>
|
||||
{
|
||||
const schema = prop as JSONSchema7;
|
||||
return <PluginOption key={key} title={schema.title} name={key} prop={schema} />;
|
||||
})}
|
||||
</div>;
|
||||
|
||||
})}
|
||||
<div className="divider"><CirclePlay className='size-14' /> Actions</div>
|
||||
{actions?.map(a => <PluginAction key={a.id} id={a.id} title={a.title} description={a.description} action={a.action} reload={handleReload} />)}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { source } = Route.useParams();
|
||||
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugins' });
|
||||
const { data } = useQuery(getPluginDetailsQuery(source));
|
||||
const navigate = useNavigate();
|
||||
const handleReturn = () => navigate({ to: '/settings/plugins', replace: true, viewTransition: { types: ['slide-up'] } });
|
||||
useShortcuts(focusKey, () => [{ label: "Return", button: GamePadButtonCode.B, action: handleReturn }]);
|
||||
|
||||
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'>
|
||||
<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>;
|
||||
}
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import { pluginCategoryIcons, pluginCategoryPriorities } from '@/mainview/components/Constants';
|
||||
import { Button } from '@/mainview/components/options/Button';
|
||||
import { OptionInput } from '@/mainview/components/options/OptionInput';
|
||||
import { OptionSpace } from '@/mainview/components/options/OptionSpace';
|
||||
import { RoundButton } from '@/mainview/components/RoundButton';
|
||||
import { enablePluginMutation, getAllPluginsQuery } from '@/mainview/scripts/queries/plugins';
|
||||
import { GamePadButtonCode, Shortcut } from '@/mainview/scripts/shortcuts';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { Puzzle, Search } from 'lucide-react';
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { Eye, Puzzle, Search, Settings2 } from 'lucide-react';
|
||||
|
||||
export const Route = createFileRoute('/settings/plugins')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -19,23 +23,46 @@ function Plugin (data: {
|
|||
setEnabled: (enabled: boolean) => void;
|
||||
})
|
||||
{
|
||||
return <OptionSpace label={<div className='flex gap-4 items-center'>
|
||||
<div className='flex bg-accent text-accent-content rounded-full size-12 p-2 items-center justify-center'>
|
||||
{data.plugin.icon ? <img src={data.plugin.icon}></img> : <Puzzle />}
|
||||
const shortcuts: Shortcut[] = [];
|
||||
const navigate = useNavigate();
|
||||
if (data.plugin.hasSettings)
|
||||
shortcuts.push({
|
||||
button: GamePadButtonCode.Y, label: "Details", action (e)
|
||||
{
|
||||
|
||||
},
|
||||
});
|
||||
const handleDetails = () => navigate({ to: '/settings/plugin/$source', params: { source: data.plugin.name }, replace: true, viewTransition: { types: ['slide-up'] } });
|
||||
|
||||
return <OptionSpace
|
||||
label={
|
||||
<div className='flex gap-4 items-center'>
|
||||
<div className='flex bg-accent text-accent-content rounded-full size-12 p-2 items-center justify-center'>
|
||||
{data.plugin.icon ? <img src={data.plugin.icon}></img> : <Puzzle />}
|
||||
</div>
|
||||
<div className='flex flex-col'>
|
||||
<div>{data.plugin.displayName}</div>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<div className=' text-sm text-base-content/40'>{data.plugin.name} ({data.plugin.version})</div>
|
||||
{data.plugin.hasSettings && <Settings2 className='bg-base-300 rounded-full p-1 size-6' />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className='flex p-4 bg-base-200 rounded-3xl scroll-m-12'
|
||||
shortcuts={shortcuts}
|
||||
>
|
||||
<div className='flex gap-4'>
|
||||
<RoundButton className='size-12 p-1' onAction={handleDetails} id={`${data.plugin.name}-details`} >{data.plugin.hasSettings ? <Settings2 /> : <Eye />}</RoundButton>
|
||||
{data.plugin.canDisable && <OptionInput compact onChange={v => data.setEnabled(!!v)} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" />}
|
||||
</div>
|
||||
<div className='flex flex-col'>
|
||||
<div>{data.plugin.displayName}</div>
|
||||
<div className='text-sm text-base-content/40'>{data.plugin.name} ({data.plugin.version})</div>
|
||||
</div>
|
||||
</div>} className='flex p-4 bg-base-200 rounded-3xl'>
|
||||
<OptionInput onChange={v => data.setEnabled(!!v)} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" />
|
||||
<Button id={`${data.plugin.name}-details`} ><Search /> Details</Button>
|
||||
</OptionSpace>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { data: plugins, refetch: refetchPlugins } = useQuery(getAllPluginsQuery);
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'plugins' });
|
||||
const pluginMutation = useMutation({
|
||||
...enablePluginMutation, onSuccess (data, variables, onMutateResult, context)
|
||||
{
|
||||
|
|
@ -43,15 +70,20 @@ function RouteComponent ()
|
|||
},
|
||||
});
|
||||
|
||||
return <>
|
||||
{!!plugins && Object.entries(Object.groupBy(plugins, p => p.source)).map(([source, plugins]) =>
|
||||
{
|
||||
return <>
|
||||
<div className="divider">{source === 'builtin' ? "Built In" : "Store"}</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{plugins.map(p => <Plugin key={p.name} plugin={p} setEnabled={(v) => pluginMutation.mutate({ id: p.name, enabled: v })} />)}
|
||||
</div>
|
||||
</>;
|
||||
})}
|
||||
</>;
|
||||
return <div ref={ref}>
|
||||
<FocusContext value={focusKey}>
|
||||
{!!plugins && Object.entries(Object.groupBy(plugins, p => p.category))
|
||||
.filter(([cat, plugins]) => !!plugins)
|
||||
.toSorted(([catA], [catB]) => pluginCategoryPriorities[catB] - pluginCategoryPriorities[catA])
|
||||
.map(([cat, plugins]) =>
|
||||
{
|
||||
return <div key={cat}>
|
||||
<div className="divider *:size-14">{pluginCategoryIcons[cat]}{cat}</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{plugins!.map(p => <Plugin key={p.name} plugin={p} setEnabled={(v) => pluginMutation.mutate({ id: p.name, enabled: v })} />)}
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import
|
|||
Outlet,
|
||||
createFileRoute,
|
||||
useMatch,
|
||||
useMatchRoute,
|
||||
useRouter,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router";
|
||||
import { ViewTransitionOptions } from "@tanstack/router-core";
|
||||
import classNames from "classnames";
|
||||
|
|
@ -22,7 +24,7 @@ import
|
|||
MonitorCog,
|
||||
Puzzle,
|
||||
} from "lucide-react";
|
||||
import { JSX } from "react";
|
||||
import { JSX, useMemo } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import z from "zod";
|
||||
import { SettingsSchema } from "../../../shared/constants";
|
||||
|
|
@ -45,17 +47,23 @@ export const Route = createFileRoute("/settings")({
|
|||
|
||||
function MenuItem (data: {
|
||||
route: string;
|
||||
matchRoutes?: string[];
|
||||
return?: boolean;
|
||||
viewTransition?: boolean | ViewTransitionOptions;
|
||||
icon: JSX.Element;
|
||||
focusSelect?: boolean;
|
||||
className?: string;
|
||||
linkClassName?: string;
|
||||
active?: boolean;
|
||||
label: string;
|
||||
})
|
||||
{
|
||||
const router = useRouter();
|
||||
const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });;
|
||||
const routerState = useRouterState();
|
||||
const matchRoute = useMatchRoute();
|
||||
|
||||
const acitve = useMemo(() => data.matchRoutes ? data.matchRoutes.some(r => !!matchRoute({ to: r })) : !!router.matchRoute({ to: data.route }),
|
||||
[routerState, matchRoute, data.matchRoutes, data.route]);
|
||||
const handleNonFocusSelect = (e?: Event) =>
|
||||
{
|
||||
if (data.return)
|
||||
|
|
@ -114,10 +122,11 @@ function MenuItem (data: {
|
|||
|
||||
function SettingsMenu (data: {})
|
||||
{
|
||||
const router = useRouter();
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusable: true,
|
||||
focusKey: 'settings-menu',
|
||||
preferredChildFocusKey: `menu-item-${location.hash.replaceAll(/#|(\?.+)/g, '')}`
|
||||
preferredChildFocusKey: `menu-item-${router.history.location.pathname}`
|
||||
});
|
||||
|
||||
return <ul
|
||||
|
|
@ -146,16 +155,17 @@ function SettingsMenu (data: {})
|
|||
/>
|
||||
<MenuItem
|
||||
focusSelect
|
||||
route="/settings/directories"
|
||||
label="Directories"
|
||||
icon={<HardDrive />}
|
||||
/>
|
||||
<MenuItem
|
||||
focusSelect
|
||||
matchRoutes={["/settings/plugin/$source", "/settings/plugins"]}
|
||||
route="/settings/plugins"
|
||||
label="Plugins"
|
||||
icon={<Puzzle />}
|
||||
/>
|
||||
<MenuItem
|
||||
focusSelect
|
||||
route="/settings/directories"
|
||||
label="Directories"
|
||||
icon={<HardDrive />}
|
||||
/>
|
||||
<MenuItem
|
||||
focusSelect
|
||||
route="/settings/about"
|
||||
|
|
|
|||
|
|
@ -387,10 +387,11 @@ export function RouteComponent ()
|
|||
More Emulators
|
||||
</h2></>}
|
||||
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
||||
onSelect={(id, focus) =>
|
||||
onSelect={(em, focus) =>
|
||||
{
|
||||
if (em.source === 'local') return;
|
||||
router.navigate({
|
||||
to: '/store/details/emulator/$id', params: { id }
|
||||
to: '/store/details/emulator/$id', params: { id: em.name }
|
||||
});
|
||||
}}
|
||||
emulators={recommendedEmulators} />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/no
|
|||
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 { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
|
||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
||||
|
|
@ -15,6 +15,7 @@ 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';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/games')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -32,7 +33,7 @@ function RouteComponent ()
|
|||
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: gameFilters } = useQuery(gameFiltersQuery({ source: 'store' }));
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
|
@ -86,23 +87,32 @@ function RouteComponent ()
|
|||
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 previewUrls = g.path_covers.map(c =>
|
||||
{
|
||||
const url = new URL(`${RPC_URL(__HOST__)}${c}`);
|
||||
url.searchParams.delete('ts');
|
||||
return url;
|
||||
});
|
||||
|
||||
|
||||
let subtitle: string | JSX.Element | undefined = undefined;
|
||||
if (g.path_platform_cover)
|
||||
{
|
||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
|
||||
platformUrl.searchParams.set('width', "64");
|
||||
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>;
|
||||
}
|
||||
|
||||
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,
|
||||
subtitle,
|
||||
previewUrls,
|
||||
badges: badges,
|
||||
onSelect: () => handleDefaultSelect(g),
|
||||
onFocus: (k, n, d) => handleFocus(k, n, d)
|
||||
|
|
@ -111,7 +121,7 @@ function RouteComponent ()
|
|||
) ?? []} 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' }} />
|
||||
<SideFilters id='filter-btns' localFilter={filter} setLocalFilter={setFilter} filterValues={gameFilters} filters={{ source: 'store' }} />
|
||||
</div>
|
||||
</FocusContext>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -50,8 +50,13 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
|
|||
}, 10);
|
||||
|
||||
const storeContext = useContext(StoreContext);
|
||||
const previewUrl = data.games ? new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`) : undefined;
|
||||
previewUrl?.searchParams.set('blur', '16');
|
||||
const previewUrls = data.games?.[selectedGame] ? data.games[selectedGame].path_covers.map(c =>
|
||||
{
|
||||
const url = new URL(`${RPC_URL(__HOST__)}${c}`);
|
||||
url.searchParams.set('blur', '16');
|
||||
return url;
|
||||
}) : undefined;
|
||||
|
||||
|
||||
return <div ref={ref} className='flex sm:flex-wrap md:flex-nowrap group-focusable md:px-12 p-4 mt-4 gap-6'>
|
||||
|
||||
|
|
@ -59,22 +64,23 @@ 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'>
|
||||
<img key={selectedGame}
|
||||
<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'
|
||||
src={previewUrl?.href}
|
||||
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>
|
||||
</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'>
|
||||
<div className='flex sm:portrait:flex-wrap sm:portrait:grow gap-4 max-h-full justify-center'>
|
||||
<div className='relative rounded-3xl max-w-xs h-48 overflow-hidden shadow-lg'>
|
||||
<div className='flex absolute bottom-4 left-4 size-8 bg-base-content text-base-100 rounded-full items-center justify-center shadow-lg '><HardDrive /></div>
|
||||
{!!data.games && <img className='object-cover w-full h-full ' src={`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`} />}
|
||||
{!!data.games && <img className='object-cover w-full h-full ' src={`${RPC_URL(__HOST__)}${data.games[selectedGame].path_covers[0]}`} />}
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 py-3 max-w-md'>
|
||||
<h1 className='font-semibold text-3xl text-shadow-md'>{game.name}</h1>
|
||||
|
|
@ -133,7 +139,7 @@ export function RouteComponent ()
|
|||
<div className='pt-4'>
|
||||
<EmulatorsSection
|
||||
id="recommended-emulators"
|
||||
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
|
||||
onSelect={(em, focus) => storeContext.showDetails('emulator', em.source, em.name, focus)}
|
||||
onFocus={scrollIntoViewHandler({ block: 'end' })}
|
||||
emulators={recommendedEmulators} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ function RouteComponent ()
|
|||
{
|
||||
if (type === 'emulator')
|
||||
{
|
||||
if (source === 'local') return;
|
||||
router.navigate({ to: '/store/details/emulator/$id', params: { id } });
|
||||
}
|
||||
else if (type === 'game')
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ export const getAllPluginsQuery = queryOptions({
|
|||
}
|
||||
});
|
||||
|
||||
export const getPluginDetailsQuery = (source: string) => queryOptions({
|
||||
queryKey: ['plugins', source], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await pluginsApi.plugins({ id: source }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
export const enablePluginMutation = mutationOptions({
|
||||
mutationKey: ['plugin', 'enable'],
|
||||
mutationFn: async (vars: { id: string, enabled: boolean; }) =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants";
|
||||
import { rommApi, settingsApi } from "../clientApi";
|
||||
import { mutationOptions, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query";
|
||||
import { InvalidateQueryFilters, mutationOptions, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query";
|
||||
import z from "zod";
|
||||
import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
|
||||
|
||||
|
|
@ -72,8 +72,8 @@ export const rommLoggedInQuery = queryOptions({
|
|||
return data;
|
||||
}
|
||||
});
|
||||
export const rommHostnameQuery = queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) });
|
||||
export const rommUsernameQuery = queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) });
|
||||
export const rommHostnameQuery = queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ source: 'local' })({ id: 'rommAddress' }).get().then(d => d.data?.value as string) });
|
||||
export const rommUsernameQuery = queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ source: 'local' })({ id: 'rommUser' }).get().then(d => d.data?.value as string) });
|
||||
export const deleteGameMutation = (id: FrontEndId) => mutationOptions({
|
||||
mutationKey: ['delete', id],
|
||||
mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete()
|
||||
|
|
@ -107,9 +107,9 @@ export const platformQuery = (source: string, id: string) => queryOptions({
|
|||
});
|
||||
export const installMutation = (source: string, id: string) => mutationOptions({
|
||||
mutationKey: ['install', source, id],
|
||||
mutationFn: async () =>
|
||||
mutationFn: async (init: { downloadId?: string; }) =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post();
|
||||
const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post({ query: { downloadId: init.downloadId } });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
|
@ -170,4 +170,45 @@ export const fixSourceMutation = mutationOptions({
|
|||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
export const updateSourceMutation = mutationOptions({
|
||||
mutationKey: ['game', "update_source"], mutationFn: async ({ source, id }: { source: string, id: string; }) =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.game({ source })({ id }).update.post();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
export const updatePlatformMutation = (id: string) => mutationOptions({
|
||||
mutationKey: ['platform', 'local', 'update', id],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.platform.local({ id }).update.post();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
export const deletePlatformMutation = (id: string) => mutationOptions({
|
||||
mutationKey: ['platform', 'local', 'delete', id],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.platform.local({ id }).delete();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
export const localPlatformFilter = (id: string) => ({
|
||||
predicate (query)
|
||||
{
|
||||
return query.queryKey.includes('platform') && ((query.queryKey.includes('local') && query.queryKey.includes(id)) || query.queryKey.includes('all'));
|
||||
},
|
||||
} satisfies InvalidateQueryFilters as InvalidateQueryFilters);
|
||||
|
||||
export const gameFiltersQuery = (filters: { source?: string; }) => queryOptions({
|
||||
queryKey: ['game', 'filters', filters], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.games.filters.get({ query: { source: filters.source } });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
|
@ -113,7 +113,7 @@ export const setSettingMutation = (id?: string) => mutationOptions({
|
|||
mutationKey: ["setting", id],
|
||||
mutationFn: async (value: any) =>
|
||||
{
|
||||
const response = await settingsApi.api.settings({ id: id! }).post({ value });
|
||||
const response = await settingsApi.api.settings.local({ id: id! }).post({ value });
|
||||
if (response.error) throw response.error;
|
||||
return response.data;
|
||||
}
|
||||
|
|
@ -123,9 +123,58 @@ export const getSettingQuery = (id: string | undefined) => queryOptions({
|
|||
queryKey: ["setting", id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data: value, error } = await settingsApi.api.settings({ id: id! }).get();
|
||||
const { data: value, error } = await settingsApi.api.settings.local({ id: id! }).get();
|
||||
if (error) throw error;
|
||||
|
||||
return value.value;
|
||||
},
|
||||
});
|
||||
export const getPluginSettingsDefinitionQuery = (source: string) => queryOptions({
|
||||
queryKey: ['settings', source, 'definitions'],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data: value, error } = await settingsApi.api.settings.definitions({ source }).get();
|
||||
if (error) throw error;
|
||||
|
||||
return value;
|
||||
}
|
||||
});
|
||||
export const getPluginSettingQuery = (source: string, id: string) => queryOptions({
|
||||
queryKey: ["setting", source, id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings({ source })({ id }).get();
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
export const setPluginSettingMutation = (source: string, id: string) => mutationOptions({
|
||||
mutationKey: ["setting", source, id],
|
||||
mutationFn: async (value: any) =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings({ source })({ id }).put({ value });
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
export const getPluginActionsQuery = (source: string) => queryOptions({
|
||||
queryKey: ['plugin', source, 'actions'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.actions({ source }).get();
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
}
|
||||
});
|
||||
export const pluginActionMutation = (source: string, id: string) => mutationOptions({
|
||||
mutationKey: ["plugin", source, "action"],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { data, error, response } = await settingsApi.api.settings.actions({ source })({ id }).post();
|
||||
if (error) throw error;
|
||||
|
||||
return { data: data as any, response };
|
||||
},
|
||||
});
|
||||
|
|
@ -43,6 +43,7 @@ export const storeEmulatorDeleteMutation = mutationOptions({
|
|||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const storeGamesInfiniteQuery = (filter: GameListFilterType) => infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({
|
||||
initialPageParam: 0,
|
||||
queryKey: ['store-games', filter],
|
||||
|
|
@ -55,6 +56,7 @@ export const storeGamesInfiniteQuery = (filter: GameListFilterType) => infiniteQ
|
|||
return { data: games.games, nextPage: pageParam + 1 };
|
||||
}
|
||||
});
|
||||
|
||||
export const storeGetStatsQuery = queryOptions({
|
||||
queryKey: ['store', 'stats'], queryFn: async () =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -46,4 +46,14 @@ export const closeMutation = mutationOptions({
|
|||
const { error } = await systemApi.api.system.exit.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
export const hasUpdateQuery = queryOptions({
|
||||
queryKey: ['update'],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await systemApi.api.system.update.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
staleTime: 1000 * 60 * 30
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue