feat: Added interface options
This commit is contained in:
parent
4739b89933
commit
2f32cbc730
25 changed files with 327 additions and 74 deletions
|
|
@ -72,6 +72,10 @@ export default new Elysia()
|
|||
}
|
||||
return status('Not Found');
|
||||
}, { 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 }) =>
|
||||
{
|
||||
const screenshot = await db.query.screenshots.findFirst({ where: eq(schema.screenshots.id, id), columns: { content: true, type: true } });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet } from "@clients/romm";
|
||||
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
|
||||
import z from "zod";
|
||||
import { count, eq, getTableColumns, notInArray } from "drizzle-orm";
|
||||
import { db } from "../app";
|
||||
|
|
@ -22,8 +22,9 @@ export default new Elysia()
|
|||
|
||||
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 = {
|
||||
slug: p.slug,
|
||||
name: p.display_name,
|
||||
|
|
@ -32,18 +33,20 @@ export default new Elysia()
|
|||
game_count: p.rom_count,
|
||||
updated_at: new Date(p.updated_at),
|
||||
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;
|
||||
});
|
||||
}));
|
||||
|
||||
rommPlatformsSet = new Set(rommPlatforms.map(p => p.slug));
|
||||
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 = {
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
|
|
@ -52,11 +55,13 @@ export default new Elysia()
|
|||
game_count: p.game_count,
|
||||
updated_at: p.created_at,
|
||||
id: { source: 'local', id: p.id },
|
||||
hasLocal: true
|
||||
hasLocal: true,
|
||||
paths_screenshots: game?.screenshots?.map(s => `/api/romm/screenshot/${s.id}`) ?? []
|
||||
|
||||
};
|
||||
|
||||
return platform;
|
||||
}));
|
||||
})));
|
||||
|
||||
return { platforms };
|
||||
}).get('/platforms/:source/:id', async ({ params: { source, id } }) =>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default async function init (events: EventEmitter, forceBrowser: boolean)
|
|||
|
||||
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,
|
||||
ref: false
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
{
|
||||
|
|
|
|||
59
src/mainview/components/options/LocalOption.tsx
Normal file
59
src/mainview/components/options/LocalOption.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/mainview/components/options/OptionDropdown.tsx
Normal file
67
src/mainview/components/options/OptionDropdown.tsx
Normal 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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
|||
onBlur={handleInputBlur}
|
||||
onChange={(e) =>
|
||||
{
|
||||
data.setLocalValue(e.currentTarget.value);
|
||||
data.setLocalValue(e);
|
||||
}}
|
||||
value={data.localValue}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import { Route as rootRouteImport } from './../routes/__root'
|
||||
import { Route as SettingsRouteRouteImport } from './../routes/settings/route'
|
||||
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 SettingsDirectoriesRouteImport } from './../routes/settings/directories'
|
||||
import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts'
|
||||
|
|
@ -30,6 +31,11 @@ const IndexRoute = IndexRouteImport.update({
|
|||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsInterfaceRoute = SettingsInterfaceRouteImport.update({
|
||||
id: '/interface',
|
||||
path: '/interface',
|
||||
getParentRoute: () => SettingsRouteRoute,
|
||||
} as any)
|
||||
const SettingsEmulatorsRoute = SettingsEmulatorsRouteImport.update({
|
||||
id: '/emulators',
|
||||
path: '/emulators',
|
||||
|
|
@ -79,6 +85,7 @@ export interface FileRoutesByFullPath {
|
|||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||
'/settings/interface': typeof SettingsInterfaceRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
|
|
@ -91,6 +98,7 @@ export interface FileRoutesByTo {
|
|||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||
'/settings/interface': typeof SettingsInterfaceRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
|
|
@ -104,6 +112,7 @@ export interface FileRoutesById {
|
|||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||
'/settings/interface': typeof SettingsInterfaceRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
|
|
@ -118,6 +127,7 @@ export interface FileRouteTypes {
|
|||
| '/settings/accounts'
|
||||
| '/settings/directories'
|
||||
| '/settings/emulators'
|
||||
| '/settings/interface'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
|
|
@ -130,6 +140,7 @@ export interface FileRouteTypes {
|
|||
| '/settings/accounts'
|
||||
| '/settings/directories'
|
||||
| '/settings/emulators'
|
||||
| '/settings/interface'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
|
|
@ -142,6 +153,7 @@ export interface FileRouteTypes {
|
|||
| '/settings/accounts'
|
||||
| '/settings/directories'
|
||||
| '/settings/emulators'
|
||||
| '/settings/interface'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
|
|
@ -172,6 +184,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings/interface': {
|
||||
id: '/settings/interface'
|
||||
path: '/interface'
|
||||
fullPath: '/settings/interface'
|
||||
preLoaderRoute: typeof SettingsInterfaceRouteImport
|
||||
parentRoute: typeof SettingsRouteRoute
|
||||
}
|
||||
'/settings/emulators': {
|
||||
id: '/settings/emulators'
|
||||
path: '/emulators'
|
||||
|
|
@ -236,6 +255,7 @@ interface SettingsRouteRouteChildren {
|
|||
SettingsAccountsRoute: typeof SettingsAccountsRoute
|
||||
SettingsDirectoriesRoute: typeof SettingsDirectoriesRoute
|
||||
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
|
||||
SettingsInterfaceRoute: typeof SettingsInterfaceRoute
|
||||
}
|
||||
|
||||
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
|
||||
|
|
@ -243,6 +263,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
|
|||
SettingsAccountsRoute: SettingsAccountsRoute,
|
||||
SettingsDirectoriesRoute: SettingsDirectoriesRoute,
|
||||
SettingsEmulatorsRoute: SettingsEmulatorsRoute,
|
||||
SettingsInterfaceRoute: SettingsInterfaceRoute,
|
||||
}
|
||||
|
||||
const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
@import 'animate.css';
|
||||
@plugin "daisyui";
|
||||
|
||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
|
||||
@theme {
|
||||
--breakpoint-sm: 0px;
|
||||
--breakpoint-md: 1280px;
|
||||
--page-scroll-bg: transparent;
|
||||
|
||||
--animate-wiggle: wiggle 0.3s ease-in-out 1;
|
||||
--animate-rotate: rotate 0.3s ease-in-out 1 0.2s;
|
||||
|
|
|
|||
|
|
@ -49,8 +49,6 @@ export const Router = createRouter({
|
|||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Register things for typesafety
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
|||
import { RouterContext } from "..";
|
||||
import Notifications from "../components/Notifications";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { mobileCheck } from "../scripts/utils";
|
||||
import { mobileCheck, useLocalSetting } from "../scripts/utils";
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
|
|
@ -13,9 +13,10 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||
function RootComponent ()
|
||||
{
|
||||
const isMobile = mobileCheck();
|
||||
const theme = useLocalSetting('theme');
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen overflow-hidden">
|
||||
<div data-theme={theme === 'auto' ? undefined : theme} className="w-screen h-screen overflow-hidden">
|
||||
<Outlet />
|
||||
<Notifications />
|
||||
<Toaster containerStyle={{ viewTimelineName: 'toasters' }} />
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ function RouteComponent ()
|
|||
{
|
||||
const { focus } = Route.useSearch();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "accounts",
|
||||
preferredChildFocusKey: focus
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
classNames({
|
||||
"ring-7": focused,
|
||||
"ring-7 ring-accent": focused,
|
||||
"border-dashed border-primary border-4": data.drive.isCurrentlyUsed,
|
||||
"border-solid": data.drive.unusableReason === 'already_used',
|
||||
"ring-error": data.drive.unusableReason === 'not_enough_space',
|
||||
|
|
@ -75,6 +75,7 @@ function RouteComponent ()
|
|||
{
|
||||
const { focus } = Route.useSearch();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "directories",
|
||||
preferredChildFocusKey: focus
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -32,11 +32,14 @@ function EmulatorsPending ()
|
|||
|
||||
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}>
|
||||
<FocusContext value={focusKey}>
|
||||
{[..."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>
|
||||
</ul>;
|
||||
|
|
@ -47,7 +50,7 @@ function EmulatorListType (data: { category: string, action: (e: string) => void
|
|||
const { ref, focusKey } = useFocusable({ focusKey: 'list-section' });
|
||||
return <div ref={ref} className='grow'>
|
||||
<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,
|
||||
action: (ctx) =>
|
||||
{
|
||||
|
|
@ -152,7 +155,12 @@ function EmulatorPath (data: { id: string; })
|
|||
};
|
||||
|
||||
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'>
|
||||
<OptionInput
|
||||
name={data.id ?? ""}
|
||||
|
|
@ -160,9 +168,9 @@ function EmulatorPath (data: { id: string; })
|
|||
onBlur={handleSave}
|
||||
autocomplete="off"
|
||||
defaultValue={remoteValue}
|
||||
onChange={(e) =>
|
||||
onChange={(v) =>
|
||||
{
|
||||
setLocalValue(e.currentTarget.value);
|
||||
setLocalValue(v);
|
||||
setDirty(true);
|
||||
}}
|
||||
value={localValue}
|
||||
|
|
@ -223,8 +231,9 @@ function EmulatorBadge (data: {
|
|||
<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',
|
||||
classNames({
|
||||
"bg-base-200/50": !data.path,
|
||||
"border-dashed border-base-content/40 border-2": focused
|
||||
"bg-base-200": !data.path,
|
||||
"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 { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "emulators-setting",
|
||||
preferredChildFocusKey: focus
|
||||
});
|
||||
|
||||
|
|
|
|||
23
src/mainview/routes/settings/interface.tsx
Normal file
23
src/mainview/routes/settings/interface.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -83,7 +83,7 @@ function MenuItem (data: {
|
|||
"group rounded-full p-3 md:pl-5 text-base-content/80",
|
||||
classNames({
|
||||
"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,
|
||||
}),
|
||||
data.linkClassName,
|
||||
|
|
@ -110,7 +110,7 @@ function SettingsMenu (data: {})
|
|||
|
||||
return <ul
|
||||
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}>
|
||||
<MenuItem
|
||||
|
|
@ -184,7 +184,7 @@ export function SettingsUI ()
|
|||
|
||||
return (
|
||||
<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 id="Menu" className="flex flex-row landscape:h-full md:landscape:w-56">
|
||||
<SettingsMenu />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants";
|
||||
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { RefObject, useEffect, useState } from "react";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
|
||||
export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void)
|
||||
{
|
||||
|
|
@ -64,6 +66,12 @@ export function mobileCheck ()
|
|||
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> (
|
||||
generator: AsyncGenerator<T> | undefined,
|
||||
deps: any[]
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ export const SettingsSchema = z.object({
|
|||
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({
|
||||
platform_source: z.string().optional(),
|
||||
platform_slug: z.string().optional(),
|
||||
|
|
@ -60,6 +65,7 @@ export interface FrontEndPlatformType
|
|||
game_count: number;
|
||||
updated_at: Date;
|
||||
hasLocal: boolean;
|
||||
paths_screenshots: string[];
|
||||
}
|
||||
|
||||
export interface FrontEndGameType
|
||||
|
|
@ -126,6 +132,7 @@ export interface Notification
|
|||
}
|
||||
|
||||
export type SettingsType = z.infer<typeof SettingsSchema>;
|
||||
export type LocalSettingsType = z.infer<typeof LocalSettingsSchema>;
|
||||
export interface GameInstallProgress
|
||||
{
|
||||
progress?: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue