feat: massive front-end overhaul and initial github release

This commit is contained in:
Simeon Radivoev 2026-02-08 21:18:10 +02:00
parent a2b40e38bf
commit d5a0e70580
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
303 changed files with 19840 additions and 676 deletions

View file

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

View file

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

View file

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

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

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

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

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

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

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