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>
|
||||
);
|
||||
}
|
||||
63
src/mainview/emulatorjs/emulator.ts
Normal file
63
src/mainview/emulatorjs/emulator.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { RPC_URL } from '@/shared/constants';
|
||||
import { basename } from 'pathe';
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
Array.from(params.entries()).forEach(([key, value]) =>
|
||||
{
|
||||
(window as any)[`EJS_${key}`] = value;
|
||||
});
|
||||
|
||||
window.addEventListener('message', (e) =>
|
||||
{
|
||||
switch (e.data.type)
|
||||
{
|
||||
case 'pause':
|
||||
if (e.data.data === true)
|
||||
{
|
||||
window.EJS_emulator.pause();
|
||||
} else
|
||||
{
|
||||
window.EJS_emulator.play();
|
||||
}
|
||||
break;
|
||||
case 'restart':
|
||||
window.EJS_emulator.elements.bottomBar.restart[0].click();
|
||||
break;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
window.EJS_player = "#game";
|
||||
window.EJS_lightgun = false;
|
||||
window.EJS_startOnLoaded = true;
|
||||
// For core downloads, it either redirects to CDN or uses local if downloaded
|
||||
window.EJS_pathtodata = `${RPC_URL(__HOST__)}/api/romm/emulatorjs/data`;
|
||||
window.EJS_Buttons = {
|
||||
exitEmulation: {
|
||||
visible: true,
|
||||
displayName: "Exit",
|
||||
callback: () =>
|
||||
{
|
||||
window.parent.postMessage(
|
||||
{ type: "exit" },
|
||||
"*"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const moduleUrls = import.meta.glob
|
||||
(['../../../node_modules/@emulatorjs/emulatorjs/data/**/*.js',
|
||||
'../../../node_modules/@emulatorjs/emulatorjs/data/**/*.css',
|
||||
'../../../node_modules/@emulatorjs/emulatorjs/data/**/*.wasm',
|
||||
'../../../node_modules/@emulatorjs/emulatorjs/data/localization/en-US.json'
|
||||
], {
|
||||
query: '?url',
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
// emulatorjs expects basenames instead of paths for some reason
|
||||
window.EJS_paths = Object.fromEntries(await Promise.all(Object.entries(moduleUrls).map(async ([key, value]) => [basename(key), await value()])));
|
||||
|
||||
await import('@emulatorjs/emulatorjs/data/loader.js');
|
||||
20
src/mainview/emulatorjs/index.html
Normal file
20
src/mainview/emulatorjs/index.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="../assets/favicon.ico" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Alan+Sans:wght@300..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="./style.css" rel="stylesheet" />
|
||||
<title>GameFlow</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./emulator.ts"></script>
|
||||
<div id="game-wrapper">
|
||||
<div id="game"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
22
src/mainview/emulatorjs/style.css
Normal file
22
src/mainview/emulatorjs/style.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#game-wrapper {
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
62
src/mainview/emulatorjs/types.d.ts
vendored
Normal file
62
src/mainview/emulatorjs/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
export declare global
|
||||
{
|
||||
interface Window
|
||||
{
|
||||
EJS_emulator: any,
|
||||
EJS_player: string,
|
||||
EJS_gameUrl: string,
|
||||
EJS_pathtodata: string,
|
||||
EJS_language: string,
|
||||
EJS_disableAutoLang: boolean,
|
||||
EJS_paths: Record<string, string>,
|
||||
EJS_volume: number,
|
||||
EJS_gameName: string,
|
||||
EJS_cheats: string[][],
|
||||
EJS_fullscreenOnLoaded: boolean,
|
||||
EJS_startOnLoaded: boolean,
|
||||
EJS_core: string,
|
||||
EJS_lightgun: boolean,
|
||||
EJS_biosUrl: string,
|
||||
EJS_color: string,
|
||||
EJS_AdUrl: string,
|
||||
EJS_AdMode: string,
|
||||
EJS_AdTimer: number,
|
||||
EJS_AdSize: number,
|
||||
EJS_alignStartButton: boolean,
|
||||
EJS_VirtualGamepadSettings,
|
||||
EJS_Buttons,
|
||||
EJS_defaultControls,
|
||||
EJS_loadStateURL: string,
|
||||
EJS_CacheLimit: number,
|
||||
EJS_cacheConfig,
|
||||
EJS_cheatPath: string,
|
||||
EJS_defaultOptions,
|
||||
EJS_gamePatchUrl: string,
|
||||
EJS_gameParentUrl: string,
|
||||
EJS_netplayServer,
|
||||
EJS_netplayICEServers,
|
||||
EJS_gameID: string,
|
||||
EJS_backgroundImage: string,
|
||||
EJS_backgroundBlur,
|
||||
EJS_backgroundColor,
|
||||
EJS_controlScheme,
|
||||
EJS_threads: boolean,
|
||||
EJS_disableCue,
|
||||
EJS_startButtonName,
|
||||
EJS_softLoad,
|
||||
EJS_screenCapture,
|
||||
EJS_externalFiles,
|
||||
EJS_dontExtractRom,
|
||||
EJS_dontExtractBIOS,
|
||||
EJS_disableLocalStorage: boolean,
|
||||
EJS_forceLegacyCores: boolean,
|
||||
EJS_noAutoFocus: boolean,
|
||||
EJS_videoRotation,
|
||||
EJS_hideSettings,
|
||||
EJS_browserMode,
|
||||
EJS_shaders,
|
||||
EJS_fixedSaveInterval,
|
||||
EJS_disableAutoUnload,
|
||||
EJS_disableBatchBootup;
|
||||
}
|
||||
}
|
||||
|
|
@ -17,9 +17,15 @@ import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/di
|
|||
import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts'
|
||||
import { Route as SettingsAboutRouteImport } from './../routes/settings/about'
|
||||
import { Route as CollectionIdRouteImport } from './../routes/collection.$id'
|
||||
import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route'
|
||||
import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index'
|
||||
import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games'
|
||||
import { Route as StoreTabEmulatorsRouteImport } from './../routes/store/tab/emulators'
|
||||
import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$source.$id'
|
||||
import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id'
|
||||
import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id'
|
||||
import { Route as EmbeddedSourceIdRouteImport } from './../routes/embedded.$source.$id'
|
||||
import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id'
|
||||
|
||||
const SettingsRouteRoute = SettingsRouteRouteImport.update({
|
||||
id: '/settings',
|
||||
|
|
@ -61,6 +67,26 @@ const CollectionIdRoute = CollectionIdRouteImport.update({
|
|||
path: '/collection/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const StoreTabRouteRoute = StoreTabRouteRouteImport.update({
|
||||
id: '/store/tab',
|
||||
path: '/store/tab',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const StoreTabIndexRoute = StoreTabIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => StoreTabRouteRoute,
|
||||
} as any)
|
||||
const StoreTabGamesRoute = StoreTabGamesRouteImport.update({
|
||||
id: '/games',
|
||||
path: '/games',
|
||||
getParentRoute: () => StoreTabRouteRoute,
|
||||
} as any)
|
||||
const StoreTabEmulatorsRoute = StoreTabEmulatorsRouteImport.update({
|
||||
id: '/emulators',
|
||||
path: '/emulators',
|
||||
getParentRoute: () => StoreTabRouteRoute,
|
||||
} as any)
|
||||
const PlatformSourceIdRoute = PlatformSourceIdRouteImport.update({
|
||||
id: '/platform/$source/$id',
|
||||
path: '/platform/$source/$id',
|
||||
|
|
@ -76,19 +102,35 @@ const GameSourceIdRoute = GameSourceIdRouteImport.update({
|
|||
path: '/game/$source/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const EmbeddedSourceIdRoute = EmbeddedSourceIdRouteImport.update({
|
||||
id: '/embedded/$source/$id',
|
||||
path: '/embedded/$source/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({
|
||||
id: '/store/details/emulator/$id',
|
||||
path: '/store/details/emulator/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRouteRouteWithChildren
|
||||
'/store/tab': typeof StoreTabRouteRouteWithChildren
|
||||
'/collection/$id': typeof CollectionIdRoute
|
||||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||
'/settings/interface': typeof SettingsInterfaceRoute
|
||||
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
||||
'/store/tab/games': typeof StoreTabGamesRoute
|
||||
'/store/tab/': typeof StoreTabIndexRoute
|
||||
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
|
|
@ -99,38 +141,55 @@ export interface FileRoutesByTo {
|
|||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||
'/settings/interface': typeof SettingsInterfaceRoute
|
||||
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
||||
'/store/tab/games': typeof StoreTabGamesRoute
|
||||
'/store/tab': typeof StoreTabIndexRoute
|
||||
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRouteRouteWithChildren
|
||||
'/store/tab': typeof StoreTabRouteRouteWithChildren
|
||||
'/collection/$id': typeof CollectionIdRoute
|
||||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||
'/settings/interface': typeof SettingsInterfaceRoute
|
||||
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
||||
'/store/tab/games': typeof StoreTabGamesRoute
|
||||
'/store/tab/': typeof StoreTabIndexRoute
|
||||
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/store/tab'
|
||||
| '/collection/$id'
|
||||
| '/settings/about'
|
||||
| '/settings/accounts'
|
||||
| '/settings/directories'
|
||||
| '/settings/emulators'
|
||||
| '/settings/interface'
|
||||
| '/embedded/$source/$id'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
| '/store/tab/emulators'
|
||||
| '/store/tab/games'
|
||||
| '/store/tab/'
|
||||
| '/store/details/emulator/$id'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
|
|
@ -141,31 +200,45 @@ export interface FileRouteTypes {
|
|||
| '/settings/directories'
|
||||
| '/settings/emulators'
|
||||
| '/settings/interface'
|
||||
| '/embedded/$source/$id'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
| '/store/tab/emulators'
|
||||
| '/store/tab/games'
|
||||
| '/store/tab'
|
||||
| '/store/details/emulator/$id'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/store/tab'
|
||||
| '/collection/$id'
|
||||
| '/settings/about'
|
||||
| '/settings/accounts'
|
||||
| '/settings/directories'
|
||||
| '/settings/emulators'
|
||||
| '/settings/interface'
|
||||
| '/embedded/$source/$id'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
| '/store/tab/emulators'
|
||||
| '/store/tab/games'
|
||||
| '/store/tab/'
|
||||
| '/store/details/emulator/$id'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
SettingsRouteRoute: typeof SettingsRouteRouteWithChildren
|
||||
StoreTabRouteRoute: typeof StoreTabRouteRouteWithChildren
|
||||
CollectionIdRoute: typeof CollectionIdRoute
|
||||
EmbeddedSourceIdRoute: typeof EmbeddedSourceIdRoute
|
||||
GameSourceIdRoute: typeof GameSourceIdRoute
|
||||
LauncherSourceIdRoute: typeof LauncherSourceIdRoute
|
||||
PlatformSourceIdRoute: typeof PlatformSourceIdRoute
|
||||
StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
|
@ -226,6 +299,34 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof CollectionIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/store/tab': {
|
||||
id: '/store/tab'
|
||||
path: '/store/tab'
|
||||
fullPath: '/store/tab'
|
||||
preLoaderRoute: typeof StoreTabRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/store/tab/': {
|
||||
id: '/store/tab/'
|
||||
path: '/'
|
||||
fullPath: '/store/tab/'
|
||||
preLoaderRoute: typeof StoreTabIndexRouteImport
|
||||
parentRoute: typeof StoreTabRouteRoute
|
||||
}
|
||||
'/store/tab/games': {
|
||||
id: '/store/tab/games'
|
||||
path: '/games'
|
||||
fullPath: '/store/tab/games'
|
||||
preLoaderRoute: typeof StoreTabGamesRouteImport
|
||||
parentRoute: typeof StoreTabRouteRoute
|
||||
}
|
||||
'/store/tab/emulators': {
|
||||
id: '/store/tab/emulators'
|
||||
path: '/emulators'
|
||||
fullPath: '/store/tab/emulators'
|
||||
preLoaderRoute: typeof StoreTabEmulatorsRouteImport
|
||||
parentRoute: typeof StoreTabRouteRoute
|
||||
}
|
||||
'/platform/$source/$id': {
|
||||
id: '/platform/$source/$id'
|
||||
path: '/platform/$source/$id'
|
||||
|
|
@ -247,6 +348,20 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof GameSourceIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/embedded/$source/$id': {
|
||||
id: '/embedded/$source/$id'
|
||||
path: '/embedded/$source/$id'
|
||||
fullPath: '/embedded/$source/$id'
|
||||
preLoaderRoute: typeof EmbeddedSourceIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/store/details/emulator/$id': {
|
||||
id: '/store/details/emulator/$id'
|
||||
path: '/store/details/emulator/$id'
|
||||
fullPath: '/store/details/emulator/$id'
|
||||
preLoaderRoute: typeof StoreDetailsEmulatorIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,13 +385,32 @@ const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
|
|||
SettingsRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface StoreTabRouteRouteChildren {
|
||||
StoreTabEmulatorsRoute: typeof StoreTabEmulatorsRoute
|
||||
StoreTabGamesRoute: typeof StoreTabGamesRoute
|
||||
StoreTabIndexRoute: typeof StoreTabIndexRoute
|
||||
}
|
||||
|
||||
const StoreTabRouteRouteChildren: StoreTabRouteRouteChildren = {
|
||||
StoreTabEmulatorsRoute: StoreTabEmulatorsRoute,
|
||||
StoreTabGamesRoute: StoreTabGamesRoute,
|
||||
StoreTabIndexRoute: StoreTabIndexRoute,
|
||||
}
|
||||
|
||||
const StoreTabRouteRouteWithChildren = StoreTabRouteRoute._addFileChildren(
|
||||
StoreTabRouteRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
SettingsRouteRoute: SettingsRouteRouteWithChildren,
|
||||
StoreTabRouteRoute: StoreTabRouteRouteWithChildren,
|
||||
CollectionIdRoute: CollectionIdRoute,
|
||||
EmbeddedSourceIdRoute: EmbeddedSourceIdRoute,
|
||||
GameSourceIdRoute: GameSourceIdRoute,
|
||||
LauncherSourceIdRoute: LauncherSourceIdRoute,
|
||||
PlatformSourceIdRoute: PlatformSourceIdRoute,
|
||||
StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@ const assets = new Set<string>([
|
|||
]);
|
||||
|
||||
// Store basePath resolved from Vite config
|
||||
const BASE_PATH = "./";
|
||||
const BASE_PATH = "/";
|
||||
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -8,15 +8,18 @@
|
|||
--breakpoint-sm: 0px;
|
||||
--breakpoint-md: 1280px;
|
||||
--page-scroll-bg: transparent;
|
||||
--animation-size: 1;
|
||||
|
||||
--animate-wiggle: wiggle 0.3s ease-in-out 1;
|
||||
--animate-rotate: rotate 0.3s ease-in-out 1 0.2s;
|
||||
--animate-rotate-small: rotate-small 0.3s ease-in-out 1 0.2s;
|
||||
--animate-rotate-instant: rotate 0.3s ease-in-out 1;
|
||||
--animate-rotate: rotate 0.3s ease-in-out 1 200ms;
|
||||
--animate-rotate-small: rotate-small 0.3s ease-in-out 1 200ms;
|
||||
--animate-scale: scale 0.3s ease-in-out 1;
|
||||
--animate-slide-up: slide-up 0.2s ease-in-out 1;
|
||||
--animate-scale-delayed: scale 0.3s ease-in-out 1 100ms;
|
||||
--animate-scale-small: scale-small 0.3s ease-in-out 1;
|
||||
--animate-fade-out: fade-out 0.3s ease-out 1;
|
||||
--animate-fade-in: fade-out 0.6s ease-out 1 reverse forwards;
|
||||
--animate-bg-zoom: zoom-in-scale 0.6s ease-out 1 forwards;
|
||||
--animate-bg-zoom-big: zoom-in-scale-big 0.6s ease-out 1 forwards;
|
||||
--animate-bg-zoom-scroll: zoom-in-bg 0.6s ease-out 1 forwards;
|
||||
|
|
@ -116,11 +119,11 @@
|
|||
}
|
||||
|
||||
25% {
|
||||
transform: rotate(1deg);
|
||||
transform: rotate(calc(1deg * var(--animation-size)));
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: rotate(-1deg);
|
||||
transform: rotate(calc(-1deg * var(--animation-size)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,8 +200,88 @@ body {
|
|||
font-family: 'Alan Sans', sans-serif;
|
||||
}
|
||||
|
||||
@utility focusable-* {
|
||||
--focus-ring-color: --value(--color-*);
|
||||
}
|
||||
|
||||
@utility hide-scrollbar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@utility scrollbar-* {
|
||||
scrollbar-width: --value(integer);
|
||||
scrollbar-width: --value([integer]);
|
||||
scrollbar-width: --value("none");
|
||||
}
|
||||
|
||||
@utility animation-size-* {
|
||||
--animation-size: --value(number);
|
||||
}
|
||||
|
||||
@utility animation-delay-* {
|
||||
--tw-animation-delay: --value([*]);
|
||||
--tw-animation-delay: --value(integer)ms;
|
||||
animation-delay: var(--tw-animation-delay);
|
||||
}
|
||||
|
||||
@custom-variant focused {
|
||||
|
||||
&:where([data-active-control="gamepad"] &[data-focused=true]),
|
||||
&:where([data-active-control="keyboard"] &[data-focused=true]) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant focused-child {
|
||||
|
||||
&:where([data-active-control="gamepad"] &:has([data-focused=true])),
|
||||
&:where([data-active-control="keyboard"] &:has([data-focused=true])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant in-focused {
|
||||
|
||||
&:where([data-active-control="gamepad"] [data-focused=true] *),
|
||||
&:where([data-active-control="keyboard"] [data-focused=true] *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant control-mouse (&:where([data-active-control="mouse"] *));
|
||||
@custom-variant control-touch (&:where([data-active-control="touch"] *));
|
||||
@custom-variant control-keyboard (&:where([data-active-control="keyboard"] *));
|
||||
@custom-variant control-gamepad (&:where([data-active-control="gamepad"] *));
|
||||
@custom-variant control-pointer (&:where([data-active-control="mouse"] *), &:where([data-active-control="touch"] *));
|
||||
@custom-variant mobile (&:where([data-device="mobile"] *));
|
||||
|
||||
@container scroll-state(stuck: top) {
|
||||
.sticky-header {
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
.focusable {
|
||||
--focus-ring-color: --value(--color-accent);
|
||||
@apply md:ring-14 sm:ring-8 ring-transparent transition-shadow;
|
||||
}
|
||||
|
||||
[data-active-control="keyboard"] .group-focusable[data-focused="true"] .focusable,
|
||||
[data-active-control="gamepad"] .group-focusable[data-focused="true"] .focusable,
|
||||
[data-active-control="keyboard"] .focusable[data-focused=true],
|
||||
[data-active-control="gamepad"] .focusable[data-focused=true],
|
||||
[data-active-control="mouse"] .group-focusable:hover .focusable-hover,
|
||||
[data-active-control="mouse"] .focusable-hover:hover {
|
||||
@apply md:ring-7 sm:ring-4 ring-(--focus-ring-color);
|
||||
}
|
||||
|
||||
.background {
|
||||
-webkit-backface-visibility: hidden;
|
||||
-webkit-perspective: 1000;
|
||||
|
|
@ -210,6 +293,17 @@ body {
|
|||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.container-scroll {
|
||||
container-type: scroll-state;
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
@apply rounded-2xl;
|
||||
}
|
||||
|
|
@ -245,8 +339,52 @@ body {
|
|||
@apply grid pb-4;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
.bg-gradient {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
--bg-gradient-opacity: 15%;
|
||||
|
||||
background:
|
||||
radial-gradient(at 10% 20%, rgb(from var(--color-error) r g b / var(--bg-gradient-opacity)), transparent 60%),
|
||||
radial-gradient(at 80% 30%, rgb(from var(--color-info) r g b / var(--bg-gradient-opacity)), transparent 60%),
|
||||
radial-gradient(at 40% 90%, rgb(from var(--color-success) r g b / var(--bg-gradient-opacity)), transparent 60%),
|
||||
radial-gradient(at 90% 80%, rgb(from var(--color-warning) r g b / var(--bg-gradient-opacity)), transparent 60%);
|
||||
|
||||
background-blend-mode: lighten;
|
||||
background-repeat: repeat;
|
||||
background-color: var(--color-base-100);
|
||||
@apply mobile:hidden;
|
||||
}
|
||||
|
||||
.bg-noise {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
background-image: url("https://momentsingraphics.de/Media/BlueNoise/BlueNoise470.png");
|
||||
mix-blend-mode: color-dodge;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.bg-gradient-back {
|
||||
|
||||
--bg-opacity: 90%;
|
||||
background:
|
||||
radial-gradient(at 10% 20%, color-mix(in srgb, var(--color-secondary), transparent var(--bg-opacity)), transparent 60%),
|
||||
radial-gradient(at 80% 30%, color-mix(in srgb, var(--color-info), transparent var(--bg-opacity)), transparent 60%),
|
||||
radial-gradient(at 40% 90%, color-mix(in srgb, var(--color-success), transparent var(--bg-opacity)), transparent 60%),
|
||||
radial-gradient(at 90% 80%, color-mix(in srgb, var(--color-warning), transparent var(--bg-opacity)), transparent 60%);
|
||||
|
||||
background-blend-mode: screen;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
|
||||
@keyframes scroll-bg {
|
||||
to {
|
||||
transform: translateY(800px);
|
||||
}
|
||||
}
|
||||
|
||||
&::view-transition-old(.game-card),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import "./scripts/gamepads";
|
|||
import "./scripts/windowEvents";
|
||||
import { client as rommClient } from "../clients/romm/client.gen";
|
||||
import "./scripts/spatialNavigation";
|
||||
import NotFound from "./components/NotFound";
|
||||
import Error from "./components/Error";
|
||||
|
||||
const hashHistory = createHashHistory({});
|
||||
|
||||
|
|
@ -38,15 +40,9 @@ export const Router = createRouter({
|
|||
defaultPreload: "intent",
|
||||
context: { queryClient },
|
||||
scrollRestoration: false,
|
||||
defaultNotFoundComponent: () =>
|
||||
{
|
||||
return (
|
||||
<div>
|
||||
<p> {window.location.href} Not found!</p>
|
||||
<Link to="/">Go home</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
defaultNotFoundComponent: NotFound,
|
||||
defaultPendingMs: 300,
|
||||
defaultErrorComponent: Error
|
||||
});
|
||||
|
||||
// Register things for typesafety
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { RouterContext } from "..";
|
||||
import Notifications from "../components/Notifications";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { mobileCheck, useLocalSetting } from "../scripts/utils";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
|
|
@ -14,18 +13,19 @@ function RootComponent ()
|
|||
{
|
||||
const isMobile = mobileCheck();
|
||||
const theme = useLocalSetting('theme');
|
||||
const { control } = useActiveControl();
|
||||
|
||||
return (
|
||||
<div data-theme={theme === 'auto' ? undefined : theme} className="w-screen h-screen overflow-hidden">
|
||||
<div data-theme={theme === 'auto' ? undefined : theme} data-device={isMobile ? 'mobile' : ''} data-active-control={control} className="w-screen h-screen overflow-hidden">
|
||||
<Outlet />
|
||||
<Notifications />
|
||||
<Toaster containerStyle={{ viewTimelineName: 'toasters' }} />
|
||||
{import.meta.env.DEV && !isMobile &&
|
||||
{/*import.meta.env.DEV && !isMobile &&
|
||||
<>
|
||||
<TanStackRouterDevtools position="top-left" />
|
||||
<ReactQueryDevtools buttonPosition="top-right" />
|
||||
</>
|
||||
}
|
||||
*/}
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
import { CollectionsDetail } from '../components/CollectionsDetail';
|
||||
import { getCollectionApiCollectionsIdGetOptions, getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen';
|
||||
import { DefaultRommStaleTime } from '../../shared/constants';
|
||||
import { getCollectionApiCollectionsIdGetOptions, getRomsApiRomsGetOptions } from '@clients/romm/@tanstack/react-query.gen';
|
||||
import { DefaultRommStaleTime } from '@shared/constants';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useContext } from 'react';
|
||||
import { AnimatedBackgroundContext } from '../scripts/contexts';
|
||||
|
||||
export const Route = createFileRoute('/collection/$id')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -17,12 +18,9 @@ function RouteComponent ()
|
|||
{
|
||||
const { id } = Route.useParams();
|
||||
const { data: collection } = useQuery({ ...getCollectionApiCollectionsIdGetOptions({ path: { id: Number(id) } }) });
|
||||
const [, setBackground] = useSessionStorage<string | undefined>(
|
||||
"home-background",
|
||||
undefined,
|
||||
);
|
||||
const animatedBgContext = useContext(AnimatedBackgroundContext);
|
||||
|
||||
return (
|
||||
<CollectionsDetail setBackground={setBackground} title={<div className="divider font-semibold text-2xl">{collection?.name}</div>} filters={{ collection_id: Number(id) }} />
|
||||
<CollectionsDetail setBackground={animatedBgContext.setBackground} title={<div className="divider font-semibold text-2xl">{collection?.name}</div>} filters={{ collection_id: Number(id) }} />
|
||||
);
|
||||
}
|
||||
|
|
|
|||
185
src/mainview/routes/embedded.$source.$id.tsx
Normal file
185
src/mainview/routes/embedded.$source.$id.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { EMULATORJS_URL, RPC_URL, SERVER_URL } from '@/shared/constants';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { gameQuery } from '../scripts/queries';
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import z from 'zod';
|
||||
import { RefObject, useEffect, useRef, useState } from 'react';
|
||||
import { Router } from '..';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { Button, ButtonStyle } from '../components/options/Button';
|
||||
import { DoorOpen, Home, RefreshCw, Undo } from 'lucide-react';
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||
import Shortcuts from '../components/Shortcuts';
|
||||
import { useEventListener, useTimeout } from 'usehooks-ts';
|
||||
import { GetFocusedElement, useGlobalFocus } from '../scripts/spatialNavigation';
|
||||
import useActiveControl from '../scripts/gamepads';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { HeaderAccounts, HeaderStatusBar } from '../components/Header';
|
||||
import { RoundButton } from '../components/RoundButton';
|
||||
|
||||
export const Route = createFileRoute('/embedded/$source/$id')({
|
||||
component: RouteComponent,
|
||||
loader: async (ctx) =>
|
||||
{
|
||||
const data = await ctx.context.queryClient.fetchQuery(gameQuery(ctx.params.source, ctx.params.id));
|
||||
return { data };
|
||||
},
|
||||
validateSearch: zodValidator(z.record(z.string(), z.string().optional().nullable()))
|
||||
});
|
||||
|
||||
function OverlayButton (data: {
|
||||
id: string,
|
||||
style: ButtonStyle,
|
||||
tooltip: string, setTooltip: (tooltip: string) => void,
|
||||
className?: string;
|
||||
children?: any;
|
||||
} & InteractParams)
|
||||
{
|
||||
return <div className="tooltip tooltip-bottom" data-tip={data.tooltip}>
|
||||
<RoundButton external onFocus={() => data.setTooltip(data.tooltip)} style={data.style} className={twMerge("", data.className)} id={data.id} onAction={data.onAction} >
|
||||
{data.children}
|
||||
</RoundButton>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Overlay (data: {
|
||||
open: boolean;
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
close: () => void;
|
||||
goBack: () => void;
|
||||
})
|
||||
{
|
||||
const { ref, focusSelf, focusKey } = useFocusable({ focusable: data.open, focusKey: 'overlay', forceFocus: true, isFocusBoundary: true });
|
||||
const [tooltip, setTooltip] = useState<string | undefined>(undefined);
|
||||
|
||||
useShortcuts(focusKey, () => data.open ? [{ label: 'Return', button: GamePadButtonCode.B, action: data.close }] : [], [data.open, data.close]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (data.open)
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
}, [data.open]);
|
||||
|
||||
const { isPointer } = useActiveControl();
|
||||
const handleEvent = (type: string, value?: any) => data.iframeRef.current?.contentWindow?.postMessage({ type, data: value });
|
||||
|
||||
return <div data-open={data.open} className='flex group w-full flex-col gap-2 transition-opacity p-4 not-data-[open=true]:pointer-events-none not-data-[open=true]:opacity-0'>
|
||||
<div className='grid grid-cols-3 justify-between items-start'>
|
||||
<div className='flex justify-start'>
|
||||
<HeaderAccounts />
|
||||
</div>
|
||||
<div className='flex justify-center'>
|
||||
<ul ref={ref} className='flex rounded-4xl bg-base-100 justify-end gap-2 p-4 group-data-[open=true]:animate-scale'>
|
||||
<FocusContext value={focusKey}>
|
||||
<OverlayButton id="return" style='primary' tooltip='Return' setTooltip={setTooltip} onAction={data.close} ><Undo /></OverlayButton>
|
||||
<OverlayButton id="restart" style='secondary' tooltip='Restart' setTooltip={setTooltip} onAction={() =>
|
||||
{
|
||||
data.close();
|
||||
handleEvent('restart');
|
||||
}} ><RefreshCw /></OverlayButton>
|
||||
<OverlayButton id="exit" style='warning' tooltip='Exit' setTooltip={setTooltip} onAction={data.goBack} ><DoorOpen /></OverlayButton>
|
||||
</FocusContext>
|
||||
</ul>
|
||||
</div>
|
||||
<div className='flex justify-end'>
|
||||
<HeaderStatusBar />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-center'>
|
||||
{!!tooltip && data.open && !isPointer && <div className='bg-accent text-accent-content rounded-full font-semibold py-1 px-4'>{tooltip}</div>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Frame (data: { ref: RefObject<HTMLIFrameElement | null>; })
|
||||
{
|
||||
const { ref } = useFocusable({ focusKey: 'frame' });
|
||||
const { data: game } = Route.useLoaderData();
|
||||
|
||||
const search = Route.useSearch();
|
||||
search['gameName'] = game.name;
|
||||
search['backgroundImage'] = `${RPC_URL(__HOST__)}${game.path_cover}`;
|
||||
search['backgroundBlur'] = "true";
|
||||
|
||||
if (!__PUBLIC__)
|
||||
{
|
||||
search['threads'] = "true";
|
||||
}
|
||||
|
||||
const params = Object.entries(search)
|
||||
.filter(kvp => kvp[1] !== null && kvp[1] !== undefined)
|
||||
.map(kvp => `${kvp[0]}=${encodeURIComponent(kvp[1]!)}`).join('&');
|
||||
|
||||
return <iframe ref={r =>
|
||||
{
|
||||
ref.current = r;
|
||||
data.ref.current = r;
|
||||
}}
|
||||
allow='fullscreen; cross-origin-isolated'
|
||||
className='absolute w-full h-full transition-[padding]' src={
|
||||
__PUBLIC__ ? `${SERVER_URL(__HOST__)}/emulatorjs/?${params}` : `${EMULATORJS_URL(__HOST__)}/?${params}`
|
||||
}></iframe>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { ref, focusSelf, focusKey } = useFocusable({
|
||||
focusKey: 'emulatorjs',
|
||||
preferredChildFocusKey: 'frame',
|
||||
forceFocus: true
|
||||
});
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [overlayOpen, setOverlayOpen] = useState(false);
|
||||
const { source, id } = Route.useParams();
|
||||
|
||||
function HandleGoBack ()
|
||||
{
|
||||
Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id } });
|
||||
}
|
||||
|
||||
useEventListener('message', e =>
|
||||
{
|
||||
if (e.data.type === 'exit')
|
||||
{
|
||||
HandleGoBack();
|
||||
}
|
||||
});
|
||||
|
||||
useShortcuts(focusKey, () => [{
|
||||
button: GamePadButtonCode.Steam, action: () =>
|
||||
{
|
||||
setOverlayOpen(!overlayOpen);
|
||||
}
|
||||
}], [overlayOpen, setOverlayOpen]);
|
||||
|
||||
const setPaused = (paused: boolean) =>
|
||||
{
|
||||
if (paused) iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: true });
|
||||
else
|
||||
{
|
||||
// we want to prevent input from closing the overlay spilling
|
||||
setTimeout(() => iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: false }), 100);
|
||||
}
|
||||
};
|
||||
useEffect(() => setPaused(overlayOpen), [overlayOpen]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
useEffect(() => { if (!overlayOpen) focusSelf(); }, [overlayOpen]);
|
||||
function handleClose ()
|
||||
{
|
||||
setOverlayOpen(false);
|
||||
}
|
||||
|
||||
return <div ref={ref} className='absolute w-full h-full'>
|
||||
<FocusContext value={focusKey}>
|
||||
<Frame ref={iframeRef} />
|
||||
<div className='flex fixed left-0 right-0 top-0'>
|
||||
<Overlay iframeRef={iframeRef} goBack={HandleGoBack} open={overlayOpen} close={handleClose} />
|
||||
</div>
|
||||
<div className='flex justify-end fixed bottom-4 right-4 left-4 z-10'>
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants";
|
||||
import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router";
|
||||
import { CommandEntry, FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants";
|
||||
import { twJoin, twMerge } from "tailwind-merge";
|
||||
import { JSX, RefObject, useEffect, useRef, useState } from "react";
|
||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
|
|
@ -17,32 +17,124 @@ import { ContextDialog, ContextList, DialogEntry } from "../../components/Contex
|
|||
import Shortcuts from "../../components/Shortcuts";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { gameQuery } from "@/mainview/scripts/queries";
|
||||
import Screenshots from "@/mainview/components/Screenshots";
|
||||
import { delay, useSticky, useStickyDataAttr } from "@/mainview/scripts/utils";
|
||||
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||
|
||||
export const Route = createFileRoute("/game/$source/$id")({
|
||||
loader: ({ params, context }) =>
|
||||
loader: async ({ params, context }) =>
|
||||
{
|
||||
context.queryClient.prefetchQuery(gameQuery(params.source, Number(params.id)));
|
||||
const data = await context.queryClient.fetchQuery(gameQuery(params.source, params.id));
|
||||
return { data };
|
||||
},
|
||||
component: GameDetailsUI,
|
||||
pendingComponent: GameDetailsUIPending,
|
||||
errorComponent: Error
|
||||
});
|
||||
|
||||
function Error (data: ErrorComponentProps)
|
||||
{
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
useEffect(() =>
|
||||
{
|
||||
focusSelf();
|
||||
}, []);
|
||||
|
||||
return <AnimatedBackground ref={ref} backgroundKey="game-details">
|
||||
<div className="relative z-10 h-full">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="h-0" />
|
||||
<div className="fixed group top-0 left-0 right-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
|
||||
<HeaderUI />
|
||||
</div>
|
||||
<div className="absolute w-full flex flex-col justify-center items-center h-full overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
|
||||
<div className="flex gap-2 items-center text-4xl text-error"><TriangleAlert className="size-12" /> {data.error.message}</div>
|
||||
</div>
|
||||
<div className="bg-base-200">
|
||||
|
||||
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</footer>
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>
|
||||
</AnimatedBackground>;
|
||||
}
|
||||
|
||||
function GameDetailsUIPending ()
|
||||
{
|
||||
return <AnimatedBackground>
|
||||
<div className="flex flex-col p-2 px-3 w-full h-full">
|
||||
<HeaderUI />
|
||||
<div className="flex flex-col justify-center items-center grow">
|
||||
<span className="loading loading-dots loading-xl"></span>
|
||||
</div>
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
useEffect(() =>
|
||||
{
|
||||
focusSelf();
|
||||
}, []);
|
||||
|
||||
return <AnimatedBackground ref={ref} backgroundKey="game-details">
|
||||
<div className="z-10">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="h-0" />
|
||||
<div className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
|
||||
<HeaderUI />
|
||||
</div>
|
||||
<div className="flex flex-col h-[80vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
|
||||
<main ref={ref} className="flex p-3 flex-col flex-1 min-h-0">
|
||||
<section className="flex portrait:flex-col my-4 sm:p-0 md:px-12 md:pb-8 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
|
||||
<div className="flex gap-6 overflow-hidden bg-base-100 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24 p-4">
|
||||
<div className="skeleton w-full h-full"></div>
|
||||
</div>
|
||||
<div className="flex-2 flex flex-col sm:gap-1 md:gap-6 sm:pt-2 md:pt-16 min-h-0">
|
||||
<div className="flex flex-wrap sm:gap-4 md:gap-6 shrink-0">
|
||||
<Detail icon={<Clock />} ></Detail>
|
||||
<Detail icon={<div className="skeleton size-6" />} ><div className="skeleton h-4 w-32"></div></Detail>
|
||||
<Detail icon={
|
||||
<Store />
|
||||
} >
|
||||
|
||||
</Detail>
|
||||
</div>
|
||||
<div className="md:hidden divider divider-vertical m-0"></div>
|
||||
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden text-lg">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="skeleton h-4 w-[30%]"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
<div className="skeleton h-4 w-[60%]"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<div className="bg-base-200">
|
||||
<div className="divider m-0 pb-12"><div className="flex items-center gap-3 opacity-60"><Image className="sm:size-4 md:size-6" />Screenshots</div></div>
|
||||
<div className="flex flex-col w-full z-0 min-h-0">
|
||||
<div
|
||||
className="flex gap-6 px-16 py-2 sm:overflow-scroll md:overflow-hidden no-scrollbar justify-center-safe"
|
||||
>
|
||||
{Array.from({ length: 5 }).map((s, i) => <div key={i} className="skeleton h-64 w-lg"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</footer>
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>
|
||||
</AnimatedBackground>;
|
||||
}
|
||||
|
||||
function HandleGoBack ()
|
||||
{
|
||||
const source = PopSource('details');
|
||||
Router.navigate({ to: source ?? '/', viewTransition: { types: ['zoom-out'] } });
|
||||
const { to, search } = PopSource('details');
|
||||
Router.navigate({ to: to ?? '/', viewTransition: { types: ['zoom-out'] }, search });
|
||||
}
|
||||
|
||||
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?: FrontEndGameTypeDetailed; })
|
||||
|
|
@ -50,7 +142,7 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
|
|||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'main-details', onFocus: () =>
|
||||
{
|
||||
data.mainAreaRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
data.mainAreaRef.current?.scrollIntoView({ block: 'end', behavior: 'smooth' });
|
||||
},
|
||||
preferredChildFocusKey: "play-btn",
|
||||
saveLastFocusedChild: false
|
||||
|
|
@ -77,10 +169,10 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
|
|||
|
||||
return <main ref={ref} className="flex p-3 flex-col flex-1 min-h-0">
|
||||
<FocusContext value={focusKey}>
|
||||
<section className="flex portrait:flex-col my-4 sm:p-0 md:p-12 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
|
||||
<div className="flex gap-6 overflow-hidden bg-base-300 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24">
|
||||
<section className="flex portrait:flex-col my-4 sm:p-0 md:px-12 md:pb-8 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
|
||||
<div className="flex gap-6 overflow-hidden bg-base-100 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24 p-4">
|
||||
{gameCoverImg ?
|
||||
<img className="drop-shadow-2xl drop-shadow-base-300/40 w-full object-cover" src={gameCoverImg}></img> :
|
||||
<img className="drop-shadow-2xl drop-shadow-base-300/40 w-full object-cover rounded-2xl" src={gameCoverImg}></img> :
|
||||
<div className="skeleton w-full h-full"></div>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -101,7 +193,7 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
|
|||
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
|
||||
</div>
|
||||
<div className="md:hidden divider divider-vertical m-0"></div>
|
||||
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden ">
|
||||
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden text-lg">
|
||||
{data.game?.summary ?? <div className="flex flex-col gap-4 w-full">
|
||||
<div className="skeleton h-4 w-[30%]"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
|
|
@ -118,60 +210,6 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
|
|||
</main>;
|
||||
}
|
||||
|
||||
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; })
|
||||
{
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: `screenshot-${data.index}`,
|
||||
onFocus: (e, p, details) =>
|
||||
{
|
||||
data.setFocused?.(data.index);
|
||||
(ref.current as HTMLElement).scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
|
||||
|
||||
|
||||
}
|
||||
}); 4096;
|
||||
return <img className={twJoin("max-h-[60vh] rounded-3xl", classNames({
|
||||
"sm:ring-4 md:ring-7 ring-primary": focused,
|
||||
"cursor-pointer": !focused
|
||||
}))} onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} ref={ref} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" />;
|
||||
}
|
||||
|
||||
function Screenshots (data: { screenshots: string[]; })
|
||||
{
|
||||
const scrollRef = useRef(null);
|
||||
const [focusedScreenshot, setFocusedScreenshot] = useState(-1);
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'screenshot-list',
|
||||
onFocus: (e, p, details) =>
|
||||
{
|
||||
if (!(details.nativeEvent instanceof TouchEvent))
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
onBlur: () => setFocusedScreenshot(-1)
|
||||
});
|
||||
|
||||
return <div ref={ref} className="flex flex-col w-full z-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} setFocused={setFocusedScreenshot} index={i} path={s} />)}
|
||||
</div>
|
||||
<div className="flex gap-2 py-6 justify-center items-center h-3">{data.screenshots.map((s, i) =>
|
||||
{
|
||||
const focused = i === focusedScreenshot;
|
||||
return <button key={i} onClick={(e) => setFocus(`screenshot-${i}`, { 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>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; })
|
||||
{
|
||||
if (!data.game.achievements)
|
||||
|
|
@ -221,6 +259,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
const [status, setStatus] = useState<GameStatusType | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [details, setDetails] = useState<string | undefined>(undefined);
|
||||
const [commands, setCommands] = useState<CommandEntry[] | undefined>(undefined);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() =>
|
||||
|
|
@ -233,13 +272,14 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
setProgress(stats.progress);
|
||||
setStatus(stats.status);
|
||||
setDetails(stats.details);
|
||||
setCommands(stats.commands);
|
||||
setError(stats.error);
|
||||
};
|
||||
|
||||
es.addEventListener('refresh', () =>
|
||||
{
|
||||
queryClient.invalidateQueries({ queryKey: ['game', data.game.id] });
|
||||
location.reload();
|
||||
Router.navigate({ to: '/game/$source/$id', params: { id, source } });
|
||||
});
|
||||
|
||||
es.addEventListener('error', (e) =>
|
||||
|
|
@ -248,6 +288,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
{
|
||||
const stats = JSON.parse((e as any).data) as GameInstallProgress;
|
||||
toast.error(stats.error);
|
||||
setError(stats.error);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -257,6 +298,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
if (error)
|
||||
{
|
||||
toast.error(error);
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -279,9 +321,18 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
{
|
||||
mainButton = <ActionButton onAction={() =>
|
||||
{
|
||||
playMutation.mutate();
|
||||
SaveSource('launch');
|
||||
Router.navigate({ to: '/launcher/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id } });
|
||||
const firstValid = commands?.find(c => c.valid);
|
||||
if (firstValid?.emulator === 'emulatorjs')
|
||||
{
|
||||
const params = new URLSearchParams(firstValid.command);
|
||||
Router.navigate({ to: '/embedded/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id }, search: Object.fromEntries(params.entries()) });
|
||||
} else
|
||||
{
|
||||
playMutation.mutate();
|
||||
SaveSource('launch');
|
||||
Router.navigate({ to: '/launcher/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id } });
|
||||
}
|
||||
|
||||
}} tooltip={details} key="primary" type='primary' id="mainAction"><Play /></ActionButton>;
|
||||
}
|
||||
else if (error)
|
||||
|
|
@ -383,6 +434,8 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
|
|||
|
||||
}, ref);
|
||||
|
||||
const { isPointer } = useActiveControl();
|
||||
|
||||
const tooltipStyles = {
|
||||
base: 'bg-base-100 text-base-content',
|
||||
accent: 'bg-accent text-accent-content',
|
||||
|
|
@ -403,7 +456,7 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
|
|||
}}>
|
||||
<ContextList options={contextOptions} />
|
||||
</ContextDialog>
|
||||
{!!hoverText && <p className={twMerge("flex sm:hidden md:inline py-1 md:py-2 md:px-4 rounded-4xl text-wrap wrap-anywhere text-base", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>}
|
||||
{!!hoverText && !isPointer && <p className={twMerge("flex sm:hidden md:inline py-1 md:py-2 md:px-4 rounded-4xl text-wrap wrap-anywhere text-base", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -434,41 +487,35 @@ function ActionButton (data: {
|
|||
{
|
||||
const { ref, focused } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true });
|
||||
const styles = {
|
||||
primary: twMerge("bg-primary text-primary-content",
|
||||
classNames({
|
||||
"bg-base-content text-base-300 sm:ring-4 md:ring-7 ring-primary": focused
|
||||
})),
|
||||
base: twMerge(" text-base-content border-dashed border-base-content/20 border-2", classNames({
|
||||
"bg-base-content text-base-300 sm:ring-4 md:ring-7 ring-primary": focused
|
||||
})),
|
||||
accent: twMerge("bg-primary text-primary-content ", classNames({
|
||||
"bg-base-content text-base-300 sm:ring-4 md:ring-7 ring-primary": focused
|
||||
})),
|
||||
error: twMerge("bg-error text-error-content ", classNames({
|
||||
"bg-error text-error-content sm:ring-4 md:ring-7 ring-primary": focused
|
||||
})),
|
||||
primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary",
|
||||
base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary",
|
||||
accent: "bg-primary text-primary-content focusable focusable-primary focusable:bg-base-content focusable:text-base-300",
|
||||
error: "bg-error text-error-content focused:bg-error focused:text-error-content",
|
||||
};
|
||||
return (
|
||||
<button
|
||||
disabled={data.disabled}
|
||||
ref={ref}
|
||||
onClick={data.onAction}
|
||||
data-tooltip={data.tooltip}
|
||||
data-tooltip_type={data.tooltip_type}
|
||||
className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30",
|
||||
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}>
|
||||
{data.icon}
|
||||
{data.children}
|
||||
</button>
|
||||
<div className="tooltip tooltip-accent tooltip-right" data-tip={data.tooltip}>
|
||||
<button
|
||||
disabled={data.disabled}
|
||||
ref={ref}
|
||||
onClick={data.onAction}
|
||||
data-tooltip={data.tooltip}
|
||||
data-tooltip_type={data.tooltip_type}
|
||||
className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content",
|
||||
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}>
|
||||
{data.icon}
|
||||
{data.children}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GameDetailsUI ()
|
||||
{
|
||||
const { source, id } = Route.useParams();
|
||||
const { data, isSuccess } = useQuery(gameQuery(source, Number(id)));
|
||||
const { data } = Route.useLoaderData();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
|
||||
const backgroundImage = data?.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined;
|
||||
const headerRef = useRef(null);
|
||||
const sentinelRef = useRef(null);
|
||||
const backgroundImage = data.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined;
|
||||
const mainAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
|
|
@ -476,27 +523,26 @@ export default function GameDetailsUI ()
|
|||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (isSuccess)
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
focusSelf();
|
||||
}, []);
|
||||
|
||||
}, [isSuccess]);
|
||||
useStickyDataAttr(headerRef, sentinelRef, ref);
|
||||
|
||||
return (
|
||||
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage} scrolling>
|
||||
<div className="z-0">
|
||||
<div className="z-10">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="flex flex-col px-3 py-2 h-[90vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
|
||||
<div ref={sentinelRef} className="h-0" />
|
||||
<div ref={headerRef} className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
|
||||
<HeaderUI />
|
||||
</div>
|
||||
<div className="flex flex-col h-[80vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
|
||||
<Details mainAreaRef={mainAreaRef} game={data} />
|
||||
</div>
|
||||
<div className="bg-base-200">
|
||||
<div className="divider m-0 pb-12"><div className="flex items-center gap-3 opacity-60"><Image className="sm:size-4 md:size-6" />Screenshots</div></div>
|
||||
{!!data && <Screenshots screenshots={data.paths_screenshots} />}
|
||||
<footer className="absolute left-0 bottom-0 w-full p-2 flex items-center justify-between z-10">
|
||||
<div className="flex gap-2 text-sm">
|
||||
</div>
|
||||
{!!data && <Screenshots screenshots={data.paths_screenshots} onFocus={(_, node) => node.scrollIntoView({ behavior: 'smooth', block: 'center' })} />}
|
||||
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import
|
|||
Gamepad2,
|
||||
Settings,
|
||||
MessageSquare,
|
||||
ShoppingBag,
|
||||
Image,
|
||||
Search,
|
||||
Power,
|
||||
OctagonAlert,
|
||||
Maximize,
|
||||
Store,
|
||||
} from "lucide-react";
|
||||
import
|
||||
{
|
||||
|
|
@ -21,13 +21,14 @@ import
|
|||
{
|
||||
FocusContext,
|
||||
FocusDetails,
|
||||
getCurrentFocusKey,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import { HeaderAccounts, HeaderStatusBar, HeaderUI } from "../components/Header";
|
||||
import { HeaderAccounts, HeaderStatusBar } from "../components/Header";
|
||||
import { FilterUI } from "../components/Filters";
|
||||
import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground";
|
||||
import { AnimatedBackground } from "../components/AnimatedBackground";
|
||||
import { GameList } from "../components/GameList";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import LoadingCardList from "../components/LoadingCardList";
|
||||
|
|
@ -43,7 +44,9 @@ import z from "zod";
|
|||
import { Router } from "..";
|
||||
import CollectionList from "../components/CollectionList";
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import { mobileCheck } from "../scripts/utils";
|
||||
import { mobileCheck, useDragScroll } from "../scripts/utils";
|
||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||
import { FrontEndId } from "@/shared/constants";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: ConsoleHomeUI,
|
||||
|
|
@ -93,6 +96,7 @@ function HomeList (data: {
|
|||
{
|
||||
const [initFocus, setInitFocus] = useState(false);
|
||||
const bg = useContext(AnimatedBackgroundContext);
|
||||
const { } = Route.useSearch;
|
||||
const { ref, focused, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "home-list",
|
||||
preferredChildFocusKey: `${data.selectedFilter}-list`
|
||||
|
|
@ -103,18 +107,54 @@ function HomeList (data: {
|
|||
const isMounseEvent = details.nativeEvent instanceof MouseEvent;
|
||||
if (!isMounseEvent)
|
||||
{
|
||||
node?.scrollIntoView({ inline: 'center', behavior: initFocus ? 'smooth' : 'instant' });
|
||||
node?.scrollIntoView({ inline: 'center', block: 'center', behavior: initFocus ? 'smooth' : 'instant' });
|
||||
}
|
||||
|
||||
setInitFocus(true);
|
||||
};
|
||||
|
||||
const lists: Record<string, JSX.Element> = {
|
||||
consoles: <PlatformsList onFocus={handleNodeFocus} className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />,
|
||||
games: <GameList onFocus={handleNodeFocus} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />,
|
||||
collections: <CollectionList onFocus={handleNodeFocus} className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />,
|
||||
function handleGameSelect (id: FrontEndId, source: string | null, sourceId: string | null)
|
||||
{
|
||||
SaveSource('details', { search: { filter: data.selectedFilter } });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
const handleCollectionSelect = (id: string) =>
|
||||
{
|
||||
SaveSource('game-list', { search: { filter: data.selectedFilter } });
|
||||
Router.navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
const handlePlatformSelect = (source: string, id: string) =>
|
||||
{
|
||||
SaveSource('game-list', { search: { filter: data.selectedFilter } });
|
||||
Router.navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
let activeList: JSX.Element;
|
||||
switch (data.selectedFilter)
|
||||
{
|
||||
case 'consoles':
|
||||
activeList = <>
|
||||
<PlatformsList onSelect={handlePlatformSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />
|
||||
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
|
||||
</>;
|
||||
break;
|
||||
case 'collections':
|
||||
activeList = <>
|
||||
<CollectionList onSelect={handleCollectionSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />
|
||||
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
|
||||
</>;
|
||||
break;
|
||||
default:
|
||||
activeList = <>
|
||||
<GameList onGameSelect={handleGameSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />
|
||||
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
|
||||
</>;
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
useEventListener('wheel', e =>
|
||||
{
|
||||
const deltaY = e.deltaY;
|
||||
|
|
@ -138,17 +178,18 @@ function HomeList (data: {
|
|||
}
|
||||
});
|
||||
|
||||
useDragScroll(ref);
|
||||
|
||||
return (
|
||||
<FocusContext value={focusKey}>
|
||||
<div ref={ref} className="flex h-full w-full landscape:overflow-x-scroll portrait:overflow-y-scroll overflow-hidden no-scrollbar justify-center-safe sm:pt-2 md:py-6 md:pb-3 md:mb-1" style={{
|
||||
<div ref={ref} className="flex h-full w-full landscape:overflow-x-scroll portrait:overflow-y-scroll overflow-hidden no-scrollbar justify-center-safe sm:py-2 md:py-6 md:pb-6 md:mb-1 not-mobile:sm:pb-4" style={{
|
||||
mask: `linear-gradient(to right, rgba(0,0,0,0.8) 0%, black 10%, black 90%, rgba(0,0,0,0.8) 100%)`
|
||||
}}>
|
||||
<div className="landscape:px-16 portrait:min-h-fit portrait:h-fit portrait:pb-32 portrait:w-full landscape:h-full">
|
||||
<div className="landscape:flex landscape:px-16 portrait:min-h-fit portrait:h-fit portrait:pb-32 portrait:w-full landscape:h-full landscape:items-center">
|
||||
<ErrorBoundary fallback={<HomeListError focused={focused} />}>
|
||||
<Suspense key={data.selectedFilter} fallback={<LoadingCardList placeholderCount={8} />}>
|
||||
{lists[data.selectedFilter]}
|
||||
{activeList}
|
||||
<SaveScroll id={`card-list-${data.selectedFilter}`} ref={ref} />
|
||||
<AutoFocus focus={focusSelf} delay={10} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
|
@ -179,7 +220,7 @@ function MainMenu (data: {})
|
|||
type="secondary"
|
||||
/>
|
||||
<CircleIcon icon={<MessageSquare />} label="News" />
|
||||
<CircleIcon icon={<ShoppingBag />} label="Shop" />
|
||||
<CircleIcon type="info" icon={<Store />} action={() => navigate({ to: "/store/tab", viewTransition: { types: ['zoom-in'] } })} label="Shop" />
|
||||
<CircleIcon icon={<Image />} label="Album" />
|
||||
<CircleIcon
|
||||
icon={<Gamepad2 />}
|
||||
|
|
@ -202,7 +243,7 @@ function MainMenu (data: {})
|
|||
|
||||
function CircleIcon (data: {
|
||||
action?: () => void;
|
||||
type?: "secondary" | "accent";
|
||||
type?: "secondary" | "accent" | "info";
|
||||
label?: string;
|
||||
icon?: JSX.Element;
|
||||
})
|
||||
|
|
@ -215,6 +256,7 @@ function CircleIcon (data: {
|
|||
const typeClasses = {
|
||||
secondary: "bg-secondary text-secondary-content",
|
||||
accent: "bg-accent text-accent-content",
|
||||
info: "bg-info text-info-content",
|
||||
none: "bg-base-content",
|
||||
};
|
||||
return (
|
||||
|
|
@ -222,15 +264,9 @@ function CircleIcon (data: {
|
|||
ref={ref}
|
||||
onClick={data.action}
|
||||
className={twMerge(
|
||||
`portrait:sm:size-12 sm:w-14 sm:h-10 menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all`,
|
||||
typeClasses[data.type ?? "none"], classNames(
|
||||
{
|
||||
"focus ring-7 ring-primary drop-shadow-2xl animate-scale": focused,
|
||||
"hover:ring-7 hover:ring-primary": true,
|
||||
})
|
||||
)}
|
||||
`portrait:sm:size-12 sm:w-14 sm:h-10 menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all focusable focusable-primary focused:drop-shadow-2xl focused:animate-scale focusable-hover bg-base-content border-6 md:border-12 border-base-content focused:border-0 hover:border-0 z-1 active:border-0 active:bg-base-300 active:text-base-content active:transition-none`, typeClasses[data.type ?? 'none'])}
|
||||
>
|
||||
{data.icon}
|
||||
<div className="in-focused:animate-rotate-instant animation-size-5">{data.icon}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -291,11 +327,11 @@ export default function ConsoleHomeUI ()
|
|||
<div className="sm:landscape:hidden md:landscape:inline sm:portrait:col-start-1 md:inline flex col-span-1 md:pl-2 md:pt-2">
|
||||
<HeaderAccounts />
|
||||
</div>
|
||||
<div className="sm:portrait:*:justify-center sm:portrait:col-span-3 sm:landscape:*:justify-start sm:px-2 sm:pt-2 md:row-start-2 md:col-start-1 sm:landscape:col-span-1 md:landscape:col-span-3 flex items-center md:*:justify-center! md:ml-0 gap-2 *:w-full *:flex">
|
||||
<div className=" sm:portrait:col-span-3 sm:px-2 sm:pt-2 md:row-start-2 md:col-start-1 sm:landscape:col-span-1 md:landscape:col-span-3 flex items-center md:ml-0 gap-2">
|
||||
<FilterUI
|
||||
id="home"
|
||||
options={filters}
|
||||
selected={filter ? filter : 'games'}
|
||||
containerClassName="flex w-full sm:landscape:justify-start sm:portrait:justify-center md:justify-center!"
|
||||
options={Object.fromEntries(Object.entries(filters).map(([key, value]) => [key, { ...value, selected: key === filter }]))}
|
||||
setSelected={setFilter}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -311,7 +347,7 @@ export default function ConsoleHomeUI ()
|
|||
<MainMenu />
|
||||
</div>
|
||||
<footer className={twMerge(
|
||||
"sm:portrait:hidden sm:col-span-1 md:col-start-2 md:col-span-2 md:relative px-2 pb-2 flex items-end justify-end",
|
||||
"fixed bottom-4 left-4 right-4 sm:portrait:hidden sm:col-span-1 md:col-start-2 md:col-span-2 flex items-end justify-end",
|
||||
)}>
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ import { AnimatedBackground } from '@/mainview/components/AnimatedBackground';
|
|||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { GameInstallProgress, RPC_URL } from '@/shared/constants';
|
||||
import DotsLoading from '../components/backgrounds/dots';
|
||||
import { useEventListener } from 'usehooks-ts';
|
||||
import { Router } from '..';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { rommApi } from '../scripts/clientApi';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEventListener, useSessionStorage } from "usehooks-ts";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { CollectionsDetail } from "../components/CollectionsDetail";
|
||||
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||
import { Suspense } from "react";
|
||||
import { useContext } from "react";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||
|
||||
export const Route = createFileRoute("/platform/$source/$id")({
|
||||
component: RouteComponent
|
||||
});
|
||||
|
||||
function PlatformTitle (data: { platformSlug?: string, platformName?: string; })
|
||||
function PlatformTitle (data: { pathCover: string | null, platformName?: string; })
|
||||
{
|
||||
return <div className="sm:landscape:hidden flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
|
||||
|
||||
<div className="divider mb-6 mt-0">
|
||||
{!!data.platformSlug && <img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${data.platformSlug.toLocaleLowerCase()}.svg`} ></img>}
|
||||
{!!data.pathCover && <img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}${data.pathCover}`} ></img>}
|
||||
{data.platformName}
|
||||
</div>
|
||||
</div>;
|
||||
|
|
@ -33,16 +33,13 @@ function RouteComponent ()
|
|||
}, staleTime: DefaultRommStaleTime
|
||||
});
|
||||
|
||||
const [, setBackground] = useSessionStorage<string | undefined>(
|
||||
"home-background",
|
||||
undefined,
|
||||
);
|
||||
const animatedBgContext = useContext(AnimatedBackgroundContext);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
{!!platform && <CollectionsDetail
|
||||
title={<PlatformTitle platformSlug={platform.slug} platformName={platform.name} />}
|
||||
setBackground={setBackground}
|
||||
title={<PlatformTitle pathCover={platform.path_cover} platformName={platform.name} />}
|
||||
setBackground={animatedBgContext.setBackground}
|
||||
filters={{ platform_id: Number(id), platform_slug: platform.slug, platform_source: source }}
|
||||
/>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { systemApi } from '@/mainview/scripts/clientApi';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
|
||||
export const Route = createFileRoute('/settings/about')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -9,56 +10,58 @@ export const Route = createFileRoute('/settings/about')({
|
|||
function RouteComponent ()
|
||||
{
|
||||
const { data: systemInfo } = useQuery({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() });
|
||||
return <div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<td>{navigator.userAgent}</td>
|
||||
</tr>
|
||||
{/* row 2 */}
|
||||
<tr>
|
||||
<th>Platform</th>
|
||||
<td>{navigator.platform}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Resolution</th>
|
||||
<td>{screen.width}x{screen.height}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Window</th>
|
||||
<td>{window.innerWidth}x{window.innerHeight}</td>
|
||||
</tr>
|
||||
{/* row 3 */}
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<td>{systemInfo?.data?.user}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Architecture</th>
|
||||
<td>{systemInfo?.data?.arch}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>System</th>
|
||||
<td>{systemInfo?.data?.platform}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<td>{systemInfo?.data?.hostname}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Machine</th>
|
||||
<td>{systemInfo?.data?.machine}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<td>{systemInfo?.data?.source}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Steam Deck</th>
|
||||
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>;
|
||||
return <table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<td>{navigator.userAgent}</td>
|
||||
</tr>
|
||||
{/* row 2 */}
|
||||
<tr>
|
||||
<th>Platform</th>
|
||||
<td>{navigator.platform}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Resolution</th>
|
||||
<td>{screen.width}x{screen.height}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Window</th>
|
||||
<td>{window.innerWidth}x{window.innerHeight}</td>
|
||||
</tr>
|
||||
{/* row 3 */}
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<td>{systemInfo?.data?.user}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Architecture</th>
|
||||
<td>{systemInfo?.data?.arch}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>System</th>
|
||||
<td>{systemInfo?.data?.platform}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<td>{systemInfo?.data?.hostname}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Machine</th>
|
||||
<td>{systemInfo?.data?.machine}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Sizes</th>
|
||||
<td>Cache: {prettyBytes(systemInfo?.data?.cacheSize ?? 0)}, Store: {prettyBytes(systemInfo?.data?.storeSize ?? 0)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<td>{systemInfo?.data?.source}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Steam Deck</th>
|
||||
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,17 +7,18 @@ import
|
|||
import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import classNames from "classnames";
|
||||
import { Key, Link, Lock, Save, ScanQrCode, Trash, User, X } from "lucide-react";
|
||||
import { Key, Link, Lock, LogOut, Save, ScanQrCode, Trash, User, X } from "lucide-react";
|
||||
import
|
||||
{
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { RPC_URL } from "../../../shared/constants";
|
||||
import { RPC_URL } from "@shared/constants";
|
||||
import
|
||||
{
|
||||
getCurrentUserApiUsersMeGetOptions,
|
||||
statsApiStatsGetOptions,
|
||||
} from "../../../clients/romm/@tanstack/react-query.gen";
|
||||
} from "@clients/romm/@tanstack/react-query.gen";
|
||||
import toast from "react-hot-toast";
|
||||
import z from "zod";
|
||||
import { OptionSpace } from "../../components/options/OptionSpace";
|
||||
|
|
@ -26,20 +27,95 @@ import { rommApi, settingsApi } from "../../scripts/clientApi";
|
|||
import { Button } from "../../components/options/Button";
|
||||
import { ContextDialog } from "@/mainview/components/ContextDialog";
|
||||
import QRCode from "react-qr-code";
|
||||
import { useAsyncGenerator } from "@/mainview/scripts/utils";
|
||||
import { useJobStatus } from "@/mainview/scripts/utils";
|
||||
import { useInterval } from "usehooks-ts";
|
||||
import { TwitchIcon } from "@/mainview/scripts/brandIcons";
|
||||
|
||||
export const Route = createFileRoute("/settings/accounts")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: string; endsAt: Date; })
|
||||
function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: string; endsAt: Date; startedAt: Date; code?: string; })
|
||||
{
|
||||
const progressRef = useRef<HTMLProgressElement>(null);
|
||||
useInterval(() =>
|
||||
{
|
||||
if (progressRef.current)
|
||||
{
|
||||
const time = data.endsAt.getTime() - data.startedAt.getTime();
|
||||
progressRef.current.value = ((data.endsAt.getTime() - new Date().getTime()) / time) * 100;
|
||||
}
|
||||
|
||||
}, 1000);
|
||||
|
||||
return <ContextDialog id={data.id} open={data.isOpen} close={() => data.cancel()} className="flex flex-col justify-center items-center gap-2">
|
||||
<QRCode value={data.url} />
|
||||
<progress ref={progressRef} className="progress w-56" max="100"></progress>
|
||||
{!!data.code && <p> Code: {data.code} </p>}
|
||||
<Button id="qr-login-cancel" focusClassName="btn-warning" type="button" onAction={() => data.cancel()}><X /> Cancel</Button>
|
||||
</ContextDialog>;
|
||||
}
|
||||
|
||||
function TwitchLogin (data: {})
|
||||
{
|
||||
|
||||
const loginStatus = useQuery({
|
||||
queryKey: ['twitch', 'login', 'status'],
|
||||
retry (failureCount, error)
|
||||
{
|
||||
if (error.status === 404)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return failureCount < 3;
|
||||
},
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error, status } = await rommApi.api.romm.login.twitch.get();
|
||||
if (error) throw { ...error, status };
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationKey: ['twitch', 'login'],
|
||||
mutationFn: (openInBrowser: boolean) =>
|
||||
{
|
||||
return rommApi.api.romm.login.twitch.post({ openInBrowser });
|
||||
},
|
||||
onSuccess: () => loginStatus.refetch()
|
||||
});
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationKey: ['twitch', 'logout'],
|
||||
mutationFn: () =>
|
||||
{
|
||||
return rommApi.api.romm.logout.twitch.post();
|
||||
},
|
||||
onSuccess: () => loginStatus.refetch()
|
||||
});
|
||||
|
||||
const { data: loginData, wsRef } = useJobStatus('twitch-login-job', { onEnded: () => loginStatus.refetch() });
|
||||
|
||||
return <div className="flex flex-wrap gap-1 items-center justify-center-safe">
|
||||
{loginStatus.isSuccess ?
|
||||
<div className="badge badge-success badge-lg rounded-full gap-2"><b>{loginStatus.data.login}</b></div> :
|
||||
<div className={classNames("badge gap-2 tooltip", { "badge-error": loginStatus.error })} data-tip={loginStatus.error?.message}>
|
||||
{loginStatus.isError || loginStatus.isRefetchError ? <Lock className="size-4" /> : <span className="loading loading-spinner loading-sm"></span>}
|
||||
</div>
|
||||
}
|
||||
<Button id="twitch-login-btn-qr" disabled={loginMutation.isPending} onAction={() => loginMutation.mutate(false)} >
|
||||
<ScanQrCode />
|
||||
</Button>
|
||||
<Button id="twitch-login-btn" disabled={loginMutation.isPending} onAction={() => loginMutation.mutate(true)} >
|
||||
{TwitchIcon}
|
||||
Login
|
||||
</Button>
|
||||
{loginStatus.isSuccess && <Button id="twitch-logout-btn" onAction={() => logoutMutation.mutate()} ><LogOut /> Logout</Button>}
|
||||
{!!loginData && <LoginQR code={loginData.user_code} url={loginData.url} cancel={() => wsRef.current?.send({ type: 'cancel' })} id='twitch-login-qr' isOpen={true} endsAt={loginData.expires_at} startedAt={loginData.started_at} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function LoginControls (data: { hasPassword: boolean; })
|
||||
{
|
||||
const user = useQuery({
|
||||
|
|
@ -48,42 +124,30 @@ function LoginControls (data: { hasPassword: boolean; })
|
|||
refetchOnWindowFocus: false,
|
||||
retry: 0
|
||||
});
|
||||
const { data: qrLoginStatusGen, refetch } = useQuery({
|
||||
queryKey: ['login', 'qr'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.login.remote.status.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
const statusValue = useAsyncGenerator(qrLoginStatusGen, [qrLoginStatusGen]);
|
||||
const cancelQrMutation = useMutation({
|
||||
const loginMutation = useMutation({
|
||||
mutationKey: ['login', 'qr', 'cancel'],
|
||||
mutationFn: () => rommApi.api.romm.login.remote.cancel.post(),
|
||||
onSuccess: () => refetch()
|
||||
});
|
||||
const requestQrLoginMutation = useMutation({
|
||||
mutationKey: ['login', 'qr'],
|
||||
mutationFn: () => rommApi.api.romm.login.remote.start.post(),
|
||||
onSuccess: () => refetch()
|
||||
mutationFn: () => rommApi.api.romm.login.romm.post()
|
||||
});
|
||||
const { data: statusValue, error: loginError, wsRef } = useJobStatus('login-job');
|
||||
const context = useSettingsFormContext({});
|
||||
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
|
||||
const logoutMutation = useMutation({
|
||||
mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post(),
|
||||
onSuccess: async (d, v, r, c) =>
|
||||
{
|
||||
user.refetch();
|
||||
c.client.invalidateQueries({ queryKey: ["romm", "auth"] });
|
||||
}
|
||||
});
|
||||
return <div className="flex gap-2 items-center flex-wrap">
|
||||
{user.isError && <div className="badge badge-error gap-2 tooltip" data-tip={(user.error as any)?.detail ?? ''}>
|
||||
<Lock className="size-4" /></div>}
|
||||
{user.isSuccess && <>
|
||||
<div className="badge badge-success badge-lg rounded-full gap-2"> <p className="sm:hidden md:inline">Logged In As:</p> <img className="size-6 rounded-full" src={`${RPC_URL(__HOST__)}/api/romm/assets/romm/assets/${user.data?.avatar_path}`} /><b>{user.data?.username}</b></div>
|
||||
</>}
|
||||
<Button id="qr-login" type="button" onAction={() => requestQrLoginMutation.mutate()}><ScanQrCode /> </Button>
|
||||
return <div className="flex gap-2 items-center flex-wrap justify-center-safe">
|
||||
{user.isSuccess ?
|
||||
<div className="badge badge-success badge-lg rounded-full gap-2"> <p className="sm:hidden md:inline">Logged In As:</p> <img className="size-6 rounded-full" src={`${RPC_URL(__HOST__)}/api/romm/assets/romm/assets/${user.data?.avatar_path}`} /><b>{user.data?.username}</b></div> :
|
||||
<div className={classNames("badge gap-2 tooltip", { "badge-error": user.error })} data-tip={user.error?.message}>
|
||||
{user.isError ? <Lock className="size-4" /> : <span className="loading loading-spinner loading-sm"></span>}
|
||||
</div>
|
||||
}
|
||||
<Button id="qr-login" type="button" disabled={loginMutation.isPending} onAction={() => loginMutation.mutate()}><ScanQrCode /> </Button>
|
||||
<Button id="can-submit" disabled={!context.state.canSubmit || !context.state.isDirty} type="submit" onAction={() => context.handleSubmit()} >
|
||||
<Save /> Save
|
||||
</Button>
|
||||
|
|
@ -99,11 +163,11 @@ function LoginControls (data: { hasPassword: boolean; })
|
|||
<Button id="cancel" disabled={context.state.isDefaultValue} type="reset" onAction={() => context.reset()}>
|
||||
<X /> Cancel
|
||||
</Button>
|
||||
{statusValue?.data?.endsAt && <LoginQR id="qr-login-context" endsAt={statusValue.data.endsAt} isOpen={true} cancel={() =>
|
||||
{!!statusValue && <LoginQR startedAt={statusValue.startedAt} id="qr-login-context" endsAt={statusValue.endsAt} isOpen={true} cancel={() =>
|
||||
{
|
||||
setFocus(`qr-login`);
|
||||
cancelQrMutation.mutate();
|
||||
}} url={statusValue?.data?.url ?? ''} />}
|
||||
wsRef.current?.send({ type: 'cancel' });
|
||||
}} url={statusValue?.url ?? ''} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +247,7 @@ function RouteComponent ()
|
|||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<ul ref={ref} className="list rounded-box gap-2">
|
||||
<ul ref={ref} className="list relative rounded-box gap-2">
|
||||
<div className="divider text-2xl mt-0 md:mt-4">
|
||||
<div className="flex flex-col">
|
||||
<h3>Romm</h3>
|
||||
|
|
@ -218,12 +282,24 @@ function RouteComponent ()
|
|||
<loginForm.AppField name="password" children={(field) =>
|
||||
<field.FormOption label={"Romm Password"} icon={<Key />} type="password" placeholder={hasPassword ? '*****' : "Password"} />} />
|
||||
<loginForm.Subscribe children={(form) =>
|
||||
<OptionSpace className="justify-end">
|
||||
<OptionSpace id="login-controls-space" className="justify-end border-0">
|
||||
<LoginControls hasPassword={hasPassword === true} />
|
||||
</OptionSpace>} />
|
||||
</form>
|
||||
</loginForm.AppForm>
|
||||
<div className="divider text-2xl mt-0 md:mt-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
{TwitchIcon}
|
||||
<h3> Twitch</h3>
|
||||
</div>
|
||||
</div>
|
||||
</ul>
|
||||
<OptionSpace label={<div className="flex flex-col">
|
||||
Twitch Login
|
||||
<small className="text-base-content/40">for IGDB Metadata</small>
|
||||
</div>} id="twitch-login-space" className="justify-end border-0">
|
||||
<TwitchLogin />
|
||||
</OptionSpace>
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spat
|
|||
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||
import FilePicker from '@/mainview/components/FilePicker';
|
||||
import { dirname } from 'pathe';
|
||||
import { autoEmulatorsQuery } from '@/mainview/scripts/queries';
|
||||
|
||||
export const Route = createFileRoute('/settings/emulators')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -75,7 +76,7 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd
|
|||
};
|
||||
|
||||
|
||||
return <OptionSpace label={"Custom Emulator Path"}>
|
||||
return <OptionSpace id={'custom-emulator-path-option'} label={"Custom Emulator Path"}>
|
||||
<Button disabled={data.isAddingOverride} id='emulator' type='button' onAction={() => setNewEmulatorTypeOpen(true)} >
|
||||
Emulator
|
||||
<ChevronDown />
|
||||
|
|
@ -155,7 +156,7 @@ function EmulatorPath (data: { id: string; })
|
|||
};
|
||||
|
||||
return (
|
||||
<OptionSpace label={
|
||||
<OptionSpace id={`${data.id}-space`} label={
|
||||
focus => <>
|
||||
<p className='font-semibold'>{data.id}</p>
|
||||
<small className='opacity-40'>{emulators[data.id]}</small>
|
||||
|
|
@ -211,6 +212,7 @@ function EmulatorBadge (data: {
|
|||
path?: string,
|
||||
exists: boolean,
|
||||
emulator: string;
|
||||
isCritical: boolean;
|
||||
pathCover?: string;
|
||||
addOverride: (emulator: string) => void;
|
||||
})
|
||||
|
|
@ -229,16 +231,16 @@ function EmulatorBadge (data: {
|
|||
|
||||
return <div className={classNames("tooltip tooltip-primary", { "tooltip-open": focused })} data-tip={`${emulators[data.emulator]}`}>
|
||||
<div ref={ref} className={
|
||||
twMerge('flex flex-col rounded-3xl bg-base-300 w-64 h-16 justify-center items-center p-4 overflow-hidden',
|
||||
twMerge('flex flex-col rounded-3xl bg-base-300 justify-center items-center p-4 overflow-hidden h-full',
|
||||
classNames({
|
||||
"bg-base-200": !data.path,
|
||||
"border-dashed border-base-content/40 border-2": !data.path && !focused,
|
||||
"border-dashed border-base-content/40 border-2": !data.path && data.isCritical && !focused,
|
||||
"border-dashed border-accent border-4": focused
|
||||
|
||||
}))
|
||||
}>
|
||||
<p className='flex gap-2 font-semibold'>
|
||||
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className='text-warning' />}
|
||||
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className={data.isCritical ? 'text-warning' : 'text-base-content/40'} />}
|
||||
{!!data.pathCover && <img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${data.pathCover}`}></img>}
|
||||
{data.emulator}
|
||||
</p>
|
||||
|
|
@ -249,11 +251,11 @@ function EmulatorBadge (data: {
|
|||
|
||||
function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; })
|
||||
{
|
||||
const { data: autoEmulators } = useQuery({ queryKey: ['auto-emulators'], queryFn: async () => settingsApi.api.settings.emulators.automatic.get() });
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators?.data && autoEmulators.data.length > 0 });
|
||||
return <div ref={ref} className='flex flex-wrap gap-2 justify-center-safe'>
|
||||
const { data: autoEmulators } = useQuery(autoEmulatorsQuery);
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators && autoEmulators.length > 0 });
|
||||
return <div ref={ref} className='grid grid-cols-[repeat(auto-fit,14rem)] auto-rows-[4rem] gap-2 justify-center-safe'>
|
||||
<FocusContext value={focusKey}>
|
||||
{autoEmulators?.data?.map(e => <EmulatorBadge key={e.emulator} addOverride={data.addOverride} pathCover={e.path_cover ?? undefined} path={e.path} exists={e.exists} emulator={e.emulator} />)}
|
||||
{autoEmulators?.map(e => <EmulatorBadge key={e.emulator} isCritical={e.isCritical} addOverride={data.addOverride} pathCover={e.path_cover ?? undefined} path={e.path?.path} exists={e.exists} emulator={e.emulator} />)}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ function RouteComponent ()
|
|||
return <ul ref={ref} className="list rounded-box gap-2">
|
||||
<FocusContext value={focusKey}>
|
||||
<LocalOption id="backgroundBlur" label="Background Blur" type='checkbox'></LocalOption>
|
||||
<LocalOption id="backgroundAnimation" label="Background Animation" type='checkbox'></LocalOption>
|
||||
<LocalOption id="theme" label="Theme" type='dropdown' values={['dark', 'light', 'auto']}></LocalOption>
|
||||
</FocusContext>
|
||||
</ul>;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import
|
|||
{
|
||||
Outlet,
|
||||
createFileRoute,
|
||||
useMatchRoute,
|
||||
useMatch,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import { ViewTransitionOptions } from "@tanstack/router-core";
|
||||
|
|
@ -29,7 +29,6 @@ import { PopSource } from "../../scripts/spatialNavigation";
|
|||
import { Router } from "../..";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import Shortcuts from "@/mainview/components/Shortcuts";
|
||||
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: SettingsUI,
|
||||
|
|
@ -49,10 +48,13 @@ function MenuItem (data: {
|
|||
label: string;
|
||||
})
|
||||
{
|
||||
const matchRoute = useMatchRoute();
|
||||
const navigate = useNavigate();
|
||||
const acitve = matchRoute({ to: data.route });
|
||||
const handleNonFocusSelect = () => navigate({ to: data.return ? PopSource('settings') ?? data.route : data.route, viewTransition: data.viewTransition });
|
||||
const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });;
|
||||
const handleNonFocusSelect = () =>
|
||||
{
|
||||
const { to, search } = PopSource('settings');
|
||||
navigate({ to: data.return ? to ?? data.route : data.route, viewTransition: data.viewTransition, search: data.return ? search : undefined });
|
||||
};
|
||||
const { ref, focusSelf, focused } = useFocusable({
|
||||
focusKey: `menu-item-${data.route}`,
|
||||
forceFocus: !!acitve,
|
||||
|
|
@ -69,29 +71,26 @@ function MenuItem (data: {
|
|||
? handleNonFocusSelect
|
||||
: undefined,
|
||||
});
|
||||
const { isPointer } = useActiveControl();
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
key={data.route}
|
||||
onClick={data.focusSelect ? focusSelf : handleNonFocusSelect}
|
||||
onFocus={focusSelf}
|
||||
className={data.className}
|
||||
className={twMerge("flex group-focusable cursor-pointer", data.className)}
|
||||
>
|
||||
<div
|
||||
aria-selected={!!acitve}
|
||||
className={twMerge(
|
||||
"group rounded-full p-3 md:pl-5 text-base-content/80",
|
||||
"rounded-full p-3 md:pl-5 text-base-content/80 focusable focusable-accent in-focused:font-semibold aria-selected:bg-primary aria-selected:text-primary-content w-full hover:bg-primary/40 active:bg-base-content active:text-base-100",
|
||||
classNames({
|
||||
"bg-primary text-primary-content": acitve,
|
||||
"font-semibold sm:ring-4 md:ring-7 ring-accent": focused && !isPointer,
|
||||
"bg-secondary text-secondary-content ring-primary": data.return && focused,
|
||||
"in-focused:bg-secondary in-focused:text-secondary-content in-focused:ring-primary": data.return,
|
||||
}),
|
||||
data.linkClassName,
|
||||
)}
|
||||
>
|
||||
<div className={twMerge("flex gap-2 items-center transition-all", classNames({
|
||||
"scale-110": focused || acitve
|
||||
}))}>
|
||||
<div className="flex gap-2 items-center transition-all in-focused:scale-110">
|
||||
{data.icon}
|
||||
<div className="sm:hidden md:inline">{data.label}</div>
|
||||
</div>
|
||||
|
|
@ -110,7 +109,7 @@ function SettingsMenu (data: {})
|
|||
|
||||
return <ul
|
||||
ref={ref}
|
||||
className="menu portrait:menu-horizontal md:menu-xl landscape:flex-nowrap bg-base-200 sm:p-2 md:p-4 sm:portrait:gap-0 sm:landscape:gap-0 md:landscape:w-128 md:gap-2! rounded-4xl overflow-auto portrait:w-full"
|
||||
className="flex flex-col portrait:flex-row md:text-2xl landscape:flex-nowrap bg-base-200 sm:p-2 md:p-4 sm:portrait:gap-0 sm:landscape:gap-0 md:landscape:w-128 md:gap-2! rounded-4xl overflow-auto portrait:w-full"
|
||||
>
|
||||
<FocusContext value={focusKey}>
|
||||
<MenuItem
|
||||
|
|
@ -158,12 +157,12 @@ function SettingsMenu (data: {})
|
|||
function HandleGoBack ()
|
||||
{
|
||||
|
||||
const source = PopSource('settings');
|
||||
if (source)
|
||||
const { to, search } = PopSource('settings');
|
||||
if (to)
|
||||
{
|
||||
console.log("Found source ", source, " to go back to");
|
||||
console.log("Found source ", to, " to go back to");
|
||||
}
|
||||
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
|
||||
Router.navigate({ to: to ?? "/", viewTransition: { types: ['zoom-out'] }, search });
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -184,7 +183,7 @@ export function SettingsUI ()
|
|||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<div ref={ref} className="bg-base-100 flex flex-col w-full h-full md:p-4">
|
||||
<div ref={ref} className="bg-base-100 flex flex-col w-full h-full sm:p-2 md:p-4">
|
||||
<div className="flex landscape:flex-row portrait:flex-col-reverse grow overflow-hidden">
|
||||
<div id="Menu" className="flex flex-row landscape:h-full md:landscape:w-56">
|
||||
<SettingsMenu />
|
||||
|
|
|
|||
198
src/mainview/routes/store/details.emulator.$id.tsx
Normal file
198
src/mainview/routes/store/details.emulator.$id.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import
|
||||
{
|
||||
useFocusable,
|
||||
FocusContext,
|
||||
setFocus,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { Router } from "@/mainview";
|
||||
import Shortcuts from "@/mainview/components/Shortcuts";
|
||||
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
|
||||
import { PopSource } from "@/mainview/scripts/spatialNavigation";
|
||||
import { systemApi } from "@/mainview/scripts/clientApi";
|
||||
import { storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@/mainview/scripts/queries";
|
||||
import { Button } from "@/mainview/components/options/Button";
|
||||
import { ChevronDown, Download, Info, Settings } from "lucide-react";
|
||||
import { ContextDialog, ContextList, DialogEntry } from "@/mainview/components/ContextDialog";
|
||||
import { FrontEndEmulator, RPC_URL } from "@/shared/constants";
|
||||
import Screenshots from "@/mainview/components/Screenshots";
|
||||
import { HeaderUI } from "@/mainview/components/Header";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { EmulatorsSection } from "@/mainview/components/store/EmulatorsSection";
|
||||
import { scrollIntoViewHandler, useStickyDataAttr } from "@/mainview/scripts/utils";
|
||||
|
||||
export const Route = createFileRoute('/store/details/emulator/$id')({
|
||||
component: RouteComponent,
|
||||
async loader (ctx)
|
||||
{
|
||||
const emulator = await ctx.context.queryClient.fetchQuery(storeEmulatorDetailsQuery(ctx.params.id));
|
||||
return { emulator };
|
||||
}
|
||||
});
|
||||
|
||||
function HomePageLink (data: { homepage: string; })
|
||||
{
|
||||
const { ref } = useFocusable({ focusKey: 'homepage-link' });
|
||||
return <a ref={ref} className="text-lg text-info cursor-pointer focusable focusable-accent focusable-hover bg-base-200 rounded-full px-4 py-1" onClick={() => systemApi.api.system.open.post({ url: data.homepage })}>{data.homepage}</a>;
|
||||
}
|
||||
|
||||
function TitleArea (data: { emulator: FrontEndEmulator; })
|
||||
{
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const installOptions: DialogEntry[] = [];
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'title-area',
|
||||
preferredChildFocusKey: "install-btn",
|
||||
onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ behavior: "smooth", block: 'end' }); }
|
||||
});
|
||||
|
||||
return <div ref={ref} className="flex flex-wrap gap-4 items-center">
|
||||
<FocusContext value={focusKey}>
|
||||
<img className="size-32" src={data.emulator.logo}></img>
|
||||
<div className="flex flex-col grow justify-start gap-1">
|
||||
<h1 className="text-4xl font-semibold">{data.emulator.name}</h1>
|
||||
<p className="flex gap-2">
|
||||
{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 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>;
|
||||
})}
|
||||
</p>
|
||||
<div className="flex pt-2 gap-1">
|
||||
<HomePageLink homepage={data.emulator.homepage} />
|
||||
</div>
|
||||
</div>
|
||||
<Button style="accent" id="install-btn" className="px-8 py-3 gap-4 rounded-4xl focusable focusable-accent" onAction={() => setInstallOpen(true)} >{
|
||||
data.emulator.exists ?
|
||||
<><Settings /> Options</> :
|
||||
<><Download />Install</>
|
||||
}
|
||||
<div className="divider divider-horizontal divider-neutral m-0 opacity-20"></div>
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
|
||||
<ContextDialog id="install-context-menu" open={installOpen} close={() =>
|
||||
{
|
||||
setInstallOpen(false);
|
||||
setFocus("install-btn");
|
||||
}}>
|
||||
<ContextList options={installOptions}>
|
||||
|
||||
</ContextList>
|
||||
</ContextDialog>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Description (data: { emulator: FrontEndEmulator; })
|
||||
{
|
||||
return <div className="flex-col sm:px-8 md:px-16 pt-8 sm:pb-8 md:pb-12 bg-base-100">
|
||||
<p>{data.emulator.description}</p>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function RouteComponent ()
|
||||
{
|
||||
const { id } = Route.useParams();
|
||||
const headerRef = useRef(null);
|
||||
const sentinelRef = useRef(null);
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: `GAME_DETAIL_${id}`,
|
||||
trackChildren: true,
|
||||
preferredChildFocusKey: 'title-area'
|
||||
});
|
||||
|
||||
const { emulator } = Route.useLoaderData();
|
||||
const { data: recommended } = useQuery(storeEmulatorsRecommendedQuery);
|
||||
|
||||
useShortcuts(focusKey, () => [{
|
||||
label: "Return",
|
||||
action: () =>
|
||||
{
|
||||
const { to, search } = PopSource('store-details');
|
||||
Router.navigate({ to: to ?? '/store/tab', viewTransition: { types: ['zoom-out'] }, search: search ?? { focus: id } });
|
||||
},
|
||||
button: GamePadButtonCode.B
|
||||
}]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
focusSelf();
|
||||
}, []);
|
||||
|
||||
const { shortcuts } = useShortcutContext();
|
||||
useStickyDataAttr(headerRef, sentinelRef, ref);
|
||||
|
||||
return (
|
||||
<AnimatedBackground ref={ref} className="bg-base-100" scrolling>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<div className="flex flex-col min-h-full z-10">
|
||||
<div ref={sentinelRef} className="h-0" />
|
||||
<div ref={headerRef} className='sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15'>
|
||||
<HeaderUI />
|
||||
</div>
|
||||
<div className=" w-full sm:px-8 md:px-16 pb-8 pt-12">
|
||||
<TitleArea emulator={emulator} />
|
||||
</div>
|
||||
<div className="flex flex-col bg-base-200 pt-4 min-h-0 grow text-lg">
|
||||
<Screenshots screenshots={emulator.screenshots} onFocus={scrollIntoViewHandler({ block: 'end' })} />
|
||||
<Description emulator={emulator} />
|
||||
</div>
|
||||
<div className='mobile:hidden bg-gradient'></div>
|
||||
<div className='mobile:hidden bg-noise'></div>
|
||||
</div>
|
||||
<div className="flex flex-col bg-base-100 py-4">
|
||||
<div className="divider"> <Info className="size-12" /> Stats</div>
|
||||
<ul className="flex flex-col table table-lg sm:px-8 md:px-16">
|
||||
{!!emulator.keywords &&
|
||||
<li className="flex flex-wrap gap-2 items-center">
|
||||
<div className="font-semibold">Tags:</div>
|
||||
<div className="flex flex-wrap gap-2">{emulator.keywords?.map(k => <span className="rounded-full bg-base-200 px-3 py-1">{k}</span>)}</div>
|
||||
</li>
|
||||
}
|
||||
{!!emulator.status.source &&
|
||||
<li>
|
||||
<div>Source</div>
|
||||
<div>{emulator.status.source}</div>
|
||||
</li>
|
||||
}
|
||||
{!!emulator.status.location &&
|
||||
<li>
|
||||
<div>Location</div>
|
||||
<div>{emulator.status.location}</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div className="relative mt-16 bg-base-200">
|
||||
{recommended && <EmulatorsSection
|
||||
id={`${id}-recommended`}
|
||||
header={<><div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
|
||||
<h2 className="font-bold uppercase tracking-widest">
|
||||
More Emulators
|
||||
</h2></>}
|
||||
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
||||
onSelect={(id, focus) =>
|
||||
{
|
||||
setFocus("title-area");
|
||||
Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } });
|
||||
}}
|
||||
emulators={recommended.map(em => ({
|
||||
name: em.name,
|
||||
id: em.name,
|
||||
installed: em.exists,
|
||||
logo: em.logo,
|
||||
systems: em.systems
|
||||
} satisfies ShopFrontEndEmulator))} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-10'>
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</div>
|
||||
</FocusContext.Provider>
|
||||
</AnimatedBackground >
|
||||
);
|
||||
}
|
||||
80
src/mainview/routes/store/tab/emulators.tsx
Normal file
80
src/mainview/routes/store/tab/emulators.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
|
||||
import { storeEmulatorsQuery } from '@/mainview/scripts/queries';
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router';
|
||||
import { Joystick } from 'lucide-react';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard';
|
||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/emulators')({
|
||||
component: RouteComponent,
|
||||
pendingComponent: PendingComponent,
|
||||
async loader ({ context })
|
||||
{
|
||||
const emulators = await context.queryClient.fetchQuery(storeEmulatorsQuery);
|
||||
return { emulators };
|
||||
},
|
||||
});
|
||||
|
||||
function PendingComponent ()
|
||||
{
|
||||
return <section className="px-6 py-4">
|
||||
<div className="divider text-info">
|
||||
<Joystick className='size-12' />
|
||||
<h2 className="font-bold uppercase tracking-widest">
|
||||
Emulators
|
||||
</h2>
|
||||
</div>
|
||||
{/* Cards */}
|
||||
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[12rem] py-2 px-4 gap-4 justify-center-safe">
|
||||
{[1, 2, 3, 4, 5, 6].map(i => <div key={i} className="skeleton h-36 rounded-2xl" />)}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = useSearch({ from: '/store/tab' });
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "main-area",
|
||||
preferredChildFocusKey: focus
|
||||
});
|
||||
const storeContext = useContext(StoreContext);
|
||||
const { emulators } = Route.useLoaderData();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (focus && !GetFocusedElement(getCurrentFocusKey()))
|
||||
{
|
||||
focusSelf({ instant: true });
|
||||
}
|
||||
|
||||
}, [focus]);
|
||||
|
||||
return <>
|
||||
<section ref={ref} className="px-6 py-4 animate-slide-up">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="divider text-info">
|
||||
<Joystick className='size-12' />
|
||||
<h2 className="font-bold uppercase tracking-widest">
|
||||
Emulators
|
||||
</h2>
|
||||
</div>
|
||||
{/* Cards */}
|
||||
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[12rem] py-2 md:px-4 gap-4 justify-center-safe">
|
||||
{emulators && emulators.map((data) => (
|
||||
<StoreEmulatorCard
|
||||
id={data.name}
|
||||
key={data.name}
|
||||
emulator={data}
|
||||
onFocus={({ node, details }) => { node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' }); }}
|
||||
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FocusContext>
|
||||
</section>
|
||||
</>;
|
||||
}
|
||||
136
src/mainview/routes/store/tab/games.tsx
Normal file
136
src/mainview/routes/store/tab/games.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { StoreGameCard } from '@/mainview/components/store/GamesSection';
|
||||
import { FocusContext, getCurrentFocusKey, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router';
|
||||
import { Gamepad, Gamepad2, HardDrive, Save } from 'lucide-react';
|
||||
import { JSX, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||
import { basename, dirname, extname } from 'pathe';
|
||||
import { rommApi } from '@/mainview/scripts/clientApi';
|
||||
import { FrontEndGameType, RPC_URL } from '@/shared/constants';
|
||||
import CardElement from '@/mainview/components/CardElement';
|
||||
import { FOCUS_KEYS } from '@/mainview/scripts/types';
|
||||
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
|
||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||
import { useIntersectionObserver } from 'usehooks-ts';
|
||||
|
||||
const staleTime = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const Route = createFileRoute('/store/tab/games')({
|
||||
component: RouteComponent,
|
||||
async loader (ctx)
|
||||
{
|
||||
|
||||
/*const gamesManifest = await ctx.context.queryClient.fetchQuery({
|
||||
queryKey: ['store-games-manifest'], queryFn: async () =>
|
||||
{
|
||||
const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json());
|
||||
|
||||
return store.tree.filter((e: any) =>
|
||||
{
|
||||
if (e.type === 'blob' && e.path !== "featured.json")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}) as [];
|
||||
}, staleTime
|
||||
});
|
||||
|
||||
return { gamesManifest };*/
|
||||
},
|
||||
});
|
||||
|
||||
function LoadMoreButton (data: { isFetching: boolean; lastId?: string; } & FocusParams & InteractParams)
|
||||
{
|
||||
const handleAction = (e?: Event) =>
|
||||
{
|
||||
data.onAction?.(e);
|
||||
if (data.lastId && focused)
|
||||
setFocus(FOCUS_KEYS.GAME_CARD(data.lastId));
|
||||
};
|
||||
|
||||
const { ref, focusKey, focused } = useFocusable({
|
||||
focusKey: 'load-more-btn',
|
||||
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details),
|
||||
onEnterPress: handleAction
|
||||
});
|
||||
|
||||
const { ref: intersct } = useIntersectionObserver({
|
||||
onChange: (isIntersecting, entry) =>
|
||||
{
|
||||
if (isIntersecting)
|
||||
{
|
||||
handleAction();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return <div ref={(r) =>
|
||||
{
|
||||
ref.current = r;
|
||||
intersct(r);
|
||||
}} className='flex bg-base-100 game-card focusable focusable-accent focusable-hover text-2xl justify-center items-center cursor-pointer' onClick={handleAction} id='load-more-btn'>{data.isFetching ? <span className="loading loading-spinner loading-xl"></span> : "Load More"}</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = useSearch({ from: '/store/tab' });
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus });
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery<{ data: FrontEndGameType[], nextPage: number; }>({
|
||||
initialPageParam: 0,
|
||||
queryKey: ['store-games'],
|
||||
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
|
||||
queryFn: async (data) =>
|
||||
{
|
||||
const pageParam = data.pageParam as number;
|
||||
const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } });
|
||||
if (error) throw error;
|
||||
return { data: games.games, nextPage: pageParam + 1 };
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (focus && !GetFocusedElement(getCurrentFocusKey()))
|
||||
{
|
||||
console.log(focus);
|
||||
focusSelf({ instant: true });
|
||||
}
|
||||
|
||||
}, [focus]);
|
||||
|
||||
const handleFocus = (focusKey: string, node: HTMLElement, details: Record<string, any>) =>
|
||||
{
|
||||
node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' });
|
||||
};
|
||||
|
||||
return <>
|
||||
<section ref={ref} className="px-6 py-4 animate-slide-up">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="divider text-accent">
|
||||
<Gamepad2 className='size-12' />
|
||||
<h2 className="font-bold uppercase tracking-widest">
|
||||
Games
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[minmax(18rem,min-content)] py-2 md:px-4 gap-4 justify-center-safe">
|
||||
{data?.pages.flatMap((page) => (
|
||||
page.data.map((g, i) => <FrontEndGameCard onFocus={handleFocus} key={g.id.id} game={g} index={i} />))
|
||||
)}
|
||||
<LoadMoreButton
|
||||
lastId={data?.pages.at(-1)?.data.at(-1)?.id.id}
|
||||
onFocus={handleFocus}
|
||||
isFetching={isFetchingNextPage}
|
||||
onAction={() =>
|
||||
{
|
||||
if (isFetchingNextPage)
|
||||
return;
|
||||
fetchNextPage();
|
||||
}} />
|
||||
</div>
|
||||
</FocusContext>
|
||||
</section>
|
||||
</>;
|
||||
}
|
||||
185
src/mainview/routes/store/tab/index.tsx
Normal file
185
src/mainview/routes/store/tab/index.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { createFileRoute, ErrorComponentProps, useSearch } from '@tanstack/react-router';
|
||||
import { useFocusable, FocusContext, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { MissingEmulatorsSection } from "../../../components/store/MissingEmulatorsSection";
|
||||
import { EmulatorsSection } from "../../../components/store/EmulatorsSection";
|
||||
import { GamesSection } from "../../../components/store/GamesSection";
|
||||
import { StatsSection } from "../../../components/store/StatsSection";
|
||||
import { FrontEndGameTypeDetailed, RPC_URL } from '@/shared/constants';
|
||||
import { autoEmulatorsQuery, storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@/mainview/scripts/queries';
|
||||
import { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
|
||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||
import { useInterval } from 'usehooks-ts';
|
||||
import { Button } from '@/mainview/components/options/Button';
|
||||
import { HardDrive, Search } from 'lucide-react';
|
||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/')({
|
||||
component: RouteComponent,
|
||||
pendingComponent: LoadingSkeleton,
|
||||
errorComponent: ErrorComponent,
|
||||
loader: async ({ context }) =>
|
||||
{
|
||||
const autoEmulators = await context.queryClient.fetchQuery(autoEmulatorsQuery);
|
||||
const crutialEmulators = autoEmulators?.filter(e => !e.exists && e.isCritical);
|
||||
const featuredGames = await await context.queryClient.fetchQuery(storeFeaturedGamesQuery);
|
||||
const recommendedEmulators = await context.queryClient.fetchQuery(storeEmulatorsRecommendedQuery);
|
||||
return { crutialEmulators, recommendedEmulators, featuredGames };
|
||||
}
|
||||
});
|
||||
|
||||
function ErrorComponent (data: ErrorComponentProps)
|
||||
{
|
||||
return <div className="flex items-center justify-center h-64">
|
||||
<div role="alert" className="alert alert-error alert-soft max-w-sm">
|
||||
<span>Failed to load store data.</span>
|
||||
<p>{data.error.message}</p>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
// ── Loading skeleton ───────────────────────────────────────────────────────
|
||||
function LoadingSkeleton ()
|
||||
{
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-6 py-4 animate-pulse">
|
||||
{/* Missing section */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[1, 2, 3].map((i) => <div key={i} className="skeleton h-40 rounded-2xl" />)}
|
||||
</div>
|
||||
{/* Emulators */}
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => <div key={i} className="skeleton h-36 rounded-2xl" />)}
|
||||
</div>
|
||||
{/* Games */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[1, 2, 3, 4].map((i) => <div key={i} className="skeleton h-44 rounded-2xl" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
|
||||
{
|
||||
const [selectedGame, setSelectedGame] = useState(new Date().getSeconds() % data.games.length);
|
||||
const [nextSwitch, setNextSwitch] = useState(new Date().getTime() + 10000);
|
||||
const progressRef = useRef<HTMLProgressElement>(null);
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'main-featured-area' });
|
||||
const game = data.games[selectedGame];
|
||||
|
||||
useInterval(() =>
|
||||
{
|
||||
setSelectedGame(current => (current + 1) % data.games.length);
|
||||
setNextSwitch(new Date().getTime() + 10000);
|
||||
}, 10000);
|
||||
|
||||
useInterval(() =>
|
||||
{
|
||||
var time = (nextSwitch - new Date().getTime()) / 10000;
|
||||
if (progressRef.current)
|
||||
progressRef.current.value = time;
|
||||
}, 10);
|
||||
|
||||
const storeContext = useContext(StoreContext);
|
||||
const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`);
|
||||
previewUrl.searchParams.set('blur', '16');
|
||||
|
||||
return <div ref={ref} className='flex sm:flex-wrap md:flex-nowrap group-focusable p-4 mt-4 gap-4'>
|
||||
|
||||
<FocusContext value={focusKey}>
|
||||
<div key={selectedGame} className="flex transition-all duration-500 flex-col sm:32 md:h-64 rounded-3xl overflow-hidden shadow-black/5 shadow-xl grow">
|
||||
<div className='flex relative h-full overflow-hidden'>
|
||||
<div className='absolute w-full h-full z-0 bg-base-200'>
|
||||
<img key={selectedGame}
|
||||
className='w-full h-full object-cover transition-all duration-500 ease-out scale-110 opacity-0 z-0 mask-l-from-0'
|
||||
src={previewUrl.href}
|
||||
onLoad={(e) =>
|
||||
{
|
||||
e.currentTarget.classList.toggle('opacity-0', false);
|
||||
e.currentTarget.classList.toggle('scale-110', false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div key={selectedGame} className='flex sm:flex-wrap md:flex-nowrap grow z-1 p-8 opacity-0 animate-fade-in h-full items-end gap-4 sm:justify-end md:justify-between'>
|
||||
<div className='flex gap-4 max-h-full z-1 grow'>
|
||||
<div className='flex sm:portrait:flex-wrap sm:portrait:grow gap-4 max-h-full justify-center'>
|
||||
<div className='relative rounded-3xl max-w-xs overflow-hidden'>
|
||||
<div className='flex absolute bottom-4 left-4 size-8 bg-base-content text-base-100 rounded-full items-center justify-center shadow-lg'><HardDrive /></div>
|
||||
<img className='object-cover w-full h-full' src={`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`} />
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 py-3 max-w-md'>
|
||||
<h1 className='font-semibold text-3xl'>{game.name}</h1>
|
||||
<p className='overflow-hidden text-wrap text-ellipsis text-base-content/60'>{game.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onAction={() => storeContext.showDetails('game', game.id.source, game.id.id, focusKey)} className='px-6 py-3 text-2xl! z-1 gap-2 focusable focusable-primary' id={'play-featured-btn'}> <Search /> Details</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.children}
|
||||
</div>
|
||||
<div className='sm:flex sm:flex-wrap grow justify-stretch md:grid sm:landscape:grid-flow-col sm:auto-cols-[minmax(8rem,1fr)] md:grid-flow-row! auto-rows-fr landscape:min-w-xs gap-4'>
|
||||
{data.games.map((g, i) =>
|
||||
<div key={i} data-active={i === selectedGame} className='flex grow flex-col gap-1 transition-opacity duration-500 data-[active=true]:opacity-50 rounded-3xl bg-base-100 p-4 justify-center shadow-md'>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<img className='size-6' src={`${RPC_URL(__HOST__)}${game.path_platform_cover}`}></img>
|
||||
<div className='flex gap-2 items-center grow'>
|
||||
{g.name}
|
||||
</div>
|
||||
</div>
|
||||
{i === selectedGame && <progress ref={progressRef} className="progress progress-accent w-full" style={{ animationName: '' }} value={0} max="1"></progress>}
|
||||
</div>)}
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function RouteComponent ()
|
||||
{
|
||||
const { focus } = useSearch({ from: '/store/tab' });
|
||||
const { crutialEmulators, recommendedEmulators, featuredGames } = Route.useLoaderData();
|
||||
|
||||
const { focusKey, ref, focusSelf } = useFocusable({ focusKey: 'main-area', preferredChildFocusKey: focus ?? "recommended-emulators" });
|
||||
const storeContext = useContext(StoreContext);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (focus && !GetFocusedElement(getCurrentFocusKey()))
|
||||
{
|
||||
focusSelf({ instant: true });
|
||||
}
|
||||
|
||||
}, [focus]);
|
||||
|
||||
return (
|
||||
<div className='animate-slide-up' ref={ref}>
|
||||
<FocusContext value={focusKey}>
|
||||
{!!featuredGames && <Main games={featuredGames} />}
|
||||
{crutialEmulators.length > 0 && <MissingEmulatorsSection
|
||||
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
|
||||
emulators={crutialEmulators} />}
|
||||
<div className='pt-4'>
|
||||
<EmulatorsSection
|
||||
id="recommended-emulators"
|
||||
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
|
||||
onFocus={scrollIntoViewHandler({ block: 'end' })}
|
||||
emulators={recommendedEmulators} />
|
||||
</div>
|
||||
|
||||
<GamesSection
|
||||
onSelect={(id, focus) => storeContext.showDetails('game', id.source, id.id, focus)}
|
||||
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
||||
games={featuredGames}
|
||||
/>
|
||||
|
||||
<StatsSection
|
||||
romCount={1240}
|
||||
missingCount={crutialEmulators.length}
|
||||
/>
|
||||
</FocusContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
src/mainview/routes/store/tab/route.tsx
Normal file
156
src/mainview/routes/store/tab/route.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { Router } from '@/mainview';
|
||||
import { FilterUI } from '@/mainview/components/Filters';
|
||||
import { HeaderUI } from '@/mainview/components/Header';
|
||||
import Shortcuts from '@/mainview/components/Shortcuts';
|
||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||
import { SaveSource } from '@/mainview/scripts/spatialNavigation';
|
||||
import { mobileCheck, useStickyDataAttr } from '@/mainview/scripts/utils';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { useMatchRoute } from '@tanstack/react-router';
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import z from 'zod';
|
||||
|
||||
export const Route = createFileRoute('/store/tab')({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(z.object({ focus: z.string().optional() }))
|
||||
});
|
||||
|
||||
function useIsSettings (subPath: string)
|
||||
{
|
||||
"use no memo";
|
||||
const matchRoute = useMatchRoute();
|
||||
const isSettings = !!matchRoute({
|
||||
to: `/store/tab/${subPath}` as any
|
||||
});
|
||||
return isSettings;
|
||||
}
|
||||
|
||||
function TopArea (data: { filters: Record<string, FilterOption>; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'top-area',
|
||||
preferredChildFocusKey: 'store-tabs',
|
||||
onFocus: () =>
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}
|
||||
});
|
||||
|
||||
return <div ref={ref}>
|
||||
<FocusContext value={focusKey}>
|
||||
<div className='w-full'>
|
||||
<FilterUI containerClassName='flex w-full justify-center' id="store-tabs" options={data.filters} setSelected={(s) => Router.navigate({ to: `/store/tab/${s === 'home' ? '' : s}` })} />
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
// Root spatial nav container
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "STORE_ROOT",
|
||||
trackChildren: true,
|
||||
preferredChildFocusKey: 'top-area'
|
||||
});
|
||||
const headerRef = useRef(null);
|
||||
const sentinelRef = useRef(null);
|
||||
const filters: Record<string, FilterOption> = {
|
||||
home: { label: "Home", selected: useIsSettings(''), },
|
||||
emulators: { label: "Emulators", selected: useIsSettings('emulators') },
|
||||
games: { label: "Games", selected: useIsSettings('games') }
|
||||
};
|
||||
|
||||
useShortcuts(focusKey, () => [{
|
||||
label: "Return",
|
||||
action: () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-out'] } }),
|
||||
button: GamePadButtonCode.B
|
||||
},
|
||||
{
|
||||
action: () =>
|
||||
{
|
||||
const filterKeys = Object.keys(filters);
|
||||
const filterIndex = Math.max(0, filterKeys.findIndex(f => filters[f].selected));
|
||||
const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1);
|
||||
const newFilter = filterKeys[selectedFilterIndex];
|
||||
Router.navigate({ to: `/store/tab/${newFilter === 'home' ? '' : newFilter}` });
|
||||
},
|
||||
button: GamePadButtonCode.R1
|
||||
},
|
||||
{
|
||||
action: () =>
|
||||
{
|
||||
const filterKeys = Object.keys(filters);
|
||||
const filterIndex = Math.max(0, filterKeys.findIndex(f => filters[f as any].selected));
|
||||
const selectedFilterIndex = Math.max(0, filterIndex - 1,);
|
||||
const newFilter = filterKeys[selectedFilterIndex];
|
||||
Router.navigate({ to: `/store/tab/${newFilter === 'home' ? '' : newFilter}` });
|
||||
},
|
||||
button: GamePadButtonCode.L1
|
||||
}], [filters]);
|
||||
|
||||
const { shortcuts } = useShortcutContext();
|
||||
const { focus } = Route.useSearch();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!focus)
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
const handleDetails = (type: string, source: string, id: string, focus: string) =>
|
||||
{
|
||||
|
||||
if (type === 'emulator')
|
||||
{
|
||||
SaveSource('store-details', { url: location.hash.replaceAll(/#|(\?.+)/g, ''), search: { focus } });
|
||||
Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } });
|
||||
}
|
||||
else if (type === 'game')
|
||||
{
|
||||
console.log(source, id);
|
||||
SaveSource('details', { url: location.hash.replaceAll(/#|(\?.+)/g, ''), search: { focus } });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { source: source, id: id }, viewTransition: { types: ['zoom-in'] } });
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const match = Route.useMatch();
|
||||
const goToSettings = () =>
|
||||
{
|
||||
SaveSource('settings', { url: match.pathname, search: { focus: "settings" } });
|
||||
Router.navigate({ to: '/settings', viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
const isMobile = mobileCheck();
|
||||
useStickyDataAttr(headerRef, sentinelRef, ref);
|
||||
|
||||
return <div ref={ref} className='overflow-y-scroll w-screen h-screen' >
|
||||
<StoreContext value={{ showDetails: handleDetails }} >
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<div className="relative flex flex-col min-h-screen text-base-content z-10" >
|
||||
<div ref={sentinelRef} className="h-0" />
|
||||
<div ref={headerRef} className='sticky p-2 group top-0 not-mobile:data-stuck:backdrop-blur-xl z-15 mobile:data-stuck:bg-base-300'>
|
||||
<HeaderUI buttons={[{ icon: <Settings />, id: "settings", action: goToSettings, external: true }]} />
|
||||
</div>
|
||||
<TopArea filters={filters} />
|
||||
<Outlet />
|
||||
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-15'>
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</div>
|
||||
{!isMobile && <>
|
||||
<div className='bg-gradient'></div>
|
||||
<div className='bg-noise'></div>
|
||||
</>}
|
||||
</div>
|
||||
</FocusContext.Provider>
|
||||
</StoreContext>
|
||||
</div >;
|
||||
}
|
||||
4
src/mainview/scripts/brandIcons.tsx
Normal file
4
src/mainview/scripts/brandIcons.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const TwitchIcon = <svg width="24" height="24" fill="currentColor" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Twitch</title>
|
||||
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z" />
|
||||
</svg>;
|
||||
|
|
@ -1,25 +1,16 @@
|
|||
import { treaty } from "@elysiajs/eden";
|
||||
import { RommAPIType, SettingsAPIType, SystemAPIType } from "../../bun/api/rpc";
|
||||
import { Treaty, treaty } from "@elysiajs/eden";
|
||||
import { JobsAPIType, RommAPIType, SettingsAPIType, StoreAPIType, SystemAPIType } from "../../bun/api/rpc";
|
||||
import { RPC_URL } from "../../shared/constants";
|
||||
|
||||
export const rommApi = treaty<RommAPIType>(RPC_URL(__HOST__), {
|
||||
const options: Treaty.Config = {
|
||||
keepDomain: true,
|
||||
fetch: {
|
||||
credentials: 'include',
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const settingsApi = treaty<SettingsAPIType>(RPC_URL(__HOST__), {
|
||||
keepDomain: true,
|
||||
fetch: {
|
||||
credentials: 'include',
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const systemApi = treaty<SystemAPIType>(RPC_URL(__HOST__), {
|
||||
keepDomain: true,
|
||||
fetch: {
|
||||
credentials: 'include',
|
||||
}
|
||||
});
|
||||
export const rommApi = treaty<RommAPIType>(RPC_URL(__HOST__), options);
|
||||
export const settingsApi = treaty<SettingsAPIType>(RPC_URL(__HOST__), options);
|
||||
export const systemApi = treaty<SystemAPIType>(RPC_URL(__HOST__), options);
|
||||
export const storeApi = treaty<StoreAPIType>(RPC_URL(__HOST__), options);
|
||||
export const jobsApi = treaty<JobsAPIType>(RPC_URL(__HOST__), options);
|
||||
34
src/mainview/scripts/contexts.ts
Normal file
34
src/mainview/scripts/contexts.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Drive } from "@/shared/constants";
|
||||
import { FocusDetails } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { createContext } from "react";
|
||||
|
||||
export const StoreContext = createContext({} as {
|
||||
showDetails: (type: 'emulator' | 'game', source: string, id: string, focusSource: string) => void;
|
||||
forceFocus?: string;
|
||||
});
|
||||
|
||||
export const AnimatedBackgroundContext = createContext({} as { setBackground: (url: string) => void; });
|
||||
|
||||
export const ContextDialogContext = createContext({} as {
|
||||
close: () => void,
|
||||
id: string;
|
||||
});
|
||||
|
||||
export const OptionContext = createContext(
|
||||
{} as {
|
||||
focused: boolean;
|
||||
focus: (focusDetails?: FocusDetails | undefined) => void;
|
||||
eventTarget: EventTarget;
|
||||
},
|
||||
);
|
||||
|
||||
export 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);
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import { getCurrentFocusKey, navigateByDirection } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { GetFocusedElement } from "./spatialNavigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { mobileCheck } from "./utils";
|
||||
|
||||
let loopStarted = false;
|
||||
let isTouching = false;
|
||||
type ActiveControlType = 'keyboard' | 'gamepad' | 'mouse' | 'touch' | undefined;
|
||||
let activeControls: ActiveControlType = undefined;
|
||||
let activeControls: ActiveControlType = mobileCheck() ? 'touch' : 'mouse';
|
||||
let mouseUpdateTimeout: any | undefined = undefined;
|
||||
let touchStopTimeout: any | undefined = undefined;
|
||||
|
||||
|
|
@ -79,7 +80,7 @@ export default function useActiveControl ()
|
|||
return () => window.removeEventListener('activecontrolschange', handler);
|
||||
});
|
||||
|
||||
return { isMouse: c === 'mouse', isPointer: c === 'mouse' || c === 'touch', control: c };
|
||||
return { isMouse: c === 'mouse', isTouch: c === 'touch', isPointer: c === 'mouse' || c === 'touch', control: c };
|
||||
}
|
||||
|
||||
const throttleMap = new Map<string, number>();
|
||||
|
|
@ -97,7 +98,10 @@ function throttleNav (key: string, dir: string, event: Event)
|
|||
navigateByDirection(dir, { event });
|
||||
throttleMap.set(key, currentDate.getTime());
|
||||
throttleAcceleration.set(key, acceleration + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function focusControl (control: typeof activeControls)
|
||||
|
|
@ -214,12 +218,14 @@ function updateStatus ()
|
|||
{
|
||||
if (gamepad.axes[0] > deadzone)
|
||||
{
|
||||
throttleNav('gpa-right', "right", gamepadEvent);
|
||||
if (throttleNav('gpa-right', "right", gamepadEvent))
|
||||
focusControl('gamepad');
|
||||
return;
|
||||
}
|
||||
else if (gamepad.axes[0] < -deadzone)
|
||||
{
|
||||
throttleNav('gpa-left', "left", gamepadEvent);
|
||||
if (throttleNav('gpa-left', "left", gamepadEvent))
|
||||
focusControl('gamepad');
|
||||
return;
|
||||
}
|
||||
else if ((throttleMap.has('gpa-left') || throttleMap.has('gpa-left')) && gamepad.axes[0] < cancelDeadzone && gamepad.axes[0] > -cancelDeadzone)
|
||||
|
|
@ -232,11 +238,13 @@ function updateStatus ()
|
|||
|
||||
if (gamepad.axes[1] > deadzone)
|
||||
{
|
||||
throttleNav('gpa-down', "down", gamepadEvent);
|
||||
if (throttleNav('gpa-down', "down", gamepadEvent))
|
||||
focusControl('gamepad');
|
||||
}
|
||||
else if (gamepad.axes[1] < -deadzone)
|
||||
{
|
||||
throttleNav('gpa-up', "up", gamepadEvent);
|
||||
if (throttleNav('gpa-up', "up", gamepadEvent))
|
||||
focusControl('gamepad');
|
||||
} else
|
||||
{
|
||||
throttleAcceleration.delete('gpa-up');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||
import { rommApi, settingsApi, systemApi } from "./clientApi";
|
||||
import { rommApi, settingsApi, storeApi, systemApi } from "./clientApi";
|
||||
import toast from "react-hot-toast";
|
||||
import { getErrorMessage } from "react-error-boundary";
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ export const changeDownloadsMutation = mutationOptions({
|
|||
}
|
||||
});
|
||||
|
||||
export const gameQuery = (source: string, id: number) => queryOptions({
|
||||
export const gameQuery = (source: string, id: string) => queryOptions({
|
||||
queryKey: ['game', source, id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
|
|
@ -60,4 +60,49 @@ export const gameQuery = (source: string, id: number) => queryOptions({
|
|||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
export const autoEmulatorsQuery = queryOptions({
|
||||
queryKey: ['auto-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.automatic.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
export const storeEmulatorsQuery = queryOptions({
|
||||
queryKey: ['store-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.emulators.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
export const storeFeaturedGamesQuery = queryOptions({
|
||||
queryKey: ['store-emulators', 'recommended'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.games.featured.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
export const storeEmulatorsRecommendedQuery = queryOptions({
|
||||
queryKey: ['store-emulators', 'recommended'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.emulators.get({ query: { limit: 6, missing: true, orderBy: 'importance' } });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
export const storeEmulatorDetailsQuery = (id: string) => queryOptions({
|
||||
queryKey: ['store-emulator', id], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.details.emulator({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
|
@ -51,6 +51,7 @@ function markDirtyThrottled ()
|
|||
|
||||
window.addEventListener('focuschanged', markDirtyThrottled);
|
||||
import.meta.hot?.dispose(() => window.removeEventListener('focuschanged', markDirtyThrottled));
|
||||
import.meta.hot?.dispose(() => shortcutMap.clear());
|
||||
|
||||
export function useShortcutContext ()
|
||||
{
|
||||
|
|
@ -81,6 +82,12 @@ export function useShortcutContext ()
|
|||
const handleGamepadButtonDown = (e: Event) =>
|
||||
{
|
||||
const event = e as GamepadButtonEvent;
|
||||
if (event.button == GamePadButtonCode.B && document.fullscreenElement)
|
||||
{
|
||||
document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shortcuts.has(event.button))
|
||||
{
|
||||
shortcuts.get(event.button)?.action?.(event);
|
||||
|
|
@ -166,6 +173,7 @@ export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps
|
|||
return () =>
|
||||
{
|
||||
shortcutMap.delete(focusKey);
|
||||
markDirtyThrottled();
|
||||
};
|
||||
}, [...deps, focusKey]);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,29 +4,36 @@ import
|
|||
getCurrentFocusKey,
|
||||
init,
|
||||
SpatialNavigation,
|
||||
useFocusable,
|
||||
UseFocusableConfig,
|
||||
UseFocusableResult,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { RefObject, useEffect } from "react";
|
||||
import { RefObject, useEffect, useState } from "react";
|
||||
import { Router } from "..";
|
||||
import { RouteIds } from "@tanstack/react-router";
|
||||
|
||||
init({
|
||||
shouldFocusDOMNode: false,
|
||||
throttle: 200,
|
||||
throttle: 200
|
||||
});
|
||||
|
||||
let addFocusable = SpatialNavigation.addFocusable.bind(SpatialNavigation);
|
||||
let updateFocusable = SpatialNavigation.updateFocusable.bind(SpatialNavigation);
|
||||
let sortSiblingsByPriority = SpatialNavigation.sortSiblingsByPriority.bind(SpatialNavigation);
|
||||
let removeFocusable = SpatialNavigation.removeFocusable.bind(SpatialNavigation);
|
||||
let setFocus = SpatialNavigation.setFocus.bind(SpatialNavigation);
|
||||
|
||||
type SaveFocusType = "session" | "local";
|
||||
|
||||
type HistorySourceType = "settings" | 'details' | 'launch' | 'game-list';
|
||||
const historySourceMap = new Map<string, string>();
|
||||
type HistorySourceType = "settings" | 'details' | 'launch' | 'game-list' | 'store-details';
|
||||
const historySourceMap = new Map<string, { to: string, search?: Record<string, any>; }>();
|
||||
|
||||
export function SaveSource (id: HistorySourceType, url?: string)
|
||||
export function SaveSource (id: HistorySourceType, init?: { url?: string, search?: Record<string, any>; })
|
||||
{
|
||||
const finalUrl = url ?? location.hash.replace("#", '');
|
||||
let finalUrl = init?.url ?? location.hash.replaceAll(/#|(\?.+)/g, '');
|
||||
if (finalUrl)
|
||||
{
|
||||
historySourceMap.set(id, finalUrl);
|
||||
historySourceMap.set(id, { to: finalUrl, search: init?.search });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -39,16 +46,22 @@ export function PopSource (id: HistorySourceType)
|
|||
{
|
||||
if (!historySourceMap.has(id))
|
||||
{
|
||||
return undefined;
|
||||
return { to: undefined, search: undefined };
|
||||
}
|
||||
const source = historySourceMap.get(id);
|
||||
historySourceMap.delete(id);
|
||||
return source;
|
||||
return source ?? { to: undefined, search: undefined };
|
||||
}
|
||||
|
||||
export function PopNavigateSource (id: HistorySourceType, fallback: RouteIds<typeof Router.routeTree>)
|
||||
{
|
||||
const { to, search } = PopSource(id);
|
||||
Router.navigate({ to: to ?? fallback, viewTransition: { types: ['zoom-out'] }, search });
|
||||
}
|
||||
|
||||
export function GetFocusedElement (focusKey: string)
|
||||
{
|
||||
return (SpatialNavigation as any).focusableComponents[focusKey]?.node as HTMLElement;
|
||||
return (SpatialNavigation as any).focusableComponents[focusKey]?.node as HTMLElement | undefined;
|
||||
}
|
||||
|
||||
export function GetFocusedTree (leaf: string): string[]
|
||||
|
|
@ -95,12 +108,38 @@ export function useFocusEventListener<K extends keyof FocusEventMap, O extends H
|
|||
}, [eventName, handler, element?.current]);
|
||||
}
|
||||
|
||||
export function useGlobalFocus ()
|
||||
{
|
||||
const [focused, setFocused] = useState<string | undefined>(undefined);
|
||||
useEffect(() =>
|
||||
{
|
||||
const handler = () => setFocused(getCurrentFocusKey());
|
||||
window.addEventListener('focuschanged', handler);
|
||||
|
||||
return () => window.removeEventListener('focuschanged', handler);
|
||||
}, []);
|
||||
|
||||
return focused;
|
||||
}
|
||||
|
||||
SpatialNavigation.setFocus = (newFocusKey, focusDetails) =>
|
||||
{
|
||||
setFocus(newFocusKey, focusDetails);
|
||||
dispatchFocusedEvent(new CustomEvent<FocusDetails>('focuschanged', { bubbles: true, detail: focusDetails }));
|
||||
};
|
||||
|
||||
|
||||
SpatialNavigation.updateFocusable = (key, data) =>
|
||||
{
|
||||
updateFocusable(key, data);
|
||||
};
|
||||
|
||||
SpatialNavigation.sortSiblingsByPriority = (siblings, currentLayout, direction, focusKey) =>
|
||||
{
|
||||
const sorted = sortSiblingsByPriority(siblings, currentLayout, direction, focusKey);
|
||||
return sorted.filter(e => e.node.checkVisibility({ visibilityProperty: true }));
|
||||
};
|
||||
|
||||
SpatialNavigation.addFocusable = (toAdd) =>
|
||||
{
|
||||
addFocusable(toAdd);
|
||||
|
|
@ -109,7 +148,9 @@ SpatialNavigation.addFocusable = (toAdd) =>
|
|||
preferredChildFocusKey?: string;
|
||||
node: HTMLElement;
|
||||
focusKey: string;
|
||||
focusableDefault?: boolean;
|
||||
} = (SpatialNavigation as any).focusableComponents[toAdd.focusKey];
|
||||
|
||||
if (component.node?.hasAttribute("save-child-focus"))
|
||||
{
|
||||
const storageKey = `${component.focusKey}-last-child-focus`;
|
||||
|
|
@ -180,3 +221,19 @@ SpatialNavigation.saveLastFocusedChildKey = (component, focusKey) =>
|
|||
{
|
||||
component.lastFocusedChildKey = focusKey;
|
||||
};
|
||||
|
||||
export function useFocusableDynamic<P> (conf?: UseFocusableConfig<P>): UseFocusableResult
|
||||
{
|
||||
const [focusable, setFocusable] = useState(conf?.focusable);
|
||||
const result = useFocusable({ ...conf, focusable: focusable && conf?.focusable });
|
||||
useEffect(() =>
|
||||
{
|
||||
const observer = new MutationObserver(() =>
|
||||
{
|
||||
setFocusable(result.ref.current.checkVisibility({ visibilityProperty: true }));
|
||||
});
|
||||
observer.observe(result.ref.current, { subtree: true, attributes: true });
|
||||
return () => observer.disconnect();
|
||||
}, [result.ref.current]);
|
||||
return result;
|
||||
}
|
||||
11
src/mainview/scripts/types.ts
Normal file
11
src/mainview/scripts/types.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export const FOCUS_KEYS = {
|
||||
NAV_CATEGORIES: "NAV_CATEGORIES",
|
||||
NAV_CATEGORY: (cat: string) => `NAV_CAT_${cat}`,
|
||||
MISSING_SECTION: "MISSING_SECTION",
|
||||
MISSING_CARD: (id: string) => `MISSING_${id}`,
|
||||
EMULATOR_SECTION: (id: string) => `EMULATOR_SECTION_${id}`,
|
||||
EMULATOR_CARD: (id: string) => `EMULATOR_${id}`,
|
||||
GAME_SECTION: "GAME_SECTION",
|
||||
GAME_CARD: (id: string) => `GAME_${id}`,
|
||||
STATS_SECTION: "STATS_SECTION",
|
||||
} as const;
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants";
|
||||
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { RefObject, useEffect, useState } from "react";
|
||||
import { Ref, RefObject, useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import { jobsApi } from "./clientApi";
|
||||
import { EdenWS } from "@elysiajs/eden/treaty";
|
||||
import { InputSchema } from "elysia/types";
|
||||
import { Treaty } from "@elysiajs/eden";
|
||||
import { JobsAPIType } from "@/bun/api/rpc";
|
||||
|
||||
export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void)
|
||||
{
|
||||
|
|
@ -107,4 +112,205 @@ export function useAsyncGenerator<T> (
|
|||
}, deps);
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollIntoNearestParent (el: HTMLElement, props?: { behavior?: ScrollBehavior; })
|
||||
{
|
||||
const parent = el.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// CENTER horizontally
|
||||
const left =
|
||||
rect.left - parentRect.left +
|
||||
parent.scrollLeft -
|
||||
parent.clientWidth / 2 +
|
||||
rect.width / 2;
|
||||
|
||||
parent.scrollTo({
|
||||
left,
|
||||
behavior: props?.behavior ?? "smooth"
|
||||
});
|
||||
|
||||
// NEAREST vertically
|
||||
if (rect.top < parentRect.top)
|
||||
{
|
||||
parent.scrollTop -= parentRect.top - rect.top;
|
||||
} else if (rect.bottom > parentRect.bottom)
|
||||
{
|
||||
parent.scrollTop += rect.bottom - parentRect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
export function useDragScroll<T extends HTMLElement | null> (ref: RefObject<T>)
|
||||
{
|
||||
useEffect(() =>
|
||||
{
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
let isDown = false;
|
||||
let isDragging = false;
|
||||
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
let startScrollLeft = 0;
|
||||
let startScrollTop = 0;
|
||||
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
const onMouseDown = (e: MouseEvent) =>
|
||||
{
|
||||
if (e.button !== 0) return;
|
||||
|
||||
isDown = true;
|
||||
isDragging = false;
|
||||
|
||||
startX = e.pageX;
|
||||
startY = e.pageY;
|
||||
|
||||
startScrollLeft = el.scrollLeft;
|
||||
startScrollTop = el.scrollTop;
|
||||
|
||||
el.style.cursor = "grabbing";
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) =>
|
||||
{
|
||||
if (!isDown) return;
|
||||
|
||||
const dx = e.pageX - startX;
|
||||
const dy = e.pageY - startY;
|
||||
|
||||
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)
|
||||
{
|
||||
isDragging = true;
|
||||
}
|
||||
|
||||
el.scrollLeft = startScrollLeft - dx;
|
||||
el.scrollTop = startScrollTop - dy;
|
||||
};
|
||||
|
||||
const onMouseUp = () =>
|
||||
{
|
||||
isDown = false;
|
||||
el.style.cursor = "";
|
||||
};
|
||||
|
||||
const onClick = (e: MouseEvent) =>
|
||||
{
|
||||
if (isDragging)
|
||||
{
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener("mousedown", onMouseDown);
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
el.addEventListener("click", onClick, true); // capture phase
|
||||
|
||||
return () =>
|
||||
{
|
||||
el.removeEventListener("mousedown", onMouseDown);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
el.removeEventListener("click", onClick, true);
|
||||
};
|
||||
}, [ref]);
|
||||
}
|
||||
|
||||
export function scrollIntoViewHandler (params?: ScrollIntoViewOptions)
|
||||
{
|
||||
return (focusKey: string, node: HTMLElement, details: any) => node.scrollIntoView({ ...params, behavior: details.instant ? 'instant' : 'smooth' });
|
||||
}
|
||||
|
||||
export function useStickyDataAttr<T extends HTMLElement, T2 extends HTMLElement, T3 extends HTMLElement> (ref: RefObject<T | null>, sentinelRef: RefObject<T2 | null>, scrollRef: RefObject<T3 | null>)
|
||||
{
|
||||
useEffect(() =>
|
||||
{
|
||||
const el = ref.current;
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!el || !sentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) =>
|
||||
{
|
||||
el.toggleAttribute("data-stuck", !entry.isIntersecting);
|
||||
},
|
||||
{
|
||||
root: scrollRef.current ?? null,
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [scrollRef.current]);
|
||||
}
|
||||
|
||||
type ExtractField<T, TYPE, K extends string> =
|
||||
T extends { type: TYPE; } & Record<K, infer V> ? V : never;
|
||||
|
||||
type JobResponse<JOB extends keyof JobsAPIType['~Routes']['api']['jobs']> =
|
||||
JobsAPIType['~Routes']['api']['jobs'][JOB]['subscribe']['response'][200];
|
||||
|
||||
export function useJobStatus<const JOB extends keyof JobsAPIType['~Routes']['api']['jobs']> (
|
||||
id: JOB,
|
||||
init?: {
|
||||
onProgress?: (process: number) => void,
|
||||
onEnded?: () => void;
|
||||
}
|
||||
)
|
||||
{
|
||||
type Response = JobResponse<JOB>;
|
||||
type DataPayload = ExtractField<Response, 'data' | 'progress' | 'started', 'data'>;
|
||||
|
||||
const ref = useRef<ReturnType<typeof jobsApi.api.jobs[JOB]['subscribe']>>(null);
|
||||
const [data, setData] = useState<DataPayload>();
|
||||
const [status, setStatus] = useState<string>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const sub = jobsApi.api.jobs[id].subscribe();
|
||||
ref.current = sub as any;
|
||||
|
||||
sub.subscribe(({ data }) =>
|
||||
{
|
||||
switch (data.type)
|
||||
{
|
||||
case 'error':
|
||||
setError(data.error);
|
||||
setStatus(status);
|
||||
setData(undefined);
|
||||
break;
|
||||
case 'ended':
|
||||
init?.onEnded?.();
|
||||
case 'completed':
|
||||
setStatus(status);
|
||||
setData(undefined);
|
||||
break;
|
||||
default:
|
||||
setData(data.data as DataPayload);
|
||||
setStatus(status);
|
||||
init?.onProgress?.(data.progress);
|
||||
}
|
||||
});
|
||||
|
||||
return () =>
|
||||
{
|
||||
sub.close();
|
||||
ref.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, status, error, wsRef: ref };
|
||||
}
|
||||
|
||||
|
|
|
|||
8
src/mainview/types.d.ts
vendored
8
src/mainview/types.d.ts
vendored
|
|
@ -1,4 +1,5 @@
|
|||
declare const __HOST__: string;
|
||||
declare const __PUBLIC__: boolean;
|
||||
declare const __EMULATORS__: Record<string, string>;
|
||||
declare module "@emulators" {
|
||||
const data: Record<string, string>;
|
||||
|
|
@ -19,15 +20,16 @@ global
|
|||
|
||||
interface FocusParams
|
||||
{
|
||||
onFocus?: () => void;
|
||||
onFocus?: (focusKey: string, node: HTMLElement, details: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
interface InteractParams
|
||||
{
|
||||
onAction?: () => void;
|
||||
onAction?: (e?: Event) => void;
|
||||
}
|
||||
|
||||
interface FilterOption extends FocusParams, InteractParams
|
||||
{
|
||||
label: string;
|
||||
}
|
||||
selected: boolean;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue