feat: Implemented launching and downloading of roms
This is just an initial implementation lots of kings to iron out
This commit is contained in:
parent
ef08fa6114
commit
f15bf9a1e0
117 changed files with 37776 additions and 1073 deletions
|
|
@ -1,8 +1,8 @@
|
|||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { useEventListener, useSessionStorage } from 'usehooks-ts';
|
||||
import { CollectionsDetail } from '../../components/CollectionsDetail';
|
||||
import { getRomsApiRomsGetOptions } from '../../../clients/romm/@tanstack/react-query.gen';
|
||||
import { DefaultRommStaleTime } from '../../../shared/constants';
|
||||
import { CollectionsDetail } from '../components/CollectionsDetail';
|
||||
import { getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen';
|
||||
import { DefaultRommStaleTime } from '../../shared/constants';
|
||||
|
||||
export const Route = createFileRoute('/collection/$id')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -20,7 +20,7 @@ function RouteComponent ()
|
|||
undefined,
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ['zoom-out'] } }));
|
||||
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ["zoom-out"] } }));
|
||||
|
||||
return (
|
||||
<CollectionsDetail setBackground={setBackground} filters={{ collectionId: Number(id) }} />
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { getRomApiRomsIdGetOptions } from "../../../clients/romm/@tanstack/react-query.gen";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../../shared/constants";
|
||||
import { twJoin, twMerge } from "tailwind-merge";
|
||||
import { JSX, Ref, RefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FocusContext, getCurrentFocusKey, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { Clock, HardDrive, Image, Play, Settings, Trophy } from "lucide-react";
|
||||
import ShortcutPrompt from "../../components/ShortcutPrompt";
|
||||
import { HeaderUI } from "../../components/Header";
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import { DetailedRomSchema } from "../../../clients/romm";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import { PopSource } from "../../scripts/spatialNavigation";
|
||||
import { gameQueryOptions } from "../../query-options";
|
||||
import { AnimatedBackground } from "../../components/AnimatedBackground";
|
||||
|
||||
export const Route = createFileRoute("/game/$id")({
|
||||
loader: ({ params, context }) => context.queryClient.fetchQuery(gameQueryOptions(Number(params.id))),
|
||||
component: GameDetailsUI,
|
||||
pendingComponent: GameDetailsUIPending
|
||||
});
|
||||
|
||||
function GameDetailsUIPending ()
|
||||
{
|
||||
return <AnimatedBackground>
|
||||
<div className="flex flex-col p-2 px-3 w-full h-full">
|
||||
<HeaderUI />
|
||||
<div className="flex flex-col justify-center items-center grow">
|
||||
<span className="loading loading-dots loading-xl"></span>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedBackground>;
|
||||
}
|
||||
|
||||
export function GameDetailsUI ()
|
||||
{
|
||||
// In a component!
|
||||
const { id } = Route.useParams();
|
||||
const data = Route.useLoaderData();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
|
||||
const backgroundImage = `${RPC_URL(__HOST__)}/api/romm${data.path_cover_small}`;
|
||||
const mainAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
focusSelf();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnimatedBackground backgroundUrl={backgroundImage}>
|
||||
<div className="z-0">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="px-3 py-2" ref={mainAreaRef}>
|
||||
<HeaderUI />
|
||||
<Details mainAreaRef={mainAreaRef} game={data} />
|
||||
</div>
|
||||
<div className="divider"><Image className="size-16" />Screenshots</div>
|
||||
<Screenshots screenshots={data.merged_screenshots} />
|
||||
<footer className="absolute left-0 bottom-0 w-full p-2 flex items-center justify-between z-10">
|
||||
<div className="flex gap-2 text-sm">
|
||||
</div>
|
||||
<Shortcuts />
|
||||
</footer>
|
||||
</FocusContext>
|
||||
</div>
|
||||
</AnimatedBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game: DetailedRomSchema; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'main-details', onFocus: () =>
|
||||
{
|
||||
data.mainAreaRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
},
|
||||
preferredChildFocusKey: "play-btn",
|
||||
saveLastFocusedChild: false
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const platformCoverImg = `${RPC_URL(__HOST__)}/api/romm/assets/platforms/${data.game.platform_slug}.svg`;
|
||||
const gameCoverImg = `${RPC_URL(__HOST__)}/api/romm${data.game.path_cover_large}`;
|
||||
useEventListener("cancel", () =>
|
||||
{
|
||||
navigate({ to: PopSource('details') ?? '/', viewTransition: { types: ['zoom-out'] } });
|
||||
});
|
||||
|
||||
return <main ref={ref} className="flex p-3 flex-col h-[75vh]">
|
||||
<FocusContext value={focusKey}>
|
||||
<section className="flex my-4 p-12 pt-4 gap-12 h-full rounded-4xl z-0">
|
||||
<div className="flex flex-col gap-6">
|
||||
<img className="h-full w-auto rounded-3xl drop-shadow-2xl drop-shadow-base-300/40 object-contain" src={gameCoverImg}></img>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col gap-6 pt-16">
|
||||
<div className="flex gap-6">
|
||||
<Detail icon={<Clock />} >{data.game.rom_user.last_played ? new Date(data.game.rom_user.last_played).toDateString() : "Never"}</Detail>
|
||||
<Detail icon={<HardDrive />} >{prettyBytes(data.game.fs_size_bytes)}</Detail>
|
||||
<Detail icon={<img className="size-6" src={platformCoverImg}></img>} >{data.game.platform_display_name}</Detail>
|
||||
</div>
|
||||
<p className="text-base-content/80 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden">
|
||||
{data.game.summary}
|
||||
</p>
|
||||
<ActionButtons game={data.game} />
|
||||
</div>
|
||||
</section>
|
||||
</FocusContext>
|
||||
</main>;
|
||||
}
|
||||
|
||||
function Screenshot (data: { url: string; index: number; setFocused?: (index: number) => void; })
|
||||
{
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: `screenshot-${data.index}`,
|
||||
onFocus: () =>
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ inline: 'center', behavior: 'smooth' });
|
||||
data.setFocused?.(data.index);
|
||||
}
|
||||
});
|
||||
return <img onClick={focusSelf} ref={ref} className={twJoin("h-[60vh] rounded-3xl", classNames({
|
||||
"ring-7 ring-primary": focused,
|
||||
"cursor-pointer": !focused
|
||||
}))} src={`${RPC_URL(__HOST__)}/api/romm${data.url}`}></img>;
|
||||
}
|
||||
|
||||
function Screenshots (data: { screenshots: string[]; })
|
||||
{
|
||||
const [focusedScreenshot, setFocusedScreenshot] = useState(-1);
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'screenshot-list',
|
||||
onFocus: () => (ref.current as HTMLElement).scrollIntoView({ block: 'center', behavior: 'smooth' }),
|
||||
onBlur: () => setFocusedScreenshot(-1)
|
||||
});
|
||||
|
||||
return <div ref={ref} className="flex flex-col p-16 pt-2 w-full z-0">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="flex gap-6 px-16 py-2 overflow-hidden">
|
||||
{data.screenshots.map((s, i) => <Screenshot setFocused={setFocusedScreenshot} index={i} url={s} />)}
|
||||
</div>
|
||||
<div className="flex gap-2 py-6 justify-center items-center h-3">{data.screenshots.map((s, i) =>
|
||||
{
|
||||
const focused = i === focusedScreenshot;
|
||||
return <button onClick={() => setFocus(`screenshot-${i}`)} className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
|
||||
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
|
||||
}))}></button>;
|
||||
})}</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PlayButton ()
|
||||
{
|
||||
const { focused, ref } = useFocusable({
|
||||
focusKey: "play-btn"
|
||||
});
|
||||
return (
|
||||
<div ref={ref} className="flex gap-3 items-center font-semibold">
|
||||
<button className={twMerge("bg-primary p-6 rounded-full cursor-pointer",
|
||||
"hover:bg-base-content hover:text-base-200 hover:ring-7 hover:ring-primary",
|
||||
classNames({
|
||||
"bg-base-content text-base-200 ring-7 ring-primary": focused
|
||||
}))}><Play className="size-8" /></button>
|
||||
<p className="text-4xl">Play</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
//<PlayButton />
|
||||
|
||||
function ActionButtons (data: { game: DetailedRomSchema; })
|
||||
{
|
||||
const [hoverText, setHoverText] = useState<string | undefined>(undefined);
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) });
|
||||
|
||||
return <div ref={ref} className="flex gap-4 items-center">
|
||||
<FocusContext value={focusKey}>
|
||||
<ActionButton onFocus={() => setHoverText("")} type='primary' id="play"><Play /></ActionButton>
|
||||
<div className="divider divider-horizontal m-0"></div>
|
||||
{!!data.game.merged_ra_metadata?.achievements && <ActionButton onFocus={() => setHoverText("Achievements")} type="base" id="achievements" >
|
||||
<div className="flex flex-col gap-2 items-center text-2xl">
|
||||
<div className="flex flex-row">
|
||||
<Trophy />
|
||||
{`${data.game.merged_ra_metadata.achievements.filter(a => a.type).length}/${data.game.merged_ra_metadata.achievements.length}`}
|
||||
</div>
|
||||
<progress className="progress progress-secondary w-full" value={50} max="100"></progress>
|
||||
</div>
|
||||
</ActionButton>}
|
||||
<ActionButton onFocus={() => setHoverText("Settings")} type="base" id="settings" icon={<Settings />} />
|
||||
{!!hoverText && <p className="py-2 px-4 bg-accent text-accent-content rounded-full">{hoverText}</p>}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Shortcuts ()
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "action-buttons" });
|
||||
return <div ref={ref} className="flex gap-2" style={{ viewTransitionName: 'shortcuts' }}>
|
||||
<FocusContext value={focusKey}>
|
||||
<ShortcutPrompt icon="steamdeck_button_a" label="Continue" />
|
||||
<ShortcutPrompt icon="steamdeck_button_b" label="Back" />
|
||||
<ShortcutPrompt icon="steamdeck_button_x" label="Close" />
|
||||
<ShortcutPrompt icon="steamdeck_button_y" label="Options" />
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Detail (data: { icon: JSX.Element; children?: any | any[]; })
|
||||
{
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{data.icon}
|
||||
{data.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton (data: { id: string, icon?: JSX.Element, children?: any | any[]; className?: string; type: "primary" | 'base' | "accent"; onFocus?: () => void; })
|
||||
{
|
||||
const { ref, focused } = useFocusable({ focusKey: data.id, onFocus: data.onFocus });
|
||||
const styles = {
|
||||
primary: twMerge("bg-primary text-primary-content rounded-full size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary",
|
||||
classNames({
|
||||
"bg-base-content text-base-300 ring-7 ring-primary": focused
|
||||
})),
|
||||
base: twMerge(" text-base-content border-dashed border-base-content/20 border-2", classNames({
|
||||
"bg-base-content text-base-300 ring-7 ring-primary": focused
|
||||
})),
|
||||
accent: twMerge("bg-primary text-primary-content ", classNames({
|
||||
"bg-base-content text-base-300 ring-7 ring-primary": focused
|
||||
}))
|
||||
};
|
||||
return (
|
||||
<button ref={ref} className={twMerge("header-icon flex flex-col gap-2 px-5 py-4 rounded-3xl text-2xl justify-center items-center cursor-pointer",
|
||||
"hover:ring-7 hover:ring-primary", styles[data.type], data.className)}>
|
||||
{data.icon}
|
||||
{data.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
490
src/mainview/routes/game/$source.$id.tsx
Normal file
490
src/mainview/routes/game/$source.$id.tsx
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants";
|
||||
import { twJoin, twMerge } from "tailwind-merge";
|
||||
import { JSX, RefObject, useEffect, useRef, useState } from "react";
|
||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { Clock, CloudDownload, Download, Folder, HardDrive, Image, PackageOpen, Play, Settings, Store, Trash, TriangleAlert, Trophy } from "lucide-react";
|
||||
import { HeaderUI } from "../../components/Header";
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import { PopSource, SaveSource, useFocusEventListener } from "../../scripts/spatialNavigation";
|
||||
import { AnimatedBackground } from "../../components/AnimatedBackground";
|
||||
import { rommApi } from "../../scripts/clientApi";
|
||||
import toast from "react-hot-toast";
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Router } from "../..";
|
||||
import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog";
|
||||
import Shortcuts from "../../components/Shortcuts";
|
||||
|
||||
const placeholderText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam eleifend ante magna, id euismod quam tempus sit amet. Maecenas sem lectus, euismod imperdiet volutpat ac, posuere in turpis. Vestibulum commodo lacinia lectus sit amet ultricies. Integer euismod consequat elit, sit amet dapibus libero fermentum nec. Aliquam accumsan placerat dui a maximus. Nunc lectus urna, scelerisque a magna non, imperdiet lobortis turpis. Aliquam magna dui, porttitor in nisl vitae, pretium fringilla sem. ";
|
||||
|
||||
const gameQuery = (source: string, id: number) => queryOptions({
|
||||
queryKey: ['game', source, id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.game({ source })({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/game/$source/$id")({
|
||||
loader: ({ params, context }) =>
|
||||
{
|
||||
context.queryClient.prefetchQuery(gameQuery(params.source, Number(params.id)));
|
||||
},
|
||||
component: GameDetailsUI,
|
||||
pendingComponent: GameDetailsUIPending,
|
||||
});
|
||||
|
||||
function GameDetailsUIPending ()
|
||||
{
|
||||
return <AnimatedBackground>
|
||||
<div className="flex flex-col p-2 px-3 w-full h-full">
|
||||
<HeaderUI />
|
||||
<div className="flex flex-col justify-center items-center grow">
|
||||
<span className="loading loading-dots loading-xl"></span>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedBackground>;
|
||||
}
|
||||
|
||||
export function GameDetailsUI ()
|
||||
{
|
||||
const { source, id } = Route.useParams();
|
||||
const { data, isSuccess } = useQuery(gameQuery(source, Number(id)));
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
|
||||
const backgroundImage = data?.path_cover ? `${RPC_URL(__HOST__)}${data?.path_cover}` : undefined;
|
||||
const mainAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEventListener("cancel", (e) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
HandleGoBack();
|
||||
}, ref);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (isSuccess)
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
|
||||
}, [isSuccess]);
|
||||
|
||||
return (
|
||||
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage}>
|
||||
<div className="z-0 overflow-y-scroll">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="px-3 py-2" ref={mainAreaRef}>
|
||||
<HeaderUI />
|
||||
<Details mainAreaRef={mainAreaRef} game={data} />
|
||||
</div>
|
||||
<div className="divider"><div className="flex items-center gap-3 opacity-60"><Image className="size-6" />Screenshots</div></div>
|
||||
{!!data && <Screenshots screenshots={data.paths_screenshots} />}
|
||||
<footer className="absolute left-0 bottom-0 w-full p-2 flex items-center justify-between z-10">
|
||||
<div className="flex gap-2 text-sm">
|
||||
</div>
|
||||
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_a', label: "Play" }]} />
|
||||
</footer>
|
||||
</FocusContext>
|
||||
</div>
|
||||
</AnimatedBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function HandleGoBack ()
|
||||
{
|
||||
Router.navigate({ to: PopSource('details') ?? '/', viewTransition: { types: ['zoom-out'] } });
|
||||
}
|
||||
|
||||
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?: FrontEndGameTypeDetailed; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'main-details', onFocus: () =>
|
||||
{
|
||||
data.mainAreaRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
},
|
||||
preferredChildFocusKey: "play-btn",
|
||||
saveLastFocusedChild: false
|
||||
});
|
||||
|
||||
const platformCoverImg = `${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`;
|
||||
const gameCoverImg = data.game?.path_cover ? `${RPC_URL(__HOST__)}${data.game?.path_cover}` : undefined;
|
||||
|
||||
let fileSizeIcon: JSX.Element | undefined;
|
||||
if (!data.game)
|
||||
{
|
||||
fileSizeIcon = <span className="loading loading-spinner loading-lg"></span>;
|
||||
} else if (data.game.missing)
|
||||
{
|
||||
fileSizeIcon = <TriangleAlert />;
|
||||
} else if (data.game.local)
|
||||
{
|
||||
fileSizeIcon = <HardDrive />;
|
||||
} else
|
||||
{
|
||||
fileSizeIcon = <CloudDownload />;
|
||||
}
|
||||
|
||||
return <main ref={ref} className="flex p-3 flex-col h-[75vh]">
|
||||
<FocusContext value={focusKey}>
|
||||
<section className="flex my-4 p-12 pt-4 gap-12 h-full rounded-4xl z-0">
|
||||
<div className="flex gap-6 overflow-hidden bg-base-300 justify-end h-full rounded-3xl aspect-3/4">
|
||||
{gameCoverImg ?
|
||||
<img className="drop-shadow-2xl drop-shadow-base-300/40 h-full" src={gameCoverImg}></img> :
|
||||
<div className="skeleton w-full h-full"></div>
|
||||
}
|
||||
</div>
|
||||
<div className="flex-2 flex flex-col gap-6 pt-16">
|
||||
<div className="flex gap-6">
|
||||
<Detail icon={<Clock />} >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"}</Detail>
|
||||
{!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) &&
|
||||
<div className={classNames({ "text-error": data.game.missing })}>
|
||||
<div className="tooltip" data-tip={data.game.path_fs}>
|
||||
<Detail icon={fileSizeIcon} >{data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}</Detail>
|
||||
</div>
|
||||
</div>}
|
||||
<Detail icon={<img className="size-6" src={platformCoverImg}></img>} >{data.game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</Detail>
|
||||
<Detail icon={
|
||||
<Store />
|
||||
} >
|
||||
{data.game?.source ?? data.game?.id.source}
|
||||
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
|
||||
</div>
|
||||
<p className="text-base-content/80 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden">
|
||||
{data.game?.summary ?? <div className="flex flex-col gap-4 w-full">
|
||||
<div className="skeleton h-4 w-[30%]"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
<div className="skeleton h-4 w-[60%]"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
</div>}
|
||||
</p>
|
||||
{!!data.game && <ActionButtons key="actions" game={data.game} />}
|
||||
</div>
|
||||
</section>
|
||||
</FocusContext>
|
||||
</main>;
|
||||
}
|
||||
|
||||
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; })
|
||||
{
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: `screenshot-${data.index}`,
|
||||
onFocus: () =>
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ inline: 'center', behavior: 'smooth' });
|
||||
data.setFocused?.(data.index);
|
||||
}
|
||||
}); 4096;
|
||||
return <img className={twJoin("h-[60vh] rounded-3xl", classNames({
|
||||
"ring-7 ring-primary": focused,
|
||||
"cursor-pointer": !focused
|
||||
}))} onClick={focusSelf} ref={ref} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" />;
|
||||
}
|
||||
|
||||
function Screenshots (data: { screenshots: string[]; })
|
||||
{
|
||||
const scrollRef = useRef(null);
|
||||
const [focusedScreenshot, setFocusedScreenshot] = useState(-1);
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'screenshot-list',
|
||||
onFocus: () => (ref.current as HTMLElement).scrollIntoView({ block: 'center', behavior: 'smooth' }),
|
||||
onBlur: () => setFocusedScreenshot(-1)
|
||||
});
|
||||
|
||||
return <div ref={ref} className="flex flex-col p-16 pt-2 w-full z-0">
|
||||
<FocusContext value={focusKey}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-6 px-16 py-2 overflow-hidden justify-center-safe"
|
||||
>
|
||||
{data.screenshots.map((s, i) => <Screenshot key={s} setFocused={setFocusedScreenshot} index={i} path={s} />)}
|
||||
</div>
|
||||
<div className="flex gap-2 py-6 justify-center items-center h-3">{data.screenshots.map((s, i) =>
|
||||
{
|
||||
const focused = i === focusedScreenshot;
|
||||
return <button key={i} onClick={() => setFocus(`screenshot-${i}`)} className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
|
||||
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
|
||||
}))}></button>;
|
||||
})}</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; })
|
||||
{
|
||||
if (!data.game.achievements)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return <ActionButton key="achievements" square tooltip="Achievements" type="base" id="achievements" >
|
||||
<div className="flex flex-col gap-2 items-center text-2xl">
|
||||
<div className="flex flex-row">
|
||||
<Trophy />
|
||||
{`${data.game.achievements.unlocked}/${data.game.achievements.total}`}
|
||||
</div>
|
||||
<progress className="progress progress-secondary w-full" value={50} max="100"></progress>
|
||||
</div>
|
||||
</ActionButton>;
|
||||
}
|
||||
|
||||
function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
||||
{
|
||||
const { source, id } = Route.useParams();
|
||||
const installMutation = useMutation({
|
||||
mutationKey: ['install'],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).install.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
const playMutation = useMutation({
|
||||
mutationKey: ['play'],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).play.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
const [progress, setProgress] = useState<number | undefined>(undefined);
|
||||
const [status, setStatus] = useState<GameStatusType | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [details, setDetails] = useState<string | undefined>(undefined);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const es = new EventSource(`${RPC_URL(__HOST__)}/api/romm/status/${data.game.id.source}/${data.game.id.id}`);
|
||||
|
||||
es.onmessage = ({ data }) =>
|
||||
{
|
||||
const stats = JSON.parse(data) as GameInstallProgress;
|
||||
setProgress(stats.progress);
|
||||
setStatus(stats.status);
|
||||
setDetails(stats.details);
|
||||
setError(stats.error);
|
||||
};
|
||||
|
||||
es.addEventListener('refresh', () =>
|
||||
{
|
||||
queryClient.invalidateQueries({ queryKey: ['game', data.game.id] });
|
||||
location.reload();
|
||||
});
|
||||
|
||||
es.onerror = (event) =>
|
||||
{
|
||||
const error = (event as any).data?.error;
|
||||
if (error)
|
||||
{
|
||||
toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return () => es.close();
|
||||
}, [data.game.id]);
|
||||
|
||||
let progressIcon: JSX.Element | undefined = undefined;
|
||||
switch (status)
|
||||
{
|
||||
case 'download':
|
||||
progressIcon = <Download />;
|
||||
break;
|
||||
case 'extract':
|
||||
progressIcon = <PackageOpen />;
|
||||
break;
|
||||
}
|
||||
|
||||
let mainButton: JSX.Element | undefined = undefined;
|
||||
if (status === 'installed')
|
||||
{
|
||||
mainButton = <ActionButton onAction={() =>
|
||||
{
|
||||
playMutation.mutate();
|
||||
SaveSource('launch');
|
||||
Router.navigate({ to: '/launcher/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id } });
|
||||
}} tooltip={details} key="primary" type='primary' id="mainAction"><Play /></ActionButton>;
|
||||
}
|
||||
else if (error)
|
||||
{
|
||||
mainButton = <ActionButton
|
||||
key="error"
|
||||
tooltip={error}
|
||||
tooltip_type="error"
|
||||
type='error'
|
||||
onAction={() =>
|
||||
{
|
||||
if (status === 'missing-emulator')
|
||||
{
|
||||
SaveSource('settings');
|
||||
Router.navigate({ to: '/settings/directories', viewTransition: { types: ['zoom-in'] } });
|
||||
}
|
||||
}}
|
||||
id="mainAction">
|
||||
<TriangleAlert />
|
||||
</ActionButton>;
|
||||
}
|
||||
else
|
||||
{
|
||||
mainButton = <ActionButton
|
||||
key={status ?? 'unknown'}
|
||||
disabled={installMutation.isPending}
|
||||
onAction={() =>
|
||||
{
|
||||
if (status === 'install')
|
||||
{
|
||||
installMutation.mutate();
|
||||
}
|
||||
}}
|
||||
tooltip={details ?? status}
|
||||
type='primary'
|
||||
id="mainAction">
|
||||
{status === 'install' ? <Download /> : <span className="loading loading-spinner loading-lg"></span>}
|
||||
</ActionButton>;
|
||||
}
|
||||
|
||||
return <div className="flex gap-2">
|
||||
{mainButton}
|
||||
<div className="divider divider-horizontal m-0"></div>
|
||||
{progress !== null && !!progressIcon && <ActionButton key="progress" square tooltip={details} type="base" id="progress" >
|
||||
<div key={`install-${status}`} data-tooltip={details ?? status} className="flex flex-col gap-2 w-16 items-center text-2xl">
|
||||
<div className="flex flex-row">
|
||||
{progressIcon}
|
||||
</div>
|
||||
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
|
||||
</div>
|
||||
</ActionButton>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
|
||||
{
|
||||
const [hoverText, setHoverText] = useState<string | undefined>(undefined);
|
||||
const [hoverTextType, setHoverTextType] = useState<string>('accent');
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) });
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: ['delete', data.game.id],
|
||||
mutationFn: () => rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).delete(),
|
||||
onSuccess: () =>
|
||||
{
|
||||
location.reload();
|
||||
console.log("Deleted");
|
||||
}
|
||||
});
|
||||
|
||||
const contextOptions: DialogEntry[] = [];
|
||||
if (data.game.local)
|
||||
{
|
||||
contextOptions.push({
|
||||
id: 'delete',
|
||||
action: () =>
|
||||
{
|
||||
deleteMutation.mutate();
|
||||
},
|
||||
icon: <Trash />,
|
||||
content: "Delete",
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
|
||||
const handleTooltipSet = (e: HTMLElement) =>
|
||||
{
|
||||
const dataTooltip = e.getAttribute('data-tooltip');
|
||||
setHoverText(dataTooltip ?? undefined);
|
||||
setHoverTextType(e.getAttribute('data-tooltip_type') ?? 'accent');
|
||||
};
|
||||
|
||||
useFocusEventListener('focuschanged', (e) =>
|
||||
{
|
||||
if (e.target instanceof HTMLElement)
|
||||
{
|
||||
handleTooltipSet(e.target);
|
||||
}
|
||||
|
||||
}, ref);
|
||||
|
||||
const tooltipStyles = {
|
||||
base: 'bg-base-100 text-base-content',
|
||||
accent: 'bg-accent text-accent-content',
|
||||
error: 'bg-error text-error-content'
|
||||
};
|
||||
|
||||
return <div ref={ref} className="flex overflow-hidden p-2 gap-4 h-32 items-center">
|
||||
<FocusContext value={focusKey}>
|
||||
<MainActions game={data.game} />
|
||||
<AchievementsInfo game={data.game} />
|
||||
<ActionButton tooltip="Settings" onAction={() => setOpen(true)} type="base" id="settings" icon={<Settings />} >
|
||||
|
||||
</ActionButton >
|
||||
<ContextDialog id="settings-context" open={open} close={() =>
|
||||
{
|
||||
setOpen(false);
|
||||
setFocus("settings");
|
||||
}}>
|
||||
<ContextList options={contextOptions} />
|
||||
</ContextDialog>
|
||||
{!!hoverText && <p className={twMerge("flex py-2 px-4 rounded-4xl text-wrap wrap-anywhere", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Detail (data: { icon: JSX.Element; children?: any | any[]; })
|
||||
{
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{data.icon}
|
||||
{data.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton (data: {
|
||||
id: string,
|
||||
icon?: JSX.Element,
|
||||
children?: any | any[];
|
||||
className?: string;
|
||||
type: "primary" | 'base' | "accent" | 'error';
|
||||
square?: boolean,
|
||||
onFocus?: () => void;
|
||||
tooltip?: string,
|
||||
tooltip_type?: 'accent' | 'error';
|
||||
onAction?: () => void;
|
||||
disabled?: boolean;
|
||||
})
|
||||
{
|
||||
const { ref, focused } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true });
|
||||
const styles = {
|
||||
primary: twMerge("bg-primary text-primary-content",
|
||||
classNames({
|
||||
"bg-base-content text-base-300 ring-7 ring-primary": focused
|
||||
})),
|
||||
base: twMerge(" text-base-content border-dashed border-base-content/20 border-2", classNames({
|
||||
"bg-base-content text-base-300 ring-7 ring-primary": focused
|
||||
})),
|
||||
accent: twMerge("bg-primary text-primary-content ", classNames({
|
||||
"bg-base-content text-base-300 ring-7 ring-primary": focused
|
||||
})),
|
||||
error: twMerge("bg-error text-error-content ", classNames({
|
||||
"bg-error text-error-content ring-7 ring-primary": focused
|
||||
})),
|
||||
};
|
||||
return (
|
||||
<button
|
||||
disabled={data.disabled}
|
||||
ref={ref}
|
||||
onClick={data.onAction}
|
||||
data-tooltip={data.tooltip}
|
||||
data-tooltip_type={data.tooltip_type}
|
||||
className={twMerge("header-icon flex flex-col gap-2 px-5 py-4 rounded-3xl text-2xl justify-center items-center cursor-pointer disabled:opacity-30",
|
||||
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}>
|
||||
{data.icon}
|
||||
{data.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { JSX, Suspense, useContext } from "react";
|
||||
import { JSX, Suspense, useContext, useState } from "react";
|
||||
import
|
||||
{
|
||||
Gamepad2,
|
||||
|
|
@ -16,7 +16,7 @@ import
|
|||
useLocation,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import
|
||||
{
|
||||
FocusContext,
|
||||
|
|
@ -24,13 +24,12 @@ import
|
|||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||
import { useLocalStorage, useSessionStorage } from "usehooks-ts";
|
||||
import { useEventListener, useLocalStorage } from "usehooks-ts";
|
||||
import
|
||||
{
|
||||
getCollectionsApiCollectionsGetOptions,
|
||||
getPlatformsApiPlatformsGetOptions,
|
||||
} from "../../clients/romm/@tanstack/react-query.gen";
|
||||
import { CardList } from "../components/CardList";
|
||||
import { CardList, GameMetaExtra } from "../components/CardList";
|
||||
import { HeaderUI } from "../components/Header";
|
||||
import { FilterUI } from "../components/Filters";
|
||||
import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground";
|
||||
|
|
@ -42,6 +41,8 @@ import SaveScroll from "../components/SaveScroll";
|
|||
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Shortcuts from "../components/Shortcuts";
|
||||
import { PlatformsList } from "../components/PlatformsList";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: ConsoleHomeUI,
|
||||
|
|
@ -60,64 +61,7 @@ const filters = {
|
|||
},
|
||||
};
|
||||
|
||||
function PlatformList (data: { id: string, setBackground: (url: string) => void; })
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { data: platforms } = useSuspenseQuery({
|
||||
...getPlatformsApiPlatformsGetOptions(),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: DefaultRommStaleTime,
|
||||
});
|
||||
|
||||
return (
|
||||
<CardList
|
||||
type="platform"
|
||||
id={data.id}
|
||||
games={platforms.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at))
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
focusKey: g.slug,
|
||||
title: g.display_name,
|
||||
subtitle: g.family_name ?? "",
|
||||
previewUrl: g.url_logo ?? "",
|
||||
badge: (
|
||||
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
||||
{g.rom_count}
|
||||
</span>
|
||||
),
|
||||
preview: (
|
||||
<div
|
||||
className="flex h-60 p-6 bg-base-100 justify-center items-center"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
color-mix(in srgb, var(--color-base-content) 60%, transparent),
|
||||
color-mix(in srgb, var(--color-base-300) 60%, transparent)
|
||||
), url(https://picsum.photos/id/${10 + g.id}/300/300.webp?blur=10) center / cover`,
|
||||
|
||||
backgroundBlendMode: "screen",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${g.slug.toLocaleLowerCase()}.svg`}
|
||||
></img>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
onSelectGame={(id) =>
|
||||
{
|
||||
navigate({ to: `/platform/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
}}
|
||||
onGameFocus={(id) =>
|
||||
{
|
||||
data.setBackground(
|
||||
`https://picsum.photos/id/${10 + (id ?? 0)}/1920/1080.webp`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionList (data: { id: string, setBackground: (url: string) => void; })
|
||||
function CollectionList (data: { id: string, setBackground: (url: string) => void; className?: string; })
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { data: collections } = useSuspenseQuery({
|
||||
|
|
@ -130,19 +74,20 @@ function CollectionList (data: { id: string, setBackground: (url: string) => voi
|
|||
<CardList
|
||||
type="collection"
|
||||
id={data.id}
|
||||
className={data.className}
|
||||
games={collections.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at))
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
id: String(g.id),
|
||||
title: g.name,
|
||||
focusKey: `collection-${g.id}`,
|
||||
subtitle: g.user__username,
|
||||
previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`,
|
||||
badge: (
|
||||
badges: [
|
||||
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
||||
{g.rom_count}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
],
|
||||
} satisfies GameMetaExtra))}
|
||||
onSelectGame={(id) =>
|
||||
{
|
||||
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
|
|
@ -171,21 +116,45 @@ function HomeList (data: {
|
|||
})
|
||||
{
|
||||
const bg = useContext(AnimatedBackgroundContext);
|
||||
|
||||
const { ref, focused, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "home-list",
|
||||
preferredChildFocusKey: `${data.selectedFilter}-list`
|
||||
});
|
||||
|
||||
const lists = {
|
||||
consoles: <PlatformList id={"consoles-list"} setBackground={bg.setBackground} />,
|
||||
games: <GameList id="games-list" setBackground={bg.setBackground} />,
|
||||
collections: <CollectionList id={"collections-list"} setBackground={bg.setBackground} />,
|
||||
consoles: <PlatformsList className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />,
|
||||
games: <GameList className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />,
|
||||
collections: <CollectionList className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />,
|
||||
};
|
||||
|
||||
useEventListener('wheel', e =>
|
||||
{
|
||||
const deltaY = e.deltaY;
|
||||
const deltaYSign = Math.sign(e.deltaY);
|
||||
|
||||
if (deltaYSign == -1)
|
||||
{
|
||||
(ref.current as HTMLElement)?.scrollBy({
|
||||
top: 0,
|
||||
left: deltaY,
|
||||
behavior: 'auto'
|
||||
});
|
||||
|
||||
} else
|
||||
{
|
||||
(ref.current as HTMLElement)?.scrollBy({
|
||||
top: 0,
|
||||
left: deltaY,
|
||||
behavior: 'auto'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<FocusContext value={focusKey}>
|
||||
<div ref={ref} className="flex overflow-x-scroll no-scrollbar pb-3 mb-1 justify-center-safe">
|
||||
<div ref={ref} className="flex overflow-x-scroll no-scrollbar pb-3 mb-1 justify-center-safe" style={{
|
||||
mask: `linear-gradient(to right, rgba(0,0,0,0.8) 0%, black 10%, black 90%, rgba(0,0,0,0.8) 100%)`
|
||||
}}>
|
||||
<div className="flex px-16">
|
||||
<ErrorBoundary fallback={<HomeListError focused={focused} />}>
|
||||
<Suspense key={data.selectedFilter} fallback={<LoadingCardList placeholderCount={8} />}>
|
||||
|
|
@ -206,6 +175,14 @@ export default function ConsoleHomeUI ()
|
|||
keyof typeof filters
|
||||
>("home-filter-selected", "games");
|
||||
|
||||
const closeMutation = useMutation({
|
||||
mutationKey: ['close'], mutationFn: async () =>
|
||||
{
|
||||
const { error } = await systemApi.api.system.exit.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
forceFocus: true,
|
||||
autoRestoreFocus: false,
|
||||
|
|
@ -220,7 +197,7 @@ export default function ConsoleHomeUI ()
|
|||
<div className="px-3 w-full pt-2">
|
||||
<HeaderUI buttons={[
|
||||
{ id: "search", icon: <Search /> },
|
||||
{ id: "power-button", icon: <Power />, external: true }
|
||||
{ id: "power-button", icon: <Power />, external: true, action: () => closeMutation.mutate() }
|
||||
]} />
|
||||
</div>
|
||||
<div className="flex w-full flex-col grow justify-evenly">
|
||||
|
|
@ -243,7 +220,7 @@ export default function ConsoleHomeUI ()
|
|||
<footer className="px-2 pb-2 flex items-center justify-between">
|
||||
<div className="flex gap-2 text-sm">
|
||||
</div>
|
||||
<Shortcuts />
|
||||
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_a', label: 'Select' }]} />
|
||||
</footer>
|
||||
</FocusContext.Provider>
|
||||
</AnimatedBackground>
|
||||
|
|
@ -282,7 +259,7 @@ function MainMenu (data: {})
|
|||
<CircleIcon
|
||||
action={() =>
|
||||
{
|
||||
SaveSource('settings', location.pathname);
|
||||
SaveSource('settings');
|
||||
navigate({ to: "/settings/accounts", viewTransition: { types: ['zoom-in'] } });
|
||||
}}
|
||||
icon={<Settings />}
|
||||
|
|
@ -319,7 +296,7 @@ function CircleIcon (data: {
|
|||
'sm:w-14 sm:h-14',
|
||||
typeClasses[data.type ?? "none"], classNames(
|
||||
{
|
||||
"ring-7 ring-primary drop-shadow-2xl": focused,
|
||||
"focus ring-7 ring-primary drop-shadow-2xl animate-scale": focused,
|
||||
"hover:ring-7 hover:ring-primary": true,
|
||||
})
|
||||
)}
|
||||
|
|
|
|||
58
src/mainview/routes/launcher.$source.$id.tsx
Normal file
58
src/mainview/routes/launcher.$source.$id.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { AnimatedBackground } from '@/mainview/components/AnimatedBackground';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { GameInstallProgress, RPC_URL } from '@/shared/constants';
|
||||
import DotsLoading from '../components/backgrounds/dots';
|
||||
import { useEventListener } from 'usehooks-ts';
|
||||
import { Router } from '..';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { rommApi } from '../scripts/clientApi';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const Route = createFileRoute('/launcher/$source/$id')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
function HandleGoBack ()
|
||||
{
|
||||
Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id } });
|
||||
}
|
||||
|
||||
const { source, id } = Route.useParams();
|
||||
const { data } = useQuery({ queryKey: ['romm', 'game'], queryFn: () => rommApi.api.romm.game({ source })({ id }).get() });
|
||||
|
||||
useEventListener("cancel", (e) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
HandleGoBack();
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const es = new EventSource(`${RPC_URL(__HOST__)}/api/romm/status/${source}/${id}`);
|
||||
|
||||
es.onmessage = ({ data }) =>
|
||||
{
|
||||
const stats = JSON.parse(data) as GameInstallProgress;
|
||||
if (stats.status !== 'playing')
|
||||
{
|
||||
HandleGoBack();
|
||||
}
|
||||
};
|
||||
|
||||
es.addEventListener('refresh', HandleGoBack);
|
||||
|
||||
es.onerror = HandleGoBack;
|
||||
|
||||
return () => es.close();
|
||||
}, []);
|
||||
|
||||
|
||||
return <AnimatedBackground backgroundKey='game-details'>
|
||||
<div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'>
|
||||
<DotsLoading />
|
||||
<h1 className='font-semibold'>Launching {data?.data?.name} ...</h1>
|
||||
</div>
|
||||
</AnimatedBackground>;
|
||||
}
|
||||
54
src/mainview/routes/platform.$source.$id.tsx
Normal file
54
src/mainview/routes/platform.$source.$id.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEventListener, useSessionStorage } from "usehooks-ts";
|
||||
import { CollectionsDetail } from "../components/CollectionsDetail";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||
import { Suspense } from "react";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
|
||||
export const Route = createFileRoute("/platform/$source/$id")({
|
||||
component: RouteComponent
|
||||
});
|
||||
|
||||
function PlatformTitle ()
|
||||
{
|
||||
const { source, id } = Route.useParams();
|
||||
const { data: platform } = useSuspenseQuery({
|
||||
queryKey: ['platform', source, id], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}, staleTime: DefaultRommStaleTime
|
||||
});
|
||||
|
||||
return <div className="flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
|
||||
|
||||
<div className="divider mb-6 mt-0">
|
||||
<img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${platform.slug.toLocaleLowerCase()}.svg`} ></img>
|
||||
{platform.display_name}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { id } = Route.useParams();
|
||||
|
||||
const [, setBackground] = useSessionStorage<string | undefined>(
|
||||
"home-background",
|
||||
undefined,
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ['zoom-out'] } }));
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<CollectionsDetail
|
||||
title={<Suspense><PlatformTitle /></Suspense>}
|
||||
setBackground={setBackground}
|
||||
filters={{ platformId: Number(id) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import
|
||||
{
|
||||
getPlatformApiPlatformsIdGetOptions,
|
||||
getRomsApiRomsGetOptions,
|
||||
} from "../../../clients/romm/@tanstack/react-query.gen";
|
||||
import { useEventListener, useSessionStorage } from "usehooks-ts";
|
||||
import { CollectionsDetail } from "../../components/CollectionsDetail";
|
||||
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { DefaultRommStaleTime, RPC_PORT, RPC_URL } from "../../../shared/constants";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export const Route = createFileRoute("/platform/$id")({
|
||||
component: RouteComponent
|
||||
});
|
||||
|
||||
function PlatformSlug ()
|
||||
{
|
||||
const { id } = Route.useParams();
|
||||
const { data: platform } = useSuspenseQuery({ ...getPlatformApiPlatformsIdGetOptions({ path: { id: Number(id) } }), staleTime: DefaultRommStaleTime });
|
||||
|
||||
return <div className="flex gap-2 pr-4 pl-2 text-2xl font-semibold text-base-content items-center justify-center drop-shadow drop-shadow-base-300/10 ">
|
||||
<img className="size-10 rounded-full bg-base-100 p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${platform.slug.toLocaleLowerCase()}.svg`} ></img>
|
||||
{platform.display_name}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PlatformTitle ()
|
||||
{
|
||||
const { id } = Route.useParams();
|
||||
const { data: platform } = useSuspenseQuery({ ...getPlatformApiPlatformsIdGetOptions({ path: { id: Number(id) } }), staleTime: DefaultRommStaleTime });
|
||||
|
||||
return <div className="flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
|
||||
|
||||
<div className="divider mb-6 mt-0">
|
||||
<img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${platform.slug.toLocaleLowerCase()}.svg`} ></img>
|
||||
{platform.display_name}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { id } = Route.useParams();
|
||||
|
||||
const [, setBackground] = useSessionStorage<string | undefined>(
|
||||
"home-background",
|
||||
undefined,
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ['zoom-out'] } }));
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<CollectionsDetail
|
||||
title={<Suspense><PlatformTitle /></Suspense>}
|
||||
setBackground={setBackground}
|
||||
filters={{ platformIds: [Number(id)] }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/mainview/routes/settings/about.tsx
Normal file
60
src/mainview/routes/settings/about.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { rommApi, systemApi } from '@/mainview/scripts/clientApi';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/settings/about')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { data: systemInfo } = useQuery({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() });
|
||||
return <div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<td>{navigator.userAgent}</td>
|
||||
</tr>
|
||||
{/* row 2 */}
|
||||
<tr>
|
||||
<th>Platform</th>
|
||||
<td>{navigator.platform}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Resolution</th>
|
||||
<td>{screen.width}x{screen.height}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Window</th>
|
||||
<td>{window.innerWidth}x{window.innerHeight}</td>
|
||||
</tr>
|
||||
{/* row 3 */}
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<td>{systemInfo?.data?.user}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Architecture</th>
|
||||
<td>{systemInfo?.data?.arch}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>System</th>
|
||||
<td>{systemInfo?.data?.platform}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<td>{systemInfo?.data?.hostname}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Machine</th>
|
||||
<td>{systemInfo?.data?.machine}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Steam Deck</th>
|
||||
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -3,119 +3,31 @@ import
|
|||
FocusContext,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { useIsMutating, useMutation, useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import classNames from "classnames";
|
||||
import { Cross, Delete, Key, Link, Lock, Save, Trash, User, X } from "lucide-react";
|
||||
import { Key, Link, Lock, Save, Trash, User, X } from "lucide-react";
|
||||
import
|
||||
{
|
||||
HTMLInputTypeAttribute,
|
||||
JSX,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { client } from "../..";
|
||||
import { RPC_URL, SettingsType } from "../../../shared/constants";
|
||||
import { RPC_URL } from "../../../shared/constants";
|
||||
import
|
||||
{
|
||||
getCurrentUserApiUsersMeGetOptions,
|
||||
statsApiStatsGetOptions,
|
||||
} from "../../../clients/romm/@tanstack/react-query.gen";
|
||||
import { UserSchema } from "../../../clients/romm";
|
||||
import toast from "react-hot-toast";
|
||||
import z from "zod";
|
||||
import { OptionSpace } from "../../components/options/OptionSpace";
|
||||
import { OptionInput } from "../../components/options/OptionInput";
|
||||
import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { rommApi, settingsApi } from "../../scripts/clientApi";
|
||||
import { Button } from "../../components/options/Button";
|
||||
|
||||
export const Route = createFileRoute("/settings/accounts")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
function Option (data: {
|
||||
label: string;
|
||||
id: KeysWithValueAssignableTo<SettingsType, string>;
|
||||
type: HTMLInputTypeAttribute;
|
||||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
})
|
||||
{
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
useQuery({
|
||||
enabled: !!data.id,
|
||||
queryKey: ["setting", data.id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const value = await client.api.settings({ id: data.id! }).get().then(d => d.data?.value);
|
||||
if (!dirty)
|
||||
{
|
||||
setLocalValue(String(value));
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
const setSettingMultation = useMutation({
|
||||
mutationKey: ["setting", data.id],
|
||||
mutationFn: (value: any) =>
|
||||
client.api.settings({ id: data.id! }).post({ value }).then(d => d.status)
|
||||
});
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
if (dirty)
|
||||
{
|
||||
setDirty(false);
|
||||
setSettingMultation.mutate(localValue);
|
||||
}
|
||||
}, [dirty, setDirty, localValue]);
|
||||
|
||||
return (
|
||||
<OptionSpace label={data.label}>
|
||||
<OptionInput
|
||||
icon={data.icon}
|
||||
name={data.id ?? ""}
|
||||
type={data.type}
|
||||
placeholder={data.placeholder}
|
||||
onBlur={handleSave}
|
||||
onChange={(e) =>
|
||||
{
|
||||
setLocalValue(e.currentTarget.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
value={localValue}
|
||||
/>
|
||||
</OptionSpace>
|
||||
);
|
||||
}
|
||||
|
||||
function Button (data: { children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: data.type,
|
||||
onEnterPress: data.onAction,
|
||||
onFocus: data.onFocus,
|
||||
focusable: !data.disabled
|
||||
});
|
||||
return <button
|
||||
ref={ref}
|
||||
onClick={data.onAction}
|
||||
disabled={data.disabled}
|
||||
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg", classNames({
|
||||
"btn-accent": focused
|
||||
}, data.className))}
|
||||
type={data.type}
|
||||
>
|
||||
{data.children}
|
||||
</button>;
|
||||
}
|
||||
|
||||
function LoginControls (data: { hasPassword: boolean; })
|
||||
{
|
||||
const user = useQuery({
|
||||
|
|
@ -128,7 +40,7 @@ function LoginControls (data: { hasPassword: boolean; })
|
|||
context.state.canSubmit;
|
||||
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
|
||||
const logoutMutation = useMutation({
|
||||
mutationKey: ["romm", "auth", "logout"], mutationFn: () => client.api.romm.logout.post(),
|
||||
mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post(),
|
||||
onSuccess: async (d, v, r, c) =>
|
||||
{
|
||||
c.client.invalidateQueries({ queryKey: ["romm", "auth"] });
|
||||
|
|
@ -167,10 +79,9 @@ function RouteComponent ()
|
|||
preferredChildFocusKey: focus
|
||||
});
|
||||
|
||||
const { data: hasPassword } = useQuery({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => client.api.romm.login.get().then(d => d.data?.hasPassword as boolean) });
|
||||
const { data: hostname } = useQuery({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => client.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) });
|
||||
const { data: username } = useQuery({ queryKey: ['romm', 'auth', 'username'], queryFn: () => client.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) });
|
||||
|
||||
const { data: hasPassword } = useQuery({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) });
|
||||
const { data: hostname } = useQuery({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) });
|
||||
const { data: username } = useQuery({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) });
|
||||
|
||||
const loginForm = useSettingsForm({
|
||||
defaultValues: {
|
||||
|
|
@ -210,7 +121,7 @@ function RouteComponent ()
|
|||
mutationKey: ["romm", "login"],
|
||||
mutationFn: (data: z.infer<typeof dataSchema>) =>
|
||||
{
|
||||
return client.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
|
||||
return rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
|
||||
},
|
||||
onSuccess: (d, v, r, c) =>
|
||||
{
|
||||
|
|
|
|||
242
src/mainview/routes/settings/directories.tsx
Normal file
242
src/mainview/routes/settings/directories.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { SettingsOption } from '../../components/options/SettingsOption';
|
||||
import { OptionSpace } from '../../components/options/OptionSpace';
|
||||
import { OptionInput } from '../../components/options/OptionInput';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { settingsApi } from '../../scripts/clientApi';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '../../components/options/Button';
|
||||
import { Check, ChevronDown, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
|
||||
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
|
||||
import classNames from 'classnames';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { RPC_URL } from '../../../shared/constants';
|
||||
import emulators from '@emulators';
|
||||
|
||||
export const Route = createFileRoute('/settings/directories')({
|
||||
component: RouteComponent,
|
||||
pendingComponent: EmulatorsPending,
|
||||
});
|
||||
|
||||
function EmulatorsPending ()
|
||||
{
|
||||
return <div className="flex flex-col p-2 px-3 w-full h-full">
|
||||
<div className="flex flex-col justify-center items-center grow">
|
||||
<span className="loading loading-dots loading-xl"></span>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function EmulatorListCat (data: { selected: string, set: (c: string) => void; })
|
||||
{
|
||||
const { ref, 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" />
|
||||
)}
|
||||
</FocusContext>
|
||||
</ul>;
|
||||
}
|
||||
|
||||
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 => ({
|
||||
id: e,
|
||||
action: (ctx) =>
|
||||
{
|
||||
data.action(e);
|
||||
ctx.close();
|
||||
},
|
||||
type: 'primary',
|
||||
content: e
|
||||
} satisfies DialogEntry))} />
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function NewEmulatorPath (data: {})
|
||||
{
|
||||
const [newEmulatorTypeOpen, setNewEmulatorTypeOpen] = useState(false);
|
||||
const [newEmulatorContextCat, setNewEmulatorContextCat] = useState('A');
|
||||
const handleCloseContext = () =>
|
||||
{
|
||||
setNewEmulatorTypeOpen(false);
|
||||
setFocus('emulator');
|
||||
};
|
||||
const addOverrideMutation = useMutation({
|
||||
mutationKey: ['emulator', 'custom', 'add'],
|
||||
mutationFn: async (id: string) =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
|
||||
});
|
||||
|
||||
return <OptionSpace label={"Custom Emulator Path"}>
|
||||
<Button disabled={addOverrideMutation.isPending} id='emulator' type='button' onAction={() => setNewEmulatorTypeOpen(true)} >
|
||||
Emulator
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
<ContextDialog open={newEmulatorTypeOpen} id='new-emulator-type-context' close={handleCloseContext}>
|
||||
<div className='flex flex-col'>
|
||||
<EmulatorListCat selected={newEmulatorContextCat} set={setNewEmulatorContextCat} />
|
||||
<div className="divider mb-1 mt-2"></div>
|
||||
<EmulatorListType category={newEmulatorContextCat} action={e =>
|
||||
{
|
||||
addOverrideMutation.mutate(e);
|
||||
}} />
|
||||
</div>
|
||||
</ContextDialog>
|
||||
</OptionSpace>;
|
||||
}
|
||||
|
||||
function EmulatorPath (data: { id: string; })
|
||||
{
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const { data: remoteValue } = useQuery({
|
||||
enabled: !!data.id,
|
||||
queryKey: ["emulator", data.id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).get();
|
||||
if (error) throw error;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
const setSettingMutation = useMutation({
|
||||
mutationKey: ["emulator", data.id, 'set'],
|
||||
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: data.id }).put({ value }),
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
{
|
||||
ctx.client.invalidateQueries({ queryKey: ["emulator", data.id] });
|
||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
}
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: ["emulator", data.id, 'delete'],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).delete();
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
{
|
||||
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
|
||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
if (dirty)
|
||||
{
|
||||
setDirty(false);
|
||||
setSettingMutation.mutate(localValue ?? '');
|
||||
}
|
||||
}, [dirty, setDirty, localValue]);
|
||||
|
||||
return (
|
||||
<OptionSpace label={<><p className='font-semibold'>{data.id}</p><small className='text-base-content/40'>{emulators[data.id]}</small></>}>
|
||||
<div className='flex gap-2'>
|
||||
<OptionInput
|
||||
name={data.id ?? ""}
|
||||
type="text"
|
||||
onBlur={handleSave}
|
||||
autocomplete="off"
|
||||
defaultValue={remoteValue}
|
||||
onChange={(e) =>
|
||||
{
|
||||
setLocalValue(e.currentTarget.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
value={localValue}
|
||||
/>
|
||||
<Button id={`delete-${data.id}`} className='p-2' onAction={() => deleteMutation.mutate()} type='button' >
|
||||
<Trash />
|
||||
</Button>
|
||||
</div>
|
||||
</OptionSpace>
|
||||
);
|
||||
}
|
||||
|
||||
function EmulatorBadge (data: { path?: string, exists: boolean, emulator: string; pathCover?: string; })
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: `badge-${data.emulator}`, onFocus: () =>
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
return <div className={classNames("tooltip tooltip-primary", { "tooltip-open": focused })} data-tip={`${emulators[data.emulator]}`}>
|
||||
<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
|
||||
|
||||
}))
|
||||
}>
|
||||
<p className='flex gap-2 font-semibold'>
|
||||
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className='text-warning' />}
|
||||
{!!data.pathCover && <img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${data.pathCover}`}></img>}
|
||||
{data.emulator}
|
||||
</p>
|
||||
{data.path ? <small className={classNames('opacity-60 max-w-full overflow-clip text-nowrap text-ellipsis', { 'text-error': !data.exists })}>{data.path}</small> : ""}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function EmulatorBadges (data: { path?: string; })
|
||||
{
|
||||
const { data: autoEmulators } = useQuery({ queryKey: ['auto-emulators'], queryFn: async () => settingsApi.api.settings.emulators.automatic.get() });
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators?.data && autoEmulators.data.length > 0 });
|
||||
return <div ref={ref} className='flex flex-wrap gap-2 justify-center-safe'>
|
||||
<FocusContext value={focusKey}>
|
||||
{autoEmulators?.data?.map(e => <EmulatorBadge pathCover={e.path_cover ?? undefined} path={e.path} exists={e.exists} emulator={e.emulator} />)}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = Route.useSearch();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
preferredChildFocusKey: focus
|
||||
});
|
||||
const { data: customEmulators } = useQuery({
|
||||
queryKey: ['custom-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
return <FocusContext value={focusKey}>
|
||||
<ul ref={ref} className="list rounded-box gap-2">
|
||||
<div className="divider text-2xl mt-0 md:mt-4">
|
||||
<div className="flex flex-col">
|
||||
<h3>Romm</h3>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsOption label="Download Path" id="downloadPath" type="text" />
|
||||
<div className="divider text-2xl mt-0 md:mt-4">
|
||||
<div className="flex flex-col">
|
||||
<h3>Emulatos</h3>
|
||||
</div>
|
||||
</div>
|
||||
<EmulatorBadges />
|
||||
<div className="divider text-base-content/40">Overrides</div>
|
||||
<NewEmulatorPath />
|
||||
{!!customEmulators && customEmulators.map((key) => <EmulatorPath key={key} id={key} />)}
|
||||
</ul>
|
||||
</FocusContext>;
|
||||
}
|
||||
|
|
@ -17,16 +17,18 @@ import
|
|||
{
|
||||
ArrowBigLeft,
|
||||
FingerprintPattern,
|
||||
HardDrive,
|
||||
Info,
|
||||
MonitorCog,
|
||||
} from "lucide-react";
|
||||
import { JSX, useEffect } from "react";
|
||||
import { JSX, useEffect, useRef } from "react";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import ShortcutPrompt from "../../components/ShortcutPrompt";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import z from "zod";
|
||||
import { SettingsSchema } from "../../../shared/constants";
|
||||
import { PopSource } from "../../scripts/spatialNavigation";
|
||||
import { Router } from "../..";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: SettingsUI,
|
||||
|
|
@ -78,8 +80,9 @@ function MenuItem (data: {
|
|||
className={twMerge(
|
||||
"group rounded-full p-3 pl-5 text-base-content/80",
|
||||
classNames({
|
||||
"bg-primary/40 text-primary-content": !focused && acitve,
|
||||
"bg-primary text-primary-content font-semibold": focused,
|
||||
"bg-primary text-primary-content": acitve,
|
||||
"font-semibold ring-7 ring-primary-content": focused,
|
||||
"bg-secondary text-secondary-content ring-primary": data.return && focused,
|
||||
}),
|
||||
data.linkClassName,
|
||||
)}
|
||||
|
|
@ -100,7 +103,7 @@ function SettingsMenu (data: {})
|
|||
const { ref, focusKey } = useFocusable({
|
||||
focusable: true,
|
||||
focusKey: 'settings-menu',
|
||||
preferredChildFocusKey: "/settings/accounts"
|
||||
preferredChildFocusKey: location.hash.replace("#", '')
|
||||
});
|
||||
|
||||
return <ul
|
||||
|
|
@ -120,6 +123,12 @@ function SettingsMenu (data: {})
|
|||
label="Visual"
|
||||
icon={<MonitorCog />}
|
||||
/>
|
||||
<MenuItem
|
||||
focusSelect
|
||||
route="/settings/directories"
|
||||
label="Directories"
|
||||
icon={<HardDrive />}
|
||||
/>
|
||||
<MenuItem
|
||||
focusSelect
|
||||
route="/settings/about"
|
||||
|
|
@ -138,15 +147,32 @@ function SettingsMenu (data: {})
|
|||
</ul>;
|
||||
}
|
||||
|
||||
function HandleGoBack ()
|
||||
{
|
||||
|
||||
if (document.activeElement && document.activeElement !== document.body && document.activeElement instanceof HTMLElement)
|
||||
{
|
||||
document.activeElement.blur();
|
||||
} else
|
||||
{
|
||||
const source = PopSource('settings');
|
||||
if (source)
|
||||
{
|
||||
console.log("Found source ", source, " to go back to");
|
||||
}
|
||||
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function SettingsUI ()
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "settings-page-layout",
|
||||
preferredChildFocusKey: 'settings-menu'
|
||||
});
|
||||
|
||||
useEventListener("cancel", () => navigate({ to: PopSource('settings') ?? "/", viewTransition: { types: ['zoom-out'] } }));
|
||||
useEventListener("cancel", HandleGoBack, ref);
|
||||
useEffect(() =>
|
||||
{
|
||||
focusSelf();
|
||||
|
|
@ -166,7 +192,7 @@ export function SettingsUI ()
|
|||
</div>
|
||||
<div className="divider divider-end">
|
||||
<ShortcutPrompt
|
||||
onClick={() => navigate({ to: "/" })}
|
||||
onClick={HandleGoBack}
|
||||
icon="steamdeck_button_b"
|
||||
label="Back"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue