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:
Simeon Radivoev 2026-05-04 14:59:43 +03:00
parent e54a6ac8f0
commit 06b7e4074d
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
66 changed files with 2216 additions and 416 deletions

View file

@ -3,6 +3,7 @@ import { SystemInfoContext } from "../scripts/contexts";
import { systemApi } from "../scripts/clientApi";
import { SystemInfoType } from "@/shared/constants";
import LoadingScreen from "./LoadingScreen";
import { GamepadKeyboard } from "./GamepadKeyboard";
export default function AppCommunication (data: { children: any; })
{
@ -55,5 +56,6 @@ export default function AppCommunication (data: { children: any; })
</div>
</LoadingScreen>
: data.children}
<GamepadKeyboard />
</SystemInfoContext>;
}

View file

@ -8,10 +8,9 @@ export default function CollectionList (data: {
id: string,
setBackground: (url: string) => void;
className?: string;
onFocus?: GameCardFocusHandler;
onSelect?: (id: string) => void;
saveChildFocus?: 'session' | 'local';
})
} & FocusParams)
{
const router = useRouter();
const { data: collections } = useSuspenseQuery(getCollectionsQuery);
@ -37,7 +36,7 @@ export default function CollectionList (data: {
id: `${g.id.source}@${g.id.id}`,
title: g.name,
focusKey: `collection-${g.id}`,
previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`,
previewUrls: `${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">
{g.game_count}

View file

@ -1,24 +1,17 @@
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { HeaderButton, StickyHeaderUI } from './Header';
import { GameList } from './GameList';
import { ArrowDownAz, CalendarArrowDown, ClockArrowDown, Drama, Filter, FunnelX, HardDrive, Rocket, Search, Settings2, SortDesc, Store, Tags, User, UserLock } from 'lucide-react';
import { JSX, Suspense, useRef, useState } from 'react';
import { JSX, Suspense } from 'react';
import { FloatingShortcuts } from './Shortcuts';
import { AutoFocus } from './AutoFocus';
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
import { GameListFilterSchema, GameListFilterType } from '@/shared/constants';
import { GameListFilterType } from '@/shared/constants';
import { HandleGoBack } from '../scripts/utils';
import LoadingCardList from './LoadingCardList';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { gameFiltersQuery, gameQuery } from '../scripts/queries/romm';
import { useNavigate, useRouter } from '@tanstack/react-router';
import { useRouter } from '@tanstack/react-router';
import SelectMenu from './SelectMenu';
import { RoundButton } from './RoundButton';
import { ContextList, DialogEntry, useContextDialog } from './ContextDialog';
import classNames from 'classnames';
import { sourceIconMap } from './Constants';
import { stat } from 'fs-extra';
import { FilterUI } from './Filters';
import SideFilters from './SideFilters';
export interface CollectionsDetailParams
@ -75,7 +68,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
<div className='absolute top-0 bottom-0 left-0 right-0 bg-radial from-base-100 to-base-300 -z-1'></div>
<div className='mobile:hidden bg-noise'></div>
<div className='mobile:hidden bg-dots'></div>
{finalFilter && data.title}
{!!finalFilter && data.title}
{<Suspense fallback={<LoadingCardList grid placeholderCount={data.countHint ?? 8} id={`${focusKey}-list`} />}>
<GameList
key={`${data.id}-${JSON.stringify(finalFilter)}`}

View file

@ -18,7 +18,7 @@ export function ContextList (data: {
{
const context = useContext(ContextDialogContext);
return <ul className={twMerge("list gap-1", data.className)}>
{data.options?.map((o, i) => <OptionElement className="list-row" key={i} {...o} />)}
{data.options?.map((o, i) => <OptionElement className="list-row" key={`${o.id}-${i}`} {...o} />)}
{data.showCloseButton !== false && <div className="divider m-0 "></div>}
{data.showCloseButton !== false && <OptionElement disabled={data.disableCloseButton} className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
</ul>;
@ -40,7 +40,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
};
const { ref, focusSelf, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id),
onEnterPress: data.shortcuts ? undefined : handleAction,
onEnterPress: handleAction,
onFocus: handleFocus,
trackChildren: typeof data.content !== 'string'
});

View file

@ -1,9 +1,8 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { ContextList, DialogEntry } from "./ContextDialog";
import { systemApi } from "../scripts/clientApi";
import { FocusEventHandler, useContext, useRef, useState } from "react";
import path from "pathe";
import { Check, File, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
import { Check, File, FileInput, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { DirType } from "@/shared/constants";
import classNames from "classnames";
@ -15,7 +14,6 @@ import toast from "react-hot-toast";
import { FilePickerContext } from "../scripts/contexts";
import useActiveControl from "../scripts/gamepads";
import { createFolderMutation, drivesQuery, filesQuery } from "@queries/system";
import { showKeyboardHandler } from "../scripts/utils";
function List (data: {
id: string,
@ -48,7 +46,7 @@ function List (data: {
let icon = <Folder className="text-warning" />;
if (isDefaultPath)
{
icon = <FolderInput className="text-warning" />;
icon = f.isDirectory ? <FolderInput className="text-accent" /> : <FileInput className="text-accent" />;
} else if (!f.isDirectory)
{
icon = <File />;
@ -97,7 +95,6 @@ function NewFolderInput (data: { id: string, name: string | undefined, setName:
const handleFocus: FocusEventHandler<HTMLInputElement> = (e) =>
{
focusSelf();
showKeyboardHandler(control as any, e.target);
};
return <div className={data.className} ref={ref}>
<input ref={inputRef}

View file

@ -0,0 +1,509 @@
import { createRef, JSX, RefObject, useEffect, useRef, useState } from "react";
import useActiveControl from "../scripts/gamepads";
import { oneShot } from "../scripts/audio/audio";
import { ArrowLeft, ArrowRight, CornerDownLeft, Delete, Space } from "lucide-react";
import { GamePadButtonCode } from "../scripts/shortcuts";
import { GamepadIconMap } from "./Shortcuts";
import ShortcutPrompt from "./ShortcutPrompt";
import { getLocalSetting, showKeyboardHandler } from "../scripts/utils";
const Keys = [
['E', 'R', 'T', 'F', 'D', 'G', 'V', 'C', 'S', 'X', 'Z', 'B', 'A', 'Q', 'W'],
['I', '⌫', 'O', '⏎', 'P', 'L', 'N', '␣', 'M', 'J', 'K', 'H', 'Y', 'U']
];
const Characters = [
["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "%", "$", "#", "@", "+"],
[",", '⌫', ".", '⏎', "/", "[", "]", '␣', "(", ")", ":", "!", "?", "&"]
];
function GetKeys (characters: boolean)
{
return characters ? Characters : Keys;
}
const KeyColors: Record<string, { bg: string, color: string; }> = {
'⌫': { bg: "var(--color-accent)", color: "var(--color-accent-content)" },
'⏎': { bg: "var(--color-secondary)", color: "var(--color-secondary-content)" },
'␣': { bg: "var(--color-info)", color: "var(--color-info-content)" },
};
const Shortcuts: Record<string, GamePadButtonCode> = {
'⌫': GamePadButtonCode.X,
'␣': GamePadButtonCode.Y,
'⏎': GamePadButtonCode.A,
'←': GamePadButtonCode.Left,
'→': GamePadButtonCode.Right,
'⇧': GamePadButtonCode.RJoy,
'⌥': GamePadButtonCode.LJoy
};
const KeyElements: Record<string, JSX.Element> = {
'⌫': <Delete />,
'␣': <Space />,
'⏎': <CornerDownLeft />,
'←': <ArrowLeft />,
'→': <ArrowRight />,
};
const DZ = 0.22, TH = 0.85, NS = 'http://www.w3.org/2000/svg';
function ang (x: number, y: number)
{
if (Math.sqrt(x * x + y * y) < DZ) return null;
let a = Math.atan2(x, -y);
if (a < 0) a += Math.PI * 2;
return a;
}
function gidx (a: number | null, n: number)
{
return a === null ? -1 : Math.floor(a / (Math.PI * 2) * n) % n;
}
function buildWheel (side: 0 | 1, shift: boolean, characters: boolean)
{
const elements: JSX.Element[] = [];
const refs: RefObject<HTMLSpanElement | null>[] = [];
const positions: { left: string; top: string; }[] = [];
const W = 258, C = 129, R2 = 107, R1 = 42, n = GetKeys(characters)[side].length, GAP = 0.028;
for (let i = 0; i < n; i++)
{
const a0 = i / n * Math.PI * 2 - Math.PI / 2 + GAP;
const a1 = (i + 1) / n * Math.PI * 2 - Math.PI / 2 - GAP;
const am = (a0 + a1) / 2;
const ref = createRef<HTMLSpanElement>();
const x = Math.cos(am);
const y = Math.sin(am);
refs.push(ref);
const tr = 66;
positions.push({ left: `50% + ${tr * x}% - 16px`, top: `50% + ${tr * y}% - 16px` });
elements.push(<>
<span key={GetKeys(characters)[side][i]} ref={ref} className='flex absolute bg-base-100 size-8 text-xl items-center justify-center p-1 rounded-full transition-[background,scale]' style={{
left: `calc(50% + ${tr * x}% - 16px)`,
top: `calc(50% + ${tr * y}% - 16px)`,
backgroundColor: KeyColors[GetKeys(characters)[side][i]]?.bg,
color: KeyColors[GetKeys(characters)[side][i]]?.color,
}}>
{KeyElements[GetKeys(characters)[side][i]] ?? shift ? GetKeys(characters)[side][i].toUpperCase() : GetKeys(characters)[side][i].toLocaleLowerCase()}
</span>
</>);
}
return { elements, refs, positions };
}
export type EditableInput = HTMLInputElement | HTMLTextAreaElement;
export function typeKey (el: EditableInput, key: string): void
{
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
el.value =
el.value.slice(0, start) +
key +
el.value.slice(end);
const pos = start + key.length;
el.setSelectionRange(pos, pos);
}
export function backspace (el: EditableInput): void
{
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
// selection delete
if (start !== end)
{
el.value =
el.value.slice(0, start) +
el.value.slice(end);
el.setSelectionRange(start, start);
return;
}
// nothing to delete
if (start === 0) return;
el.value =
el.value.slice(0, start - 1) +
el.value.slice(end);
el.setSelectionRange(start - 1, start - 1);
}
export function deleteForward (el: EditableInput): void
{
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
if (start !== end)
{
el.value =
el.value.slice(0, start) +
el.value.slice(end);
el.setSelectionRange(start, start);
return;
}
if (start >= el.value.length) return;
el.value =
el.value.slice(0, start) +
el.value.slice(start + 1);
el.setSelectionRange(start, start);
}
export function enter (el: EditableInput): void
{
if (el instanceof HTMLTextAreaElement)
{
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const insert = "\n";
el.value =
el.value.slice(0, start) +
insert +
el.value.slice(end);
const pos = start + 1;
el.setSelectionRange(pos, pos);
} else
{
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true }));
}
}
export function arrowLeft (el: EditableInput): void
{
const pos = el.selectionStart ?? 0;
const newPos = Math.max(0, pos - 1);
el.setSelectionRange(newPos, newPos);
}
export function arrowRight (el: EditableInput): void
{
const pos = el.selectionStart ?? 0;
const newPos = Math.min(el.value.length, pos + 1);
el.setSelectionRange(newPos, newPos);
}
export function GamepadKeyboard ()
{
const triggerThreshold = 0.85;
const [focusedInput, setFocusedInput] = useState<HTMLInputElement | null>(null);
const circleRefs = [useRef<HTMLDivElement>(null), useRef<HTMLDivElement>(null)];
const sideRefs = [useRef<HTMLDivElement>(null), useRef<HTMLDivElement>(null)];
const keyIndicatorRefs = [useRef<HTMLDivElement>(null), useRef<HTMLDivElement>(null)];
const activeControl = useActiveControl();
const hidden = !focusedInput || activeControl.control !== 'gamepad';
const keyboardRef = useRef<HTMLDivElement>(null);
const [shift, setShift] = useState(false);
const [characters, setCharacters] = useState(false);
useEffect(() =>
{
if (!hidden)
{
oneShot('openKeyboard');
}
}, [hidden]);
const elements = [buildWheel(0, shift, characters), buildWheel(1, shift, characters)];
useEffect(() =>
{
let disposed = false;
const lockedIds: [number | undefined, number | undefined] = [undefined, undefined];
const actionRepeatTimeout: [NodeJS.Timeout | undefined, NodeJS.Timeout | undefined] = [undefined, undefined];
const actionRepeatCount = [0, 0];
const prevTriggerValues = [0, 0];
const buttonValues: Record<number, number> = {};
const buttonRepeatTimeout: Record<number, NodeJS.Timeout> = {};
const buttonRepeatCounts: Record<number, number> = {};
const lastIndexes = [-1, -1];
function update ()
{
const gps = navigator.getGamepads ? navigator.getGamepads() : [];
const gp = [...gps].find(g => g);
if (keyboardRef.current && focusedInput && !hidden)
{
const targetRect = focusedInput.getBoundingClientRect();
const el = keyboardRef.current;
// First, measure the element itself
const elRect = el.getBoundingClientRect();
const margin = 64; // keep some space from edges
let left = targetRect.left;
let top = targetRect.bottom + 128;
// Clamp horizontally
if (left + elRect.width > window.innerWidth - margin)
{
left = window.innerWidth - elRect.width - margin;
}
if (left < margin)
{
left = margin;
}
// Clamp vertically
if (top + elRect.height > window.innerHeight - margin)
{
// flip above the input if it doesn't fit below
top = targetRect.top - elRect.height - 128;
}
if (top < margin)
{
top = margin;
}
el.style.position = "fixed";
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}
if (gp && !hidden)
{
function pressKey (el: EditableInput, key: string, repeatCount: number): void
{
const hapticIntensity = 1 / Math.max(repeatCount, 1);
const soundIntensity = 1 / Math.min(2, Math.max(repeatCount * 0.2, 1));
gp?.vibrationActuator.playEffect('dual-rumble', { duration: 60, strongMagnitude: hapticIntensity, weakMagnitude: hapticIntensity });
switch (key)
{
case "⌫":
oneShot('keyPressBackspace', { volume: soundIntensity });
return backspace(el);
case "Delete":
oneShot('keyPressBackspace', { volume: soundIntensity });
return deleteForward(el);
case "←":
oneShot('keyPress', { volume: soundIntensity });
return arrowLeft(el);
case "→":
oneShot('keyPress', { volume: soundIntensity });
return arrowRight(el);
case "⏎":
oneShot('keyPress', { volume: soundIntensity });
return enter(el);
case "␣":
oneShot('keyPressSpace', { volume: soundIntensity });
return typeKey(el, ' ');
case "⇧":
setShift(v => !v);
return;
case "⌥":
setCharacters(v => !v);
return;
default:
oneShot('keyPress', { volume: soundIntensity });
return typeKey(el, shift ? key.toUpperCase() : key.toLocaleLowerCase());
}
}
for (let side = 0; side < 2; side++)
{
const x = gp.axes[side * 2] ?? 0;
const y = gp.axes[side * 2 + 1] ?? 0;
const triggerValue = Math.max(gp.buttons[6 + side]?.value ?? 0, gp.buttons[4 + side]?.value ?? 0);
const angle = ang(x, y);
const keyIndex = lockedIds[side] !== undefined ? lockedIds[side]! : gidx(angle, GetKeys(characters)[side].length);
elements[side].refs.filter(e => e.current).forEach((e, i) =>
{
const active = keyIndex === i;
const key = GetKeys(characters)[side][i];
const elem = e.current!;
elem.style.backgroundColor = active ? 'var(--color-primary)' : KeyColors[key]?.bg ?? '';
elem.style.color = active ? 'var(--color-primary-content)' : KeyColors[key]?.color ?? '';
elem.style.scale = `${active ? 150 : 100}%`;
elem.style.fontStyle = active ? 'bold' : 'normal';
});
const circle = circleRefs[side].current!;
// Update actions
if (keyIndex >= 0)
{
if (focusedInput)
{
if (triggerValue >= triggerThreshold && prevTriggerValues[side] < triggerThreshold)
{
const timeoutCalc = () => 400 / Math.min(4, Math.max(1, 1 + (actionRepeatCount[side] ?? 0)));
const handleRepeat = () =>
{
elements[side].refs[keyIndex].current!.animate([
{ boxShadow: "0 0 0 0 var(--color-base-content)" },
{ boxShadow: "0 0 0 10px transparent" }
],
{ duration: 300, easing: 'ease-out', fill: 'none' }
);
pressKey(focusedInput, GetKeys(characters)[side][keyIndex], actionRepeatCount[side]);
actionRepeatCount[side]++;
actionRepeatTimeout[side] = setTimeout(handleRepeat, timeoutCalc());
};
handleRepeat();
}
else if (triggerValue < triggerThreshold && prevTriggerValues[side] >= triggerThreshold)
{
clearTimeout(actionRepeatTimeout[side]);
actionRepeatCount[side] = -1;
}
if (lockedIds[side] === undefined && triggerValue > 0.1)
{
lockedIds[side] = keyIndex;
} else if (lockedIds[side] !== undefined && triggerValue <= 0.1)
{
lockedIds[side] = undefined;
}
}
keyIndicatorRefs[side].current!.textContent = shift ? GetKeys(characters)[side][keyIndex].toUpperCase() : GetKeys(characters)[side][keyIndex].toLowerCase();
} else
{
keyIndicatorRefs[side].current!.textContent = "";
}
// Update cirlce
const magnitudeSqr = (x * x) + (y * y);
const magnitude = Math.sqrt(magnitudeSqr);
const elementPos = keyIndex < 0 ? undefined : elements[side].positions[keyIndex];
//const lerpX = (element?.left ?? 0);
//const lerpY = (element?.top ?? 0);
const size = 12;
circle.style.left = `calc(50% + ${50 * x}% - 16px)`;
circle.style.top = `calc(50% + ${50 * y}% - 16px)`;
circle.style.opacity = `${1 - Math.pow(magnitude, 2)}`;
circle.style.backgroundColor = `color-mix(in srgb, var(--color-base-content), 'var(--color-primary)'} ${magnitude * 100}%)`;
if (sideRefs[side].current)
{
sideRefs[side].current!.style.background = `radial-gradient(
circle at calc(50% + ${100 * x}px) calc(50% + ${100 * y}px),
color-mix(in srgb, var(--color-primary) 20%, transparent),
transparent
)`;
}
if (lastIndexes[side] !== keyIndex)
{
gp.vibrationActuator.playEffect('dual-rumble', { duration: 30, strongMagnitude: 0, weakMagnitude: 0.2 });
oneShot('keyHover');
}
prevTriggerValues[side] = triggerValue;
lastIndexes[side] = keyIndex;
}
const shortcutKeys = Object.entries(Shortcuts);
function handleButton (key: number, repeatCount: number)
{
if (!focusedInput) return;
const entry = shortcutKeys.find(([n, value]) => value === key);
if (key === GamePadButtonCode.A) return;
if (entry)
{
pressKey(focusedInput, entry[0], repeatCount);
}
}
for (let i = 0; i < gp.buttons.length; i++)
{
const btn = gp.buttons[i];
if (btn.value >= 0.85 && buttonValues[i] < 0.85)
{
const timeoutCalc = () => 400 / Math.min(8, Math.max(1, 1 + (buttonRepeatCounts[i] ?? 0)));
const handleRepeat = () =>
{
handleButton(i, buttonRepeatCounts[i]);
buttonRepeatCounts[i] = (buttonRepeatCounts[i] ?? -1) + 1;
buttonRepeatTimeout[i] = setTimeout(handleRepeat, timeoutCalc());
};
handleRepeat();
}
else if (btn.value < 0.85 && buttonValues[i] >= 0.85)
{
clearTimeout(buttonRepeatTimeout[i]);
buttonRepeatCounts[i] = -1;
}
buttonValues[i] = btn.value;
}
}
if (!disposed && !hidden) requestAnimationFrame(update);
}
if (!disposed && !hidden) requestAnimationFrame(update);
return () =>
{
disposed = true;
Object.values(buttonRepeatTimeout).forEach(v => clearTimeout(v));
Object.values(actionRepeatTimeout).forEach(v => clearTimeout(v));
};
}, [focusedInput, elements, shift, characters, hidden]);
useEffect(() =>
{
const handleFocus = (e: FocusEvent) =>
{
if (e.target instanceof HTMLInputElement && (e.target.type === 'text' || e.target.type === 'search'))
{
if (!getLocalSetting('autoKeybaord')) return;
if (getLocalSetting('useGameflowKeyboard'))
{
setFocusedInput(e.target);
} else
{
showKeyboardHandler(activeControl.control, e.target);
}
}
};
const handleBlur = (e: FocusEvent) =>
{
setFocusedInput(null);
};
document.addEventListener('focusin', handleFocus);
document.addEventListener('focusout', handleBlur);
return () =>
{
document.removeEventListener('focusin', handleFocus);
document.removeEventListener('focusout', handleBlur);
};
}, []);
return <div hidden={hidden} style={{ left: '256px' }} ref={keyboardRef} className='fixed flex justify-center items-center gap-32 rounded-2xl pointer-events-none z-1000'>
{elements.map((e, i) => <div ref={sideRefs[i]} key={i} data-shift={shift} className='flex justify-center items-center size-48 rounded-full border-8 ring-4 ring-offset-48 ring-offset-base-300 ring-base-100 data-[shift=true]:ring-base-content border-base-300 backdrop-blur-2xl bg-base-100/40'>
<div ref={circleRefs[i]} className='absolute bg-base-300 rounded-full size-8'></div>
{e.elements}
<div className='text-3xl font-semibold' ref={keyIndicatorRefs[i]}></div>
</div>)}
<div className='absolute flex gap-2 mb-92'>{Object.entries(Shortcuts).map(([key, value], i) => <ShortcutPrompt key={i} id={key} icon={GamepadIconMap[value]} label={KeyElements[key] ?? key} />)}</div>
</div>;
}

View file

@ -1,13 +1,12 @@
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { FocusEventHandler, Ref, RefObject, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { oneShot } from "../scripts/audio/audio";
import { Search } from "lucide-react";
import { RoundButton } from "./RoundButton";
import { useEventListener } from "usehooks-ts";
import { systemApi } from "../scripts/clientApi";
import { showKeyboardHandler } from "../scripts/utils";
import useActiveControl from "../scripts/gamepads";
import { twMerge } from "tailwind-merge";
function SearchInput (data: {
id: string;
@ -16,6 +15,7 @@ function SearchInput (data: {
compact: boolean | undefined;
onInputFocus: () => void;
setShowInput: (show: boolean) => void;
className?: string;
onSubmit: (search: string | undefined) => void;
} & FocusParams)
{
@ -63,9 +63,7 @@ function SearchInput (data: {
data.onSubmit?.(undefined);
}, inputRef as any);
const handlInputFocus: FocusEventHandler<HTMLInputElement> = e => showKeyboardHandler(control as any, e.target);
return <label ref={ref} onFocus={data.onInputFocus} className='input rounded-full input-lg w-full max-w-xs has-focus:bg-base-300 ring-primary focused:ring-7 has-focus:ring-7 has-focus:ring-base-content'>
return <label ref={ref} onFocus={data.onInputFocus} className={twMerge('input rounded-full input-lg w-full max-w-xs bg-base-200 has-focus:bg-base-300 ring-primary focused:ring-7 has-focus:ring-7 has-focus:ring-base-content', data.className)}>
<Search />
<input
onBlur={e =>
@ -74,7 +72,6 @@ function SearchInput (data: {
setLocalSearch(data.search);
}}
autoFocus={data.compact}
onFocus={handlInputFocus}
ref={inputRef}
value={localSearch ?? ""}
onChange={v => setLocalSearch(v.target.value)}
@ -89,6 +86,7 @@ export default function HeaderSearchField (data: {
autoSearch?: boolean;
search: string | undefined,
onSubmit: (search: string | undefined) => void;
className?: string;
compact?: boolean;
} & FocusParams)
{
@ -102,7 +100,7 @@ export default function HeaderSearchField (data: {
return <div ref={ref} className='flex items-center'>
<FocusContext value={focusKey}>
{(!data.compact || showInput) && <SearchInput autoSearch={data.autoSearch} onFocus={data.onFocus} id={`${data.id}-field`} search={data.search} onSubmit={data.onSubmit} compact={data.compact} setShowInput={setShowInput} onInputFocus={focusSelf} />}
{(!data.compact || showInput) && <SearchInput className={data.className} autoSearch={data.autoSearch} onFocus={data.onFocus} id={`${data.id}-field`} search={data.search} onSubmit={data.onSubmit} compact={data.compact} setShowInput={setShowInput} onInputFocus={focusSelf} />}
{data.compact && !showInput && <RoundButton onAction={e => setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} ><Search /></RoundButton>}
</FocusContext>
</div>;

View file

@ -1,6 +1,6 @@
import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { MatchRoute, useMatch, useMatchRoute, useNavigate, useRouterState } from "@tanstack/react-router";
import { useMatchRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react";
import { systemApi } from "../scripts/clientApi";
@ -10,6 +10,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
{
const navigate = useNavigate();
const matchRoute = useMatchRoute();
const router = useRouter();
const options: DialogEntry[] = [
{
@ -95,7 +96,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
}
];
const { dialog, setOpen, open } = useContextDialog('select-menu', {
content: <ContextList showCloseButton={false} options={options} />,
content: <><ContextList showCloseButton={false} options={options} /><div className="absolute left-2 right-2 top-2 text-base-content/20 text-center">{router.history.location.pathname}</div></>,
className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none max-h-screen',
preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '')
});

View file

@ -1,4 +1,4 @@
import { MouseEventHandler } from "react";
import { JSX, MouseEventHandler } from "react";
import SvgIcon, { IconType } from "./SvgIcon";
import classNames from "classnames";
import { twMerge } from "tailwind-merge";
@ -6,8 +6,9 @@ import { twMerge } from "tailwind-merge";
export default function ShortcutPrompt (data: {
id: string;
icon?: IconType;
label?: string;
label?: string | JSX.Element;
className?: string;
iconClassName?: string;
onClick?: MouseEventHandler;
})
{
@ -23,7 +24,7 @@ export default function ShortcutPrompt (data: {
})
)}
>
{data.icon && <SvgIcon className="size-6 portrait:size-6 md:size-8" icon={data.icon} />}
{data.icon && <SvgIcon className={twMerge("size-6 portrait:size-6 md:size-8", data.iconClassName)} icon={data.icon} />}
{data.label}
</div>
);

View file

@ -1,36 +1,36 @@
import { useContext } from 'react';
import useActiveControl, { GamepadButtonEvent } from '../scripts/gamepads';
import { GamePadButtonCode, Shortcut, useShortcutContext } from '../scripts/shortcuts';
import { GamePadButtonCode, useShortcutContext } from '../scripts/shortcuts';
import ShortcutPrompt from './ShortcutPrompt';
import { IconType } from './SvgIcon';
import { ShortcutsContext } from '../scripts/contexts';
export function FloatingShortcuts ()
{
return <div className="mobile:hidden fixed flex bottom-4 right-4 left-4 justify-between pointer-events-none z-1000"><Shortcuts /></div>;
}
export const GamepadIconMap: Record<GamePadButtonCode, IconType> = {
[GamePadButtonCode.A]: 'steamdeck_button_a',
[GamePadButtonCode.B]: 'steamdeck_button_b',
[GamePadButtonCode.X]: 'steamdeck_button_x',
[GamePadButtonCode.Y]: 'steamdeck_button_y',
[GamePadButtonCode.L1]: 'steamdeck_button_l1',
[GamePadButtonCode.R1]: 'steamdeck_button_r1',
[GamePadButtonCode.L2]: 'steamdeck_button_l2',
[GamePadButtonCode.R2]: 'steamdeck_button_r2',
[GamePadButtonCode.Select]: 'steamdeck_button_guide',
[GamePadButtonCode.Start]: 'steamdeck_button_options',
[GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press',
[GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press',
[GamePadButtonCode.Up]: 'steamdeck_dpad_up',
[GamePadButtonCode.Down]: 'steamdeck_dpad_down',
[GamePadButtonCode.Left]: 'steamdeck_dpad_left',
[GamePadButtonCode.Right]: 'steamdeck_dpad_right',
[GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess'
};
export default function Shortcuts (data: { centerElement?: any; })
{
const iconMap: Record<GamePadButtonCode, IconType> = {
[GamePadButtonCode.A]: 'steamdeck_button_a',
[GamePadButtonCode.B]: 'steamdeck_button_b',
[GamePadButtonCode.X]: 'steamdeck_button_x',
[GamePadButtonCode.Y]: 'steamdeck_button_y',
[GamePadButtonCode.L1]: 'steamdeck_button_l1',
[GamePadButtonCode.R1]: 'steamdeck_button_r1',
[GamePadButtonCode.L2]: 'steamdeck_button_l2',
[GamePadButtonCode.R2]: 'steamdeck_button_r2',
[GamePadButtonCode.Select]: 'steamdeck_button_guide',
[GamePadButtonCode.Start]: 'steamdeck_button_options',
[GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press',
[GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press',
[GamePadButtonCode.Up]: 'steamdeck_dpad_up',
[GamePadButtonCode.Down]: 'steamdeck_dpad_down',
[GamePadButtonCode.Left]: 'steamdeck_dpad_left',
[GamePadButtonCode.Right]: 'steamdeck_dpad_right',
[GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess'
};
const keyboardMap: Record<GamePadButtonCode, string> = {
[GamePadButtonCode.A]: 'ENTER',
@ -62,7 +62,7 @@ export default function Shortcuts (data: { centerElement?: any; })
key={s.button}
id={`shortcut-${s.button}`}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={showKeyboard ? undefined : iconMap[s.button]}
icon={showKeyboard ? undefined : GamepadIconMap[s.button]}
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
)}
</div>
@ -72,7 +72,7 @@ export default function Shortcuts (data: { centerElement?: any; })
key={s.button}
id={`shortcut-${s.button}`}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={showKeyboard ? undefined : iconMap[s.button]}
icon={showKeyboard ? undefined : GamepadIconMap[s.button]}
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
)}
</div>

View file

@ -1,5 +1,6 @@
import "virtual:svg-icons/register";
import { StaticAssetPath } from "../gen/static-icon-assets.gen";
import { CSSProperties } from "react";
type OnlySvgIcon<T extends string> = T extends `${infer Rest}.svg`
? Rest
@ -15,17 +16,19 @@ export default function SvgIcon ({
icon,
prefix = "icon",
className,
style,
...props
}: {
icon: IconType;
prefix?: string;
className?: string;
style?: CSSProperties;
})
{
const symbolId = `#${prefix}-${icon}`;
return (
<svg className={className} {...props} aria-hidden="true">
<svg style={style} className={className} {...props} aria-hidden="true">
<use href={symbolId} />
</svg>
);

View file

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

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

View file

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

View file

@ -1,12 +1,14 @@
import { useState } from "react";
import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
import { useMutation } from "@tanstack/react-query";
import { changeDownloadsMutation } from "@queries/settings";
import { useMutation, useQuery } from "@tanstack/react-query";
import { changeDownloadsMutation, getSettingQuery } from "@queries/settings";
import { SettingsType } from "@/shared/constants";
export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
export default function DownloadDirectoryOption (data: PathSettingsOptionParams & { id: KeysWithValueAssignableTo<SettingsType, string>; })
{
const [localValue, setLocalValue] = useState<string | undefined>();
const [dirty, setDirty] = useState(false);
const { data: defaultValue } = useQuery(getSettingQuery(data.id));
const setSettingMutation = useMutation({
...changeDownloadsMutation,
onSuccess: (d, v, r, cx) =>
@ -25,6 +27,7 @@ export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
requireConfirmation={data.requireConfirmation}
isDirectoryPicker={true}
localValue={localValue}
defaultValue={defaultValue as any}
setLocalValue={(v) =>
{
setLocalValue(v);

View file

@ -1,4 +1,4 @@
import { HTMLInputTypeAttribute, JSX } from "react";
import { JSX } from "react";
import { LocalSettingsSchema, LocalSettingsType } from "@shared/constants";
import { OptionSpace } from "./OptionSpace";
import { OptionInput } from "./OptionInput";
@ -6,14 +6,9 @@ import { useLocalStorage } from "usehooks-ts";
import { OptionDropdown } from "./OptionDropdown";
export function LocalOption (data: {
label: string;
id: keyof LocalSettingsType;
type: HTMLInputTypeAttribute | 'dropdown';
min?: number;
max?: number;
step?: number;
placeholder?: string;
values?: string[];
icon?: JSX.Element;
children?: any;
})
@ -22,9 +17,20 @@ export function LocalOption (data: {
deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v))
});
const schema = LocalSettingsSchema.shape[data.id].toJSONSchema();
const typeMapping: Record<string, string> = {
string: 'text',
integer: 'range',
number: 'range',
boolean: 'checkbox'
};
return (
<OptionSpace id={`${data.id}-space`} label={data.label}>
{data.type === 'dropdown' && data.values && <OptionDropdown values={data.values} icon={data.icon}
<OptionSpace id={`${data.id}-space`} label={<div className="flex flex-col gap-1">
<div>{schema.title ?? data.id}</div>
<div className="text-base-content/40 text-sm">{schema.description}</div>
</div>}>
{!!schema.enum && <OptionDropdown values={schema.enum.map(v => String(v))} icon={data.icon}
name={data.id ?? ""}
placeholder={data.placeholder}
defaultValue={localValue}
@ -33,12 +39,12 @@ export function LocalOption (data: {
setLocalValue(v);
}}
value={localValue} />}
{data.type !== 'dropdown' && <OptionInput
{!schema.enum && <OptionInput
icon={data.icon}
name={data.id ?? ""}
type={data.type}
min={data.min}
max={data.max}
type={schema.type ? typeMapping[schema.type] : 'text'}
min={schema.minimum}
max={schema.maximum}
step={data.step}
placeholder={data.placeholder}
defaultValue={localValue}

View file

@ -2,11 +2,9 @@ import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribu
import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
import { CheckIcon, X } from "lucide-react";
import { oneShot } from "@/mainview/scripts/audio/audio";
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
import { showKeyboardHandler } from "@/mainview/scripts/utils";
import useActiveControl from "@/mainview/scripts/gamepads";
export function OptionInput (data: {
@ -106,7 +104,6 @@ export function OptionInput (data: {
{
option.focus();
setInputFocused(true);
showKeyboardHandler(control as any, e.target);
};
const handleInputBlur = (e: any) =>

View file

@ -35,6 +35,7 @@ export function useOptionContext (params?: { onOptionEnterPress?: () => void; })
export function OptionSpace (data: {
id?: string;
className?: string;
innerClassName?: string;
focusable?: boolean;
children?: any | any[];
label?: string | JSX.Element | ((focused: boolean) => JSX.Element);
@ -90,7 +91,7 @@ export function OptionSpace (data: {
{!!labelElement && <div className="flex gap-2 items-center flex-1 md:text-lg pr-4">
{labelElement}
</div>}
<div className="flex flex-1 justify-end-safe">
<div className={twMerge("flex flex-1 justify-end-safe", data.innerClassName)}>
{data.children}
</div>
</li>

View file

@ -13,7 +13,7 @@ import { getSettingQuery, setSettingMutation } from "@queries/settings";
export interface PathSettingsOptionParams
{
label: string;
id: KeysWithValueAssignableTo<SettingsType, string>;
id: string;
type: HTMLInputTypeAttribute;
placeholder?: string;
icon?: JSX.Element;
@ -24,10 +24,11 @@ export interface PathSettingsOptionParams
allowNewFolderCreation?: boolean;
}
export function PathSettingsOption (data: PathSettingsOptionParams)
export function PathSettingsOption (data: PathSettingsOptionParams & { id: KeysWithValueAssignableTo<SettingsType, string>; })
{
const [localValue, setLocalValue] = useState<string | undefined>();
const [dirty, setDirty] = useState(false);
const { data: defaultValue } = useQuery(getSettingQuery(data.id));
const setMutation = useMutation({
...setSettingMutation(data.id),
onSuccess: (d, v, r, cx) =>
@ -44,6 +45,7 @@ export function PathSettingsOption (data: PathSettingsOptionParams)
save={setMutation.mutate}
localValue={localValue}
allowNewFolderCreation={data.allowNewFolderCreation}
defaultValue={defaultValue as any}
setLocalValue={(v) =>
{
setLocalValue(v);
@ -56,16 +58,17 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
localValue: string | undefined;
setLocalValue: (value: string | undefined) => void;
isDirty: boolean;
className?: string;
defaultValue: string | undefined;
})
{
const [isBrowsing, setIsBrowsing] = useState(false);
const { data: defaultValue } = useQuery(getSettingQuery(data.id));
const changed = defaultValue !== data.localValue;
const changed = data.defaultValue !== data.localValue;
useEffect(() =>
{
data.setLocalValue(String(defaultValue));
}, [defaultValue]);
data.setLocalValue(String(data.defaultValue ?? ''));
}, [data.defaultValue]);
const handleSelectPath = (path: string) =>
{
@ -92,7 +95,8 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
};
return (
<OptionSpace id={`${data.id}-space`} className="gap-2" label={<>{data.label}{changed && <Pen />}</>}>
<OptionSpace id={`${data.id}-space`} innerClassName="gap-2" className={data.className} label={<>{data.label}{changed && <Pen />}</>}>
<OptionInput
icon={data.icon}
name={`${data.id}-input`}
@ -105,7 +109,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
}}
value={data.localValue}
/>
<Button id={`${data.id}-browse`} className="ring-accent-content" focusClassName="ring-7" onAction={() =>
<Button id={`${data.id}-browse`} className="focusable focusable-accent" onAction={() =>
{
setIsBrowsing(true);
data.onBrowseAction?.(data.localValue);
@ -113,7 +117,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
{data.isDirectoryPicker ? <FolderSearch /> : <FileSearchCorner />}
</Button>
{data.requireConfirmation === true && <Button
disabled={defaultValue === data.localValue}
disabled={data.defaultValue === data.localValue}
id={`${data.id}-save`}
onAction={() => data.save(data.localValue)}
type="button">

View file

@ -15,10 +15,15 @@ export const { useAppForm: useSettingsForm, useTypedAppFormContext: useSettingsF
formComponents: {}
});
function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; label?: string | JSX.Element; placeholder?: string; })
function FormOption (data: {
type: HTMLInputTypeAttribute,
icon?: JSX.Element;
label?: string | JSX.Element;
placeholder?: string;
})
{
const field = useFieldContext<string>();
return <OptionSpace id={`${field.name}-space`} label={<div className="flex flex-1 gap-2">
return <OptionSpace id={`${field.name}-space`} label={<div className="flex items-center flex-1 gap-2">
{data.label}
{field.getMeta().errors.length > 0 && <div className="badge badge-error">
{field.state.meta.errors.map(e => e.message).join(',')}

View file

@ -7,6 +7,7 @@ import { getSettingQuery, setSettingMutation } from "@queries/settings";
export function SettingsOption (data: {
label: string;
help?: string;
id: KeysWithValueAssignableTo<SettingsType, string | boolean>;
type: HTMLInputTypeAttribute;
placeholder?: string;
@ -35,7 +36,10 @@ export function SettingsOption (data: {
}, [dirty, setDirty, localValue]);
return (
<OptionSpace id={`${data.id}-space`} label={data.label}>
<OptionSpace id={`${data.id}-space`} label={<div className="flex flex-col">
<div>{data.label}</div>
<div className="text-base-content/40 text-sm">{data.help}</div>
</div>}>
<OptionInput
icon={data.icon}
name={data.id ?? ""}

View file

@ -3,9 +3,7 @@ import
useFocusable,
FocusContext,
} from "@noriginmedia/norigin-spatial-navigation";
import { Button } from "../options/Button";
import useActiveControl from "@/mainview/scripts/gamepads";
import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react";
import { CircleQuestionMark, SearchAlert } from "lucide-react";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { RPC_URL } from "@/shared/constants";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
@ -15,14 +13,14 @@ import { oneShot } from "@/mainview/scripts/audio/audio";
interface MissingCardProps
{
emulator: FrontEndEmulator;
onSelect?: (id: string, focusKey: string) => void;
onSelect?: (em: FrontEndEmulator, focusKey: string) => void;
}
function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
{
const handleSelect = () =>
{
onSelect?.(em.name, focusKey);
onSelect?.(em, focusKey);
oneShot('click');
};
@ -31,7 +29,6 @@ function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
onEnterPress: handleSelect,
});
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
const { isMouse } = useActiveControl();
return (
<div
@ -40,7 +37,7 @@ function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
tabIndex={0}
onClick={handleSelect}
onKeyDown={(e) => e.key === "Enter" && handleSelect}
className={"focusable focusable-accent bg-base-100 rounded-4xl transition-all focused:animate-scale-small shadow-lg"}
className={"focusable focusable-accent focusable-hover cursor-pointer bg-base-100 rounded-4xl transition-all focused:animate-scale-small shadow-lg"}
>
<div className="card-body p-5 gap-3">
<div className="flex gap-4">
@ -57,10 +54,6 @@ function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
<p className="text-base-content/40 mt-0.5">{em.systems?.map(s => s.name).join(',')}</p>
</div>
</div>
<div className="flex items-center grow h-8">
<p className="text-xs text-error/80 leading-relaxed">{em.name}</p>
{isMouse && <Button className="hover:btn-error hover:text-primary-content text-base-content/40 font-normal md:text-base" onAction={handleSelect} id={`details-${em.name}`}>Details<ChevronRight /></Button>}
</div>
</div>
</div>
);
@ -71,7 +64,7 @@ export function MissingEmulatorsSection ({
onSelect,
}: {
emulators: FrontEndEmulator[];
onSelect?: (id: string, focusKey: string) => void;
onSelect?: (em: FrontEndEmulator, focusKey: string) => void;
})
{
const { ref, focusKey } = useFocusable({