gameflow-deck/src/mainview/routes/settings/route.tsx
2026-04-26 14:56:54 +03:00

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>
);
}