feat: Implemented launching and downloading of roms

This is just an initial implementation lots of kings to iron out
This commit is contained in:
Simeon Radivoev 2026-02-19 16:10:29 +02:00
parent ef08fa6114
commit f15bf9a1e0
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
117 changed files with 37776 additions and 1073 deletions

View file

@ -0,0 +1,28 @@
import { twMerge } from "tailwind-merge";
import
{
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
export function Button (data: { id: string, children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
{
const { ref, focused } = useFocusable({
focusKey: data.id,
onEnterPress: data.onAction,
onFocus: data.onFocus,
focusable: !data.disabled
});
return <button
ref={ref}
onClick={data.onAction}
disabled={data.disabled}
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg", classNames({
"btn-accent": focused
}, data.className))}
type={data.type}
>
{data.children}
</button>;
}

View file

@ -1,7 +1,9 @@
import classNames from "classnames";
import { ChangeEventHandler, FocusEventHandler, HTMLInputTypeAttribute, JSX, useRef } from "react";
import { ChangeEventHandler, FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react";
import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
export function OptionInput (data: {
name: string;
@ -11,10 +13,18 @@ export function OptionInput (data: {
icon?: JSX.Element;
value?: string;
defaultValue?: string;
autocomplete?: HTMLInputAutoCompleteAttribute;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChange?: ChangeEventHandler<HTMLInputElement>;
})
{
const { ref, focused } = useFocusable({
focusKey: data.name, onEnterPress: () =>
{
inputRef.current?.focus();
systemApi.api.system.show_keyboard.post();
}
});
const inputRef = useRef<HTMLInputElement>(null);
const option = useOptionContext({
onOptionEnterPress ()
@ -24,10 +34,11 @@ export function OptionInput (data: {
});
return (
<label className="flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent">
<span className={twMerge("text-base-content/80", classNames({
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
classNames({ "[&_input]:not-focus:ring-7 [&_input]:not-focus:ring-accent": focused }))}>
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
"text-primary-content": option.focused
}))}>{data.icon}</span>
}))}>{data.icon}</span>}
<input
ref={inputRef}
id={data.name}
@ -35,12 +46,13 @@ export function OptionInput (data: {
value={data.value}
defaultValue={data.defaultValue}
type={data.type}
autoComplete={data.autocomplete}
onFocus={() => option.focus()}
placeholder={data.placeholder}
onChange={data.onChange}
onBlur={data.onBlur}
className={twMerge(
"input grow rounded-full ring-primary-content focus:ring-3",
"input grow rounded-full ring-primary-content focus:ring-7",
data.className,
)}
/>

View file

@ -42,8 +42,9 @@ export function OptionSpace (data: {
id?: string;
className?: string;
focusable?: boolean;
children: JSX.Element;
children?: any | any[];
label?: string | JSX.Element;
saveLastFocusedChild?: boolean;
})
{
const eventTarget = useMemo(() => new EventTarget(), []);
@ -51,6 +52,11 @@ export function OptionSpace (data: {
focusKey: data.id,
focusable: data.focusable !== false,
trackChildren: true,
saveLastFocusedChild: data.saveLastFocusedChild ?? false,
onFocus ()
{
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
},
onEnterPress ()
{
eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));

View file

@ -0,0 +1,72 @@
import { HTMLInputTypeAttribute, JSX, useCallback, useState } from "react";
import { SettingsType } from "../../../shared/constants";
import { useMutation, useQuery } from "@tanstack/react-query";
import { OptionSpace } from "./OptionSpace";
import { OptionInput } from "./OptionInput";
import { settingsApi } from "../../scripts/clientApi";
type KeysWithValueAssignableTo<T, Value> = {
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
}[keyof T];
export function SettingsOption (data: {
label: string;
id: KeysWithValueAssignableTo<SettingsType, string>;
type: HTMLInputTypeAttribute;
placeholder?: string;
icon?: JSX.Element;
})
{
const [dirty, setDirty] = useState(false);
const [localValue, setLocalValue] = useState<string | undefined>();
useQuery({
enabled: !!data.id,
queryKey: ["setting", data.id],
queryFn: async () =>
{
const { data: value, error } = await settingsApi.api.settings({ id: data.id! }).get();
if (error) throw error;
if (!dirty)
{
setLocalValue(String(value.value));
}
return value.value;
},
});
const setSettingMutation = useMutation({
mutationKey: ["setting", data.id],
mutationFn: async (value: any) =>
{
const response = await settingsApi.api.settings({ id: data.id! }).post({ value });
if (response.error) throw response.error;
return response.data;
}
});
const handleSave = useCallback(() =>
{
if (dirty)
{
setDirty(false);
setSettingMutation.mutate(localValue);
}
}, [dirty, setDirty, localValue]);
return (
<OptionSpace label={data.label}>
<OptionInput
icon={data.icon}
name={data.id ?? ""}
type={data.type}
placeholder={data.placeholder}
onBlur={handleSave}
onChange={(e) =>
{
setLocalValue(e.currentTarget.value);
setDirty(true);
}}
value={localValue}
/>
</OptionSpace>
);
}