feat: Added interface options

This commit is contained in:
Simeon Radivoev 2026-03-04 13:18:18 +02:00
parent 4739b89933
commit 2f32cbc730
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
25 changed files with 327 additions and 74 deletions

View file

@ -3,6 +3,7 @@ import classNames from 'classnames';
import { createContext, JSX, Ref, useContext, useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { useSessionStorage } from 'usehooks-ts';
import { useLocalSetting } from '../scripts/utils';
export const AnimatedBackgroundContext = createContext({} as { setBackground: (url: string) => void; });
@ -28,8 +29,13 @@ export function AnimatedBackground (data: {
setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined);
}, [data.backgroundUrl]);
const finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined;
const blur = localStorage.getItem('background-blur') !== "false";
let finalBackgroundUrl;
try
{
finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined;
} catch { }
const blur = useLocalSetting('backgroundBlur');
if (blur)
{
if (!finalBackgroundUrl?.searchParams.has('blur'))

View file

@ -15,7 +15,7 @@ export function ContextList (data: { options?: DialogEntry[]; className?: string
const context = useContext(ContextDialogContext);
return <ul className={twMerge("list", data.className)}>
{data.options?.map(o => <OptionElement className="list-row" key={o.id} {...o} />)}
{data.showCloseButton !== false && <OptionElement className="list-row" type='accent' icon={<X />} action={context.close} id="close" content="Close" />}
{data.showCloseButton !== false && <OptionElement className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
</ul>;
}
@ -30,7 +30,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
focusKey: `${context.id}-list-option-${data.id}`,
onEnterPress: data.shortcuts ? handleAction : undefined,
onEnterPress: data.shortcuts ? undefined : handleAction,
onFocus: handleFocus,
trackChildren: typeof data.content !== 'string'
});
@ -52,9 +52,9 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
twMerge("flex cursor-pointer sm:text-sm md:text-base")}>
<FocusContext value={focusKey}>
<div className={twMerge("flex w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl transition-all gap-2",
colors[data.type],
classNames({ "font-semibold": focused || hasFocusedChild }),
data.className)}>
data.className,
colors[data.type])}>
{data.icon}
{data.content}
</div>
@ -75,7 +75,8 @@ export interface DialogEntry
export function ContextDialog (data: {
id: string,
children: any | any[],
open: boolean, close: () => void;
open: boolean,
close: () => void;
className?: string;
preferredChildFocusKey?: string;
})

View file

@ -8,6 +8,7 @@ import { HardDrive } from "lucide-react";
import { JSX } from "react";
import { GameCardFocusHandler } from "./GameCard";
import { gameQuery } from "../scripts/queries";
import { useLocalSetting } from "../scripts/utils";
export interface GameListParams
{
@ -30,6 +31,7 @@ export function GameList (data: GameListParams)
});
const navigator = useNavigate();
const queryClient = useQueryClient();
const blur = useLocalSetting('backgroundBlur');
const handleFocus = (id: FrontEndId, source: string | null, sourceId: number | null) =>
{
@ -40,10 +42,9 @@ export function GameList (data: GameListParams)
{
const screenshotUrl = new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`);
const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_cover}`);
const previewUrl = localStorage.getItem('background-blur') !== "false" ? coverUrl : screenshotUrl;
const previewUrl = blur ? coverUrl : screenshotUrl;
previewUrl.searchParams.delete('ts');
data.setBackground?.(previewUrl.href);
//queryClient.prefetchQuery(gameQuery(source ?? id.source, sourceId ?? id.id));
} catch
{

View file

@ -42,7 +42,7 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
previewUrl: "",
badges,
onFocus: () => data.setBackground(
`https://picsum.photos/id/${10 + i}/100/100.webp?blur=10`,
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: () =>
{

View file

@ -0,0 +1,59 @@
import { HTMLInputTypeAttribute, JSX } from "react";
import { LocalSettingsSchema, LocalSettingsType } from "../../../shared/constants";
import { OptionSpace } from "./OptionSpace";
import { OptionInput } from "./OptionInput";
import { useLocalStorage } from "usehooks-ts";
import { OptionDropdown } from "./OptionDropdown";
export function LocalOption (data: {
label: string;
id: keyof LocalSettingsType;
type: HTMLInputTypeAttribute | 'dropdown';
placeholder?: string;
values?: string[];
icon?: JSX.Element;
children?: any;
})
{
const [localValue, setLocalValue] = useLocalStorage<any>(data.id, LocalSettingsSchema.shape[data.id].parse(undefined), { deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) });
return (
<OptionSpace label={data.label}>
{data.type === 'dropdown' && data.values && <OptionDropdown values={data.values} icon={data.icon}
name={data.id ?? ""}
type={data.type}
placeholder={data.placeholder}
defaultValue={localValue}
onChange={(v) =>
{
if (data.type === 'checkbox')
{
setLocalValue(v);
} else
{
setLocalValue(v);
}
}}
value={localValue} />}
{data.type !== 'dropdown' && <OptionInput
icon={data.icon}
name={data.id ?? ""}
type={data.type}
placeholder={data.placeholder}
defaultValue={localValue}
onChange={(v) =>
{
if (data.type === 'checkbox')
{
setLocalValue(v);
} else
{
setLocalValue(v);
}
}}
value={localValue}
/>}
{data.children}
</OptionSpace>
);
}

View file

@ -0,0 +1,67 @@
import classNames from "classnames";
import { ChangeEventHandler, FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog";
import { ChevronDown } from "lucide-react";
export function OptionDropdown (data: {
name: string;
type: HTMLInputTypeAttribute;
className?: string;
placeholder?: string;
icon?: JSX.Element;
value?: string;
values: string[];
defaultValue?: string | boolean;
autocomplete?: HTMLInputAutoCompleteAttribute;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChange?: (value: any) => void;
})
{
const [open, setOpen] = useState(false);
const handlePress = () =>
{
setOpen(true);
};
const handleClose = () => setOpen(false);
const { ref, focused, focusKey } = useFocusable({
focusKey: data.name, onEnterPress: handlePress
});
const inputRef = useRef<HTMLInputElement>(null);
const option = useOptionContext({
onOptionEnterPress: handlePress,
});
const valueIndex = data.value ? data.values?.indexOf(data.value) : -1;
return (
<>
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
classNames({ "[&_button]:not-focus:ring-7 [&_button]:not-focus:ring-accent": focused }))}>
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
"text-primary-content": option.focused
}))}>{data.icon}</span>}
<button onClick={() =>
{
console.log("Open");
setOpen(true);
}} className={classNames('btn input rounded-full cursor-pointer grow', { "bg-base-200": !focused })}>{data.value}<ChevronDown /></button>
</label>
{open && <ContextDialog id={`${data.name}-context`} open={true} close={handleClose}>
<ContextList options={data.values.map((v, i) => ({
content: v,
id: String(i),
type: 'primary',
action: () =>
{
data.onChange?.(v);
setOpen(false);
}
} satisfies DialogEntry))} />
</ContextDialog>}
</>
);
}

View file

@ -4,6 +4,7 @@ import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
import { Check, CheckIcon, X } from "lucide-react";
export function OptionInput (data: {
name: string;
@ -12,24 +13,28 @@ export function OptionInput (data: {
placeholder?: string;
icon?: JSX.Element;
value?: string;
defaultValue?: string;
defaultValue?: string | boolean;
autocomplete?: HTMLInputAutoCompleteAttribute;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChange?: ChangeEventHandler<HTMLInputElement>;
onChange?: (value: any) => void;
})
{
const { ref, focused } = useFocusable({
focusKey: data.name, onEnterPress: () =>
const handlePress = () =>
{
if (data.type === 'checkbox')
{
inputRef.current?.click();
} else
{
inputRef.current?.focus();
}
};
const { ref, focused } = useFocusable({
focusKey: data.name, onEnterPress: handlePress
});
const inputRef = useRef<HTMLInputElement>(null);
const option = useOptionContext({
onOptionEnterPress ()
{
inputRef.current?.focus();
},
onOptionEnterPress: handlePress,
});
const handleFocus = () =>
{
@ -44,32 +49,60 @@ export function OptionInput (data: {
Height: rect.height
});
}
};
return (
<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 }))}>
classNames({ "[&_.focus-target]:not-focus:ring-7 [&_.focus-target]:not-focus:ring-accent": focused, "pl-1": data.type === 'checkbox' }))}>
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
"text-primary-content": option.focused
}))}>{data.icon}</span>}
<input
{data.type !== 'checkbox' && <input
ref={inputRef}
id={data.name}
data-focus={"input"}
name={data.name}
value={data.value}
defaultValue={data.defaultValue}
defaultValue={typeof data.defaultValue === 'string' ? data.defaultValue : undefined}
type={data.type}
autoComplete={data.autocomplete}
onFocus={handleFocus}
placeholder={data.placeholder}
onChange={data.onChange}
onChange={e => data.onChange?.(typeof data.defaultValue === 'boolean' ? e.target.checked : e.target.value)}
onBlur={data.onBlur}
defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined}
className={twMerge(
"input grow rounded-full ring-primary-content focus:ring-7",
data.className,
"focus-target text-base-content",
"input grow rounded-full ring-primary-content focus:ring-7", classNames({
"bg-base-200": !focused
}),
data.className
)}
/>
/>}
{data.type === 'checkbox' && <div className={classNames("toggle focus-target toggle-primary toggle-xl border-base-content/30 rounded-full before:rounded-full text-base-content", {
"bg-base-200": !focused,
"border-0": focused,
})}>
<input
ref={inputRef}
id={data.name}
name={data.name}
value={data.value}
defaultValue={typeof data.defaultValue === 'string' ? data.defaultValue : undefined}
type={data.type}
autoComplete={data.autocomplete}
onFocus={handleFocus}
placeholder={data.placeholder}
onChange={e => data.onChange?.(typeof data.defaultValue === 'boolean' ? e.target.checked : e.target.value)}
onBlur={data.onBlur}
defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined}
className={twMerge(
data.className
)}
/>
<X />
<CheckIcon />
</div>}
</label>
);
}

View file

@ -43,7 +43,7 @@ export function OptionSpace (data: {
className?: string;
focusable?: boolean;
children?: any | any[];
label?: string | JSX.Element;
label?: string | JSX.Element | ((focused: boolean) => JSX.Element);
saveLastFocusedChild?: boolean;
})
{
@ -62,32 +62,37 @@ export function OptionSpace (data: {
eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));
},
});
let labelElement: any = data.label;
if (data.label instanceof Function)
{
labelElement = data.label(focused);
} else if (typeof data.label === 'string')
{
labelElement = <label
className={classNames({
"font-semibold": focused,
})}
>
{data.label}
</label>;
}
return (<FocusContext value={focusKey}>
<OptionContext value={{ focused, focus: focusSelf, eventTarget }}>
<li
ref={ref}
className={twMerge("flex portrait:flex-col portrait:gap-2 portrait:p-4 md:flex-row sm:p-2 md:p-4 md:pl-8! portrait:rounded-3xl landscape:rounded-full bg-base-content/1", classNames(
{
"text-primary-content bg-primary ": focused || hasFocusedChild,
}),
className={twMerge("flex portrait:flex-col portrait:gap-2 portrait:p-4 md:flex-row sm:p-2 md:p-4 md:pl-8! rounded-3xl border-b border-base-content/5",
classNames(
{
"bg-base-300": focused || hasFocusedChild,
}),
data.className,
)}
>
<div className="label flex-1 md:text-lg pr-4">
{typeof data.label === "string" ? (
<label
className={classNames({
"text-primary-content font-semibold": focused,
})}
>
{data.label}
</label>
) : (
data.label
)}
</div>
<div className="flex grow justify-end-safe">
{!!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">
{data.children}
</div>
</li>

View file

@ -119,7 +119,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
onBlur={handleInputBlur}
onChange={(e) =>
{
data.setLocalValue(e.currentTarget.value);
data.setLocalValue(e);
}}
value={data.localValue}
/>

View file

@ -3,7 +3,6 @@ import { HTMLInputTypeAttribute, JSX } from "react";
import { OptionInput } from "./OptionInput";
import { OptionSpace } from "./OptionSpace";
import classNames from "classnames";
import { TriangleAlert } from "lucide-react";
// export useFieldContext for use in your custom components
export const { fieldContext, formContext, useFieldContext } =
@ -30,7 +29,7 @@ function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; l
name={field.name}
value={field.state.value}
type={data.type}
onChange={e => field.handleChange(e.target.value)}
onChange={v => field.handleChange(v)}
placeholder={data.placeholder}
className={classNames({ " flex-3 ring-4 ring-accent": field.getMeta().isDirty })}
/>

View file

@ -61,9 +61,9 @@ export function SettingsOption (data: {
type={data.type}
placeholder={data.placeholder}
onBlur={handleSave}
onChange={(e) =>
onChange={(v) =>
{
setLocalValue(e.currentTarget.value);
setLocalValue(v);
setDirty(true);
}}
value={localValue}