feat: massive front-end overhaul and initial github release
This commit is contained in:
parent
a2b40e38bf
commit
d5a0e70580
303 changed files with 19840 additions and 676 deletions
|
|
@ -1,225 +0,0 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Power,
|
||||
Sun,
|
||||
Wifi,
|
||||
BatteryFull,
|
||||
Gamepad2,
|
||||
Bluetooth,
|
||||
Settings2,
|
||||
Bell,
|
||||
HardDrive,
|
||||
} from "lucide-react";
|
||||
import { createFileRoute, Link, linkOptions } from "@tanstack/react-router";
|
||||
import "gamepad.css/styles.min.css";
|
||||
import GamepadIcon from "../components/GamepadIcon";
|
||||
import Clock from "../components/Clock";
|
||||
import classNames from "classnames";
|
||||
|
||||
export const Route = createFileRoute("/Dashboard")({
|
||||
component: ConsoleHomeUI,
|
||||
});
|
||||
|
||||
const games = [
|
||||
{
|
||||
title: "The Legend of Zelda",
|
||||
subtitle: "Link's Awakening",
|
||||
},
|
||||
{
|
||||
title: "Captain Toad",
|
||||
subtitle: "Treasure Tracker",
|
||||
focused: true,
|
||||
},
|
||||
{
|
||||
title: "Crash Bandicoot",
|
||||
subtitle: "N. Sane Trilogy",
|
||||
},
|
||||
{
|
||||
title: "Super Mario",
|
||||
subtitle: "Odyssey",
|
||||
},
|
||||
{
|
||||
title: "Animal Crossing",
|
||||
subtitle: "New Horizons",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ConsoleHomeUI() {
|
||||
const [focus, setFocus] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowRight")
|
||||
setFocus((i) => Math.min(i + 1, games.length - 1));
|
||||
if (e.key === "ArrowLeft") setFocus((i) => Math.max(i - 1, 0));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex flex-col overflow-hidden justify-around"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
color-mix(in srgb, var(--color-dark) 60%, transparent),
|
||||
color-mix(in srgb, var(--color-dark) 60%, transparent)
|
||||
), url(https://picsum.photos/id/${10 + focus}/1920/1080.webp?blur=10)`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<header className="h-14 px-6 mt-2 flex items-center justify-between text-white">
|
||||
<div className="flex items-center gap-3 drop-shadow-sm">
|
||||
<div className="w-16 h-16 rounded-full bg-alert" />
|
||||
<div className="w-16 h-16 rounded-full bg-cyan-500 ring-4 ring-primary" />
|
||||
<button className="w-16 h-16 rounded-full bg-dark flex items-center justify-center">
|
||||
<Plus className="w-8 h-8" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5 text drop-shadow-sm">
|
||||
<Clock />
|
||||
<Wifi className="w-6 h-6" />
|
||||
<Bluetooth className="w-6 h-6" />
|
||||
<Bell className="w-6 h-6" />
|
||||
<div className="flex gap-2 items-center">
|
||||
<BatteryFull className="w-6 h-6" />
|
||||
<span className="font-semibold">100%</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center text-dark bg-white">
|
||||
<Sun className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center text-dark bg-white">
|
||||
<Power className="w-8 h-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex items-center justify-center px-8 gap-2 py-3 drop-shadow-sm">
|
||||
<button className="flex w-14 h-14 items-center justify-center bg-dark rounded-full text-white">
|
||||
<Settings2 className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex bg-dark rounded-full p-1">
|
||||
<button className="px-4 h-12 rounded-full text-white/70">All</button>
|
||||
<button className="px-4 h-12 rounded-full bg-primary drop-shadow-sm text-black font-bold">
|
||||
Digital
|
||||
</button>
|
||||
<button className="px-4 h-12 rounded-full text-white/70">
|
||||
Physical
|
||||
</button>
|
||||
</div>
|
||||
<button className="flex w-14 h-14 items-center justify-center bg-dark rounded-full text-white">
|
||||
<Search className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Game carousel */}
|
||||
<main
|
||||
className="flex w-full px-8 py-4 overflow-x-scroll items-center gap-6"
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
{games.map((g, i) => {
|
||||
const focused = i === focus;
|
||||
return (
|
||||
<div
|
||||
key={g.title}
|
||||
className={classNames(
|
||||
`min-w-64 h-82 rounded-2xl bg-dark flex flex-col justify-end overflow-hidden transition-all duration-200 drop-shadow-md`,
|
||||
{
|
||||
"ring-7 ring-primary scale-105": focused,
|
||||
"drop-shadow-lg": focused,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex-1 bg-white p-4"
|
||||
style={{
|
||||
backgroundImage: `url(https://picsum.photos/id/${10 + i}/300/300.webp)`,
|
||||
}}
|
||||
></div>
|
||||
<div className="h-0 flex pr-2 justify-end items-center">
|
||||
<div className="flex rounded-full bg-white w-10 h-10 justify-center items-center text-dark drop-shadow-sm">
|
||||
<HardDrive className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col p-4 pt-6 text-light2">
|
||||
<div className="text-xl font-bold">{g.title}</div>
|
||||
<div className="text-s">{g.subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
|
||||
{/* Menu */}
|
||||
|
||||
<div className="flex w-full items-center justify-center gap-3">
|
||||
<CircleIcon
|
||||
to={linkOptions({
|
||||
to: "/Dashboard",
|
||||
})}
|
||||
label="Home"
|
||||
active
|
||||
/>
|
||||
<CircleIcon label="News" />
|
||||
<CircleIcon label="Shop" />
|
||||
<CircleIcon label="Album" />
|
||||
<CircleIcon label="Controllers" />
|
||||
<CircleIcon label="Settings" highlight />
|
||||
<span className="flex items-center rounded-full bg-primary text-dark px-4 py-2 font-semibold">
|
||||
Settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<footer className="px-8 flex flex-col items-center justify-between text-light2">
|
||||
<div className="flex gap-2 text-sm text-light2">
|
||||
<span className="flex gap-2 bg-dark pl-2 pr-3 py-1.5 rounded-full items-center text-lg font-semibold drop-shadow-sm">
|
||||
<GamepadIcon platform="xbox" variant="one" button="a" text="a" />
|
||||
Continue
|
||||
</span>
|
||||
<span className="flex gap-2 bg-dark pl-2 pr-3 py-1.5 rounded-full items-center text-lg font-semibold drop-shadow-sm">
|
||||
<GamepadIcon platform="xbox" variant="one" button="b" text="b" />
|
||||
Back
|
||||
</span>
|
||||
<span className="flex gap-2 bg-dark pl-2 pr-3 py-1.5 rounded-full items-center text-lg font-semibold drop-shadow-sm">
|
||||
<GamepadIcon platform="xbox" variant="one" button="x" text="x" />
|
||||
Close
|
||||
</span>
|
||||
<span className="flex gap-2 bg-dark pl-2 pr-3 py-1.5 rounded-full items-center text-lg font-semibold drop-shadow-sm">
|
||||
<GamepadIcon platform="xbox" variant="one" button="y" text="y" />
|
||||
Options
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CircleIcon({
|
||||
to,
|
||||
active,
|
||||
highlight,
|
||||
}: {
|
||||
to?: any;
|
||||
active?: boolean;
|
||||
highlight?: boolean;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
{...to}
|
||||
className={`w-20 h-20 rounded-full flex items-center justify-center text-dark drop-shadow-lg
|
||||
${highlight === true ? "bg-primary" : active === true ? "bg-alert text-white" : "bg-white"}`}
|
||||
>
|
||||
<Gamepad2 className="w-10 h-10" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Bell, Library, Store, Settings, Gamepad2 } from "lucide-react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
const games = [
|
||||
"Halo Infinite",
|
||||
"Cyberpunk",
|
||||
"Hades",
|
||||
"Stardew Valley",
|
||||
"Neon Skies",
|
||||
"Void Runner",
|
||||
"Rogue Light",
|
||||
"Drift City",
|
||||
];
|
||||
|
||||
export const Route = createFileRoute("/GameDetails")({
|
||||
loader: ({ params }) => params.postId,
|
||||
component: GameDetailsUI,
|
||||
});
|
||||
|
||||
export function GameDetailsUI() {
|
||||
// In a component!
|
||||
const { postId } = Route.useParams();
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-10 flex flex-col gap-10">
|
||||
{/* Header */}
|
||||
<header className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-slate-400">Now Playing</div>
|
||||
<h1 className="text-3xl font-semibold text-cyan-400">
|
||||
Halo Infinite
|
||||
</h1>
|
||||
<div className="mt-2 text-slate-400 text-sm">
|
||||
Action · FPS · Sci-Fi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-slate-300">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="text-sm">3</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content split */}
|
||||
<section className="flex gap-10 flex-1">
|
||||
{/* Cover / media */}
|
||||
<div className="w-[360px] shrink-0">
|
||||
<div className="relative h-[480px] rounded-3xl bg-gradient-to-br from-slate-700/60 to-slate-900/90 ring-4 ring-cyan-400/80 shadow-[0_0_50px_rgba(34,211,238,0.6)]" />
|
||||
|
||||
{/* Primary action */}
|
||||
<button className="mt-6 w-full rounded-xl bg-cyan-400 text-black font-semibold py-3 text-lg shadow-[0_0_30px_rgba(34,211,238,0.6)]">
|
||||
▶ Play
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex-1 flex flex-col gap-6">
|
||||
{/* Description */}
|
||||
<p className="text-slate-300 leading-relaxed max-w-3xl">
|
||||
Experience the epic sci-fi saga and master chief’s greatest journey
|
||||
yet. Explore vast open worlds, engage in tactical combat, and
|
||||
uncover the mysteries of Zeta Halo.
|
||||
</p>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-6 max-w-3xl">
|
||||
<Detail label="Developer" value="343 Industries" />
|
||||
<Detail label="Publisher" value="Xbox Game Studios" />
|
||||
<Detail label="Release" value="Dec 8, 2021" />
|
||||
<Detail label="Playtime" value="42 hours" />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-4 mt-4">
|
||||
<SecondaryButton label="Achievements" />
|
||||
<SecondaryButton label="DLC" />
|
||||
<SecondaryButton label="Settings" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer hints */}
|
||||
<footer className="text-sm text-slate-400 flex gap-6">
|
||||
<span>A Play</span>
|
||||
<span>B Back</span>
|
||||
<span>Y Options</span>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-slate-500">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-slate-200 text-sm mt-1">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryButton({ label }: { label: string }) {
|
||||
return (
|
||||
<button className="px-5 py-3 rounded-xl bg-white/5 hover:bg-white/10 text-slate-200">
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,23 @@
|
|||
import { Link, Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { Gamepad2, Library, Settings, Store } from "lucide-react";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { RouterContext } from "..";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
function RootComponent ()
|
||||
{
|
||||
return (
|
||||
<div className="w-screen h-screen overflow-hidden">
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
</div>
|
||||
{import.meta.env.DEV && false &&
|
||||
<>
|
||||
<TanStackRouterDevtools position="top-left" />
|
||||
<ReactQueryDevtools buttonPosition="top-right" />
|
||||
</>
|
||||
}
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
|
|
|||
28
src/mainview/routes/collection/$id.tsx
Normal file
28
src/mainview/routes/collection/$id.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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';
|
||||
|
||||
export const Route = createFileRoute('/collection/$id')({
|
||||
component: RouteComponent,
|
||||
loader: ({ params, context }) => context.queryClient.fetchQuery({
|
||||
...getRomsApiRomsGetOptions({ query: { collection_id: Number(params.id) } }),
|
||||
staleTime: DefaultRommStaleTime,
|
||||
})
|
||||
});
|
||||
|
||||
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 (
|
||||
<CollectionsDetail setBackground={setBackground} filters={{ collectionId: Number(id) }} />
|
||||
);
|
||||
}
|
||||
241
src/mainview/routes/game/$id.tsx
Normal file
241
src/mainview/routes/game/$id.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
326
src/mainview/routes/index.tsx
Normal file
326
src/mainview/routes/index.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import { JSX, Suspense, useContext } from "react";
|
||||
import
|
||||
{
|
||||
Gamepad2,
|
||||
Settings,
|
||||
MessageSquare,
|
||||
ShoppingBag,
|
||||
Image,
|
||||
Search,
|
||||
Power,
|
||||
OctagonAlert,
|
||||
} from "lucide-react";
|
||||
import
|
||||
{
|
||||
createFileRoute,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import
|
||||
{
|
||||
FocusContext,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||
import { useLocalStorage, useSessionStorage } from "usehooks-ts";
|
||||
import
|
||||
{
|
||||
getCollectionsApiCollectionsGetOptions,
|
||||
getPlatformsApiPlatformsGetOptions,
|
||||
} from "../../clients/romm/@tanstack/react-query.gen";
|
||||
import { CardList } from "../components/CardList";
|
||||
import { HeaderUI } from "../components/Header";
|
||||
import { FilterUI } from "../components/Filters";
|
||||
import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground";
|
||||
import { GameList } from "../components/GameList";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import LoadingCardList from "../components/LoadingCardList";
|
||||
import { AutoFocus } from "../components/AutoFocus";
|
||||
import SaveScroll from "../components/SaveScroll";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Shortcuts from "../components/Shortcuts";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: ConsoleHomeUI,
|
||||
|
||||
});
|
||||
|
||||
const filters = {
|
||||
consoles: {
|
||||
label: "Consoles",
|
||||
},
|
||||
games: {
|
||||
label: "Games",
|
||||
},
|
||||
collections: {
|
||||
label: "Collections",
|
||||
},
|
||||
};
|
||||
|
||||
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; })
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { data: collections } = useSuspenseQuery({
|
||||
...getCollectionsApiCollectionsGetOptions(),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: DefaultRommStaleTime
|
||||
});
|
||||
|
||||
return (
|
||||
<CardList
|
||||
type="collection"
|
||||
id={data.id}
|
||||
games={collections.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at))
|
||||
.map((g) => ({
|
||||
id: 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: (
|
||||
<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>
|
||||
),
|
||||
}))}
|
||||
onSelectGame={(id) =>
|
||||
{
|
||||
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
}}
|
||||
onGameFocus={(id) =>
|
||||
{
|
||||
data.setBackground(
|
||||
`https://picsum.photos/id/${10 + (id ?? 0)}/1920/1080.webp`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeList (data: {
|
||||
selectedFilter: keyof typeof filters;
|
||||
})
|
||||
{
|
||||
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} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<FocusContext value={focusKey}>
|
||||
<div ref={ref} className="flex overflow-x-scroll no-scrollbar pb-3 mb-1">
|
||||
<div className="flex px-16">
|
||||
<ErrorBoundary fallback={
|
||||
<div role="alert" className="alert alert-error alert-outline">
|
||||
<OctagonAlert />
|
||||
<span>Error! Task failed successfully.</span>
|
||||
</div>
|
||||
}>
|
||||
<Suspense key={data.selectedFilter} fallback={<LoadingCardList placeholderCount={8} />}>
|
||||
{lists[data.selectedFilter]}
|
||||
<SaveScroll id={`card-list-${data.selectedFilter}`} ref={ref} />
|
||||
<AutoFocus focus={focusSelf} delay={10} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</FocusContext>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConsoleHomeUI ()
|
||||
{
|
||||
const [selectedFilter, setSelectedFilter] = useLocalStorage<
|
||||
keyof typeof filters
|
||||
>("home-filter-selected", "games");
|
||||
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
forceFocus: true,
|
||||
autoRestoreFocus: false,
|
||||
saveLastFocusedChild: false,
|
||||
focusKey: "Home",
|
||||
preferredChildFocusKey: `home-list`,
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatedBackground animated ref={ref} backgroundKey="home-background">
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<div className="px-3 w-full pt-2">
|
||||
<HeaderUI buttons={[
|
||||
{ id: "search", icon: <Search /> },
|
||||
{ id: "power-button", icon: <Power />, external: true }
|
||||
]} />
|
||||
</div>
|
||||
<div className="flex w-full flex-col grow justify-evenly">
|
||||
<FilterUI
|
||||
id="home"
|
||||
options={filters}
|
||||
selected={selectedFilter}
|
||||
setSelected={setSelectedFilter as any}
|
||||
/>
|
||||
<div className="-mb-1">
|
||||
<HomeList
|
||||
selectedFilter={selectedFilter}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<MainMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="px-2 pb-2 flex items-center justify-between">
|
||||
<div className="flex gap-2 text-sm">
|
||||
</div>
|
||||
<Shortcuts />
|
||||
</footer>
|
||||
</FocusContext.Provider>
|
||||
</AnimatedBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function MainMenu (data: {})
|
||||
{
|
||||
const { ref, focusKey, hasFocusedChild } = useFocusable({
|
||||
focusKey: `main-menu`,
|
||||
trackChildren: true,
|
||||
onBlur: (layout, props, details) => { },
|
||||
});
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<ul
|
||||
ref={ref}
|
||||
save-child-focus="session"
|
||||
className="flex items-center justify-center gap-3"
|
||||
>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<CircleIcon
|
||||
action={() => navigate({ to: "/" })}
|
||||
icon={<Gamepad2 />}
|
||||
label="Home"
|
||||
type="secondary"
|
||||
/>
|
||||
<CircleIcon icon={<MessageSquare />} label="News" />
|
||||
<CircleIcon icon={<ShoppingBag />} label="Shop" />
|
||||
<CircleIcon icon={<Image />} label="Album" />
|
||||
<CircleIcon
|
||||
icon={<Gamepad2 />}
|
||||
label="Controllers"
|
||||
/>
|
||||
<CircleIcon
|
||||
action={() =>
|
||||
{
|
||||
SaveSource('settings', location.pathname);
|
||||
navigate({ to: "/settings/accounts", viewTransition: { types: ['zoom-in'] } });
|
||||
}}
|
||||
icon={<Settings />}
|
||||
label="Settings"
|
||||
type="accent"
|
||||
/>
|
||||
</FocusContext.Provider>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function CircleIcon (data: {
|
||||
action?: () => void;
|
||||
type?: "secondary" | "accent";
|
||||
label?: string;
|
||||
icon?: JSX.Element;
|
||||
})
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: `navigation-icon-${data.label}`,
|
||||
onEnterPress: data.action,
|
||||
});
|
||||
const typeClasses = {
|
||||
secondary: "bg-secondary text-secondary-content",
|
||||
accent: "bg-accent text-accent-content",
|
||||
none: "bg-base-content",
|
||||
};
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
onClick={data.action}
|
||||
className={twMerge(
|
||||
`menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all`,
|
||||
'sm:w-14 sm:h-14',
|
||||
typeClasses[data.type ?? "none"], classNames(
|
||||
{
|
||||
"ring-7 ring-primary drop-shadow-2xl": focused,
|
||||
"hover:ring-7 hover:ring-primary": true,
|
||||
})
|
||||
)}
|
||||
>
|
||||
{data.icon}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
62
src/mainview/routes/platform/$id.tsx
Normal file
62
src/mainview/routes/platform/$id.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
385
src/mainview/routes/settings/accounts.tsx
Normal file
385
src/mainview/routes/settings/accounts.tsx
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import
|
||||
{
|
||||
FocusContext,
|
||||
FocusDetails,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { QueriesResults, useIsMutating, useMutation, useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { createFileRoute, useSearch } from "@tanstack/react-router";
|
||||
import classNames from "classnames";
|
||||
import { DoorOpen, Key, Link, Lock, User } from "lucide-react";
|
||||
import
|
||||
{
|
||||
ChangeEventHandler,
|
||||
createContext,
|
||||
FocusEventHandler,
|
||||
HTMLInputTypeAttribute,
|
||||
JSX,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { client } from "../..";
|
||||
import { SettingsType } from "../../../shared/constants";
|
||||
import
|
||||
{
|
||||
getCurrentUserApiUsersMeGetOptions,
|
||||
loginApiLoginPostMutation,
|
||||
logoutApiLogoutPostMutation,
|
||||
statsApiStatsGetOptions,
|
||||
} from "../../../clients/romm/@tanstack/react-query.gen";
|
||||
import { useToasters } from "../../contexts/ToasterContext";
|
||||
import { UserSchema } from "../../../clients/romm";
|
||||
import toast from "react-hot-toast";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const Route = createFileRoute("/settings/accounts")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
const OptionContext = createContext(
|
||||
{} as {
|
||||
focused: boolean;
|
||||
focus: (focusDetails?: FocusDetails | undefined) => void;
|
||||
eventTarget: EventTarget;
|
||||
},
|
||||
);
|
||||
|
||||
function useOptionContext (params?: { onOptionEnterPress?: () => void; })
|
||||
{
|
||||
const context = useContext(OptionContext);
|
||||
useEffect(() =>
|
||||
{
|
||||
if (params?.onOptionEnterPress)
|
||||
{
|
||||
context.eventTarget.addEventListener(
|
||||
"onEnterPress",
|
||||
params.onOptionEnterPress,
|
||||
);
|
||||
}
|
||||
|
||||
return () =>
|
||||
{
|
||||
if (params?.onOptionEnterPress)
|
||||
{
|
||||
context.eventTarget.removeEventListener(
|
||||
"onEnterPress",
|
||||
params.onOptionEnterPress,
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [context.eventTarget]);
|
||||
return context;
|
||||
}
|
||||
|
||||
function OptionSpace (data: {
|
||||
id?: string;
|
||||
className?: string;
|
||||
focusable?: boolean;
|
||||
children: JSX.Element;
|
||||
label?: string | JSX.Element;
|
||||
})
|
||||
{
|
||||
const eventTarget = useMemo(() => new EventTarget(), []);
|
||||
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
|
||||
focusKey: data.id,
|
||||
focusable: data.focusable !== false,
|
||||
trackChildren: true,
|
||||
onEnterPress ()
|
||||
{
|
||||
eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));
|
||||
},
|
||||
});
|
||||
|
||||
return (<FocusContext value={focusKey}>
|
||||
<OptionContext value={{ focused, focus: focusSelf, eventTarget }}>
|
||||
<li
|
||||
ref={ref}
|
||||
className={twMerge("flex sm:p-2 md:p-4 pl-8! rounded-full bg-base-content/1", classNames(
|
||||
{
|
||||
"text-primary-content bg-primary ": focused || hasFocusedChild,
|
||||
}),
|
||||
data.className,
|
||||
)}
|
||||
>
|
||||
{typeof data.label === "string" ? (
|
||||
<label
|
||||
className={classNames("label flex-1 md:text-lg pr-4", {
|
||||
"text-primary-content font-semibold": focused,
|
||||
})}
|
||||
>
|
||||
{data.label}
|
||||
</label>
|
||||
) : (
|
||||
data.label
|
||||
)}
|
||||
{data.children}
|
||||
</li>
|
||||
</OptionContext>
|
||||
</FocusContext>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionInput (data: {
|
||||
name: string;
|
||||
type: HTMLInputTypeAttribute;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
value?: string;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
})
|
||||
{
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const option = useOptionContext({
|
||||
onOptionEnterPress ()
|
||||
{
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent">
|
||||
<span className={twMerge("text-base-content/80", classNames({
|
||||
"text-primary-content": option.focused
|
||||
}))}>{data.icon}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={data.name}
|
||||
name={data.name}
|
||||
value={data.value}
|
||||
type={data.type}
|
||||
onFocus={() => option.focus()}
|
||||
placeholder={data.placeholder}
|
||||
onChange={data.onChange}
|
||||
onBlur={data.onBlur}
|
||||
className={classNames(
|
||||
"input grow rounded-full ring-primary-content focus:ring-3",
|
||||
data.className,
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
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()).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, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: data.type,
|
||||
onEnterPress: data.onAction,
|
||||
onFocus: data.onFocus
|
||||
});
|
||||
return <button
|
||||
ref={ref}
|
||||
onClick={data.onAction}
|
||||
disabled={data.disabled}
|
||||
className={classNames("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg", {
|
||||
"btn-accent": focused
|
||||
})}
|
||||
type={data.type}
|
||||
>
|
||||
{data.children}
|
||||
</button>;
|
||||
}
|
||||
|
||||
function LoginControls (data: { user: UseQueryResult<UserSchema | null, Error>; })
|
||||
{
|
||||
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
|
||||
const logoutMutation = useMutation({
|
||||
mutationKey: ["romm", "auth", "logout"], mutationFn: () => window.cookieStore.delete({ name: "romm_session" }),
|
||||
onSuccess: async (d, v, r, c) =>
|
||||
{
|
||||
c.client.invalidateQueries({ queryKey: ["romm", "auth"] });
|
||||
}
|
||||
});
|
||||
return <div className="flex gap-2 items-center">
|
||||
{data.user.isError && <div className="badge badge-error gap-2 tooltip" data-tip={(data.user.error as any)?.detail ?? ''}>
|
||||
<Lock className="size-4" /></div>}
|
||||
{data.user.isSuccess && <div className="badge badge-success badge-lg rounded-full gap-2">Logged In As: <b>{data.user.data?.username}</b></div>}
|
||||
<Button disabled={isMutatingRomm} type="submit" >
|
||||
<Lock /> Login
|
||||
</Button>
|
||||
<Button onAction={() =>
|
||||
{
|
||||
toast("Logout", { id: 'romm-logout-noti' });
|
||||
logoutMutation.mutate();
|
||||
}} disabled={isMutatingRomm} type="button" >
|
||||
<DoorOpen /> Logout
|
||||
</Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = Route.useSearch();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
preferredChildFocusKey: focus
|
||||
});
|
||||
const rommOnline = useQuery({
|
||||
...statsApiStatsGetOptions(),
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const user = useQuery({
|
||||
...getCurrentUserApiUsersMeGetOptions(),
|
||||
queryKey: ['romm', 'auth', "login"],
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 0
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (focus)
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
}, [focus]);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationKey: ["romm", "login"],
|
||||
...loginApiLoginPostMutation(),
|
||||
onSuccess: (d, v, r, c) =>
|
||||
{
|
||||
c.client.invalidateQueries({ queryKey: ['romm', 'auth'] });
|
||||
},
|
||||
onError: (e) =>
|
||||
{
|
||||
console.error(e);
|
||||
},
|
||||
});
|
||||
|
||||
let indicator = "";
|
||||
if (rommOnline.isError)
|
||||
{
|
||||
indicator = "status-error";
|
||||
} else if (rommOnline.isSuccess)
|
||||
{
|
||||
indicator = "status-success";
|
||||
}
|
||||
|
||||
return (
|
||||
<FocusContext.Provider 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>
|
||||
<Option
|
||||
id="rommAddress"
|
||||
type="text"
|
||||
icon={
|
||||
<div className="indicator">
|
||||
<span
|
||||
className={classNames("indicator-item status", indicator)}
|
||||
></span>
|
||||
<Link />
|
||||
</div>
|
||||
}
|
||||
label="Romm Address"
|
||||
/>
|
||||
<form
|
||||
className="flex flex-col gap-2"
|
||||
onSubmit={(e) =>
|
||||
{
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.currentTarget);
|
||||
toast.promise(loginMutation.mutateAsync({
|
||||
auth: `${data.get("username")}:${data.get("password")}`,
|
||||
}), {
|
||||
loading: "Logging In",
|
||||
success: "Logged In",
|
||||
error: e => e?.detail ?? "Error Logging In",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<OptionSpace label="User">
|
||||
<OptionInput
|
||||
icon={<User />}
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
/>
|
||||
</OptionSpace>
|
||||
<OptionSpace label="Password">
|
||||
<OptionInput
|
||||
icon={<Key />}
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</OptionSpace>
|
||||
<OptionSpace className="justify-end">
|
||||
<LoginControls user={user} />
|
||||
</OptionSpace>
|
||||
</form>
|
||||
</ul>
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
177
src/mainview/routes/settings/route.tsx
Normal file
177
src/mainview/routes/settings/route.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import
|
||||
{
|
||||
FocusContext,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import
|
||||
{
|
||||
Outlet,
|
||||
Link,
|
||||
createFileRoute,
|
||||
useMatchRoute,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import { retainSearchParams, ViewTransitionOptions } from "@tanstack/router-core";
|
||||
import classNames from "classnames";
|
||||
import
|
||||
{
|
||||
ArrowBigLeft,
|
||||
FingerprintPattern,
|
||||
Info,
|
||||
MonitorCog,
|
||||
} from "lucide-react";
|
||||
import { JSX, useEffect } 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";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: SettingsUI,
|
||||
validateSearch: z.object({
|
||||
focus: z.keyof(SettingsSchema).optional()
|
||||
})
|
||||
});
|
||||
|
||||
function MenuItem (data: {
|
||||
route: string;
|
||||
return?: boolean;
|
||||
viewTransition?: boolean | ViewTransitionOptions;
|
||||
icon: JSX.Element;
|
||||
focusSelect?: boolean;
|
||||
className?: string;
|
||||
linkClassName?: string;
|
||||
label: string;
|
||||
})
|
||||
{
|
||||
const matchRoute = useMatchRoute();
|
||||
const navigate = useNavigate();
|
||||
const acitve = matchRoute({ to: data.route });
|
||||
const handleNonFocusSelect = () => navigate({ to: data.return ? PopSource('settings') ?? data.route : data.route, viewTransition: data.viewTransition });
|
||||
const { ref, focusSelf, focused } = useFocusable({
|
||||
focusKey: data.route,
|
||||
forceFocus: !!acitve,
|
||||
onFocus: () =>
|
||||
{
|
||||
if (data.focusSelect)
|
||||
{
|
||||
navigate({ to: data.route });
|
||||
}
|
||||
(ref.current as HTMLElement).scrollIntoView({ inline: 'center' });
|
||||
},
|
||||
onEnterPress:
|
||||
data.focusSelect !== true
|
||||
? handleNonFocusSelect
|
||||
: undefined,
|
||||
});
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
key={data.route}
|
||||
onClick={data.focusSelect ? focusSelf : handleNonFocusSelect}
|
||||
onFocus={focusSelf}
|
||||
className={data.className}
|
||||
>
|
||||
<div
|
||||
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,
|
||||
}),
|
||||
data.linkClassName,
|
||||
)}
|
||||
>
|
||||
<div className={twMerge("flex gap-2 items-center transition-all group-hover:scale-110", classNames({
|
||||
"scale-110": focused || acitve
|
||||
}))}>
|
||||
{data.icon}
|
||||
{data.label}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsMenu (data: {})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusable: true,
|
||||
focusKey: 'settings-menu',
|
||||
preferredChildFocusKey: "/settings/accounts"
|
||||
});
|
||||
|
||||
return <ul
|
||||
ref={ref}
|
||||
className="menu md:menu-xl flex-nowrap bg-base-200 w-56 p-4 gap-2 rounded-4xl overflow-y-scroll no-scrollbar"
|
||||
>
|
||||
<FocusContext value={focusKey}>
|
||||
<MenuItem
|
||||
focusSelect
|
||||
label="Accounts"
|
||||
route="/settings/accounts"
|
||||
icon={<FingerprintPattern />}
|
||||
/>
|
||||
<MenuItem
|
||||
focusSelect
|
||||
route="/settings/visual"
|
||||
label="Visual"
|
||||
icon={<MonitorCog />}
|
||||
/>
|
||||
<MenuItem
|
||||
focusSelect
|
||||
route="/settings/about"
|
||||
label="About"
|
||||
icon={<Info />}
|
||||
/>
|
||||
<MenuItem
|
||||
className={"mt-auto"}
|
||||
route={"/"}
|
||||
return
|
||||
label="Return"
|
||||
viewTransition={{ types: ['zoom-out'] }}
|
||||
icon={<ArrowBigLeft />}
|
||||
/>
|
||||
</FocusContext>
|
||||
</ul>;
|
||||
}
|
||||
|
||||
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'] } }));
|
||||
useEffect(() =>
|
||||
{
|
||||
focusSelf();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<div ref={ref} className="flex flex-col w-full h-full p-4 bg-base-100">
|
||||
<div className="flex flex-row grow overflow-hidden">
|
||||
<div id="Menu" className="flex flex-row h-full">
|
||||
<SettingsMenu />
|
||||
</div>
|
||||
<div className="divider divider-horizontal"></div>
|
||||
<div id="Settings" className="flex flex-col grow h-full py-8 overflow-y-scroll">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider divider-end">
|
||||
<ShortcutPrompt
|
||||
onClick={() => navigate({ to: "/" })}
|
||||
icon="steamdeck_button_b"
|
||||
label="Back"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue