225 lines
6.5 KiB
TypeScript
225 lines
6.5 KiB
TypeScript
import
|
|
{
|
|
FocusContext,
|
|
useFocusable,
|
|
} from "@noriginmedia/norigin-spatial-navigation";
|
|
import
|
|
{
|
|
Outlet,
|
|
createFileRoute,
|
|
useMatch,
|
|
useMatchRoute,
|
|
useRouter,
|
|
useRouterState,
|
|
} from "@tanstack/react-router";
|
|
import { ViewTransitionOptions } from "@tanstack/router-core";
|
|
import classNames from "classnames";
|
|
import
|
|
{
|
|
ArrowBigLeft,
|
|
FingerprintPattern,
|
|
HardDrive,
|
|
Info,
|
|
Joystick,
|
|
MonitorCog,
|
|
Puzzle,
|
|
RefreshCcw,
|
|
} from "lucide-react";
|
|
import { JSX, useMemo } from "react";
|
|
import { twMerge } from "tailwind-merge";
|
|
import z from "zod";
|
|
import { SettingsSchema } from "../../../shared/constants";
|
|
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
|
import Shortcuts, { FloatingShortcuts } from "@/mainview/components/Shortcuts";
|
|
import { HandleGoBack } from "@/mainview/scripts/utils";
|
|
import { AutoFocus } from "@/mainview/components/AutoFocus";
|
|
import { oneShot } from "@/mainview/scripts/audio/audio";
|
|
import SelectMenu from "@/mainview/components/SelectMenu";
|
|
|
|
export const Route = createFileRoute("/settings")({
|
|
component: SettingsUI,
|
|
validateSearch: z.object({
|
|
focus: z.keyof(SettingsSchema).optional()
|
|
}),
|
|
staticData: {
|
|
enterSound: 'openSettings'
|
|
}
|
|
});
|
|
|
|
function MenuItem (data: {
|
|
route: string;
|
|
matchRoutes?: string[];
|
|
return?: boolean;
|
|
viewTransition?: boolean | ViewTransitionOptions;
|
|
icon: JSX.Element;
|
|
focusSelect?: boolean;
|
|
className?: string;
|
|
linkClassName?: string;
|
|
active?: boolean;
|
|
label: string;
|
|
})
|
|
{
|
|
const router = useRouter();
|
|
const routerState = useRouterState();
|
|
const matchRoute = useMatchRoute();
|
|
|
|
const acitve = useMemo(() => data.matchRoutes ? data.matchRoutes.some(r => !!matchRoute({ to: r })) : !!router.matchRoute({ to: data.route }),
|
|
[routerState, matchRoute, data.matchRoutes, data.route]);
|
|
const handleNonFocusSelect = (e?: Event) =>
|
|
{
|
|
if (data.return)
|
|
{
|
|
HandleGoBack(router, e);
|
|
} else if (!acitve)
|
|
{
|
|
router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true });
|
|
}
|
|
oneShot('click');
|
|
};
|
|
const { ref, focusSelf } = useFocusable({
|
|
focusKey: `menu-item-${data.route}`,
|
|
forceFocus: !!acitve,
|
|
onFocus: () =>
|
|
{
|
|
if (data.focusSelect && !acitve)
|
|
{
|
|
router.navigate({ to: data.route, viewTransition: { types: ['slide-up'] }, replace: true });
|
|
}
|
|
(ref.current as HTMLElement).scrollIntoView({ inline: 'center' });
|
|
},
|
|
onEnterPress:
|
|
data.focusSelect !== true
|
|
? handleNonFocusSelect
|
|
: undefined,
|
|
});
|
|
|
|
return (
|
|
<li
|
|
ref={ref}
|
|
key={data.route}
|
|
data-sound-category={"menu"}
|
|
onClick={data.focusSelect ? focusSelf : (e) => handleNonFocusSelect(e.nativeEvent)}
|
|
onFocus={focusSelf}
|
|
className={twMerge("flex group-focusable cursor-pointer", data.className)}
|
|
>
|
|
<div
|
|
aria-selected={!!acitve}
|
|
className={twMerge(
|
|
"rounded-full p-3 md:pl-5 text-base-content/80 focusable focusable-accent in-focused:font-semibold aria-selected:bg-primary aria-selected:text-primary-content w-full hover:bg-primary/40 active:bg-base-content active:text-base-100",
|
|
classNames({
|
|
"in-focused:bg-secondary in-focused:text-secondary-content in-focused:ring-primary": data.return,
|
|
}),
|
|
data.linkClassName,
|
|
)}
|
|
>
|
|
<div className="flex gap-2 items-center transition-all in-focused:scale-110">
|
|
{data.icon}
|
|
<div className="sm:hidden md:inline">{data.label}</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function SettingsMenu (data: {})
|
|
{
|
|
const router = useRouter();
|
|
const { ref, focusKey } = useFocusable({
|
|
focusable: true,
|
|
focusKey: 'settings-menu',
|
|
preferredChildFocusKey: `menu-item-${router.history.location.pathname}`
|
|
});
|
|
|
|
return <ul
|
|
ref={ref}
|
|
className="flex flex-col portrait:flex-row md:text-2xl 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"
|
|
style={{ viewTransitionName: 'settings-menu' }}
|
|
>
|
|
<FocusContext value={focusKey}>
|
|
<MenuItem
|
|
focusSelect
|
|
label="Accounts"
|
|
route="/settings/accounts"
|
|
icon={<FingerprintPattern />}
|
|
/>
|
|
<MenuItem
|
|
focusSelect
|
|
route="/settings/interface"
|
|
label="Interface"
|
|
icon={<MonitorCog />}
|
|
/>
|
|
<MenuItem
|
|
focusSelect
|
|
route="/settings/emulators"
|
|
label="Emulators"
|
|
icon={<Joystick />}
|
|
/>
|
|
<MenuItem
|
|
focusSelect
|
|
matchRoutes={["/settings/plugin/$source", "/settings/plugins"]}
|
|
route="/settings/plugins"
|
|
label="Plugins"
|
|
icon={<Puzzle />}
|
|
/>
|
|
<MenuItem
|
|
focusSelect
|
|
route="/settings/directories"
|
|
label="Directories"
|
|
icon={<HardDrive />}
|
|
/>
|
|
<MenuItem
|
|
focusSelect
|
|
route="/settings/update"
|
|
label="Updates"
|
|
icon={<RefreshCcw />}
|
|
/>
|
|
<MenuItem
|
|
focusSelect
|
|
route="/settings/about"
|
|
label="About"
|
|
icon={<Info />}
|
|
/>
|
|
<MenuItem
|
|
className={"landscape:mt-auto"}
|
|
route={"/"}
|
|
return
|
|
label="Return"
|
|
icon={<ArrowBigLeft />}
|
|
/>
|
|
</FocusContext>
|
|
</ul>;
|
|
}
|
|
|
|
export function SettingsUI ()
|
|
{
|
|
const router = useRouter();
|
|
const { ref, focusKey, focusSelf } = useFocusable({
|
|
focusKey: "settings-page-layout",
|
|
preferredChildFocusKey: 'settings-menu'
|
|
});
|
|
|
|
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]);
|
|
|
|
return (
|
|
<FocusContext.Provider value={focusKey}>
|
|
<div ref={ref} className="bg-base-100 flex flex-col w-full h-full sm:p-2 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 />
|
|
</div>
|
|
<div className="divider divider-horizontal"></div>
|
|
<div id="Settings" className="flex flex-col grow landscape:h-full py-8 overflow-y-scroll">
|
|
<Outlet />
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-between pt-2">
|
|
<Shortcuts centerElement={
|
|
<div className="divider divider-vertical grow px-4"></div>
|
|
} />
|
|
</div>
|
|
</div>
|
|
<SelectMenu rootFocusKey={focusKey} />
|
|
<AutoFocus focus={focusSelf} />
|
|
</FocusContext.Provider>
|
|
);
|
|
}
|