feat: Implemented launching and downloading of roms
This is just an initial implementation lots of kings to iron out
This commit is contained in:
parent
ef08fa6114
commit
f15bf9a1e0
117 changed files with 37776 additions and 1073 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import React, { createContext, Ref, useContext, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import React, { createContext, JSX, Ref, useContext, useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
|
||||
|
|
@ -13,15 +14,23 @@ export function AnimatedBackground (data: {
|
|||
animated?: boolean,
|
||||
})
|
||||
{
|
||||
const [lastBackgroundUrl, setLastBackgroundUrl] = data.backgroundUrl ? useSessionStorage<string | undefined>(
|
||||
const blurBackground = true;
|
||||
const animateBackground = true;
|
||||
|
||||
const [lastBackgroundUrl, setLastBackgroundUrl] = data.backgroundKey ? useSessionStorage<string | undefined>(
|
||||
`${data.backgroundKey!}-last`,
|
||||
data.backgroundUrl,
|
||||
) : useState<string | undefined>();
|
||||
|
||||
const [backgroundUrl, setBackgroundUrl] = data.backgroundUrl ? useSessionStorage<string | undefined>(
|
||||
const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ? useSessionStorage<string | undefined>(
|
||||
data.backgroundKey!,
|
||||
data.backgroundUrl,
|
||||
) : useState(data.backgroundUrl);
|
||||
) : useState<string | undefined>();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setBackgroundUrl(data.backgroundUrl);
|
||||
}, [data.backgroundUrl]);
|
||||
|
||||
function handleSetBackground (url: string)
|
||||
{
|
||||
|
|
@ -36,6 +45,20 @@ export function AnimatedBackground (data: {
|
|||
color-mix(in srgb, var(--color-base-100) 80%, transparent)
|
||||
), url('${url}') center / cover`;
|
||||
|
||||
let backgroundElements: JSX.Element | undefined = undefined;
|
||||
if (true)
|
||||
{
|
||||
backgroundElements = <div id="container">
|
||||
<div id="container-inside">
|
||||
<div className={bgColor} id="circle-small"></div>
|
||||
<div className={bgColor} id="circle-medium"></div>
|
||||
<div className={bgColor} id="circle-large"></div>
|
||||
<div className={bgColor} id="circle-xlarge"></div>
|
||||
<div className={bgColor} id="circle-xxlarge"></div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedBackgroundContext value={{ setBackground: handleSetBackground }}>
|
||||
<div ref={data.ref}
|
||||
|
|
@ -43,17 +66,9 @@ export function AnimatedBackground (data: {
|
|||
>
|
||||
{!!lastBackgroundUrl && <div className='absolute w-full h-full' style={{ background: backgroundStyle(lastBackgroundUrl), zIndex: -4 }}></div>}
|
||||
{!!backgroundUrl && <div key={backgroundUrl} className='absolute w-full h-full animate__animated animate__fadeIn' style={{ background: backgroundStyle(backgroundUrl), zIndex: -3 }}></div>}
|
||||
<div className="absolute w-full h-full backdrop-blur-3xl" style={{ zIndex: -2 }}></div>
|
||||
{data.animated && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}>
|
||||
<div id="container">
|
||||
<div id="container-inside">
|
||||
<div className={bgColor} id="circle-small"></div>
|
||||
<div className={bgColor} id="circle-medium"></div>
|
||||
<div className={bgColor} id="circle-large"></div>
|
||||
<div className={bgColor} id="circle-xlarge"></div>
|
||||
<div className={bgColor} id="circle-xxlarge"></div>
|
||||
</div>
|
||||
</div>
|
||||
{blurBackground && <div className={"absolute w-full h-full backdrop-blur-3xl"} style={{ zIndex: -2 }}></div>}
|
||||
{data.animated && animateBackground && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}>
|
||||
{backgroundElements}
|
||||
</div>}
|
||||
{data.children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,17 +3,16 @@ import
|
|||
FocusContext,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { GameMeta } from "../../shared/constants";
|
||||
import GameCard, { GameCardSkeleton } from "./GameCard";
|
||||
import { JSX, useEffect, useMemo, useState } from "react";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import { useScrollSave } from "../scripts/utils";
|
||||
import { FrontEndId, GameMeta } from "../../shared/constants";
|
||||
import GameCard, { GameCardParams } from "./GameCard";
|
||||
import { JSX, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export interface GameMetaExtra extends GameMeta
|
||||
{
|
||||
preview?: JSX.Element;
|
||||
badge?: JSX.Element;
|
||||
preview?: GameCardParams['preview'];
|
||||
badges?: JSX.Element[];
|
||||
focusKey: string;
|
||||
}
|
||||
|
||||
|
|
@ -22,8 +21,9 @@ export function CardList (data: {
|
|||
type?: string;
|
||||
games: GameMetaExtra[];
|
||||
grid?: boolean;
|
||||
onSelectGame?: (id: number) => void;
|
||||
onGameFocus?: (id: number) => void;
|
||||
onSelectGame?: (id: string) => void;
|
||||
onGameFocus?: (id: string) => void;
|
||||
className?: string;
|
||||
})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
|
|
@ -32,7 +32,7 @@ export function CardList (data: {
|
|||
|
||||
function BuildGame (g: GameMetaExtra, i: number)
|
||||
{
|
||||
let preview: JSX.Element | string | undefined = g.preview;
|
||||
let preview: GameCardParams['preview'] = g.preview;
|
||||
if (!preview && g.previewUrl)
|
||||
{
|
||||
preview = g.previewUrl;
|
||||
|
|
@ -48,11 +48,17 @@ export function CardList (data: {
|
|||
subtitle={g.subtitle ?? ""}
|
||||
onFocus={() =>
|
||||
{
|
||||
g.onFocus?.();
|
||||
data.onGameFocus?.(g.id);
|
||||
(document.querySelector(":root") as HTMLElement).style.setProperty('--selected-card-offset', `${i}s`);
|
||||
}}
|
||||
onAction={() =>
|
||||
{
|
||||
g.onSelect?.();
|
||||
data.onSelectGame?.(g.id);
|
||||
}}
|
||||
onAction={() => data.onSelectGame?.(g.id)}
|
||||
preview={preview}
|
||||
badge={g.badge}
|
||||
badges={g.badges}
|
||||
id={g.id}
|
||||
/>
|
||||
);
|
||||
|
|
@ -64,8 +70,9 @@ export function CardList (data: {
|
|||
id={`card-list-${data.id}`}
|
||||
ref={ref}
|
||||
save-child-focus="session"
|
||||
className={classNames("my-6 items-center justify-center-safe h-(--game-card-height) ",
|
||||
data.grid ? "card-grid h-fit gap-5" : 'card-list gap-6'
|
||||
className={twMerge("my-6 items-center justify-center-safe h-(--game-card-height) ",
|
||||
data.grid ? "card-grid h-fit gap-5" : 'card-list gap-6',
|
||||
data.className
|
||||
)}
|
||||
onKeyDown={(e) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga
|
|||
import { HeaderUI } from './Header';
|
||||
import { GameList, GameListFilter } from './GameList';
|
||||
import { Search, Settings2 } from 'lucide-react';
|
||||
import ShortcutPrompt from './ShortcutPrompt';
|
||||
import { selfFocusSmart } from '../scripts/utils';
|
||||
import { JSX, Suspense, useEffect, useState } from 'react';
|
||||
import { JSX, Suspense } from 'react';
|
||||
import Shortcuts from './Shortcuts';
|
||||
import { AutoFocus } from './AutoFocus';
|
||||
|
||||
|
|
@ -21,7 +19,7 @@ export interface CollectionsDetailParams
|
|||
|
||||
export function CollectionsDetail (data: CollectionsDetailParams)
|
||||
{
|
||||
const focusKey = `game-list-${data.id}-${data.filters.platformIds?.join()}-${data.filters.collectionId}`;
|
||||
const focusKey = `game-list-${data.id}-${data.filters.platformId}-${data.filters.collectionId}`;
|
||||
const { ref, focusSelf } = useFocusable({
|
||||
focusKey,
|
||||
preferredChildFocusKey: `${focusKey}-list`,
|
||||
|
|
@ -46,7 +44,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
<div>
|
||||
{data.footer}
|
||||
</div>
|
||||
<Shortcuts />
|
||||
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_b', label: 'Back' }]} />
|
||||
</footer>
|
||||
</AnimatedBackground>
|
||||
</FocusContext>
|
||||
|
|
|
|||
107
src/mainview/components/ContextDialog.tsx
Normal file
107
src/mainview/components/ContextDialog.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { createContext, JSX, useContext, useEffect } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
const ContextDialogContext = createContext({} as {
|
||||
close: () => void,
|
||||
id: string;
|
||||
});
|
||||
|
||||
export function ContextList (data: { options: DialogEntry[]; className?: string; showCloseButton?: boolean; })
|
||||
{
|
||||
const context = useContext(ContextDialogContext);
|
||||
return <ul className={twMerge("list max-h-[70vh] overflow-y-auto", data.className)}>
|
||||
{data.options.map(o => <OptionElement className="list-row" key={o.id} {...o} />)}
|
||||
{data.showCloseButton !== false && <OptionElement className="list-row" type='accent' icon={<X />} action={context.close} id="close" content="Close" />}
|
||||
</ul>;
|
||||
}
|
||||
|
||||
export function OptionElement (data: DialogEntry & { onFocus?: () => void; className?: string; })
|
||||
{
|
||||
const context = useContext(ContextDialogContext);
|
||||
const handleFocus = () =>
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
data.onFocus?.();
|
||||
};
|
||||
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: `${context.id}-list-option-${data.id}`,
|
||||
onEnterPress: handleAction,
|
||||
onFocus: handleFocus
|
||||
});
|
||||
const colors = {
|
||||
primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused }),
|
||||
secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused }),
|
||||
accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused }),
|
||||
info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused }),
|
||||
warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused }),
|
||||
error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused })
|
||||
};
|
||||
return <li ref={ref}
|
||||
onClick={handleAction}
|
||||
className={
|
||||
twMerge("flex cursor-pointer")}>
|
||||
<p className={twMerge("flex w-full h-14 items-center px-5 rounded-2xl transition-all gap-2",
|
||||
colors[data.type],
|
||||
classNames({ "font-semibold": focused }),
|
||||
data.className)}>
|
||||
{data.icon}
|
||||
{data.content}
|
||||
</p>
|
||||
</li>;
|
||||
}
|
||||
|
||||
export interface DialogEntry
|
||||
{
|
||||
id: string,
|
||||
content: string | JSX.Element;
|
||||
icon?: string | JSX.Element;
|
||||
type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error';
|
||||
action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void;
|
||||
}
|
||||
|
||||
export function ContextDialog (data: { id: string, children: any | any[], open: boolean, close: () => void; })
|
||||
{
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusable: data.open, focusKey: `${data.id}-context-dialog`, isFocusBoundary: true });
|
||||
useEffect(() =>
|
||||
{
|
||||
if (data.open)
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
}, [data.open]);
|
||||
|
||||
useEventListener('cancel', (e) =>
|
||||
{
|
||||
if (data.open)
|
||||
{
|
||||
e.stopPropagation();
|
||||
data.close();
|
||||
}
|
||||
}, ref);
|
||||
|
||||
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",
|
||||
classNames({ "opacity-0": !data.open }))
|
||||
}
|
||||
onClick={() =>
|
||||
{
|
||||
if (data.open) data.close();
|
||||
}}>
|
||||
<FocusContext value={focusKey}>
|
||||
<ContextDialogContext value={{ id: data.id, close: data.close }} >
|
||||
<div
|
||||
className={twMerge("bg-base-100/80 delay-200 rounded-4xl p-6 min-w-[30vw] cursor-auto", data.open ? "animate-scale-delayed" : "opacity-0")}
|
||||
style={{ backdropFilter: 'blur(24px)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{data.children}
|
||||
</div>
|
||||
</ContextDialogContext>
|
||||
</FocusContext>
|
||||
</dialog>;
|
||||
}
|
||||
|
|
@ -27,10 +27,10 @@ function FilterCat (
|
|||
className={classNames(
|
||||
"flex px-4 h-12 items-center justify-center rounded-full transition-all",
|
||||
{
|
||||
"bg-primary text-primary-content drop-shadow-sm cursor-default":
|
||||
"bg-base-content px-3 text-base-300 drop-shadow cursor-default":
|
||||
focused || data.active,
|
||||
"ring-base-content ring-7": focused,
|
||||
"hover:bg-base-300 cursor-pointer": !focused,
|
||||
"ring-primary ring-7": focused,
|
||||
"hover:bg-base-content/40 cursor-pointer": !focused,
|
||||
},
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -16,23 +16,30 @@ export function GameCardSkeleton ()
|
|||
);
|
||||
}
|
||||
|
||||
export default function GameCard (data: {
|
||||
export interface GameCardParams
|
||||
{
|
||||
title: string;
|
||||
type?: string;
|
||||
subtitle: string;
|
||||
preview?: string | JSX.Element;
|
||||
subtitle: string | JSX.Element;
|
||||
preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element);
|
||||
focusKey: string;
|
||||
index: number;
|
||||
id: number;
|
||||
badge?: JSX.Element;
|
||||
onFocus?: (id: number) => void;
|
||||
id: string;
|
||||
badges?: JSX.Element[];
|
||||
className?: string;
|
||||
onFocus?: (id: string) => void;
|
||||
onBlur?: (id: string) => void;
|
||||
onAction?: () => void;
|
||||
})
|
||||
clickFocuses?: boolean;
|
||||
}
|
||||
|
||||
export default function GameCard (data: GameCardParams)
|
||||
{
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: data.focusKey,
|
||||
onFocus: () => data.onFocus?.(data.id),
|
||||
onEnterPress: () => data.onAction?.(),
|
||||
onBlur: () => data.onBlur?.(data.id)
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
|
|
@ -59,33 +66,48 @@ export default function GameCard (data: {
|
|||
}}
|
||||
onFocus={focusSelf}
|
||||
onDoubleClick={data.onAction}
|
||||
onClick={focused ? data.onAction : focusSelf}
|
||||
onClick={() =>
|
||||
{
|
||||
focusSelf();
|
||||
data.onAction?.();
|
||||
}}
|
||||
className={twMerge(
|
||||
`game-card game-card-height flex flex-col justify-end`,
|
||||
`game-card game-card-height flex flex-col justify-end z-5`,
|
||||
'max-h-(--game-card-height) min-w-(--game-card-width) w-(--game-card-width)',
|
||||
"overflow-hidden transition-all duration-200 drop-shadow-lg cursor-pointer",
|
||||
focused ?
|
||||
`animate-wiggle ring-7 bg-base-content text-base-300 ring-primary drop-shadow-xl drop-shadow-base-300/60 scale-102 z-10` :
|
||||
"bg-base-300 text-base-content",
|
||||
`focused animate-wiggle ring-7 bg-base-content text-base-300 ring-primary drop-shadow-xl drop-shadow-black/30 scale-102 z-10` :
|
||||
"bg-base-300 hover:bg-base-100 hover:scale-102 text-base-content",
|
||||
classNames({
|
||||
"h-(--game-card-height)": typeof data.preview === "string"
|
||||
})
|
||||
}),
|
||||
data.className
|
||||
)}
|
||||
>
|
||||
<div className={twMerge("overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all", focused ? "mt-2 mx-2" : "mt-2 mx-2")}>
|
||||
{typeof data.preview === "string" ? (
|
||||
<img src={data.preview}></img>
|
||||
<img width={5192} height={5192} className={classNames({ "animate-rotate-small": focused })} src={data.preview} ></img>
|
||||
) : (
|
||||
data.preview
|
||||
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
|
||||
)}</div>
|
||||
|
||||
<div className="h-0 flex pr-2 justify-end items-center">{data.badge}</div>
|
||||
<div className="h-0 flex pr-2 justify-end items-center">
|
||||
{data.badges?.map(b =>
|
||||
<div
|
||||
className={
|
||||
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 mr-4 transition-colors",
|
||||
classNames({ "bg-primary text-primary-content": focused }))}
|
||||
>
|
||||
{b}
|
||||
</div>)
|
||||
}
|
||||
</div>
|
||||
<div className="flex flex-col p-4">
|
||||
<div className="text-xl font-bold text-nowrap text-ellipsis overflow-hidden">
|
||||
{data.title}
|
||||
</div>
|
||||
<div className="text-s">{data.subtitle}</div>
|
||||
</div>
|
||||
</li>
|
||||
</li >
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { keepPreviousData, useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { getRomsApiRomsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { GameMetaExtra, CardList } from "./CardList";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||
import { FrontEndId, RPC_URL } from "../../shared/constants";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { gamesQueryOptions } from "../query-options";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { JSX } from "react";
|
||||
|
||||
export interface GameListFilter
|
||||
{
|
||||
platformIds?: number[];
|
||||
platformId?: number;
|
||||
collectionId?: number;
|
||||
}
|
||||
|
||||
|
|
@ -19,30 +19,39 @@ export interface GameListParams
|
|||
filters?: GameListFilter,
|
||||
grid?: boolean,
|
||||
setBackground?: (url: string) => void;
|
||||
onGameSelect?: (id: number) => void;
|
||||
onGameSelect?: (id: FrontEndId) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GameList (data: GameListParams)
|
||||
{
|
||||
const games = useSuspenseQuery(gamesQueryOptions(data.filters));
|
||||
const games = useSuspenseQuery({
|
||||
queryKey: ['games', data.filters ?? 'all'],
|
||||
queryFn: () => rommApi.api.romm.games.get({
|
||||
query: {
|
||||
platform_id: data.filters?.platformId,
|
||||
collection_id: data.filters?.collectionId
|
||||
}
|
||||
}).then(d => d.data)
|
||||
});
|
||||
const navigator = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleFocus = (id: number) =>
|
||||
const handleFocus = (id: FrontEndId) =>
|
||||
{
|
||||
const game = games.data?.items.find((g) => g.id === id);
|
||||
const game = games.data?.games.find((g) => g.id === id);
|
||||
if (game)
|
||||
{
|
||||
data.setBackground?.(
|
||||
`${RPC_URL(__HOST__)}/api/romm${game.path_cover_small}`,
|
||||
`${RPC_URL(__HOST__)}${game.path_cover}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function handleDefaultSelect (id: number)
|
||||
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: number | null)
|
||||
{
|
||||
SaveSource('details', location.pathname);
|
||||
navigator({ to: '/game/$id', params: { id: String(id) }, viewTransition: { types: ['zoom-in'] } });
|
||||
SaveSource('details');
|
||||
navigator({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -51,23 +60,34 @@ export function GameList (data: GameListParams)
|
|||
id={data.id}
|
||||
type="game"
|
||||
grid={data.grid}
|
||||
games={games.data.items.sort(
|
||||
(a, b) =>
|
||||
Date.parse(b.rom_user.last_played ?? b.updated_at) -
|
||||
Date.parse(a.rom_user.last_played ?? a.updated_at),
|
||||
)
|
||||
className={data.className}
|
||||
games={games.data?.games
|
||||
.map(
|
||||
(g) =>
|
||||
({
|
||||
id: g.id,
|
||||
{
|
||||
const badges: JSX.Element[] = [];
|
||||
if (g.id.source === 'local')
|
||||
{
|
||||
badges.push(<HardDrive className="size-8 m-1" />);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `game-${g.id.source}-${g.id.id}`,
|
||||
focusKey: g.slug ?? `game-${g.id}`,
|
||||
title: g.name ?? "",
|
||||
subtitle: g.platform_display_name ?? "",
|
||||
previewUrl: `${RPC_URL(__HOST__)}/api/romm${g.path_cover_large}`,
|
||||
}) satisfies GameMetaExtra,
|
||||
)}
|
||||
onGameFocus={handleFocus}
|
||||
onSelectGame={id => data.onGameSelect ? data.onGameSelect(id) : handleDefaultSelect(id)}
|
||||
subtitle: (
|
||||
<div className="flex gap-1 items-center">
|
||||
{!!g.path_platform_cover && <img className="size-4" src={`${RPC_URL(__HOST__)}${g.path_platform_cover}`} />}
|
||||
<p className="opacity-80">{g.platform_display_name}</p>
|
||||
</div>
|
||||
),
|
||||
previewUrl: `${RPC_URL(__HOST__)}${g.path_cover}`,
|
||||
badges: badges,
|
||||
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id) : handleDefaultSelect(g.id, g.source, g.source_id),
|
||||
onFocus: () => handleFocus(g.id)
|
||||
} satisfies GameMetaExtra;
|
||||
},
|
||||
) ?? []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ function HeaderAvatar (data: {
|
|||
id={data.id}
|
||||
ref={ref}
|
||||
onClick={data.onSelect}
|
||||
style={{ viewTransitionName: data.id }}
|
||||
className={classNames(
|
||||
`avatar indicator ring-base-100 ring-offset-base-100 size-14 rounded-full flex items-center justify-center`,
|
||||
bgColors[data.type ?? "none"],
|
||||
|
|
@ -92,6 +91,7 @@ export interface HeaderButton
|
|||
id: string;
|
||||
icon: JSX.Element;
|
||||
external?: boolean;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export interface HeaderAccount
|
||||
|
|
@ -135,7 +135,7 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
|
|||
],
|
||||
action: () =>
|
||||
{
|
||||
SaveSource('settings', location.pathname);
|
||||
SaveSource('settings');
|
||||
navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } });
|
||||
},
|
||||
status: user.data ? "status-success" : 'status-error',
|
||||
|
|
@ -182,6 +182,7 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
|
|||
id={b.id}
|
||||
icon={b.icon}
|
||||
external={b.external}
|
||||
action={b.action}
|
||||
/>)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
72
src/mainview/components/PlatformsList.tsx
Normal file
72
src/mainview/components/PlatformsList.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { getPlatformsApiPlatformsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
|
||||
import { DefaultRommStaleTime, GameMeta, RPC_URL } from "../../shared/constants";
|
||||
import { CardList, GameMetaExtra } from "./CardList";
|
||||
import classNames from "classnames";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
|
||||
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; })
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { data: platforms } = useSuspenseQuery(
|
||||
{
|
||||
queryKey: ['platform', 'all'],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.platforms.get();
|
||||
if (error) throw error;
|
||||
return data.platforms;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: DefaultRommStaleTime,
|
||||
});
|
||||
|
||||
return (
|
||||
<CardList
|
||||
type="platform"
|
||||
id={data.id}
|
||||
className={data.className}
|
||||
games={platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
|
||||
.map((g) => ({
|
||||
id: g.slug,
|
||||
focusKey: g.slug,
|
||||
title: g.name,
|
||||
subtitle: g.family_name ?? "",
|
||||
previewUrl: "",
|
||||
badges: [(<span className="text-lg font-bold p-2 rounded-full">
|
||||
{g.game_count}
|
||||
</span>)],
|
||||
onFocus: () => data.setBackground(
|
||||
`https://picsum.photos/id/${10 + g.slug.length}/1920/1080.webp`,
|
||||
),
|
||||
onSelect: () =>
|
||||
{
|
||||
navigate({ to: `/platform/${g.source ?? g.id.source}/${g.source_id ?? g.id.id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
},
|
||||
preview:
|
||||
({ focused }) => <div
|
||||
className="flex h-60 p-6 bg-base-100 justify-center"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
color-mix(in srgb, var(--color-base-content) 60%, transparent),
|
||||
color-mix(in srgb, var(--color-base-300) 60%, transparent)
|
||||
), url(https://picsum.photos/id/${8 + g.slug.length}/300/300.webp?blur=10) center / cover`,
|
||||
|
||||
backgroundBlendMode: "screen",
|
||||
boxShadow: 'inset 0 0 32px rgba(0,0,0,0.6)'
|
||||
}}
|
||||
>
|
||||
<img className={classNames("drop-shadow-2xl", { "animate-rotate": focused })}
|
||||
src={`${RPC_URL(__HOST__)}${g.path_cover}`}
|
||||
></img>
|
||||
</div>
|
||||
,
|
||||
} satisfies GameMetaExtra))}
|
||||
onSelectGame={(id) =>
|
||||
{
|
||||
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ export default function ShortcutPrompt (data: {
|
|||
<span
|
||||
onClick={data.onClick}
|
||||
className={twMerge(
|
||||
"flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm",
|
||||
"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",
|
||||
"sm:text-sm",
|
||||
data.className,
|
||||
classNames({
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import React from 'react';
|
||||
import ShortcutPrompt from './ShortcutPrompt';
|
||||
import { IconType } from './SvgIcon';
|
||||
|
||||
export default function Shortcuts ()
|
||||
export interface Shortcut
|
||||
{
|
||||
icon: IconType;
|
||||
label: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export default function Shortcuts (data: { shortcuts: Shortcut[]; })
|
||||
{
|
||||
return (
|
||||
<div style={{ viewTransitionName: 'shortcuts' }} className="flex gap-2">
|
||||
<ShortcutPrompt icon="steamdeck_button_a" label="Continue" />
|
||||
<ShortcutPrompt icon="steamdeck_button_b" label="Back" />
|
||||
<ShortcutPrompt icon="steamdeck_button_x" label="Close" />
|
||||
<ShortcutPrompt icon="steamdeck_button_y" label="Options" />
|
||||
{data.shortcuts.map((s, i) => <ShortcutPrompt key={i} onClick={s.action} icon={s.icon} label={s.label} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
29
src/mainview/components/backgrounds/dots.css
Normal file
29
src/mainview/components/backgrounds/dots.css
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
.ball {
|
||||
border-radius: 50%;
|
||||
animation: bounce 0.6s 32 alternate;
|
||||
}
|
||||
|
||||
.ball:nth-child(1) {
|
||||
background: var(--color-accent);
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.ball:nth-child(2) {
|
||||
background: var(--color-secondary);
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.ball:nth-child(3) {
|
||||
background: var(--color-primary);
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
}
|
||||
10
src/mainview/components/backgrounds/dots.tsx
Normal file
10
src/mainview/components/backgrounds/dots.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import './dots.css';
|
||||
|
||||
export default function DotsLoading ()
|
||||
{
|
||||
return <div className="flex gap-3 justify-center animation_alternate items-center pt-8">
|
||||
<div className="ball size-6"></div>
|
||||
<div className="ball size-6"></div>
|
||||
<div className="ball size-6"></div>
|
||||
</div>;
|
||||
}
|
||||
28
src/mainview/components/options/Button.tsx
Normal file
28
src/mainview/components/options/Button.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import
|
||||
{
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
|
||||
export function Button (data: { id: string, children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: data.id,
|
||||
onEnterPress: data.onAction,
|
||||
onFocus: data.onFocus,
|
||||
focusable: !data.disabled
|
||||
});
|
||||
return <button
|
||||
ref={ref}
|
||||
onClick={data.onAction}
|
||||
disabled={data.disabled}
|
||||
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg", classNames({
|
||||
"btn-accent": focused
|
||||
}, data.className))}
|
||||
type={data.type}
|
||||
>
|
||||
{data.children}
|
||||
</button>;
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import classNames from "classnames";
|
||||
import { ChangeEventHandler, FocusEventHandler, HTMLInputTypeAttribute, JSX, useRef } from "react";
|
||||
import { ChangeEventHandler, 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";
|
||||
|
||||
export function OptionInput (data: {
|
||||
name: string;
|
||||
|
|
@ -11,10 +13,18 @@ export function OptionInput (data: {
|
|||
icon?: JSX.Element;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
autocomplete?: HTMLInputAutoCompleteAttribute;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
})
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: data.name, onEnterPress: () =>
|
||||
{
|
||||
inputRef.current?.focus();
|
||||
systemApi.api.system.show_keyboard.post();
|
||||
}
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const option = useOptionContext({
|
||||
onOptionEnterPress ()
|
||||
|
|
@ -24,10 +34,11 @@ export function OptionInput (data: {
|
|||
});
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent">
|
||||
<span className={twMerge("text-base-content/80", classNames({
|
||||
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
|
||||
classNames({ "[&_input]:not-focus:ring-7 [&_input]:not-focus:ring-accent": focused }))}>
|
||||
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
|
||||
"text-primary-content": option.focused
|
||||
}))}>{data.icon}</span>
|
||||
}))}>{data.icon}</span>}
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={data.name}
|
||||
|
|
@ -35,12 +46,13 @@ export function OptionInput (data: {
|
|||
value={data.value}
|
||||
defaultValue={data.defaultValue}
|
||||
type={data.type}
|
||||
autoComplete={data.autocomplete}
|
||||
onFocus={() => option.focus()}
|
||||
placeholder={data.placeholder}
|
||||
onChange={data.onChange}
|
||||
onBlur={data.onBlur}
|
||||
className={twMerge(
|
||||
"input grow rounded-full ring-primary-content focus:ring-3",
|
||||
"input grow rounded-full ring-primary-content focus:ring-7",
|
||||
data.className,
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -42,8 +42,9 @@ export function OptionSpace (data: {
|
|||
id?: string;
|
||||
className?: string;
|
||||
focusable?: boolean;
|
||||
children: JSX.Element;
|
||||
children?: any | any[];
|
||||
label?: string | JSX.Element;
|
||||
saveLastFocusedChild?: boolean;
|
||||
})
|
||||
{
|
||||
const eventTarget = useMemo(() => new EventTarget(), []);
|
||||
|
|
@ -51,6 +52,11 @@ export function OptionSpace (data: {
|
|||
focusKey: data.id,
|
||||
focusable: data.focusable !== false,
|
||||
trackChildren: true,
|
||||
saveLastFocusedChild: data.saveLastFocusedChild ?? false,
|
||||
onFocus ()
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
},
|
||||
onEnterPress ()
|
||||
{
|
||||
eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));
|
||||
|
|
|
|||
72
src/mainview/components/options/SettingsOption.tsx
Normal file
72
src/mainview/components/options/SettingsOption.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { HTMLInputTypeAttribute, JSX, useCallback, useState } from "react";
|
||||
import { SettingsType } from "../../../shared/constants";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { OptionSpace } from "./OptionSpace";
|
||||
import { OptionInput } from "./OptionInput";
|
||||
import { settingsApi } from "../../scripts/clientApi";
|
||||
|
||||
type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
export function SettingsOption (data: {
|
||||
label: string;
|
||||
id: KeysWithValueAssignableTo<SettingsType, string>;
|
||||
type: HTMLInputTypeAttribute;
|
||||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
})
|
||||
{
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
useQuery({
|
||||
enabled: !!data.id,
|
||||
queryKey: ["setting", data.id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data: value, error } = await settingsApi.api.settings({ id: data.id! }).get();
|
||||
if (error) throw error;
|
||||
if (!dirty)
|
||||
{
|
||||
setLocalValue(String(value.value));
|
||||
}
|
||||
return value.value;
|
||||
},
|
||||
});
|
||||
const setSettingMutation = useMutation({
|
||||
mutationKey: ["setting", data.id],
|
||||
mutationFn: async (value: any) =>
|
||||
{
|
||||
const response = await settingsApi.api.settings({ id: data.id! }).post({ value });
|
||||
if (response.error) throw response.error;
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
if (dirty)
|
||||
{
|
||||
setDirty(false);
|
||||
setSettingMutation.mutate(localValue);
|
||||
}
|
||||
}, [dirty, setDirty, localValue]);
|
||||
|
||||
return (
|
||||
<OptionSpace label={data.label}>
|
||||
<OptionInput
|
||||
icon={data.icon}
|
||||
name={data.id ?? ""}
|
||||
type={data.type}
|
||||
placeholder={data.placeholder}
|
||||
onBlur={handleSave}
|
||||
onChange={(e) =>
|
||||
{
|
||||
setLocalValue(e.currentTarget.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
value={localValue}
|
||||
/>
|
||||
</OptionSpace>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue