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

@ -72,6 +72,10 @@ export default new Elysia()
} }
return status('Not Found'); return status('Not Found');
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) }) }, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) })
.get('/image', async ({ query }) =>
{
return processImage(query.url, query);
}, { query: z.object({ url: z.url(), blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) })
.get('/screenshot/:id', async ({ params: { id }, query, set }) => .get('/screenshot/:id', async ({ params: { id }, query, set }) =>
{ {
const screenshot = await db.query.screenshots.findFirst({ where: eq(schema.screenshots.id, id), columns: { content: true, type: true } }); const screenshot = await db.query.screenshots.findFirst({ where: eq(schema.screenshots.id, id), columns: { content: true, type: true } });

View file

@ -1,5 +1,5 @@
import Elysia, { status } from "elysia"; import Elysia, { status } from "elysia";
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet } from "@clients/romm"; import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
import z from "zod"; import z from "zod";
import { count, eq, getTableColumns, notInArray } from "drizzle-orm"; import { count, eq, getTableColumns, notInArray } from "drizzle-orm";
import { db } from "../app"; import { db } from "../app";
@ -22,8 +22,9 @@ export default new Elysia()
if (rommPlatforms) if (rommPlatforms)
{ {
const frontEndPlatforms = rommPlatforms.map(p => const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p =>
{ {
const game = await getRomsApiRomsGet({ query: { platform_ids: [p.id] } });
const platform: FrontEndPlatformType = { const platform: FrontEndPlatformType = {
slug: p.slug, slug: p.slug,
name: p.display_name, name: p.display_name,
@ -32,18 +33,20 @@ export default new Elysia()
game_count: p.rom_count, game_count: p.rom_count,
updated_at: new Date(p.updated_at), updated_at: new Date(p.updated_at),
id: { source: 'romm', id: p.id }, id: { source: 'romm', id: p.id },
hasLocal: localPlatformSet.has(p.slug) hasLocal: localPlatformSet.has(p.slug),
paths_screenshots: game.data?.items[0]?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`) ?? []
}; };
return platform; return platform;
}); }));
rommPlatformsSet = new Set(rommPlatforms.map(p => p.slug)); rommPlatformsSet = new Set(rommPlatforms.map(p => p.slug));
platforms.push(...frontEndPlatforms); platforms.push(...frontEndPlatforms);
} }
platforms.push(...localPlatforms.filter(p => !rommPlatformsSet?.has(p.slug)).map(p => platforms.push(...await Promise.all(localPlatforms.filter(p => !rommPlatformsSet?.has(p.slug)).map(async p =>
{ {
const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id), with: { screenshots: true }, columns: {} });
const platform: FrontEndPlatformType = { const platform: FrontEndPlatformType = {
slug: p.slug, slug: p.slug,
name: p.name, name: p.name,
@ -52,11 +55,13 @@ export default new Elysia()
game_count: p.game_count, game_count: p.game_count,
updated_at: p.created_at, updated_at: p.created_at,
id: { source: 'local', id: p.id }, id: { source: 'local', id: p.id },
hasLocal: true hasLocal: true,
paths_screenshots: game?.screenshots?.map(s => `/api/romm/screenshot/${s.id}`) ?? []
}; };
return platform; return platform;
})); })));
return { platforms }; return { platforms };
}).get('/platforms/:source/:id', async ({ params: { source, id } }) => }).get('/platforms/:source/:id', async ({ params: { source, id } }) =>

View file

@ -24,7 +24,7 @@ export default async function init (events: EventEmitter, forceBrowser: boolean)
async function runWebview (events: EventEmitter) async function runWebview (events: EventEmitter)
{ {
const webviewWorker = new Worker(Bun.env.IS_BINARY ? new URL(`./webview/${os.platform()}`, import.meta.url).href : `./webview/${os.platform()}.ts`, { const webviewWorker = new Worker(new URL(`./webview/${os.platform()}`, import.meta.url).href, {
smol: true, smol: true,
ref: false ref: false
}); });

View file

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

View file

@ -15,7 +15,7 @@ export function ContextList (data: { options?: DialogEntry[]; className?: string
const context = useContext(ContextDialogContext); const context = useContext(ContextDialogContext);
return <ul className={twMerge("list", data.className)}> return <ul className={twMerge("list", data.className)}>
{data.options?.map(o => <OptionElement className="list-row" key={o.id} {...o} />)} {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>; </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 handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({ const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
focusKey: `${context.id}-list-option-${data.id}`, focusKey: `${context.id}-list-option-${data.id}`,
onEnterPress: data.shortcuts ? handleAction : undefined, onEnterPress: data.shortcuts ? undefined : handleAction,
onFocus: handleFocus, onFocus: handleFocus,
trackChildren: typeof data.content !== 'string' 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")}> twMerge("flex cursor-pointer sm:text-sm md:text-base")}>
<FocusContext value={focusKey}> <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", <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 }), classNames({ "font-semibold": focused || hasFocusedChild }),
data.className)}> data.className,
colors[data.type])}>
{data.icon} {data.icon}
{data.content} {data.content}
</div> </div>
@ -75,7 +75,8 @@ export interface DialogEntry
export function ContextDialog (data: { export function ContextDialog (data: {
id: string, id: string,
children: any | any[], children: any | any[],
open: boolean, close: () => void; open: boolean,
close: () => void;
className?: string; className?: string;
preferredChildFocusKey?: string; preferredChildFocusKey?: string;
}) })

View file

@ -8,6 +8,7 @@ import { HardDrive } from "lucide-react";
import { JSX } from "react"; import { JSX } from "react";
import { GameCardFocusHandler } from "./GameCard"; import { GameCardFocusHandler } from "./GameCard";
import { gameQuery } from "../scripts/queries"; import { gameQuery } from "../scripts/queries";
import { useLocalSetting } from "../scripts/utils";
export interface GameListParams export interface GameListParams
{ {
@ -30,6 +31,7 @@ export function GameList (data: GameListParams)
}); });
const navigator = useNavigate(); const navigator = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const blur = useLocalSetting('backgroundBlur');
const handleFocus = (id: FrontEndId, source: string | null, sourceId: number | null) => 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 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 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'); previewUrl.searchParams.delete('ts');
data.setBackground?.(previewUrl.href); data.setBackground?.(previewUrl.href);
//queryClient.prefetchQuery(gameQuery(source ?? id.source, sourceId ?? id.id));
} catch } catch
{ {

View file

@ -42,7 +42,7 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
previewUrl: "", previewUrl: "",
badges, badges,
onFocus: () => data.setBackground( 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: () => 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 { useOptionContext } from "./OptionSpace";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi"; import { systemApi } from "../../scripts/clientApi";
import { Check, CheckIcon, X } from "lucide-react";
export function OptionInput (data: { export function OptionInput (data: {
name: string; name: string;
@ -12,24 +13,28 @@ export function OptionInput (data: {
placeholder?: string; placeholder?: string;
icon?: JSX.Element; icon?: JSX.Element;
value?: string; value?: string;
defaultValue?: string; defaultValue?: string | boolean;
autocomplete?: HTMLInputAutoCompleteAttribute; autocomplete?: HTMLInputAutoCompleteAttribute;
onBlur?: FocusEventHandler<HTMLInputElement>; onBlur?: FocusEventHandler<HTMLInputElement>;
onChange?: ChangeEventHandler<HTMLInputElement>; onChange?: (value: any) => void;
}) })
{ {
const { ref, focused } = useFocusable({ const handlePress = () =>
focusKey: data.name, onEnterPress: () => {
if (data.type === 'checkbox')
{
inputRef.current?.click();
} else
{ {
inputRef.current?.focus(); inputRef.current?.focus();
} }
};
const { ref, focused } = useFocusable({
focusKey: data.name, onEnterPress: handlePress
}); });
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const option = useOptionContext({ const option = useOptionContext({
onOptionEnterPress () onOptionEnterPress: handlePress,
{
inputRef.current?.focus();
},
}); });
const handleFocus = () => const handleFocus = () =>
{ {
@ -44,32 +49,60 @@ export function OptionInput (data: {
Height: rect.height Height: rect.height
}); });
} }
}; };
return ( return (
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent", <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({ {!!data.icon && <span className={twMerge("text-base-content/80", classNames({
"text-primary-content": option.focused "text-primary-content": option.focused
}))}>{data.icon}</span>} }))}>{data.icon}</span>}
{data.type !== 'checkbox' && <input
ref={inputRef}
id={data.name}
data-focus={"input"}
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(
"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 <input
ref={inputRef} ref={inputRef}
id={data.name} id={data.name}
name={data.name} name={data.name}
value={data.value} value={data.value}
defaultValue={data.defaultValue} defaultValue={typeof data.defaultValue === 'string' ? data.defaultValue : undefined}
type={data.type} type={data.type}
autoComplete={data.autocomplete} autoComplete={data.autocomplete}
onFocus={handleFocus} onFocus={handleFocus}
placeholder={data.placeholder} placeholder={data.placeholder}
onChange={data.onChange} onChange={e => data.onChange?.(typeof data.defaultValue === 'boolean' ? e.target.checked : e.target.value)}
onBlur={data.onBlur} onBlur={data.onBlur}
defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined}
className={twMerge( className={twMerge(
"input grow rounded-full ring-primary-content focus:ring-7", data.className
data.className,
)} )}
/> />
<X />
<CheckIcon />
</div>}
</label> </label>
); );
} }

View file

@ -43,7 +43,7 @@ export function OptionSpace (data: {
className?: string; className?: string;
focusable?: boolean; focusable?: boolean;
children?: any | any[]; children?: any | any[];
label?: string | JSX.Element; label?: string | JSX.Element | ((focused: boolean) => JSX.Element);
saveLastFocusedChild?: boolean; saveLastFocusedChild?: boolean;
}) })
{ {
@ -62,32 +62,37 @@ export function OptionSpace (data: {
eventTarget.dispatchEvent(new CustomEvent("onEnterPress")); 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}> return (<FocusContext value={focusKey}>
<OptionContext value={{ focused, focus: focusSelf, eventTarget }}> <OptionContext value={{ focused, focus: focusSelf, eventTarget }}>
<li <li
ref={ref} 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( 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(
{ {
"text-primary-content bg-primary ": focused || hasFocusedChild, "bg-base-300": focused || hasFocusedChild,
}), }),
data.className, data.className,
)} )}
> >
<div className="label flex-1 md:text-lg pr-4"> {!!labelElement && <div className="flex gap-2 items-center flex-1 md:text-lg pr-4">
{typeof data.label === "string" ? ( {labelElement}
<label </div>}
className={classNames({ <div className="flex flex-1 justify-end-safe">
"text-primary-content font-semibold": focused,
})}
>
{data.label}
</label>
) : (
data.label
)}
</div>
<div className="flex grow justify-end-safe">
{data.children} {data.children}
</div> </div>
</li> </li>

View file

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './../routes/__root' import { Route as rootRouteImport } from './../routes/__root'
import { Route as SettingsRouteRouteImport } from './../routes/settings/route' import { Route as SettingsRouteRouteImport } from './../routes/settings/route'
import { Route as IndexRouteImport } from './../routes/index' import { Route as IndexRouteImport } from './../routes/index'
import { Route as SettingsInterfaceRouteImport } from './../routes/settings/interface'
import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emulators' import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emulators'
import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories' import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories'
import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts' import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts'
@ -30,6 +31,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SettingsInterfaceRoute = SettingsInterfaceRouteImport.update({
id: '/interface',
path: '/interface',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsEmulatorsRoute = SettingsEmulatorsRouteImport.update({ const SettingsEmulatorsRoute = SettingsEmulatorsRouteImport.update({
id: '/emulators', id: '/emulators',
path: '/emulators', path: '/emulators',
@ -79,6 +85,7 @@ export interface FileRoutesByFullPath {
'/settings/accounts': typeof SettingsAccountsRoute '/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute '/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute '/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/game/$source/$id': typeof GameSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute
@ -91,6 +98,7 @@ export interface FileRoutesByTo {
'/settings/accounts': typeof SettingsAccountsRoute '/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute '/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute '/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/game/$source/$id': typeof GameSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute
@ -104,6 +112,7 @@ export interface FileRoutesById {
'/settings/accounts': typeof SettingsAccountsRoute '/settings/accounts': typeof SettingsAccountsRoute
'/settings/directories': typeof SettingsDirectoriesRoute '/settings/directories': typeof SettingsDirectoriesRoute
'/settings/emulators': typeof SettingsEmulatorsRoute '/settings/emulators': typeof SettingsEmulatorsRoute
'/settings/interface': typeof SettingsInterfaceRoute
'/game/$source/$id': typeof GameSourceIdRoute '/game/$source/$id': typeof GameSourceIdRoute
'/launcher/$source/$id': typeof LauncherSourceIdRoute '/launcher/$source/$id': typeof LauncherSourceIdRoute
'/platform/$source/$id': typeof PlatformSourceIdRoute '/platform/$source/$id': typeof PlatformSourceIdRoute
@ -118,6 +127,7 @@ export interface FileRouteTypes {
| '/settings/accounts' | '/settings/accounts'
| '/settings/directories' | '/settings/directories'
| '/settings/emulators' | '/settings/emulators'
| '/settings/interface'
| '/game/$source/$id' | '/game/$source/$id'
| '/launcher/$source/$id' | '/launcher/$source/$id'
| '/platform/$source/$id' | '/platform/$source/$id'
@ -130,6 +140,7 @@ export interface FileRouteTypes {
| '/settings/accounts' | '/settings/accounts'
| '/settings/directories' | '/settings/directories'
| '/settings/emulators' | '/settings/emulators'
| '/settings/interface'
| '/game/$source/$id' | '/game/$source/$id'
| '/launcher/$source/$id' | '/launcher/$source/$id'
| '/platform/$source/$id' | '/platform/$source/$id'
@ -142,6 +153,7 @@ export interface FileRouteTypes {
| '/settings/accounts' | '/settings/accounts'
| '/settings/directories' | '/settings/directories'
| '/settings/emulators' | '/settings/emulators'
| '/settings/interface'
| '/game/$source/$id' | '/game/$source/$id'
| '/launcher/$source/$id' | '/launcher/$source/$id'
| '/platform/$source/$id' | '/platform/$source/$id'
@ -172,6 +184,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/settings/interface': {
id: '/settings/interface'
path: '/interface'
fullPath: '/settings/interface'
preLoaderRoute: typeof SettingsInterfaceRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/emulators': { '/settings/emulators': {
id: '/settings/emulators' id: '/settings/emulators'
path: '/emulators' path: '/emulators'
@ -236,6 +255,7 @@ interface SettingsRouteRouteChildren {
SettingsAccountsRoute: typeof SettingsAccountsRoute SettingsAccountsRoute: typeof SettingsAccountsRoute
SettingsDirectoriesRoute: typeof SettingsDirectoriesRoute SettingsDirectoriesRoute: typeof SettingsDirectoriesRoute
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
SettingsInterfaceRoute: typeof SettingsInterfaceRoute
} }
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = { const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
@ -243,6 +263,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
SettingsAccountsRoute: SettingsAccountsRoute, SettingsAccountsRoute: SettingsAccountsRoute,
SettingsDirectoriesRoute: SettingsDirectoriesRoute, SettingsDirectoriesRoute: SettingsDirectoriesRoute,
SettingsEmulatorsRoute: SettingsEmulatorsRoute, SettingsEmulatorsRoute: SettingsEmulatorsRoute,
SettingsInterfaceRoute: SettingsInterfaceRoute,
} }
const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren( const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(

View file

@ -2,9 +2,12 @@
@import 'animate.css'; @import 'animate.css';
@plugin "daisyui"; @plugin "daisyui";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@theme { @theme {
--breakpoint-sm: 0px; --breakpoint-sm: 0px;
--breakpoint-md: 1280px; --breakpoint-md: 1280px;
--page-scroll-bg: transparent;
--animate-wiggle: wiggle 0.3s ease-in-out 1; --animate-wiggle: wiggle 0.3s ease-in-out 1;
--animate-rotate: rotate 0.3s ease-in-out 1 0.2s; --animate-rotate: rotate 0.3s ease-in-out 1 0.2s;

View file

@ -49,8 +49,6 @@ export const Router = createRouter({
}, },
}); });
// Register things for typesafety // Register things for typesafety
declare module "@tanstack/react-router" { declare module "@tanstack/react-router" {
interface Register interface Register

View file

@ -4,7 +4,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { RouterContext } from ".."; import { RouterContext } from "..";
import Notifications from "../components/Notifications"; import Notifications from "../components/Notifications";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import { mobileCheck } from "../scripts/utils"; import { mobileCheck, useLocalSetting } from "../scripts/utils";
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent, component: RootComponent,
@ -13,9 +13,10 @@ export const Route = createRootRouteWithContext<RouterContext>()({
function RootComponent () function RootComponent ()
{ {
const isMobile = mobileCheck(); const isMobile = mobileCheck();
const theme = useLocalSetting('theme');
return ( return (
<div className="w-screen h-screen overflow-hidden"> <div data-theme={theme === 'auto' ? undefined : theme} className="w-screen h-screen overflow-hidden">
<Outlet /> <Outlet />
<Notifications /> <Notifications />
<Toaster containerStyle={{ viewTimelineName: 'toasters' }} /> <Toaster containerStyle={{ viewTimelineName: 'toasters' }} />

View file

@ -113,6 +113,7 @@ function RouteComponent ()
{ {
const { focus } = Route.useSearch(); const { focus } = Route.useSearch();
const { ref, focusKey, focusSelf } = useFocusable({ const { ref, focusKey, focusSelf } = useFocusable({
focusKey: "accounts",
preferredChildFocusKey: focus preferredChildFocusKey: focus
}); });

View file

@ -41,7 +41,7 @@ function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; r
return <li ref={ref} className={twMerge('flex flex-row p-4 bg-base-300 rounded-2xl gap-1 items-end', return <li ref={ref} className={twMerge('flex flex-row p-4 bg-base-300 rounded-2xl gap-1 items-end',
classNames({ classNames({
"ring-7": focused, "ring-7 ring-accent": focused,
"border-dashed border-primary border-4": data.drive.isCurrentlyUsed, "border-dashed border-primary border-4": data.drive.isCurrentlyUsed,
"border-solid": data.drive.unusableReason === 'already_used', "border-solid": data.drive.unusableReason === 'already_used',
"ring-error": data.drive.unusableReason === 'not_enough_space', "ring-error": data.drive.unusableReason === 'not_enough_space',
@ -75,6 +75,7 @@ function RouteComponent ()
{ {
const { focus } = Route.useSearch(); const { focus } = Route.useSearch();
const { ref, focusKey, focusSelf } = useFocusable({ const { ref, focusKey, focusSelf } = useFocusable({
focusKey: "directories",
preferredChildFocusKey: focus preferredChildFocusKey: focus
}); });

View file

@ -32,11 +32,14 @@ function EmulatorsPending ()
function EmulatorListCat (data: { selected: string, set: (c: string) => void; }) function EmulatorListCat (data: { selected: string, set: (c: string) => void; })
{ {
const { ref, focusKey } = useFocusable({ focusKey: 'categories' }); const { ref, focused, focusKey } = useFocusable({ focusKey: 'categories' });
return <ul className='flex gap-1' ref={ref}> return <ul className='flex gap-1' ref={ref}>
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
{[..."ABCDEFGHIJKLMNOPQRSTVWXYZ"].map(c => {[..."ABCDEFGHIJKLMNOPQRSTVWXYZ"].map(c =>
<OptionElement key={c} className={classNames('p-2 justify-center size-8 text-base-content bg-base-300 text-lg', { "ring-4 ring-primary": data.selected === c })} onFocus={() => data.set(c)} content={c} id={c} action={(ctx) => ctx.focus()} type="primary" /> <OptionElement key={c} className={twMerge('p-2 justify-center size-8 text-base-content bg-base-300 text-lg',
classNames({
"ring-4 ring-primary": data.selected === c,
}))} onFocus={() => data.set(c)} content={c} id={c} action={(ctx) => ctx.focus()} type="primary" />
)} )}
</FocusContext> </FocusContext>
</ul>; </ul>;
@ -47,7 +50,7 @@ function EmulatorListType (data: { category: string, action: (e: string) => void
const { ref, focusKey } = useFocusable({ focusKey: 'list-section' }); const { ref, focusKey } = useFocusable({ focusKey: 'list-section' });
return <div ref={ref} className='grow'> return <div ref={ref} className='grow'>
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
<ContextList className='h-[60vh]' options={Object.keys(emulators).filter(e => e.startsWith(data.category)).map(e => ({ <ContextList className='sm:h-[80vh] md:h-[60vh] overflow-auto' options={Object.keys(emulators).filter(e => e.startsWith(data.category)).map(e => ({
id: e, id: e,
action: (ctx) => action: (ctx) =>
{ {
@ -152,7 +155,12 @@ function EmulatorPath (data: { id: string; })
}; };
return ( return (
<OptionSpace label={<><p className='font-semibold'>{data.id}</p><small className='text-base-content/40'>{emulators[data.id]}</small></>}> <OptionSpace label={
focus => <>
<p className='font-semibold'>{data.id}</p>
<small className='opacity-40'>{emulators[data.id]}</small>
</>
}>
<div className='flex gap-2'> <div className='flex gap-2'>
<OptionInput <OptionInput
name={data.id ?? ""} name={data.id ?? ""}
@ -160,9 +168,9 @@ function EmulatorPath (data: { id: string; })
onBlur={handleSave} onBlur={handleSave}
autocomplete="off" autocomplete="off"
defaultValue={remoteValue} defaultValue={remoteValue}
onChange={(e) => onChange={(v) =>
{ {
setLocalValue(e.currentTarget.value); setLocalValue(v);
setDirty(true); setDirty(true);
}} }}
value={localValue} value={localValue}
@ -223,8 +231,9 @@ function EmulatorBadge (data: {
<div ref={ref} className={ <div ref={ref} className={
twMerge('flex flex-col rounded-3xl bg-base-300 w-64 h-16 justify-center items-center p-4 overflow-hidden', twMerge('flex flex-col rounded-3xl bg-base-300 w-64 h-16 justify-center items-center p-4 overflow-hidden',
classNames({ classNames({
"bg-base-200/50": !data.path, "bg-base-200": !data.path,
"border-dashed border-base-content/40 border-2": focused "border-dashed border-base-content/40 border-2": !data.path && !focused,
"border-dashed border-accent border-4": focused
})) }))
}> }>
@ -253,6 +262,7 @@ function RouteComponent ()
{ {
const { focus } = Route.useSearch(); const { focus } = Route.useSearch();
const { ref, focusKey, focusSelf } = useFocusable({ const { ref, focusKey, focusSelf } = useFocusable({
focusKey: "emulators-setting",
preferredChildFocusKey: focus preferredChildFocusKey: focus
}); });

View file

@ -0,0 +1,23 @@
import { LocalOption } from '@/mainview/components/options/LocalOption';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/settings/interface')({
component: RouteComponent,
});
function RouteComponent ()
{
const { focus } = Route.useSearch();
const { ref, focusKey, focusSelf } = useFocusable({
focusKey: "interface-settings",
preferredChildFocusKey: focus
});
return <ul ref={ref} className="list rounded-box gap-2">
<FocusContext value={focusKey}>
<LocalOption id="backgroundBlur" label="Background Blur" type='checkbox'></LocalOption>
<LocalOption id="theme" label="Theme" type='dropdown' values={['dark', 'light', 'auto']}></LocalOption>
</FocusContext>
</ul>;
}

View file

@ -83,7 +83,7 @@ function MenuItem (data: {
"group rounded-full p-3 md:pl-5 text-base-content/80", "group rounded-full p-3 md:pl-5 text-base-content/80",
classNames({ classNames({
"bg-primary text-primary-content": acitve, "bg-primary text-primary-content": acitve,
"font-semibold sm:ring-4 md:ring-7 ring-primary-content": focused && !isPointer, "font-semibold sm:ring-4 md:ring-7 ring-accent": focused && !isPointer,
"bg-secondary text-secondary-content ring-primary": data.return && focused, "bg-secondary text-secondary-content ring-primary": data.return && focused,
}), }),
data.linkClassName, data.linkClassName,
@ -110,7 +110,7 @@ function SettingsMenu (data: {})
return <ul return <ul
ref={ref} ref={ref}
className="menu portrait:menu-horizontal md:menu-xl landscape:flex-nowrap bg-base-200 sm:p-2 md:p-4 sm:portrait:gap-0 sm:landscape:gap-0 md:gap-2! rounded-4xl overflow-auto portrait:w-full" className="menu portrait:menu-horizontal md:menu-xl landscape:flex-nowrap bg-base-200 sm:p-2 md:p-4 sm:portrait:gap-0 sm:landscape:gap-0 md:landscape:w-128 md:gap-2! rounded-4xl overflow-auto portrait:w-full"
> >
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
<MenuItem <MenuItem
@ -184,7 +184,7 @@ export function SettingsUI ()
return ( return (
<FocusContext.Provider value={focusKey}> <FocusContext.Provider value={focusKey}>
<div ref={ref} className="flex flex-col w-full h-full md:p-4 bg-base-100"> <div ref={ref} className="bg-base-100 flex flex-col w-full h-full md:p-4">
<div className="flex landscape:flex-row portrait:flex-col-reverse grow overflow-hidden"> <div className="flex landscape:flex-row portrait:flex-col-reverse grow overflow-hidden">
<div id="Menu" className="flex flex-row landscape:h-full md:landscape:w-56"> <div id="Menu" className="flex flex-row landscape:h-full md:landscape:w-56">
<SettingsMenu /> <SettingsMenu />

View file

@ -1,5 +1,7 @@
import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants";
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { RefObject, useEffect, useState } from "react"; import { RefObject, useEffect, useState } from "react";
import { useLocalStorage } from "usehooks-ts";
export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void) export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void)
{ {
@ -64,6 +66,12 @@ export function mobileCheck ()
return check; return check;
}; };
export function useLocalSetting (key: keyof LocalSettingsType)
{
const [localValue] = useLocalStorage(key, LocalSettingsSchema.shape[key].parse(undefined), { deserializer: (value) => LocalSettingsSchema.shape[key].parse(JSON.parse(value)) });
return localValue;
}
export function useAsyncGenerator<T> ( export function useAsyncGenerator<T> (
generator: AsyncGenerator<T> | undefined, generator: AsyncGenerator<T> | undefined,
deps: any[] deps: any[]

View file

@ -30,6 +30,11 @@ export const SettingsSchema = z.object({
downloadPath: z.string() downloadPath: z.string()
}); });
export const LocalSettingsSchema = z.object({
backgroundBlur: z.stringbool().or(z.boolean()).default(true),
theme: z.enum(['dark', 'light', 'auto']).default('auto')
});
export const GameListFilterSchema = z.object({ export const GameListFilterSchema = z.object({
platform_source: z.string().optional(), platform_source: z.string().optional(),
platform_slug: z.string().optional(), platform_slug: z.string().optional(),
@ -60,6 +65,7 @@ export interface FrontEndPlatformType
game_count: number; game_count: number;
updated_at: Date; updated_at: Date;
hasLocal: boolean; hasLocal: boolean;
paths_screenshots: string[];
} }
export interface FrontEndGameType export interface FrontEndGameType
@ -126,6 +132,7 @@ export interface Notification
} }
export type SettingsType = z.infer<typeof SettingsSchema>; export type SettingsType = z.infer<typeof SettingsSchema>;
export type LocalSettingsType = z.infer<typeof LocalSettingsSchema>;
export interface GameInstallProgress export interface GameInstallProgress
{ {
progress?: number; progress?: number;