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:
Simeon Radivoev 2026-04-17 21:21:14 +03:00
parent 444d8c4c27
commit c09fbd3dc8
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
115 changed files with 4139 additions and 1502 deletions

View file

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

View file

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

View file

@ -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
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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>;
}

View file

@ -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!',

View 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>;
}

View file

@ -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]);

View file

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

View file

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

View file

@ -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 });

View file

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

View file

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

View file

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

View file

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

View file

@ -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' });
}} />