feat: implemented a basic store and emulatorjs

This commit is contained in:
Simeon Radivoev 2026-03-14 02:15:57 +02:00
parent 2f32cbc730
commit 7286541822
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
121 changed files with 5900 additions and 1092 deletions

View file

@ -1,11 +1,10 @@
import classNames from 'classnames';
import { createContext, JSX, Ref, useContext, useEffect, useState } from 'react';
import { CSSProperties, JSX, Ref, useEffect, useRef, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { useSessionStorage } from 'usehooks-ts';
import { useLocalSetting } from '../scripts/utils';
export const AnimatedBackgroundContext = createContext({} as { setBackground: (url: string) => void; });
import { mobileCheck, useLocalSetting } from '../scripts/utils';
import { AnimatedBackgroundContext } from '../scripts/contexts';
export function AnimatedBackground (data: {
children?: any;
@ -15,26 +14,43 @@ export function AnimatedBackground (data: {
className?: string;
animated?: boolean,
scrolling?: boolean;
style?: CSSProperties;
})
{
const animateBackground = true;
const animateBackground = useLocalSetting('backgroundAnimation');
const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ?
useSessionStorage<string | undefined>(
data.backgroundKey,
data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined,
)
: useState<string | undefined>();
const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ? useSessionStorage<string | undefined>(
data.backgroundKey!,
data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined,
) : useState<string | undefined>();
const [lastBackgroundUrl, setLastBackgroundUrl] = useState<string | undefined>(undefined);
const backgroundElementRef = useRef<HTMLDivElement>(null);
useEffect(() =>
{
setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined);
const lastBg = backgroundUrl;
if (data.backgroundUrl != backgroundUrl)
{
setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined);
setLastBackgroundUrl(lastBg);
}
}, [data.backgroundUrl]);
let finalBackgroundUrl;
let finalBackgroundUrl: URL | undefined;
try
{
finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined;
} catch { }
let finalLastBackgroundUrl: URL | undefined;
try
{
finalLastBackgroundUrl = lastBackgroundUrl ? new URL(lastBackgroundUrl) : undefined;
} catch { }
const blur = useLocalSetting('backgroundBlur');
if (blur)
{
@ -43,11 +59,41 @@ export function AnimatedBackground (data: {
finalBackgroundUrl?.searchParams.set('blur', String(24));
}
if (!finalLastBackgroundUrl?.searchParams.has('blur'))
{
finalLastBackgroundUrl?.searchParams.set('blur', String(24));
}
finalBackgroundUrl?.searchParams.set('height', String(320));
finalLastBackgroundUrl?.searchParams.set('height', String(320));
}
useEffect(() =>
{
if (finalBackgroundUrl && backgroundElementRef.current)
{
const finalBackgroundImg = new Image();
finalBackgroundImg.addEventListener('load', e =>
{
if (backgroundElementRef.current)
{
backgroundElementRef.current.style.backgroundImage = `url('${finalBackgroundUrl.href}')`;
backgroundElementRef.current.style.opacity = "1";
backgroundElementRef.current.style.backgroundSize = "100%";
}
});
finalBackgroundImg.src = finalBackgroundUrl.href;
}
}, [finalBackgroundUrl]);
const isMobile = mobileCheck();
function handleSetBackground (url: string)
{
setLastBackgroundUrl(backgroundUrl);
setBackgroundUrl(url);
}
@ -70,30 +116,40 @@ export function AnimatedBackground (data: {
return (
<AnimatedBackgroundContext value={{ setBackground: handleSetBackground }}>
<div ref={data.ref}
className={twMerge("w-full h-full flex flex-col", data.scrolling ? "overflow-y-scroll animate-bg-zoom-scroll" : "overflow-hidden", data.className)}
style={data.scrolling ? {
backgroundImage: `url('${finalBackgroundUrl?.href}')`,
backgroundAttachment: 'local',
backgroundSize: '100%',
backgroundPositionY: 'bottom',
backgroundPositionX: 'center',
backgroundBlendMode: blur ? 'normal' : 'soft-light',
backgroundColor: "var(--color-base-100)",
} : {}}
style={data.style}
className={twMerge("relative w-full h-full flex flex-col", data.scrolling ? "overflow-y-scroll animate-bg-zoom-scroll" : "overflow-hidden", data.className)}
>
{!data.scrolling && <div className='absolute top-0 left-0 overflow-hidden w-full h-full'>
{<img
{!data.scrolling && <div className='absolute top-0 left-0 right-0 bottom-0 overflow-hidden'>
<div className='fixed bg-base-100 top-0 left-0 right-0 bottom-0 -z-5'></div>
{blur && finalLastBackgroundUrl && <img className='absolute w-full h-full object-cover object-center -z-4' src={finalLastBackgroundUrl.href}></img>}
{finalBackgroundUrl ? <img
key={finalBackgroundUrl?.href}
className={classNames('absolute w-full h-full object-cover object-center opacity-0 -z-3')}
className={'absolute w-full h-full object-cover object-center opacity-0 -z-3'}
src={finalBackgroundUrl?.href}
onLoad={e => e.currentTarget.classList.add(blur ? "animate-bg-zoom-big" : "animate-bg-zoom")}
></img>}
<div className='absolute w-full h-full bg-linear-to-b from-base-100/60 to-base-300/80 -z-2' />
></img> : <><div className='mobile:hidden bg-gradient'></div></>}
<div className='absolute top-0 left-0 right-0 bottom-0 bg-linear-to-b from-base-100/60 to-base-300/80 -z-2' />
<div className='mobile:hidden bg-noise'></div>
</div>}
{data.animated && animateBackground && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}>
{data.animated && animateBackground && <div className="fixed overflow-hidden top-0 left-0 right-0 bottom-0" style={{ zIndex: -1 }}>
{backgroundElements}
</div>}
{data.children}
{!!data.scrolling && <>
<div key={finalBackgroundUrl?.href} ref={backgroundElementRef} className='absolute top-0 bottom-0 left-0 right-0' style={data.scrolling ? {
backgroundAttachment: 'local',
backgroundSize: '105%',
opacity: 0,
transition: 'all ease-out',
backgroundPositionY: 'bottom',
backgroundPositionX: 'center',
transitionDuration: "400ms",
backgroundBlendMode: blur ? 'normal' : 'soft-light',
backgroundColor: "var(--color-base-300)",
} : {}}></div>
<div className='mobile:hidden bg-noise opacity-30 z-1'></div>
</>}
</div>
</AnimatedBackgroundContext >
);

View file

@ -1,13 +1,18 @@
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { useEffect } from "react";
export function AutoFocus (data: { focus: () => void; force?: boolean; delay?: number; })
export function AutoFocus (data: {
parentKey?: string;
focus: () => void;
force?: boolean;
delay?: number;
})
{
useEffect(() =>
{
let delayTimeout: number | undefined;
if (data.force || !getCurrentFocusKey() || !doesFocusableExist(getCurrentFocusKey()))
if (data.force || !getCurrentFocusKey() || getCurrentFocusKey() === data.parentKey || !doesFocusableExist(getCurrentFocusKey()))
{
if (data.delay)
{

View file

@ -32,11 +32,10 @@ export interface GameCardParams
className?: string;
onFocus?: GameCardFocusHandler;
onBlur?: (id: string) => void;
onAction?: () => void;
clickFocuses?: boolean;
}
export default function GameCard (data: GameCardParams)
export default function CardElement (data: GameCardParams & InteractParams)
{
const { ref, focused, focusSelf } = useFocusable({
focusKey: data.focusKey,
@ -57,40 +56,35 @@ export default function GameCard (data: GameCardParams)
scrollSnapAlign: "center"
}}
onFocus={focusSelf}
onDoubleClick={data.onAction}
onDoubleClick={e => data.onAction?.(e.nativeEvent)}
onClick={() =>
{
focusSelf();
data.onAction?.();
}}
className={twMerge(
`game-card bg-base-300 game-card-height flex flex-col justify-end z-5 ring-primary`,
'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",
classNames({
"focused animate-wiggle ring-7 bg-base-content text-base-300 drop-shadow-xl drop-shadow-black/30 scale-102 z-10": focused && !isPointer,
"group hover:focused hover:animate-wiggle sm:hover:ring-4 md:hover:ring-7 hover:bg-base-content hover:text-base-300 hover:drop-shadow-xl hover:drop-shadow-black/30 hover:scale-102 hover:z-10": isMouse,
"h-(--game-card-height)": typeof data.preview === "string"
}),
"relative game-card bg-base-300 flex flex-col z-5 overflow-hidden transition-all duration-200 not-mobile:drop-shadow-lg cursor-pointer focusable focusable-primary focusable-hover select-none focused focused:not-control-mouse:animate-wiggle focused:not-control-mouse:bg-base-content focused:not-control-mouse:text-base-300 focused:not-control-mouse:drop-shadow-xl focused:not-control-mouse:drop-shadow-black/30 focused:not-control-mouse:scale-102 focused:not-control-mouse:z-10 group control-mouse:hover:bg-base-200 h-full [--tw-border-style:inset] border-2 border-base-content/5 backdrop-opacity-0 active:bg-base-content! active:text-base-100 active:transition-none",
data.className
)}
>
<div className={twMerge(
"overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all",
<div id="preview" className={twMerge(
"overflow-hidden bg-base-400 rounded-t-xl rounded-b-md transition-all",
focused ? "sm:mt-1 sm:mx-1" : "sm:mt-1 sm:mx-1",
focused ? "md:mt-2 md:mx-2" : "md:mt-2 md:mx-2",
classNames({ "h-full": typeof data.preview === "string" })
)}>
{typeof data.preview === "string" ? (
<img className={classNames("object-cover w-full h-full", { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
<img draggable={false} className={classNames("object-cover w-full h-full", { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
) : (
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
)}</div>
)}
</div>
<div className="h-0 flex pr-2 justify-end items-center sm:gap-1 md:gap-2">
<div className="h-0 flex pr-2 justify-end items-center sm:gap-1 md:gap-2 z-2">
{data.badges?.map((b, i) =>
<div key={i}
className={
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 sm:last:mr-1 md:last:mr-4 transition-colors",
twMerge("bg-base-100 text-base-content not-mobile:not-in-focused:drop-shadow-lg sm:border-3 md:border-6 border-base-300 in-focused:border-base-content overflow-hidden rounded-full sm:last:mr-1 md:last:mr-4 transition-colors",
classNames({
"bg-primary text-primary-content": focused && !isPointer,
"group-hover:bg-primary group-hover:text-primary-content": isPointer
@ -100,7 +94,7 @@ export default function GameCard (data: GameCardParams)
</div>)
}
</div>
<div className="flex flex-col sm:p-2 md:p-4">
<div className="flex flex-col sm:p-2 grow md:p-4 justify-center">
<div className="md:text-xl sm:text-sm font-bold text-nowrap text-ellipsis overflow-hidden">
{data.title}
</div>

View file

@ -1,11 +1,10 @@
import
{
FocusContext,
FocusDetails,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import { GameMeta } from "../../shared/constants";
import GameCard, { GameCardFocusHandler, GameCardParams } from "./GameCard";
import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement";
import { JSX } from "react";
import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
@ -47,7 +46,7 @@ export function CardList (data: {
useShortcuts(g.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]);
return (
<GameCard
<CardElement
key={g.id}
type={data.type}
index={i}
@ -74,9 +73,9 @@ export function CardList (data: {
id={`card-list-${data.id}`}
ref={ref}
save-child-focus="session"
className={twMerge("items-center justify-center-safe landscape:h-(--game-card-height) ",
data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-(--game-card-height) grid-cols-[repeat(auto-fill,var(--game-card-width))]" :
'landscape:flex sm:gap-2 md:gap-6 portrait:grid portrait:auto-rows-(--game-card-height) portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))]',
className={twMerge("items-center justify-center-safe h-full",
data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-min grid-cols-[repeat(auto-fill,var(--game-card-width))]" :
'landscape:grid landscape:grid-flow-col landscape:auto-cols-min auto-rows-[1fr] sm:gap-2 md:gap-4 portrait:grid portrait:auto-rows-min portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))] *:portrait:aspect-8/10 *:landscape:aspect-8/12 sm:landscape:max-h-84 md:max-h-128!',
data.className
)}
onKeyDown={(e) =>

View file

@ -1,15 +1,19 @@
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
export default function Clock() {
export default function Clock ()
{
const locale = "en";
const [today, setDate] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => {
useEffect(() =>
{
const timer = setInterval(() =>
{
setDate(new Date());
}, 60 * 1000);
return () => {
return () =>
{
clearInterval(timer);
};
}, []);

View file

@ -4,13 +4,15 @@ import { useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { CardList, GameMetaExtra } from "./CardList";
import { SaveSource } from "../scripts/spatialNavigation";
import { GameCardFocusHandler } from "./GameCard";
import { GameCardFocusHandler } from "./CardElement";
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
export default function CollectionList (data: {
id: string,
setBackground: (url: string) => void;
className?: string;
onFocus?: GameCardFocusHandler;
onSelect?: (id: string) => void;
})
{
const navigate = useNavigate();
@ -20,6 +22,12 @@ export default function CollectionList (data: {
staleTime: DefaultRommStaleTime
});
const handleDefaultSelect = (id: string) =>
{
SaveSource('game-list', { search: { focus: getCurrentFocusKey() } });
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
};
return (
<CardList
type="collection"
@ -38,11 +46,7 @@ export default function CollectionList (data: {
</span>
],
} satisfies GameMetaExtra))}
onSelectGame={(id) =>
{
SaveSource('game-list');
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
}}
onSelectGame={data.onSelect ? data.onSelect : handleDefaultSelect}
onGameFocus={(id, node, details) =>
{
data.setBackground(

View file

@ -1,16 +1,16 @@
import { AnimatedBackground } from './AnimatedBackground';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { HeaderUI } from './Header';
import { GameList, GameListFilter } from './GameList';
import { GameList } from './GameList';
import { Search, Settings2 } from 'lucide-react';
import { JSX, Suspense } from 'react';
import Shortcuts from './Shortcuts';
import { AutoFocus } from './AutoFocus';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { Router } from '..';
import { PopSource } from '../scripts/spatialNavigation';
import { PopNavigateSource, PopSource } from '../scripts/spatialNavigation';
import { GameListFilterType } from '@/shared/constants';
import { GameCardFocusHandler } from './GameCard';
import { GameCardFocusHandler } from './CardElement';
export interface CollectionsDetailParams
{
@ -22,16 +22,6 @@ export interface CollectionsDetailParams
footer?: JSX.Element;
}
function HandleGoBack ()
{
const source = PopSource('game-list');
if (source)
{
console.log("Found source ", source, " to go back to");
}
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
}
export function CollectionsDetail (data: CollectionsDetailParams)
{
const focusKey = `game-list-${data.id}-${data.filters ? Object.values(data.filters).map(f => String(f)).join(",") : ''}`;
@ -40,7 +30,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
preferredChildFocusKey: `${focusKey}-list`,
});
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => PopNavigateSource('game-list', '/') }]);
const { shortcuts } = useShortcutContext();
const handleScroll: GameCardFocusHandler = (id, node, details) =>

View file

@ -1,14 +1,10 @@
import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { FocusContext, FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { createContext, JSX, useContext, useEffect } from "react";
import { JSX, useContext, useEffect } from "react";
import { twMerge } from "tailwind-merge";
import { X } from "lucide-react";
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
const ContextDialogContext = createContext({} as {
close: () => void,
id: string;
});
import { ContextDialogContext } from "../scripts/contexts";
export function ContextList (data: { options?: DialogEntry[]; className?: string; showCloseButton?: boolean; })
{
@ -35,12 +31,12 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
trackChildren: typeof data.content !== 'string'
});
const colors = {
primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused || hasFocusedChild }),
secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused || hasFocusedChild }),
accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused || hasFocusedChild }),
info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused || hasFocusedChild }),
warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused || hasFocusedChild }),
error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused || hasFocusedChild })
primary: "active:bg-primary control-pointer:hover:bg-primary focused:bg-primary focused:text-primary-content in-focused:bg-primary in-focused:text-primary-content",
secondary: "active:bg-secondary control-pointer:hover:bg-secondary focused:bg-secondary focused:text-secondary-content in-focused:bg-secondary in-focused:text-secondary-content",
accent: "active:bg-accent control-pointer:hover:bg-accent focused:bg-accent focused:text-accent-content in-focused:bg-accent in-focused:text-accent-content",
info: "active:bg-info control-pointer:hover:bg-info focused:bg-info focused:text-info-content in-focused:bg-info in-focused:text-info-content",
warning: "active:bg-warning control-pointer:hover:bg-warning focused:bg-warning focused:text-warning-content in-focused:bg-warning in-focused:text-warning-content",
error: "active:bg-error control-pointer:hover:bg-error focused:bg-error focused:text-error-content in-focused:bg-error in-focused:text-error-content"
};
if (data.shortcuts)
{
@ -51,8 +47,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
className={
twMerge("flex cursor-pointer sm:text-sm md:text-base")}>
<FocusContext value={focusKey}>
<div className={twMerge("flex w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl transition-all gap-2",
classNames({ "font-semibold": focused || hasFocusedChild }),
<div className={twMerge("flex w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl transition-all gap-2 active:animate-scale in-focused:font-semibold",
data.className,
colors[data.type])}>
{data.icon}
@ -105,7 +100,7 @@ export function ContextDialog (data: {
}] : [], [data.open]);
return <dialog ref={ref} open={data.open} closedby="any" className={
twMerge("absolute modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
twMerge("fixed modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
classNames({ "opacity-0": !data.open }))
}
onClick={() =>

View file

@ -0,0 +1,33 @@
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Home, TriangleAlert } from "lucide-react";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
import { Router } from "..";
import Shortcuts from "./Shortcuts";
import { Button } from "./options/Button";
import { useEffect } from "react";
import { ErrorComponentProps } from "@tanstack/react-router";
import { mobileCheck } from "../scripts/utils";
export default function Error (data: ErrorComponentProps)
{
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "not-found" });
const handleReturn = () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]);
const { shortcuts } = useShortcutContext();
useEffect(() => { focusSelf(); }, []);
return <div ref={ref} className="absolute flex flex-col justify-center items-center w-full h-full gap-4">
<FocusContext value={focusKey}>
<p className="flex gap-2 items-center text-4xl text-error text-shadow-lg">
<TriangleAlert className="size-12" />
{data.error.message}
</p>
<p className="flex gap-2 text-lg text-base-content/50 text-shadow-lg">{window.location.href} </p>
<Button className="text-2xl! p-6! focusable focusable-primary" id="return" onAction={handleReturn}><Home />Return Home</Button>
<div className="mobile:hidden bg-gradient"></div>
<div className="mobile:hidden bg-noise"></div>
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
</FocusContext>
</div>;
}

View file

@ -1,11 +1,11 @@
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { ContextList, DialogEntry, OptionElement } from "./ContextDialog";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ContextList, DialogEntry } from "./ContextDialog";
import { systemApi } from "../scripts/clientApi";
import { createContext, useContext, useRef, useState } from "react";
import { useContext, useRef, useState } from "react";
import path from "pathe";
import { Check, File, Folder, FolderClosed, FolderInput, FolderOutput, FolderPlus, HardDrive, Plus, Save, Undo, Usb, X } from "lucide-react";
import { Check, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { DirType, Drive } from "@/shared/constants";
import { DirType } from "@/shared/constants";
import classNames from "classnames";
import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
@ -13,17 +13,8 @@ import SvgIcon from "./SvgIcon";
import { Button } from "./options/Button";
import toast from "react-hot-toast";
import { drivesQuery, filesQuery } from "../scripts/queries";
const FilePickerContext = createContext<{
allowNewFolderCreation: boolean;
isDirectoryPicker: boolean;
setCurrentPath: (path: string) => void;
currentPath: string | undefined,
startingPath: string | undefined;
refetchFiles: () => void;
drives: Drive[],
activeDrive: Drive | undefined;
}>({} as any);
import { FilePickerContext } from "../scripts/contexts";
import useActiveControl from "../scripts/gamepads";
function List (data: {
id: string,
@ -137,7 +128,7 @@ function NewFolderOption (data: { id: string, dirname: string; })
});
return <div className="flex gap-2 grow -ml-2">
<NewFolderInput className="grow" id={`${data.id}-input`} setName={setName} name={name} />
<Button id={`${data.id}-create`} onAction={createMutation.mutate} type="button" ><FolderPlus /></Button>
<Button id={`${data.id}-create`} onAction={e => createMutation.mutate()} type="button" ><FolderPlus /></Button>
</div>;
}
@ -149,7 +140,7 @@ function OptionButtons (data: {
})
{
const { ref, focusKey } = useFocusable({ focusKey: `options-${data.id}`, onEnterPress: data.onSelect });
return <div ref={ref} className="flex md:inline h-12 w-full justify-end gap-2">
return <div ref={ref} className="flex h-12 w-full justify-end gap-2">
<FocusContext value={focusKey}>
{data.showConfirm && <Button className="p-6 ring-accent-content" onAction={data.onSelect} id={`${data.id}-select`} focusClassName="ring-7" type="button" ><Check />Select</Button>}
<Button className="md:p-6 ring-warning-content" onAction={data.onCancel} id={`${data.id}-cancel`} type="button" focusClassName="ring-7 btn-warning" ><X />Cancel</Button>
@ -252,6 +243,8 @@ export default function FilePicker (data: {
[<><HardDrive />{activeDrive?.label}</>, ...fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep)] :
fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep);
const { isPointer } = useActiveControl();
return <div className="flex flex-col h-full max-h-full gap-3">
<FilePickerContext value={{
setCurrentPath,
@ -271,8 +264,9 @@ export default function FilePicker (data: {
setCurrentPath(path.join(...fullPath.slice(-i)))
}>{p}</a>
</li>)}
{(filesLoading || drivesLoading) && <li className="mr-2 loading loading-spinner sm:loading-md md:loading-sm"></li>}
</ul>
{(filesLoading || drivesLoading) && <span className="loading loading-spinner sm:loading-md md:loading-lg"></span>}
</div>}
<ListWithDrives
@ -281,11 +275,11 @@ export default function FilePicker (data: {
onSelect={data.onSelect}
parentPath={files?.parentPath ?? ''}
/>
<OptionButtons
{isPointer && <OptionButtons
showConfirm={!!data.isDirectoryPicker}
onCancel={data.cancel}
onSelect={() => currentPath ? data.onSelect(currentPath) : undefined}
id={data.id} />
id={data.id} />}
</FilePickerContext>
</div>;
}

View file

@ -4,10 +4,7 @@ import
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import SvgIcon from "./SvgIcon";
import classNames from "classnames";
import { useSearch } from "@tanstack/react-router";
import { useEffect } from "react";
import useActiveControl from "../scripts/gamepads";
import { twMerge } from "tailwind-merge";
function FilterCat (
data: {
@ -25,31 +22,12 @@ function FilterCat (
onEnterPress: data.onAction
});
const { filter } = useSearch({ from: '/' });
useEffect(() =>
{
if (filter == data.id && data.hasFocusedPeer)
{
focusSelf();
}
}, [filter]);
const { isMouse } = useActiveControl();
return (
<li
aria-selected={data.active}
ref={ref}
onClick={focusSelf}
className={classNames(
"sm:text-sm sm:px-2",
"flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg",
{
"bg-base-content px-3 text-base-300 drop-shadow cursor-default":
focused || data.active,
"ring-primary ring-7": focused && !isMouse,
"hover:bg-base-content/40 cursor-pointer": !focused,
},
)}
className={"sm:text-sm sm:px-2 flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg focusable focusable-primary hover:not-focused:not-aria-selected:bg-base-content/40 not-focused:cursor-pointer aria-selected:bg-base-content aria-selected:text-base-300 aria-selected:drop-shadow aria-selected:cursor-default active:bg-accent! active:text-accent-content! active:ring-offset-7 active:ring-offset-base-content select-none"}
>
{data.children ?? data.label}
</li>
@ -59,35 +37,37 @@ function FilterCat (
export function FilterUI (data: {
id: string;
options: Record<string, FilterOption>;
selected: string;
setSelected: (id: string) => void;
containerClassName?: string;
className?: string;
})
{
const defaultFocus = Object.entries(data.options).filter(o => o[1].selected)[0]?.[0];
const { ref, focusKey, hasFocusedChild } = useFocusable({
focusKey: `filter-${data.id}`,
focusKey: data.id,
saveLastFocusedChild: false,
autoRestoreFocus: false,
preferredChildFocusKey: data.selected,
preferredChildFocusKey: `${data.id}-${defaultFocus}`,
trackChildren: true
});
return (
<div
ref={ref}
save-child-focus="session"
className={data.containerClassName}
>
<FocusContext.Provider value={focusKey}>
<ul className="flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm sm:h-9 md:h-14">
<ul className={twMerge("flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm sm:portrait:h-12 sm:landscape:h-9 md:h-14!", data.className)}>
<li className=" flex px-4 items-center justify-center rounded-full">
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_l1_outline" />
</li>
{Object.entries(data.options)?.map(([id, option]) => (
<FilterCat
hasFocusedPeer={hasFocusedChild}
id={id}
id={`${data.id}-${id}`}
key={id}
onFocus={() => data.setSelected(id)}
active={id === data.selected}
active={option.selected}
{...option}
/>
))}

View file

@ -0,0 +1,21 @@
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { twMerge } from "tailwind-merge";
import { useGlobalFocus } from "../scripts/spatialNavigation";
export default function FocusDots (data: {
elements: string[];
})
{
const focusedKey = useGlobalFocus();
return <div className="divider opacity-20"><div className="flex gap-2 py-6 justify-center items-center h-3">{data.elements.map((em, i) =>
{
const focused = em === focusedKey;
return <button key={i} onClick={(e) => setFocus(em, { nativeEvent: e.nativeEvent })}
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></div>;
}

View file

@ -0,0 +1,46 @@
import { FrontEndGameType, FrontEndId, RPC_URL } from "@/shared/constants";
import CardElement from "./CardElement";
import { SaveSource } from "../scripts/spatialNavigation";
import { Router } from "..";
import { HardDrive } from "lucide-react";
import { JSX } from "react";
import { FOCUS_KEYS } from "../scripts/types";
export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; } & FocusParams & InteractParams)
{
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null)
{
SaveSource('details', { search: { focus: FOCUS_KEYS.GAME_CARD(data.game.id.id) } });
console.log({ id: String(sourceId ?? id.id), source: source ?? id.source });
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
};
const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`);
platformUrl.searchParams.set('width', "64");
const subtitle = <div className="flex gap-1 items-center">
{!!data.game.path_platform_cover && <img className="sm:hidden md:inline size-4" src={platformUrl.href} />}
<p className="opacity-80">{data.game.platform_display_name}</p>
</div>;
const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_cover}`);
previewUrl.searchParams.delete('ts');
previewUrl.searchParams.set('width', "640");
const badges: JSX.Element[] = [];
if (data.game.id.source === 'local')
{
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
}
return <CardElement
badges={badges}
onFocus={data.onFocus}
onAction={(e) => data.onAction ? data.onAction(e) : handleDefaultSelect(data.game.id, data.game.source, data.game.source_id)}
preview={previewUrl.href}
title={data.game.name ?? ""}
subtitle={subtitle}
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id.id)}
index={data.index}
id={`game-${data.game.id.source}-${data.game.id.id}`}
/>;
}

View file

@ -6,8 +6,7 @@ import { SaveSource } from "../scripts/spatialNavigation";
import { rommApi } from "../scripts/clientApi";
import { HardDrive } from "lucide-react";
import { JSX } from "react";
import { GameCardFocusHandler } from "./GameCard";
import { gameQuery } from "../scripts/queries";
import { GameCardFocusHandler } from "./CardElement";
import { useLocalSetting } from "../scripts/utils";
export interface GameListParams
@ -16,7 +15,7 @@ export interface GameListParams
filters?: GameListFilterType,
grid?: boolean,
setBackground?: (url: string) => void;
onGameSelect?: (id: FrontEndId) => void;
onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
onFocus?: GameCardFocusHandler;
className?: string;
}
@ -33,7 +32,7 @@ export function GameList (data: GameListParams)
const queryClient = useQueryClient();
const blur = useLocalSetting('backgroundBlur');
const handleFocus = (id: FrontEndId, source: string | null, sourceId: number | null) =>
const handleFocus = (id: FrontEndId, source: string | null, sourceId: string | null) =>
{
const game = games.data?.games.find((g) => g.id === id);
if (game)
@ -52,7 +51,7 @@ export function GameList (data: GameListParams)
}
};
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: number | null)
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null)
{
SaveSource('details');
navigator({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
@ -73,11 +72,11 @@ export function GameList (data: GameListParams)
const badges: JSX.Element[] = [];
if (g.id.source === 'local')
{
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
badges.push(<HardDrive className="sm:size-4 md:size-8 md:p-1 m-1" />);
}
const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
previewUrl.searchParams.delete('ts');
previewUrl.searchParams.set('width', "640");
previewUrl.searchParams.set('width', "16");
const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
platformUrl.searchParams.set('width', "64");
@ -93,7 +92,7 @@ export function GameList (data: GameListParams)
),
previewUrl: previewUrl.href,
badges: badges,
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id) : handleDefaultSelect(g.id, g.source, g.source_id),
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g.id, g.source, g.source_id),
onFocus: () => handleFocus(g.id, g.source, g.source_id)
} satisfies GameMetaExtra;
},

View file

@ -22,10 +22,10 @@ import
} from "lucide-react";
import { RoundButton } from "./RoundButton";
import { useQuery } from "@tanstack/react-query";
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen";
import { RPC_URL } from "../../shared/constants";
import { JSX, useEffect, useRef } from "react";
import { SaveSource } from "../scripts/spatialNavigation";
import { SaveSource, useFocusableDynamic } from "../scripts/spatialNavigation";
import { systemApi } from "../scripts/clientApi";
import { Router } from "..";
@ -54,14 +54,14 @@ function HeaderAvatar (data: {
id={data.id}
ref={ref}
onClick={data.onSelect}
style={{ viewTransitionName: `header-account-${data.id}` }}
className={classNames(
`avatar indicator ring-base-100 ring-offset-base-100 sm:size-8 md:size-14 rounded-full flex items-center justify-center`,
`avatar indicator ring-offset-base-100 sm:size-8 md: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",
"hover:ring-primary hover:ring-7 focusable focusable-primary focused:ring-offset-base-100",
{
"ring-5 hover:ring-offset-5": data.active,
"sm:ring-4 md:ring-7 ring-primary ring-offset-base-100": focused,
"ring-offset-5": focused && data.active,
},
data.className,
@ -276,7 +276,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; })
{
return <div className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
<div className="flex sm:gap-2 md:gap-5 items-center">
<div className="flex sm:gap-2 md:gap-5 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
<ClockStatus />
<WiFiStatus />
<BluetoothStatus />
@ -289,22 +289,29 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
key={b.id}
className="header-icon sm:size-10 md:size-16"
id={b.id}
icon={b.icon}
external={b.external}
action={b.action}
/>)}
style={{ viewTransitionName: `header-button-${b.id}` }}
onAction={b.action}
>{b.icon}</RoundButton>)}
</div>
</div>;
}
export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[]; buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; })
export function HeaderUI (data: {
buttons?: HeaderButton[];
accounts?: HeaderAccount[];
buttonElements?: JSX.Element[] | JSX.Element;
title?: JSX.Element;
preferredChildFocusKey?: string;
})
{
const { ref, focusKey } = useFocusable({ focusKey: "header-elements" });
const { ref, focusKey } = useFocusable({ focusKey: "header-elements", preferredChildFocusKey: data.preferredChildFocusKey });
return (
<FocusContext.Provider value={focusKey}>
<header
ref={ref}
className={`flex items-center justify-between text-base-content`}
className="flex items-center justify-between text-base-content"
style={{ viewTimelineName: 'header' }}
>
<HeaderAccounts accounts={data.accounts} />
{data.title}

View file

@ -1,5 +1,5 @@
import classNames from 'classnames';
import { GameCardSkeleton } from './GameCard';
import { GameCardSkeleton } from './CardElement';
export default function LoadingCardList (data: { placeholderCount: number, grid?: boolean; })
{

View file

@ -0,0 +1,31 @@
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Home, TriangleAlert } from "lucide-react";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
import { Router } from "..";
import Shortcuts from "./Shortcuts";
import { Button } from "./options/Button";
import { useEffect } from "react";
export default function NotFound ()
{
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "not-found" });
const handleReturn = () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]);
const { shortcuts } = useShortcutContext();
useEffect(() => { focusSelf(); }, []);
return <div ref={ref} className="absolute flex flex-col justify-center items-center w-full h-full gap-4">
<FocusContext value={focusKey}>
<p className="flex gap-2 items-center text-4xl text-error text-shadow-lg">
<TriangleAlert className="size-12" />
Not found
</p>
<p className="flex gap-2 text-lg text-base-content/50 text-shadow-lg">{window.location.href} </p>
<Button className="text-2xl! p-6! focusable focusable-primary" id="return" onAction={handleReturn}><Home />Return Home</Button>
<div className="mobile:hidden bg-gradient"></div>
<div className="mobile:hidden bg-noise"></div>
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
</FocusContext>
</div>;
}

View file

@ -1,16 +1,25 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
import { CardList, GameMetaExtra } from "./CardList";
import classNames from "classnames";
import { rommApi } from "../scripts/clientApi";
import { SaveSource } from "../scripts/spatialNavigation";
import { JSX, useMemo } from "react";
import { HardDrive } from "lucide-react";
import { GameCardFocusHandler } from "./GameCard";
import { GameCardFocusHandler } from "./CardElement";
import { mobileCheck } from "../scripts/utils";
import { twMerge } from "tailwind-merge";
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: GameCardFocusHandler; grid?: boolean; })
export function PlatformsList (data: {
id: string,
setBackground: (url: string) => void;
className?: string;
onFocus?: GameCardFocusHandler;
grid?: boolean;
onSelect?: (source: string, id: string) => void;
})
{
const isMobile = mobileCheck();
const navigate = useNavigate();
const { data: platforms } = useSuspenseQuery(
{
@ -25,6 +34,12 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
staleTime: DefaultRommStaleTime,
});
const handleDefaultSelect = (source: string, id: string) =>
{
SaveSource('game-list');
navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
};
const platformsMapped = useMemo(() => platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
.map((g, i) =>
{
@ -44,13 +59,9 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
onFocus: () => data.setBackground(
g.paths_screenshots.length > 0 ? `${RPC_URL(__HOST__)}${g.paths_screenshots[new Date().getMinutes() % g.paths_screenshots.length]}` : `${RPC_URL(__HOST__)}/api/romm/image?url=https://picsum.photos/id/${10 + i}/1280/720.webp`,
),
onSelect: () =>
{
SaveSource('game-list');
navigate({ to: `/platform/${g.id.source}/${g.id.id}`, viewTransition: { types: ['zoom-in'] } });
},
onSelect: () => data.onSelect ? data.onSelect(g.id.source, g.id.id) : handleDefaultSelect(g.id.source, g.id.id),
preview:
({ focused }) => <div
() => <div
className="flex p-6 bg-base-100 justify-center"
style={{
background: `linear-gradient(
@ -58,11 +69,11 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
color-mix(in srgb, var(--color-base-300) 60%, transparent)
), url(https://picsum.photos/id/${10 + i}/100/100.webp?blur=10) center / cover`,
backgroundBlendMode: "screen",
boxShadow: 'inset 0 0 32px rgba(0,0,0,0.6)'
backgroundBlendMode: isMobile ? undefined : "screen",
boxShadow: isMobile ? undefined : 'inset 0 0 32px rgba(0,0,0,0.6)'
}}
>
<img className={classNames("drop-shadow-2xl", { "animate-rotate": focused })}
<img draggable={false} className={"not-mobile:drop-shadow-2xl in-focus:animate-rotate"}
src={coverUrl.href}
></img>
</div>
@ -76,7 +87,7 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
type="platform"
id={data.id}
grid={data.grid}
className={data.className}
className={twMerge('*:aspect-8/10! md:py-12', data.className)}
onGameFocus={data.onFocus}
games={platformsMapped}
onSelectGame={(id) =>

View file

@ -1,39 +1,20 @@
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { JSX } from "react";
import { CSSProperties, JSX } from "react";
import { twMerge } from 'tailwind-merge';
import { Button, ButtonStyle } from "./options/Button";
export function RoundButton (data: {
id: string;
icon: JSX.Element;
children?: any;
className?: string;
external?: boolean;
action?: () => void;
})
style?: ButtonStyle;
} & InteractParams & FocusParams)
{
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>
<Button onFocus={data.onFocus} id={data.id} style={data.style} className={twMerge("rounded-full", data.external && "focusable focusable-primary focusable-hover", data.className)} onAction={data.onAction}>
{data.children}
</Button>
);
}

View file

@ -0,0 +1,49 @@
import { RPC_URL } from "@/shared/constants";
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { useRef, useState } from "react";
import FocusDots from "./FocusDots";
import { scrollIntoNearestParent, useDragScroll } from "../scripts/utils";
import { Fullscreen } from "lucide-react";
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; })
{
const imageRef = useRef<HTMLImageElement>(null);
const { ref, focused, focusSelf } = useFocusable({
focusKey: `screenshot-${data.index}`,
onEnterPress: () => (ref.current as HTMLElement).requestFullscreen(),
onFocus: (e, p, details) =>
{
data.setFocused?.(data.index);
scrollIntoNearestParent(ref.current, { behavior: details.instant ? 'instant' : 'smooth' });
}
}); 4096;
return <div ref={ref} className="group relative flex min-w-fit aspect-video max-h-[60vh] rounded-3xl focusable focusable-accent not-focused:cursor-pointer overflow-hidden">
<img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" />
<div className="absolute flex justify-center items-center bottom-2 right-2 size-10 rounded-full bg-base-100 hover:bg-base-content hover:text-base-300 cursor-pointer opacity-60 not-control-mouse:hidden invisible group-has-hover:visible" onClick={() => imageRef.current?.requestFullscreen()}> <Fullscreen /> </div>
</div>;
}
export default function Screenshots (data: { screenshots: string[]; } & FocusParams)
{
const scrollRef = useRef(null);
const { ref, focusKey } = useFocusable({
focusKey: 'screenshot-list',
onFocus: (e, p, details) =>
{
data.onFocus?.(focusKey, ref.current, details);
}
});
useDragScroll(scrollRef);
return <div ref={ref} className="flex flex-col w-full z-0 min-h-0">
<FocusContext value={focusKey}>
<div
ref={scrollRef}
className="flex gap-6 px-16 py-2 sm:overflow-scroll md:overflow-hidden no-scrollbar justify-center-safe"
>
{data.screenshots.map((s, i) => <Screenshot key={s} index={i} path={s} />)}
</div>
<FocusDots elements={data.screenshots.map((_, i) => `screenshot-${i}`)} />
</FocusContext>
</div>;
}

View file

@ -16,7 +16,7 @@ export default function ShortcutPrompt (data: {
onClick={data.onClick}
style={{ viewTransitionName: data.id }}
className={twMerge("xs:text-xs sm:p-1 sm:text-sm",
"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 ring-[1px] ring-base-content/10 drop-shadow-black/30",
"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 ring-[1px] ring-base-content/10 drop-shadow-black/30 active:text-base-300 active:bg-base-content",
data.className,
classNames({
"hover:bg-base-300 cursor-pointer": !!data.onClick,

View file

@ -48,7 +48,7 @@ export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
const { control } = useActiveControl();
const showKeyboard = control === 'keyboard' || control === 'mouse';
return (
<div className="flex gap-2 z-1000 h-10">
<div className="flex gap-2 z-1000" style={{ viewTimelineName: "shortcuts" }}>
{data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt
key={s.button}
id={`shortcut-${s.button}`}

View file

@ -7,12 +7,26 @@ import
import classNames from "classnames";
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error';
const styles = {
base: 'bg-base-200 text-base-content active:bg-base-300! active:text-base-content! active:ring-offset-base-content',
accent: "bg-accent text-accent-content active:bg-base-content! active:text-base-content active:ring-offset-accent",
primary: "bg-primary text-primary-content active:bg-base-content! active:text-base-content! active:ring-offset-primary",
secondary: "bg-secondary text-secondary-content active:bg-base-content! active:text-base-content! active:ring-offset-secondary",
info: "bg-info text-info-content active:bg-base-content! active:text-base-content! active:ring-offset-info",
success: "bg-success text-success-content active:bg-base-content! active:text-base-content! active:ring-offset-success",
warning: "bg-warning text-warning-content active:bg-base-content! active:text-base-content! active:ring-offset-warning",
error: "bg-error text-error-content active:bg-base-content! active:text-base-content! active:ring-offset-error",
};
export function Button (data: {
id: string,
children?: any,
className?: string,
disabled?: boolean,
type?: "reset" | "button" | "submit";
style?: ButtonStyle,
shortcutLabel?: string;
focusClassName?: string;
} & InteractParams & FocusParams)
@ -20,7 +34,7 @@ export function Button (data: {
const { ref, focused, focusKey } = useFocusable({
focusKey: data.id,
onEnterPress: data.onAction,
onFocus: data.onFocus,
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details),
focusable: !data.disabled
});
@ -31,9 +45,10 @@ export function Button (data: {
return <button
ref={ref}
onClick={data.onAction}
onClick={e => data.onAction?.(e.nativeEvent)}
disabled={data.disabled}
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg",
className={twMerge("flex items-center justify-center px-4 py-2 disabled:bg-base-200/40 disabled:text-base-content/40 cursor-pointer rounded-3xl md:text-lg not-control-mouse:focused:drop-shadow-lg border border-base-content/5 not-control-mouse:focused:bg-base-content not-control-mouse:focused:text-base-100 control-mouse:hover:bg-base-content control-mouse:hover:text-base-100 active:transition-none active:ring-offset-4",
styles[data.style ?? 'base'],
focused ? data.focusClassName : undefined,
classNames({
"btn-accent": focused,

View file

@ -1,5 +1,5 @@
import { HTMLInputTypeAttribute, JSX } from "react";
import { LocalSettingsSchema, LocalSettingsType } from "../../../shared/constants";
import { LocalSettingsSchema, LocalSettingsType } from "@shared/constants";
import { OptionSpace } from "./OptionSpace";
import { OptionInput } from "./OptionInput";
import { useLocalStorage } from "usehooks-ts";
@ -18,7 +18,7 @@ export function LocalOption (data: {
const [localValue, setLocalValue] = useLocalStorage<any>(data.id, LocalSettingsSchema.shape[data.id].parse(undefined), { deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) });
return (
<OptionSpace label={data.label}>
<OptionSpace id={`${data.id}-space`} label={data.label}>
{data.type === 'dropdown' && data.values && <OptionDropdown values={data.values} icon={data.icon}
name={data.id ?? ""}
type={data.type}

View file

@ -1,9 +1,7 @@
import classNames from "classnames";
import { ChangeEventHandler, FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef, useState } from "react";
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog";
import { ChevronDown } from "lucide-react";
@ -39,16 +37,13 @@ export function OptionDropdown (data: {
return (
<>
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
classNames({ "[&_button]:not-focus:ring-7 [&_button]:not-focus:ring-accent": focused }))}>
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
"text-primary-content": option.focused
}))}>{data.icon}</span>}
<label ref={ref} className={twMerge("flex group-focusable items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent")}>
{!!data.icon && <span className={"text-base-content/80 is-focused:text-primary-content"}>{data.icon}</span>}
<button onClick={() =>
{
console.log("Open");
setOpen(true);
}} className={classNames('btn input rounded-full cursor-pointer grow', { "bg-base-200": !focused })}>{data.value}<ChevronDown /></button>
}} className={'flex items-center justify-center border h-10 border-base-content/30 px-4 py-2 rounded-full cursor-pointer grow not-in-focused:bg-base-200 focusable focusable-accent hover:border-base-content hover:bg-base-content hover:text-base-300'}>{data.value}<ChevronDown /></button>
</label>
{open && <ContextDialog id={`${data.name}-context`} open={true} close={handleClose}>
<ContextList options={data.values.map((v, i) => ({

View file

@ -1,10 +1,9 @@
import classNames from "classnames";
import { ChangeEventHandler, FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react";
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react";
import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
import { Check, CheckIcon, X } from "lucide-react";
import { CheckIcon, X } from "lucide-react";
export function OptionInput (data: {
name: string;
@ -52,11 +51,8 @@ export function OptionInput (data: {
};
return (
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
classNames({ "[&_.focus-target]:not-focus:ring-7 [&_.focus-target]:not-focus:ring-accent": focused, "pl-1": data.type === 'checkbox' }))}>
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
"text-primary-content": option.focused
}))}>{data.icon}</span>}
<label ref={ref} className={`flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent group-focusable`}>
{!!data.icon && <span className="text-base-content/80">{data.icon}</span>}
{data.type !== 'checkbox' && <input
ref={inputRef}
id={data.name}
@ -72,17 +68,11 @@ export function OptionInput (data: {
onBlur={data.onBlur}
defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined}
className={twMerge(
"focus-target text-base-content",
"input grow rounded-full ring-primary-content focus:ring-7", classNames({
"bg-base-200": !focused
}),
"flex text-base-content px-4 py-2 items-center justify-center border border-base-content/20 grow rounded-full focus:ring-base-content in-focused:bg-base-200 focusable focusable-accent focus:not-focused:ring-7 control-mouse:ring-0! hover:border-base-content",
data.className
)}
/>}
{data.type === 'checkbox' && <div className={classNames("toggle focus-target toggle-primary toggle-xl border-base-content/30 rounded-full before:rounded-full text-base-content", {
"bg-base-200": !focused,
"border-0": focused,
})}>
{data.type === 'checkbox' && <div className="toggle toggle-xl before:size-6 h-8 border-base-content/30 rounded-full before:rounded-full text-base-content not-in-focus:bg-base-200 focused-child:border-0 ml-1 ring-7 hover:border-base-content focusable focusable-accent">
<input
ref={inputRef}
id={data.name}

View file

@ -1,16 +1,9 @@
import { FocusContext, FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { OptionContext } from "@/mainview/scripts/contexts";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { createContext, JSX, useContext, useEffect, useMemo } from "react";
import { JSX, useContext, useEffect, useMemo } from "react";
import { twMerge } from "tailwind-merge";
export const OptionContext = createContext(
{} as {
focused: boolean;
focus: (focusDetails?: FocusDetails | undefined) => void;
eventTarget: EventTarget;
},
);
export function useOptionContext (params?: { onOptionEnterPress?: () => void; })
{
const context = useContext(OptionContext);
@ -81,11 +74,7 @@ export function OptionSpace (data: {
<OptionContext value={{ focused, focus: focusSelf, eventTarget }}>
<li
ref={ref}
className={twMerge("flex portrait:flex-col portrait:gap-2 portrait:p-4 md:flex-row sm:p-2 md:p-4 md:pl-8! rounded-3xl border-b border-base-content/5",
classNames(
{
"bg-base-300": focused || hasFocusedChild,
}),
className={twMerge("flex portrait:flex-col portrait:gap-2 portrait:p-4 md:flex-row sm:p-2 md:p-4 md:pl-8! rounded-3xl border-b border-base-content/5 focused:bg-base-300 focused-child:bg-base-300",
data.className,
)}
>

View file

@ -110,7 +110,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
};
return (
<OptionSpace id={data.id} className="gap-2" label={<>{data.label}{changed && <Pen />}</>}>
<OptionSpace id={`${data.id}-space`} className="gap-2" label={<>{data.label}{changed && <Pen />}</>}>
<OptionInput
icon={data.icon}
name={`${data.id}-input`}

View file

@ -18,7 +18,7 @@ export const { useAppForm: useSettingsForm, useTypedAppFormContext: useSettingsF
function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; label?: string | JSX.Element; placeholder?: string; })
{
const field = useFieldContext<string>();
return <OptionSpace label={<div className="flex flex-1 gap-2">
return <OptionSpace id={`${field.name}-space`} label={<div className="flex flex-1 gap-2">
{data.label}
{field.getMeta().errors.length > 0 && <div className="badge badge-error">
{field.state.meta.errors.map(e => e.message).join(',')}

View file

@ -54,7 +54,7 @@ export function SettingsOption (data: {
}, [dirty, setDirty, localValue]);
return (
<OptionSpace label={data.label}>
<OptionSpace id={`${data.id}-space`} label={data.label}>
<OptionInput
icon={data.icon}
name={data.id ?? ""}

View file

@ -0,0 +1,76 @@
import { useRef } from "react";
import
{
useFocusable,
FocusContext,
} from "@noriginmedia/norigin-spatial-navigation";
import { ChevronRight, Joystick } from "lucide-react";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils";
import FocusDots from "../FocusDots";
import { Router } from "@/mainview";
import { StoreEmulatorCard } from "./StoreEmulatorCard";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import { FrontEndEmulator } from "@/shared/constants";
function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant: boolean; }) => void; })
{
const { ref, focusKey } = useFocusable({
focusKey: data.id,
onFocus: (_l, _p, details) => data.onFocus?.({ node: ref.current, instant: details.instant }),
onEnterPress: data.onAction
});
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "See All", action: data.onAction }], []);
return <div
ref={ref}
role="button"
tabIndex={0}
onClick={data.onAction}
className={"flex focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:animate-scale-small p-4 justify-center items-center min-w-2xs gap-2 hover:bg-base-300 cursor-pointer"}
>
See All Emulators <ChevronRight />
</div>;
}
export function EmulatorsSection (data: {
id: string;
emulators: FrontEndEmulator[];
onSelect?: (id: string, focusKey: string) => void;
header?: any;
} & FocusParams)
{
const { ref, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.EMULATOR_SECTION(data.id),
trackChildren: true,
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details)
});
const containerRef = useRef(null);
useDragScroll(containerRef);
return (
<FocusContext.Provider value={focusKey}>
<section ref={ref} className="px-2 py-4">
<div className="flex items-center gap-3 px-4 mb-4 text-info">
{data.header ?? <>
<div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
<Joystick />
<h2 className="font-bold uppercase tracking-widest">
Recommended Emulators
</h2>
</>}
</div>
<div ref={containerRef} className="flex *:min-w-[18rem] overflow-y-hidden overflow-x-scroll scrollbar-none py-2 px-4 gap-4 select-none">
{data.emulators?.map((em) => (
<StoreEmulatorCard id={`${data.id}-${em.name}`} key={em.name} emulator={em} onSelect={(id, focusKey) => data.onSelect?.(em.name, focusKey)} onFocus={({ node, details }) =>
{
scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' });
}} />
))}
<SeeAllCard id={`${FOCUS_KEYS.EMULATOR_SECTION}-see-all`} onAction={() => Router.navigate({ to: '/store/tab/emulators' })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} />
</div>
</section>
{!!data.emulators && <FocusDots elements={data.emulators.map(e => FOCUS_KEYS.EMULATOR_CARD(e.name))} />}
</FocusContext.Provider>
);
}

View file

@ -0,0 +1,49 @@
import { useRef } from "react";
import
{
useFocusable,
FocusContext,
} from "@noriginmedia/norigin-spatial-navigation";
import { Gamepad2 } from "lucide-react";
import { useDragScroll } from "@/mainview/scripts/utils";
import FocusDots from "../FocusDots";
import { FrontEndGameType, FrontEndId } from "@/shared/constants";
import FrontEndGameCard from "../FrontEndGameCard";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
export function GamesSection ({ games, onSelect, onFocus }: {
games: FrontEndGameType[];
onSelect?: (id: FrontEndId, focusKey: string) => void;
} & FocusParams)
{
const { ref, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.GAME_SECTION,
trackChildren: true,
onFocus: (_l, _p, details) => onFocus?.(focusKey, ref.current, details)
});
const containerRef = useRef(null);
useDragScroll(containerRef);
return (
<FocusContext.Provider value={focusKey}>
<section ref={ref} className="px-6 py-3 select-none">
<div className="flex items-center gap-3 mb-4">
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
<Gamepad2 className="text-accent" />
<h2 className="font-bold uppercase tracking-widest text-accent grow">
Featured Games
</h2>
<div className="badge badge-xl badge-accent badge-soft">Curated picks</div>
</div>
<div ref={containerRef} className="grid grid-flow-col auto-cols-[18rem] overflow-y-hidden overflow-x-auto hide-scrollbar p-4 gap-4 justify-center-safe">
{games.map((g, i) => <FrontEndGameCard
key={g.id.id}
game={g}
onAction={() => onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id.id))}
index={i} />)}
</div>
</section>
<FocusDots elements={games.map(e => FOCUS_KEYS.GAME_CARD(e.id.id))} />
</FocusContext.Provider>
);
}

View file

@ -0,0 +1,98 @@
import
{
useFocusable,
FocusContext,
} from "@noriginmedia/norigin-spatial-navigation";
import { Button } from "../options/Button";
import useActiveControl from "@/mainview/scripts/gamepads";
import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { FrontEndEmulator, RPC_URL } from "@/shared/constants";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
// ── Single missing-emulator card ───────────────────────────────────────────
interface MissingCardProps
{
emulator: FrontEndEmulator;
onSelect?: (id: string, focusKey: string) => void;
}
function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
{
const handleSelect = () => onSelect?.(em.name, focusKey);
const { ref, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.MISSING_CARD(em.name),
onEnterPress: handleSelect,
});
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
const { isMouse } = useActiveControl();
return (
<div
ref={ref}
role="button"
tabIndex={0}
onClick={handleSelect}
onKeyDown={(e) => e.key === "Enter" && handleSelect}
className={"focusable focusable-accent bg-base-100 rounded-4xl transition-all focused:animate-scale-small shadow-lg"}
>
<div className="card-body p-5 gap-3">
<div className="flex gap-4">
<div
className={`size-14 bg-base-content rounded-full flex items-center justify-center text-2xl shadow-md shrink-0 text-base-300`}
>
{em.logo ?
<img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${em.logo}`}></img> :
<CircleQuestionMark />
}
</div>
<div className="grow">
<p className="font-bold text-base-content text-xl leading-tight">{em.name}</p>
<p className="text-base-content/40 mt-0.5">{em.systems?.map(s => s.name).join(',')}</p>
</div>
</div>
<div className="flex items-center grow h-8">
<p className="text-xs text-error/80 leading-relaxed">{em.name}</p>
{isMouse && <Button className="hover:btn-error hover:text-primary-content text-base-content/40 font-normal md:text-base" onAction={handleSelect} id={`details-${em.name}`}>Details<ChevronRight /></Button>}
</div>
</div>
</div>
);
}
export function MissingEmulatorsSection ({
emulators,
onSelect,
}: {
emulators: FrontEndEmulator[];
onSelect?: (id: string, focusKey: string) => void;
})
{
const { ref, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.MISSING_SECTION,
trackChildren: true,
onFocus: (_l, _p, details) => (ref.current as HTMLElement)?.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'end' })
});
return (
<FocusContext.Provider value={focusKey}>
<section ref={ref} className="px-6 pt-5 pb-2">
<div className="flex items-center gap-3 mb-4 text-error">
<div className="w-2 h-5 rounded-full bg-error shadow-sm shadow-error/40" />
<SearchAlert />
<h2 className="font-bold uppercase tracking-widest">
Missing Emulators
</h2>
</div>
<div className="grid sm:grid-cols-1 md:grid-cols-3 gap-4">
{emulators.map((em) => (
<MissingCard key={em.name} emulator={em} onSelect={onSelect} />
))}
</div>
</section>
<div className="divider opacity-20" />
</FocusContext.Provider>
);
}

View file

@ -0,0 +1,52 @@
import { storeApi } from "@/mainview/scripts/clientApi";
import { useQuery } from "@tanstack/react-query";
import { Joystick, LibraryBig, Save, TriangleAlert } from "lucide-react";
interface StatsSectionProps
{
romCount: number;
missingCount: number;
}
export function StatsSection ({
romCount,
missingCount,
}: StatsSectionProps)
{
const { data: stats } = useQuery({
queryKey: ['store', 'stats'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.stats.get();
if (error) throw error;
return data;
}
});
return (
<section className="px-6 pt-3 pb-4">
<div className="stats stats-horizontal w-full rounded-2xl text-shadow-sm">
<div className="stat">
<div className="stat-figure text-2xl text-primary shadow-2xl"><Joystick /></div>
<div className="stat-value text-xl font-black text-primary shadow-2xl">{stats?.storeEmulatorCount}</div>
<div className="stat-desc ">Emulators Available</div>
</div>
<div className="stat">
<div className="stat-figure text-2xl text-secondary"><Save /></div>
<div className="stat-value text-xl font-black text-secondary">{romCount.toLocaleString()}+</div>
<div className="stat-desc">ROMs in Store</div>
</div>
<div className="stat">
<div className="stat-figure text-2xl text-success"><LibraryBig /></div>
<div className="stat-value text-xl font-black text-success">{stats?.gameCount}</div>
<div className="stat-desc">Your Library</div>
</div>
<div className="stat">
<div className="stat-figure text-2xl text-warning"><TriangleAlert /></div>
<div className="stat-value text-xl font-black text-warning">{missingCount}</div>
<div className="stat-desc">Missing Emulators</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,84 @@
import { twMerge } from "tailwind-merge";
import { FrontEndEmulator, RPC_URL } from "@/shared/constants";
import { Button } from "../options/Button";
import useActiveControl from "@/mainview/scripts/gamepads";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { ChevronRight, EllipsisVertical, HardDrive } from "lucide-react";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
export function StoreEmulatorCard (data: {
id: string;
emulator: FrontEndEmulator;
onSelect?: (id: string, focusKey: string) => void;
onFocus?: (data: { id: string; node: HTMLElement; details: Record<string, any>; }) => void;
className?: string;
})
{
const handleSelect = () => data.onSelect?.(data.emulator.name, focusKey);
const { ref, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.EMULATOR_CARD(data.id),
onEnterPress: handleSelect,
onFocus: (_l, _p, details) =>
{
data.onFocus?.({ id: data.emulator.name, node: ref.current as HTMLElement, details });
}
});
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
const { isMouse, isTouch } = useActiveControl();
return (
<div
ref={ref}
role="button"
tabIndex={0}
data-installed={data.emulator.exists ? true : undefined}
onClick={isTouch ? handleSelect : undefined}
className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)}
>
<div className="flex flex-col justify-between p-4 gap-2 h-full">
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<div className="flex items-start">
<div
data-installed={data.emulator.exists}
className={`size-14 p-2 rounded-full bg-info flex items-center justify-center text-xl shadow-lg data-[installed=true]:bg-success`}
>
<img draggable={false} src={data.emulator.logo}></img>
</div>
</div>
<div>
<p data-installed={data.emulator.exists} className="font-bold text-base-content text-xl leading-snug data-[installed=true]:text-success">{data.emulator.name}</p>
<ul className="flex flex-wrap gap-1">
{data.emulator.systems.map(({ id, name, icon }) =>
{
return <div key={id} className="flex gap-1 items-center text-base-content/35 mt-0.5">
{!!icon && <img draggable={false} className="size-6 p-1 bg-base-200 rounded-full" src={`${RPC_URL(__HOST__)}${icon}`} />}
<p className="text-nowrap text-ellipsis overflow-hidden">{name}</p>
</div>;
})}
</ul>
</div>
</div>
</div>
<div className="flex gap-0.5 mt-1 h-10 items-center">
{data.emulator.exists && <div className="tooltip" data-tip="Installed">
<div className="flex items-center justify-center rounded-full p-1 size-8 bg-success text-success-content"><HardDrive /></div>
</div>}
{<div className="tooltip" data-tip="Game Count">
<div className="flex items-center justify-center rounded-full font-semibold size-9 p-2 bg-base-200 text-base-content/40">{data.emulator.gameCount}</div>
</div>}
{isMouse && <>
<Button onAction={handleSelect} style="base" className="grow text-base-content/40" id={`${data.emulator.name}-details`} >Details<ChevronRight /></Button>
<Button className="bg-transparent border-none shadow-none w-6 p-0" id={`${data.emulator.name}-options`} ><EllipsisVertical /></Button>
</>}
</div>
</div>
</div>
);
}