feat: Implemented local game import (with a wizard)
feat: Implemented a radial virtual gamepad keyboard. fix: Fixed shortcuts for file explorer
This commit is contained in:
parent
e54a6ac8f0
commit
06b7e4074d
66 changed files with 2216 additions and 416 deletions
|
|
@ -4,12 +4,12 @@ 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, RefreshCcw, Settings, Trash, Trophy } from "lucide-react";
|
||||
import { Hammer, RefreshCcw, RefreshCcwDot, Settings, Trash, Trophy } from "lucide-react";
|
||||
import MainActions from "./MainActions";
|
||||
import ActionButton from "./ActionButton";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import FocusTooltip from "../FocusTooltip";
|
||||
import { useBlocker, useRouter } from "@tanstack/react-router";
|
||||
import { useBlocker, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
|
||||
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractParams)
|
||||
{
|
||||
|
|
@ -32,6 +32,7 @@ function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractP
|
|||
export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
|
||||
{
|
||||
const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fixMutation = useMutation({
|
||||
...fixSourceMutation,
|
||||
|
|
@ -64,7 +65,8 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
...deleteGameMutation({ id: data.id, source: data.source }),
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
{
|
||||
ctx.client.invalidateQueries(gameInvalidationQuery(data.id, data.source)).then(() => router.history.back());
|
||||
ctx.client.invalidateQueries(gameInvalidationQuery(data.id, data.source));
|
||||
router.history.back();
|
||||
},
|
||||
onError (error)
|
||||
{
|
||||
|
|
@ -84,9 +86,10 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
{
|
||||
contextOptions.push({
|
||||
id: 'delete',
|
||||
action: () =>
|
||||
action: (ctx) =>
|
||||
{
|
||||
deleteMutation.mutate();
|
||||
ctx.close();
|
||||
},
|
||||
icon: deleteMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Trash />,
|
||||
content: deleteMutation.isPending ? "Deleting" : "Delete",
|
||||
|
|
@ -98,12 +101,16 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
{
|
||||
contextOptions.push({
|
||||
id: "fix_source",
|
||||
async action (ctx)
|
||||
action (ctx)
|
||||
{
|
||||
if (!data.game) return;
|
||||
await fixMutation.mutateAsync({ source: data.game.id.source, id: data.game.id.id });
|
||||
fixMutation.mutate({ source: data.game.id.source, id: data.game.id.id }, {
|
||||
onSuccess (data, variables, onMutateResult, context)
|
||||
{
|
||||
router.navigate({ replace: true });
|
||||
},
|
||||
});
|
||||
ctx.close();
|
||||
router.navigate({ replace: true });
|
||||
},
|
||||
icon: fixMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Hammer />,
|
||||
content: "Try Fix Source",
|
||||
|
|
@ -126,6 +133,18 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
content: "Update Metadata",
|
||||
type: "primary"
|
||||
});
|
||||
|
||||
contextOptions.push({
|
||||
id: 'update-custom',
|
||||
action (ctx)
|
||||
{
|
||||
ctx.close();
|
||||
navigate({ to: '/game/update/$source/$id', params: { source: data.source, id: data.id } });
|
||||
},
|
||||
icon: updateMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcwDot />,
|
||||
content: "Update Metadata (Interactive)",
|
||||
type: "primary"
|
||||
});
|
||||
}
|
||||
|
||||
const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: <ContextList disableCloseButton={deleteMutation.isPending} options={contextOptions} />, canClose: !deleteMutation.isPending });
|
||||
|
|
|
|||
80
src/mainview/components/game/GameLookup.tsx
Normal file
80
src/mainview/components/game/GameLookup.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { gameLookup } from "@/mainview/scripts/queries/romm";
|
||||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import HeaderSearchField from "../HeaderSearchField";
|
||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { scrollIntoViewHandler } from "@/mainview/scripts/utils";
|
||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||
|
||||
function Result (data: {
|
||||
match: GameLookup;
|
||||
showPlatform: boolean;
|
||||
selected: boolean;
|
||||
} & InteractParams)
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: FOCUS_KEYS.GAME_MATCH({ source: data.match.source, id: data.match.id }),
|
||||
onFocus (l, p, d) { scrollIntoViewHandler({ block: 'center' })(focusKey, ref.current, d); },
|
||||
onEnterPress (p, d) { data.onAction?.({ focusKey }); }
|
||||
});
|
||||
useShortcuts(focusKey, () => [{
|
||||
label: "Select", action (e)
|
||||
{
|
||||
data.onAction?.({ event: e, focusKey });
|
||||
}, button: GamePadButtonCode.A
|
||||
}]);
|
||||
return <li ref={ref} onClick={(e) => data.onAction?.({ event: e.nativeEvent, focusKey })} className='flex gap-4 items-center not-mobile:drop-shadow-md light:bg-base-100 dark:bg-base-300 p-2 rounded-2xl focusable focusable-primary focusable-hover cursor-pointer'>
|
||||
{data.match.coverUrl ? <div>
|
||||
<img className='h-32 rounded-xl' src={data.match.coverUrl}></img>
|
||||
{data.selected && <span className="absolute top-4 left-4 bg-accent drop-shadow-sm text-accent-content ring-2 ring-base-100 p-1 rounded-full"><Check className="size-5" /></span>}
|
||||
</div> : <div></div>}
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='font-bold text-xl'>{data.match.name}</div>
|
||||
<div className='text-base-content/60 max-w-lg max-h-12 overflow-hidden text-ellipsis text-wrap wrap-anywhere'>{data.match.summary}</div>
|
||||
<ul className='flex flex-wrap gap-1'>
|
||||
{data.showPlatform && <>
|
||||
{data.match.platforms.map(p => <li className="bg-primary text-primary-content p-1 px-2 text-sm rounded-2xl">{p.name}</li>)}
|
||||
<div className="divider divider-horizontal m-0"></div>
|
||||
</>}
|
||||
{data.match.genres.map(g => <li className='bg-base-100 p-1 px-2 text-sm rounded-2xl'>{g}</li>)}
|
||||
{data.match.first_release_date && <li className='bg-base-100 p-1 px-2 text-sm rounded-2xl'>{new Date(data.match.first_release_date).toDateString()}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</li>;
|
||||
}
|
||||
|
||||
function SearchField (data: { setSearch: (search: string | undefined) => void; search: string | undefined; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `search-field-section` });
|
||||
return <div ref={ref} className='flex w-full justify-center my-4'>
|
||||
<FocusContext value={focusKey}>
|
||||
<HeaderSearchField className="md:min-w-xl" onSubmit={v => data.setSearch(v)} search={data.search} id='search-field' />
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function GameLookup (data: {
|
||||
search: string | undefined,
|
||||
setSearch: (search: string | undefined) => void,
|
||||
onSelect: (match: GameLookup) => void;
|
||||
showPlatforms?: boolean;
|
||||
selected?: FrontEndId;
|
||||
})
|
||||
{
|
||||
const { data: lookups, isFetching } = useQuery({ ...gameLookup(data.search), staleTime: 1000 * 60 * 60 });
|
||||
|
||||
return <div>
|
||||
<SearchField setSearch={data.setSearch} search={data.search} />
|
||||
<div className="divider">{isFetching ? <span className="loading loading-spinner loading-lg"></span> : <Search className='size-10' />}Results</div>
|
||||
<ul className='flex flex-col gap-2 justify-center p-2 px-4'>
|
||||
{lookups?.map((l, i) =>
|
||||
{
|
||||
return <Result key={i} selected={data.selected?.id === l.id && data.selected?.source === l.source} showPlatform={data.showPlatforms ?? false} match={l} onAction={(ctx) =>
|
||||
{
|
||||
data.onSelect(l);
|
||||
}} />;
|
||||
})}
|
||||
</ul>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview
|
|||
import ActionButton from "./ActionButton";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { DownloadSourceType } from "@/shared/constants";
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
|
||||
export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
|
||||
{
|
||||
|
|
@ -118,10 +119,14 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
};
|
||||
|
||||
let mainButton: any | undefined = undefined;
|
||||
let showAllCommandsAction: ((focusKey: string) => void) | undefined;
|
||||
let mainAction: () => void;
|
||||
if (status === 'installed')
|
||||
{
|
||||
if (validCommands.length > 1) showAllCommandsAction = (focusKey) => showAllCommands(true, focusKey);
|
||||
mainAction = () => handlePlay(validDefaultCommand);
|
||||
mainButton = <div className="flex gap-2">
|
||||
<ActionButton onAction={() => handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details}
|
||||
<ActionButton onAction={mainAction} tooltip={validDefaultCommand?.label ?? details}
|
||||
key="primary"
|
||||
type='primary'
|
||||
id="mainAction"
|
||||
|
|
@ -130,25 +135,26 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
|
||||
</ActionButton>
|
||||
|
||||
{validCommands.length > 1 &&
|
||||
<ActionButton className="size-11! header-icon-small" tooltip={"All Commands"} type="base" id="allActionsBtn" onAction={() => showAllCommands(true, 'allActionsBtn')}>
|
||||
{showAllCommandsAction &&
|
||||
<ActionButton className="size-11! header-icon-small" tooltip={"All Commands"} type="base" id="allActionsBtn" onAction={() => showAllCommandsAction!('allActionsBtn')}>
|
||||
<EllipsisVertical />
|
||||
</ActionButton>}</div>;
|
||||
}
|
||||
else if (error)
|
||||
{
|
||||
mainAction = () =>
|
||||
{
|
||||
if (status === 'missing-emulator')
|
||||
{
|
||||
router.navigate({ to: '/settings/directories' });
|
||||
}
|
||||
};
|
||||
mainButton = <ActionButton
|
||||
key="error"
|
||||
tooltip={error}
|
||||
tooltipType="error"
|
||||
type='error'
|
||||
onAction={() =>
|
||||
{
|
||||
if (status === 'missing-emulator')
|
||||
{
|
||||
router.navigate({ to: '/settings/directories' });
|
||||
}
|
||||
}}
|
||||
onAction={mainAction}
|
||||
id="mainAction">
|
||||
<TriangleAlert />
|
||||
</ActionButton>;
|
||||
|
|
@ -167,26 +173,27 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
{
|
||||
icon = <Import />;
|
||||
}
|
||||
mainAction = () =>
|
||||
{
|
||||
if (installMut.isPending) return;
|
||||
switch (status)
|
||||
{
|
||||
case 'present':
|
||||
case 'install':
|
||||
if (installSources && installSources.length > 1)
|
||||
{
|
||||
showInstallSource(true, 'mainAction');
|
||||
} else
|
||||
{
|
||||
installMut.mutate({});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
mainButton = <ActionButton
|
||||
key={status ?? 'unknown'}
|
||||
onAction={() =>
|
||||
{
|
||||
if (installMut.isPending) return;
|
||||
switch (status)
|
||||
{
|
||||
case 'present':
|
||||
case 'install':
|
||||
if (installSources && installSources.length > 1)
|
||||
{
|
||||
showInstallSource(true, 'mainAction');
|
||||
} else
|
||||
{
|
||||
installMut.mutate({});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}}
|
||||
onAction={mainAction}
|
||||
tooltip={details ?? status}
|
||||
type='primary'
|
||||
id="mainAction">
|
||||
|
|
@ -194,6 +201,27 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
</ActionButton>;
|
||||
}
|
||||
|
||||
useShortcuts('mainAction', () =>
|
||||
{
|
||||
const shortcuts: Shortcut[] = [{
|
||||
button: GamePadButtonCode.A,
|
||||
action: mainAction
|
||||
}];
|
||||
|
||||
if (showAllCommandsAction)
|
||||
shortcuts.push(
|
||||
{
|
||||
button: GamePadButtonCode.Y,
|
||||
label: "All Commands",
|
||||
action (e)
|
||||
{
|
||||
showAllCommandsAction('mainAction');
|
||||
},
|
||||
});
|
||||
|
||||
return shortcuts;
|
||||
}, [showAllCommandsAction, mainAction]);
|
||||
|
||||
const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', {
|
||||
content: <ContextList options={validCommands.map((c, i) =>
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue