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');
|
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 } });
|
||||||
|
|
|
||||||
|
|
@ -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 } }) =>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: () =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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 })}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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' }} />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
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",
|
"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 />
|
||||||
|
|
|
||||||
|
|
@ -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[]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue