feat: implemented a basic store and emulatorjs
This commit is contained in:
parent
2f32cbc730
commit
7286541822
121 changed files with 5900 additions and 1092 deletions
|
|
@ -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 >
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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={() =>
|
||||
|
|
|
|||
33
src/mainview/components/Error.tsx
Normal file
33
src/mainview/components/Error.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
21
src/mainview/components/FocusDots.tsx
Normal file
21
src/mainview/components/FocusDots.tsx
Normal 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>;
|
||||
}
|
||||
46
src/mainview/components/FrontEndGameCard.tsx
Normal file
46
src/mainview/components/FrontEndGameCard.tsx
Normal 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}`}
|
||||
/>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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; })
|
||||
{
|
||||
|
|
|
|||
31
src/mainview/components/NotFound.tsx
Normal file
31
src/mainview/components/NotFound.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
);
|
||||
}
|
||||
|
|
|
|||
49
src/mainview/components/Screenshots.tsx
Normal file
49
src/mainview/components/Screenshots.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
|
|
|
|||
|
|
@ -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(',')}
|
||||
|
|
|
|||
|
|
@ -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 ?? ""}
|
||||
|
|
|
|||
76
src/mainview/components/store/EmulatorsSection.tsx
Normal file
76
src/mainview/components/store/EmulatorsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/mainview/components/store/GamesSection.tsx
Normal file
49
src/mainview/components/store/GamesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/mainview/components/store/MissingEmulatorsSection.tsx
Normal file
98
src/mainview/components/store/MissingEmulatorsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/mainview/components/store/StatsSection.tsx
Normal file
52
src/mainview/components/store/StatsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/mainview/components/store/StoreEmulatorCard.tsx
Normal file
84
src/mainview/components/store/StoreEmulatorCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue