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

@ -0,0 +1,62 @@
import React, { createContext, Ref, useContext, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { useSessionStorage } from 'usehooks-ts';
export const AnimatedBackgroundContext = createContext({} as { setBackground: (url: string) => void; });
export function AnimatedBackground (data: {
children?: any;
backgroundKey?: string;
backgroundUrl?: string;
ref?: Ref<HTMLDivElement>;
className?: string;
animated?: boolean,
})
{
const [lastBackgroundUrl, setLastBackgroundUrl] = data.backgroundUrl ? useSessionStorage<string | undefined>(
`${data.backgroundKey!}-last`,
data.backgroundUrl,
) : useState<string | undefined>();
const [backgroundUrl, setBackgroundUrl] = data.backgroundUrl ? useSessionStorage<string | undefined>(
data.backgroundKey!,
data.backgroundUrl,
) : useState(data.backgroundUrl);
function handleSetBackground (url: string)
{
setLastBackgroundUrl(backgroundUrl);
setBackgroundUrl(url);
}
const bgColor = "bg-base-content";
let backgroundStyle = (url: string) => `linear-gradient(
color-mix(in srgb, var(--color-base-300) 60%, transparent),
color-mix(in srgb, var(--color-base-100) 80%, transparent)
), url('${url}') center / cover`;
return (
<AnimatedBackgroundContext value={{ setBackground: handleSetBackground }}>
<div ref={data.ref}
className={twMerge("w-full h-full flex flex-col overflow-hidden", data.className)}
>
{!!lastBackgroundUrl && <div className='absolute w-full h-full' style={{ background: backgroundStyle(lastBackgroundUrl), zIndex: -4 }}></div>}
{!!backgroundUrl && <div key={backgroundUrl} className='absolute w-full h-full animate__animated animate__fadeIn' style={{ background: backgroundStyle(backgroundUrl), zIndex: -3 }}></div>}
<div className="absolute w-full h-full backdrop-blur-3xl" style={{ zIndex: -2 }}></div>
{data.animated && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}>
<div id="container">
<div id="container-inside">
<div className={bgColor} id="circle-small"></div>
<div className={bgColor} id="circle-medium"></div>
<div className={bgColor} id="circle-large"></div>
<div className={bgColor} id="circle-xlarge"></div>
<div className={bgColor} id="circle-xxlarge"></div>
</div>
</div>
</div>}
{data.children}
</div>
</AnimatedBackgroundContext>
);
}

View file

@ -0,0 +1,30 @@
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { useEffect } from "react";
export function AutoFocus (data: { focus: () => void; force?: boolean; delay?: number; })
{
useEffect(() =>
{
let delayTimeout: number | undefined;
if (data.force || !getCurrentFocusKey() || !doesFocusableExist(getCurrentFocusKey()))
{
if (data.delay)
{
delayTimeout = window.setTimeout(() => data.focus(), data.delay);
} else
{
data.focus();
}
}
return () =>
{
if (delayTimeout)
{
window.clearTimeout(delayTimeout);
}
};
}, []);
return <></>;
}

View file

@ -0,0 +1,82 @@
import
{
FocusContext,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import { GameMeta } from "../../shared/constants";
import GameCard, { GameCardSkeleton } from "./GameCard";
import { JSX, useEffect, useMemo, useState } from "react";
import { useLocalStorage } from "usehooks-ts";
import { useScrollSave } from "../scripts/utils";
import classNames from "classnames";
export interface GameMetaExtra extends GameMeta
{
preview?: JSX.Element;
badge?: JSX.Element;
focusKey: string;
}
export function CardList (data: {
id: string;
type?: string;
games: GameMetaExtra[];
grid?: boolean;
onSelectGame?: (id: number) => void;
onGameFocus?: (id: number) => void;
})
{
const { ref, focusKey } = useFocusable({
focusKey: data.id,
});
function BuildGame (g: GameMetaExtra, i: number)
{
let preview: JSX.Element | string | undefined = g.preview;
if (!preview && g.previewUrl)
{
preview = g.previewUrl;
}
return (
<GameCard
key={g.id}
type={data.type}
index={i}
focusKey={g.focusKey}
data-index={i}
title={g.title}
subtitle={g.subtitle ?? ""}
onFocus={() =>
{
data.onGameFocus?.(g.id);
}}
onAction={() => data.onSelectGame?.(g.id)}
preview={preview}
badge={g.badge}
id={g.id}
/>
);
}
return (
<ul
title="Games"
id={`card-list-${data.id}`}
ref={ref}
save-child-focus="session"
className={classNames("my-6 items-center justify-center-safe h-(--game-card-height) ",
data.grid ? "card-grid h-fit gap-5" : 'card-list gap-6'
)}
onKeyDown={(e) =>
{
e.preventDefault();
e.stopPropagation();
}}
style={{ scrollbarWidth: "none" }}
>
<FocusContext.Provider value={focusKey}>
{data.games.map(BuildGame)}
</FocusContext.Provider>
</ul>
);
}

View file

@ -0,0 +1,54 @@
import { AnimatedBackground } from './AnimatedBackground';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { HeaderUI } from './Header';
import { GameList, GameListFilter } from './GameList';
import { Search, Settings2 } from 'lucide-react';
import ShortcutPrompt from './ShortcutPrompt';
import { selfFocusSmart } from '../scripts/utils';
import { JSX, Suspense, useEffect, useState } from 'react';
import Shortcuts from './Shortcuts';
import { AutoFocus } from './AutoFocus';
export interface CollectionsDetailParams
{
id?: string;
setBackground: (url: string) => void;
filters: GameListFilter;
headerTitle?: JSX.Element;
title?: JSX.Element;
footer?: JSX.Element;
}
export function CollectionsDetail (data: CollectionsDetailParams)
{
const focusKey = `game-list-${data.id}-${data.filters.platformIds?.join()}-${data.filters.collectionId}`;
const { ref, focusSelf } = useFocusable({
focusKey,
preferredChildFocusKey: `${focusKey}-list`,
});
return (
<FocusContext value={focusKey}>
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className='flex'>
<div className="px-3 w-full pt-2">
<HeaderUI title={data.headerTitle} buttons={[{ id: "search", icon: <Search /> }, { id: "filter", icon: <Settings2 /> }]} />
</div>
<div className="w-full grow mt-4 rounded-2xl px-2 overflow-y-scroll justify-center mask-alpha mask-t-from-transparent mask-t-to-20 mask-t-to-black">
<div className="h-fit w-full px-6 pt-4 pb-32">
{data.title}
<Suspense>
<GameList grid setBackground={data.setBackground} filters={data.filters} id={`${focusKey}-list`}></GameList>
<AutoFocus focus={focusSelf} />
</Suspense>
</div>
</div>
<footer className="px-2 pb-2 absolute bottom-0 w-full h-12 flex items-center justify-between">
<div>
{data.footer}
</div>
<Shortcuts />
</footer>
</AnimatedBackground>
</FocusContext>
);
}

View file

@ -0,0 +1,78 @@
import
{
FocusContext,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import SvgIcon from "./SvgIcon";
import classNames from "classnames";
function FilterCat (
data: {
id: string;
children?: any;
active: boolean;
onFocus: () => void;
} & FilterOption,
)
{
const { ref, focusSelf, focused } = useFocusable({
focusKey: data.id,
onFocus: data.onFocus,
onEnterPress: data.onAction,
});
return (
<li
ref={ref}
onClick={focusSelf}
className={classNames(
"flex px-4 h-12 items-center justify-center rounded-full transition-all",
{
"bg-primary text-primary-content drop-shadow-sm cursor-default":
focused || data.active,
"ring-base-content ring-7": focused,
"hover:bg-base-300 cursor-pointer": !focused,
},
)}
>
{data.children ?? data.label}
</li>
);
}
export function FilterUI (data: {
id: string;
options: Record<string, FilterOption>;
selected: string;
setSelected: (id: string) => void;
})
{
const { ref, focusKey } = useFocusable({ focusKey: `filter-${data.id}` });
return (
<div
ref={ref}
className="flex items-center justify-center gap-2"
save-child-focus="session"
>
<FocusContext.Provider value={focusKey}>
<ul className="flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm">
<li className=" flex px-4 h-12 items-center justify-center rounded-full">
<SvgIcon className="size-8" icon="steamdeck_button_l1_outline" />
</li>
{Object.entries(data.options)?.map(([id, option]) => (
<FilterCat
id={id}
key={id}
onFocus={() => data.setSelected(id)}
active={id === data.selected}
{...option}
/>
))}
<li className=" flex px-4 h-12 items-center justify-center rounded-full">
<SvgIcon className="size-8" icon="steamdeck_button_r1_outline" />
</li>
</ul>
</FocusContext.Provider>
</div>
);
}

View file

@ -0,0 +1,91 @@
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { JSX, useEffect } from "react";
import { twMerge } from "tailwind-merge";
export function GameCardSkeleton ()
{
return (
<li className="game-card bg-base-100/80 p-4 z-0 mx-2 max-h-(--game-card-height) min-w-(--game-card-width) w-(--game-card-width)">
<div className="flex flex-col gap-4">
<div className="skeleton h-60 w-full opacity-40"></div>
<div className="skeleton h-4 w-full opacity-40"></div>
<div className="skeleton h-4 w-28 opacity-40"></div>
</div>
</li>
);
}
export default function GameCard (data: {
title: string;
type?: string;
subtitle: string;
preview?: string | JSX.Element;
focusKey: string;
index: number;
id: number;
badge?: JSX.Element;
onFocus?: (id: number) => void;
onAction?: () => void;
})
{
const { ref, focused, focusSelf } = useFocusable({
focusKey: data.focusKey,
onFocus: () => data.onFocus?.(data.id),
onEnterPress: () => data.onAction?.(),
});
useEffect(() =>
{
if (focused)
{
(ref.current as HTMLElement).scrollIntoView({
behavior: "smooth",
inline: "center",
block: 'center'
});
}
}, [focused]);
return (
<li
id={`game-entry-${data.id}`}
key={data.id}
data-index={data.id}
role="button"
ref={ref}
style={{
scrollSnapAlign: "center"
}}
onFocus={focusSelf}
onDoubleClick={data.onAction}
onClick={focused ? data.onAction : focusSelf}
className={twMerge(
`game-card game-card-height flex flex-col justify-end`,
'max-h-(--game-card-height) min-w-(--game-card-width) w-(--game-card-width)',
"overflow-hidden transition-all duration-200 drop-shadow-lg cursor-pointer",
focused ?
`animate-wiggle ring-7 bg-base-content text-base-300 ring-primary drop-shadow-xl drop-shadow-base-300/60 scale-102 z-10` :
"bg-base-300 text-base-content",
classNames({
"h-(--game-card-height)": typeof data.preview === "string"
})
)}
>
<div className={twMerge("overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all", focused ? "mt-2 mx-2" : "mt-2 mx-2")}>
{typeof data.preview === "string" ? (
<img src={data.preview}></img>
) : (
data.preview
)}</div>
<div className="h-0 flex pr-2 justify-end items-center">{data.badge}</div>
<div className="flex flex-col p-4">
<div className="text-xl font-bold text-nowrap text-ellipsis overflow-hidden">
{data.title}
</div>
<div className="text-s">{data.subtitle}</div>
</div>
</li>
);
}

View file

@ -0,0 +1,74 @@
import { keepPreviousData, useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { getRomsApiRomsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
import { GameMetaExtra, CardList } from "./CardList";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { Suspense, useEffect } from "react";
import { SaveSource } from "../scripts/spatialNavigation";
import { gamesQueryOptions } from "../query-options";
export interface GameListFilter
{
platformIds?: number[];
collectionId?: number;
}
export interface GameListParams
{
id: string,
filters?: GameListFilter,
grid?: boolean,
setBackground?: (url: string) => void;
onGameSelect?: (id: number) => void;
}
export function GameList (data: GameListParams)
{
const games = useSuspenseQuery(gamesQueryOptions(data.filters));
const navigator = useNavigate();
const location = useLocation();
const handleFocus = (id: number) =>
{
const game = games.data?.items.find((g) => g.id === id);
if (game)
{
data.setBackground?.(
`${RPC_URL(__HOST__)}/api/romm${game.path_cover_small}`,
);
}
};
function handleDefaultSelect (id: number)
{
SaveSource('details', location.pathname);
navigator({ to: '/game/$id', params: { id: String(id) }, viewTransition: { types: ['zoom-in'] } });
};
return (
<>
<CardList
id={data.id}
type="game"
grid={data.grid}
games={games.data.items.sort(
(a, b) =>
Date.parse(b.rom_user.last_played ?? b.updated_at) -
Date.parse(a.rom_user.last_played ?? a.updated_at),
)
.map(
(g) =>
({
id: g.id,
focusKey: g.slug ?? `game-${g.id}`,
title: g.name ?? "",
subtitle: g.platform_display_name ?? "",
previewUrl: `${RPC_URL(__HOST__)}/api/romm${g.path_cover_large}`,
}) satisfies GameMetaExtra,
)}
onGameFocus={handleFocus}
onSelectGame={id => data.onGameSelect ? data.onGameSelect(id) : handleDefaultSelect(id)}
/>
</>
);
}

View file

@ -1,23 +0,0 @@
import React from "react";
export default function GamepadIcon({
platform,
variant,
button,
text,
}: {
platform: "xbox" | "playstation" | "nintendo";
variant: string;
button: string;
text?: string;
}) {
return (
<div className="gamepad-button-wrapper">
<i
className={`gamepad-button gamepad-button-${platform} gamepad-button-${platform}--${button} gamepad-button-${platform}--variant-${variant}`}
>
{text}
</i>
</div>
);
}

View file

@ -0,0 +1,191 @@
import
{
FocusContext,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import
{
BatteryFull,
Bell,
Bluetooth,
Clock,
Lock,
Power,
ShieldAlert,
Sun,
User,
Wifi,
} from "lucide-react";
import { RoundButton } from "./RoundButton";
import { useQuery } from "@tanstack/react-query";
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
import { RPC_URL } from "../../shared/constants";
import { JSX } from "react";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { SaveSource } from "../scripts/spatialNavigation";
function HeaderAvatar (data: {
id: string;
imageSrc?: string | string[];
className?: string;
active?: boolean;
status?: HeaderAccount['status'];
locked?: boolean;
type?: HeaderAccount['type'];
onSelect?: () => void;
})
{
const { ref, focused } = useFocusable({ focusKey: data.id, onEnterPress: data.onSelect });
const bgColors = {
primary: " text-primary-content",
secondary: " text-secondary-content",
accent: " text-accent-content",
base: "bg-base-100",
none: undefined,
};
return (
<div
id={data.id}
ref={ref}
onClick={data.onSelect}
style={{ viewTransitionName: data.id }}
className={classNames(
`avatar indicator ring-base-100 ring-offset-base-100 size-14 rounded-full flex items-center justify-center`,
bgColors[data.type ?? "none"],
"text-base-content cursor-pointer transition-all drop-shadow-md",
"hover:ring-primary hover:ring-7",
{
"ring-5 hover:ring-offset-5": data.active,
"ring-7 ring-primary ring-offset-base-100": focused,
"ring-offset-5": focused && data.active,
},
data.className,
)}
>
{data.imageSrc ? (
<div className="overflow rounded-full w-full h-full">
<picture>
{typeof data.imageSrc === 'string' && <img key={"og-image"} src={data.imageSrc}></img>}
{Array.isArray(data.imageSrc) && data.imageSrc.map((s, i) =>
{
if (i === (data.imageSrc!.length - 1))
{
return <img key={'fallback-image'} src={s}></img>;
}
return <source key={`alt-img-${i}`} srcSet={s}></source>;
})}
</picture>
</div>
) : (
<User />
)}
<span className={classNames("indicator-item status left-1 top-1 ring-3 ring-base-100 z-1", data.status)}></span>
</div>
);
}
export interface HeaderButton
{
id: string;
icon: JSX.Element;
external?: boolean;
}
export interface HeaderAccount
{
id: string;
previewUrl?: string | string[];
status?: "status-error" | "status-success" | "status-neutral";
type?: "base" | "primary" | "secondary" | "accent";
locked?: boolean;
action?: () => void;
}
export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[], buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; })
{
const { ref, focusKey } = useFocusable({ focusKey: "header-elements" });
const navigate = useNavigate();
const location = useLocation();
const rommOnline = useQuery({
...statsApiStatsGetOptions(),
refetchInterval: 30000,
retry: false,
});
const user = useQuery({
...getCurrentUserApiUsersMeGetOptions(),
refetchOnWindowFocus: false,
retry: 1
});
let indicator = "status-neutral";
if (user.isError)
{
indicator = "status-error";
} else if (!user.isPending && rommOnline.isSuccess)
{
indicator = "status-success";
}
const accounts: HeaderAccount[] = [{
id: 'romm', previewUrl: [
`${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`,
],
action: () =>
{
SaveSource('settings', location.pathname);
navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } });
},
status: user.data ? "status-success" : 'status-error',
type: 'secondary'
}, ...data.accounts ?? []];
return (
<FocusContext.Provider value={focusKey}>
<header
ref={ref}
className="h-14 mt-2 flex items-center justify-between text-white"
>
<div className="flex items-center gap-2 drop-shadow-sm">
{accounts?.map(a => <HeaderAvatar
key={`header-avatar-${a.id}`}
type={a.type}
id={`account-${a.id}`}
status={a.status}
locked={a.locked}
imageSrc={a.previewUrl}
onSelect={a.action}
/>)}
{data.title}
</div>
<div className="flex items-center gap-2 text drop-shadow-sm">
<div className="flex gap-5">
<Clock />
<Wifi className="w-6 h-6" />
<Bluetooth className="w-6 h-6" />
<div className="indicator">
<span className="indicator-item status status-error"></span>
<Bell className="w-6 h-6" />
</div>
<div className="flex gap-2 items-center">
<BatteryFull className="w-6 h-6" />
<span className="font-semibold">100%</span>
</div>
</div>
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
<div className="flex gap-2">
{data.buttonElements ?? data.buttons?.map(b => <RoundButton
key={b.id}
className="header-icon size-16"
id={b.id}
icon={b.icon}
external={b.external}
/>)}
</div>
</div>
</header>
</FocusContext.Provider>
);
}

View file

@ -0,0 +1,24 @@
import classNames from 'classnames';
import { GameCardSkeleton } from './GameCard';
export default function LoadingCardList (data: { placeholderCount: number, grid?: boolean; })
{
return (
<ul
title="Games"
id={`card-list-placeholder`}
save-child-focus="session"
className={classNames("my-6 items-center justify-center-safe h-(--game-card-height) ",
data.grid ? "card-grid gap-5" : 'card-list gap-6'
)}
onKeyDown={(e) =>
{
e.preventDefault();
e.stopPropagation();
}}
style={{ scrollbarWidth: "none" }}
>
{new Array(data.placeholderCount).fill(1).map(p => <GameCardSkeleton />)}
</ul>
);
}

View file

@ -0,0 +1,39 @@
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { JSX } from "react";
import { twMerge } from 'tailwind-merge';
export function RoundButton (data: {
id: string;
icon: JSX.Element;
className?: string;
external?: boolean;
action?: () => void;
})
{
const { ref, focused } = useFocusable({
focusKey: data.id,
onEnterPress: data.action,
});
return (
<div
id={data.id}
ref={ref}
onClick={data.action}
className={classNames(twMerge(
"rounded-full size-14 flex items-center justify-center bg-base-100 text-base-content cursor-pointer transition-all drop-shadow-sm",
data.className, classNames(data.external === true
? {
"hover:ring-7 hover:ring-primary hover:bg-base-content hover:text-base-300": true,
"ring-7 ring-primary bg-base-content text-base-100": focused,
}
: {
"hover:bg-primary hover:text-primary-content": true,
"bg-primary text-primary-content": focused,
},)),
)}
>
{data.icon}
</div>
);
}

View file

@ -0,0 +1,7 @@
import { ScrollSaveParams, useScrollSave } from "../scripts/utils";
export default function ScrollSave (data: ScrollSaveParams)
{
useScrollSave(data);
return <></>;
}

View file

@ -0,0 +1,29 @@
import React, { MouseEventHandler } from "react";
import SvgIcon, { IconType } from "./SvgIcon";
import classNames from "classnames";
import { twMerge } from "tailwind-merge";
export default function ShortcutPrompt (data: {
icon: IconType;
label?: string;
className?: string;
onClick?: MouseEventHandler;
})
{
return (
<span
onClick={data.onClick}
className={twMerge(
"flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm",
"sm:text-sm",
data.className,
classNames({
"hover:bg-base-300 cursor-pointer": !!data.onClick,
})
)}
>
<SvgIcon className="md:size-8 sm:size-6" icon={data.icon} />
{data.label}
</span>
);
}

View file

@ -0,0 +1,14 @@
import React from 'react';
import ShortcutPrompt from './ShortcutPrompt';
export default function Shortcuts ()
{
return (
<div style={{ viewTransitionName: 'shortcuts' }} className="flex gap-2">
<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" />
</div>
);
}

View file

@ -0,0 +1,32 @@
import "virtual:svg-icons/register";
import { StaticAssetPath } from "../gen/static-icon-assets.gen";
type OnlySvgIcon<T extends string> = T extends `${infer Rest}.svg`
? Rest
: never;
type StripSvg<T extends string> = T extends `${infer Rest}.svg` ? Rest : T;
type ReplaceSlash<T extends string> = T extends `${infer Left}/${infer Right}`
? `${Left}-${ReplaceSlash<Right>}`
: T;
type IconName<T extends string> = ReplaceSlash<StripSvg<OnlySvgIcon<T>>>;
export type IconType = IconName<StaticAssetPath>;
export default function SvgIcon ({
icon,
prefix = "icon",
className,
...props
}: {
icon: IconType;
prefix?: string;
className?: string;
})
{
const symbolId = `#${prefix}-${icon}`;
return (
<svg className={className} {...props} aria-hidden="true">
<use href={symbolId} />
</svg>
);
}