feat: Implemented filtering and searching
This commit is contained in:
parent
4806f3487a
commit
444d8c4c27
49 changed files with 841 additions and 290 deletions
|
|
@ -18,9 +18,7 @@ export function GameCardSkeleton ()
|
|||
);
|
||||
}
|
||||
|
||||
export type GameCardFocusHandler = (id: string, node: HTMLElement, details: FocusDetails) => void;
|
||||
|
||||
export interface GameCardParams
|
||||
export interface GameCardParams extends FocusParams
|
||||
{
|
||||
title: string;
|
||||
subtitle: string | JSX.Element;
|
||||
|
|
@ -31,7 +29,6 @@ export interface GameCardParams
|
|||
id: string;
|
||||
badges?: JSX.Element[];
|
||||
className?: string;
|
||||
onFocus?: GameCardFocusHandler;
|
||||
onBlur?: (id: string) => void;
|
||||
clickFocuses?: boolean;
|
||||
previewClassName?: string;
|
||||
|
|
@ -39,14 +36,14 @@ export interface GameCardParams
|
|||
|
||||
export default function CardElement (data: GameCardParams & InteractParams)
|
||||
{
|
||||
const handleAction = () =>
|
||||
const handleAction = (event?: Event) =>
|
||||
{
|
||||
data.onAction?.();
|
||||
data.onAction?.({ event, focusKey });
|
||||
oneShot('click');
|
||||
};
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
const { ref, focused, focusSelf, focusKey } = useFocusable({
|
||||
focusKey: data.focusKey,
|
||||
onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current as any, details),
|
||||
onFocus: (l, p, details) => data.onFocus?.(focusKey, ref.current as any, details),
|
||||
onEnterPress: handleAction,
|
||||
onBlur: () => data.onBlur?.(data.id),
|
||||
});
|
||||
|
|
@ -63,10 +60,10 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
|||
scrollSnapAlign: isPointer ? "center" : "none"
|
||||
}}
|
||||
onFocus={focusSelf}
|
||||
onClick={() =>
|
||||
onClick={(e) =>
|
||||
{
|
||||
focusSelf();
|
||||
handleAction();
|
||||
handleAction(e.nativeEvent);
|
||||
}}
|
||||
className={twMerge(
|
||||
"relative game-card light:bg-base-100 dark: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-lg 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",
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@ import
|
|||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { GameMeta } from "../../shared/constants";
|
||||
import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement";
|
||||
import CardElement, { GameCardParams } from "./CardElement";
|
||||
import { JSX } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||
import { oneShot } from "../scripts/audio/audio";
|
||||
import { GamepadButtonEvent } from "../scripts/gamepads";
|
||||
|
||||
export interface GameMetaExtra extends GameMeta
|
||||
{
|
||||
|
|
@ -26,13 +25,14 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
|
|||
preview = data.game.previewUrl;
|
||||
}
|
||||
|
||||
const handleAction = (e?: Event) =>
|
||||
const handleAction = (ctx: InteractParamsArgs) =>
|
||||
{
|
||||
data.game.onSelect?.();
|
||||
data.onAction?.();
|
||||
data.onAction?.({ event, focusKey: data.game.focusKey });
|
||||
oneShot('click');
|
||||
};
|
||||
useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]);
|
||||
|
||||
useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]);
|
||||
|
||||
return (
|
||||
<CardElement
|
||||
|
|
@ -42,10 +42,10 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
|
|||
title={data.game.title}
|
||||
subtitle={data.game.subtitle ?? ""}
|
||||
srcset={data.game.previewSrcset}
|
||||
onFocus={(id, node, details) =>
|
||||
onFocus={(focusKey, node, details) =>
|
||||
{
|
||||
data.game.onFocus?.(details);
|
||||
data.onFocus?.(id, node, details);
|
||||
data.game.onFocus?.(focusKey, node, details);
|
||||
data.onFocus?.(focusKey, node, details);
|
||||
}}
|
||||
onAction={handleAction}
|
||||
preview={preview}
|
||||
|
|
@ -61,16 +61,18 @@ export function CardList (data: {
|
|||
games: GameMetaExtra[];
|
||||
grid?: boolean;
|
||||
onSelectGame?: (id: string) => void;
|
||||
onGameFocus?: GameCardFocusHandler;
|
||||
focus?: string;
|
||||
className?: string;
|
||||
finalElement?: JSX.Element;
|
||||
saveChildFocus?: 'session' | 'local';
|
||||
})
|
||||
} & FocusParams)
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: data.id,
|
||||
forceFocus: true,
|
||||
autoRestoreFocus: true
|
||||
autoRestoreFocus: true,
|
||||
focusable: data.games.length > 0,
|
||||
preferredChildFocusKey: data.focus
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -92,7 +94,7 @@ export function CardList (data: {
|
|||
>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
{data.games.map((g, i) => <LocalCardElement
|
||||
key={g.id} onFocus={data.onGameFocus} game={g} onAction={() => data.onSelectGame?.(g.id)} i={i} />)}
|
||||
key={g.id} onFocus={data.onFocus} game={g} onAction={() => data.onSelectGame?.(g.id)} i={i} />)}
|
||||
{data.finalElement}
|
||||
</FocusContext.Provider>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { RPC_URL } from "@/shared/constants";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { CardList, GameMetaExtra } from "./CardList";
|
||||
import { GameCardFocusHandler } from "./CardElement";
|
||||
import { getCollectionsQuery } from "@queries/romm";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,44 +1,50 @@
|
|||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { StickyHeaderUI } from './Header';
|
||||
import { HeaderButton, StickyHeaderUI } from './Header';
|
||||
import { GameList } from './GameList';
|
||||
import { Search, Settings2 } from 'lucide-react';
|
||||
import { JSX, Suspense } from 'react';
|
||||
import { ArrowDownAz, CalendarArrowDown, ClockArrowDown, Drama, Filter, FunnelX, HardDrive, Rocket, Search, Settings2, SortDesc, Store, Tags, User, UserLock } from 'lucide-react';
|
||||
import { JSX, Suspense, useRef, useState } from 'react';
|
||||
import { FloatingShortcuts } from './Shortcuts';
|
||||
import { AutoFocus } from './AutoFocus';
|
||||
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
|
||||
import { GameListFilterType } from '@/shared/constants';
|
||||
import { GameCardFocusHandler } from './CardElement';
|
||||
import { GameListFilterSchema, GameListFilterType } from '@/shared/constants';
|
||||
import { HandleGoBack } from '../scripts/utils';
|
||||
import LoadingCardList from './LoadingCardList';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { gameQuery } from '../scripts/queries/romm';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
import { useNavigate, useRouter } from '@tanstack/react-router';
|
||||
import SelectMenu from './SelectMenu';
|
||||
import { RoundButton } from './RoundButton';
|
||||
import { ContextList, DialogEntry, useContextDialog } from './ContextDialog';
|
||||
import classNames from 'classnames';
|
||||
import { sourceIconMap } from './Constants';
|
||||
import { stat } from 'fs-extra';
|
||||
import { FilterUI } from './Filters';
|
||||
import SideFilters from './SideFilters';
|
||||
|
||||
export interface CollectionsDetailParams
|
||||
{
|
||||
id?: string;
|
||||
setBackground?: (url: string) => void;
|
||||
filters?: GameListFilterType;
|
||||
builder?: () => Promise<{ filter?: GameListFilterType, title?: JSX.Element; }>;
|
||||
setLocalFilter: (filter: GameListFilterType) => void,
|
||||
localFilter: GameListFilterType,
|
||||
headerTitle?: JSX.Element;
|
||||
headerChildren?: any;
|
||||
title?: JSX.Element;
|
||||
footer?: JSX.Element;
|
||||
focus?: string;
|
||||
countHit?: number;
|
||||
countHint?: number;
|
||||
headerButtons?: HeaderButton[];
|
||||
headerButtonElements?: JSX.Element | JSX.Element[];
|
||||
}
|
||||
|
||||
export function CollectionsDetail (data: CollectionsDetailParams)
|
||||
{
|
||||
const router = useRouter();
|
||||
const builtData = useQuery({
|
||||
queryKey: ['filter', data.id], queryFn: async () =>
|
||||
{
|
||||
return data.builder?.() ?? { filter: data.filters, title: data.title };
|
||||
}
|
||||
});
|
||||
const [filterValues, setFilterValues] = useState<FrontEndFilterLists>();
|
||||
const queryClient = useQueryClient();
|
||||
const focusKey = `game-list-${data.id}-${data?.filters ? Object.values(data?.filters).map(f => String(f)).join(",") : ''}`;
|
||||
const finalFilter = { ...data.localFilter, ...data.filters };
|
||||
const focusKey = `game-list-${data.id}`;
|
||||
const { ref, focusSelf } = useFocusable({
|
||||
focusKey,
|
||||
preferredChildFocusKey: `${focusKey}-list`
|
||||
|
|
@ -46,9 +52,8 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]);
|
||||
|
||||
const handleScroll: GameCardFocusHandler = (cardId, node, details) =>
|
||||
const handleScroll: FocusParams['onFocus'] = (cardId, node, details) =>
|
||||
{
|
||||
|
||||
const [source, id] = cardId.split('@');
|
||||
queryClient.prefetchQuery(gameQuery(source, id));
|
||||
|
||||
|
|
@ -61,22 +66,27 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
return (
|
||||
<FocusContext value={focusKey}>
|
||||
<div ref={ref} className='absolute w-screen h-screen overflow-y-scroll'>
|
||||
<StickyHeaderUI title={data.headerTitle} buttons={[{ id: "search", icon: <Search /> }, { id: "filter", icon: <Settings2 /> }]} ref={ref} />
|
||||
<div className="w-full grow rounded-2xl justify-center mask-alpha sm:portrait:mask-t-from-transparent md:landscape:mask-t-from-transparent mask-t-to-20 mask-t-to-black">
|
||||
<div className="relative h-fit w-full md:px-6 pt-4 pb-32">
|
||||
{builtData.data?.filter && data.title}
|
||||
{(builtData.data?.filter || (!data.filters && !data.builder)) && <Suspense fallback={<LoadingCardList grid placeholderCount={data.countHit ?? 8} id={`${focusKey}-list`} />}>
|
||||
<StickyHeaderUI title={data.headerTitle} buttonElements={data.headerButtonElements} buttons={data.headerButtons} ref={ref} >
|
||||
{data.headerChildren}
|
||||
</StickyHeaderUI>
|
||||
<div className="w-full grow justify-center mask-alpha sm:portrait:mask-t-from-transparent md:landscape:mask-t-from-transparent mask-t-to-20 mask-t-to-black">
|
||||
<div className="relative h-fit w-full md:pr-6 pt-4 pb-32 pl-16">
|
||||
<div className='absolute top-0 bottom-0 left-0 right-0 bg-radial from-base-100 to-base-300 -z-1'></div>
|
||||
<div className='mobile:hidden bg-noise'></div>
|
||||
<div className='mobile:hidden bg-dots'></div>
|
||||
{finalFilter && data.title}
|
||||
{<Suspense fallback={<LoadingCardList grid placeholderCount={data.countHint ?? 8} id={`${focusKey}-list`} />}>
|
||||
<GameList
|
||||
key={`${data.id}-${JSON.stringify(finalFilter)}`}
|
||||
grid
|
||||
filters={builtData.data?.filter}
|
||||
setFilterValues={setFilterValues}
|
||||
filters={finalFilter}
|
||||
onFocus={handleScroll}
|
||||
focus={data.focus}
|
||||
id={`${focusKey}-list`}>
|
||||
</GameList>
|
||||
<AutoFocus parentKey={focusKey} focus={focusSelf} />
|
||||
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={100} />
|
||||
</Suspense>}
|
||||
<div className='absolute top-0 bottom-0 left-0 right-0 bg-radial from-base-100 to-base-300'></div>
|
||||
<div className='mobile:hidden bg-noise z-1'></div>
|
||||
<div className='mobile:hidden bg-dots z-1'></div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="px-2 pb-2 fixed bottom-0 w-full h-12 flex items-center justify-between">
|
||||
|
|
@ -85,6 +95,9 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
</div>
|
||||
<FloatingShortcuts />
|
||||
</footer>
|
||||
<div className='fixed left-2 top-24 bottom-0 sm:w-10 md:w-14'>
|
||||
<SideFilters id='filter-btns' localFilter={data.localFilter} setLocalFilter={data.setLocalFilter} filterValues={filterValues} filters={data.filters} />
|
||||
</div>
|
||||
</div>
|
||||
<SelectMenu rootFocusKey={focusKey} />
|
||||
</FocusContext>
|
||||
|
|
|
|||
7
src/mainview/components/Constants.tsx
Normal file
7
src/mainview/components/Constants.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Gamepad2, HardDrive, Store } from "lucide-react";
|
||||
|
||||
export const sourceIconMap: Record<string, any> = {
|
||||
store: <Store />,
|
||||
local: <HardDrive />,
|
||||
romm: <Gamepad2 />
|
||||
};
|
||||
|
|
@ -35,7 +35,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
|||
const handleAction = () =>
|
||||
{
|
||||
if (data.disabled === true) return;
|
||||
data.action?.({ close: context.close, focus: focusSelf });
|
||||
data.action?.({ close: context.close, focus: focusSelf, selected: data.selected });
|
||||
oneShot('click');
|
||||
};
|
||||
const { ref, focusSelf, focusKey } = useFocusable({
|
||||
|
|
@ -82,7 +82,7 @@ export interface DialogEntry
|
|||
icon?: string | JSX.Element;
|
||||
type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error';
|
||||
selected?: boolean;
|
||||
action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void;
|
||||
action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; selected?: boolean; }) => void;
|
||||
shortcuts?: Shortcut[];
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +102,7 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla
|
|||
{
|
||||
setOpen(false);
|
||||
data.onClose?.();
|
||||
oneShot('closeContext');
|
||||
if (newSourceFocusKey)
|
||||
{
|
||||
setFocus(newSourceFocusKey, { instant: true });
|
||||
|
|
@ -118,7 +119,12 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla
|
|||
return {
|
||||
dialog,
|
||||
open,
|
||||
setOpen: handleClose
|
||||
setOpen: handleClose,
|
||||
setToggle: (focNewSourceFocusKey?: string | undefined) =>
|
||||
{
|
||||
if (open) handleClose(false, focNewSourceFocusKey);
|
||||
else handleClose(true, focNewSourceFocusKey);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -142,7 +148,6 @@ export function ContextDialog (data: {
|
|||
const handleClose = () =>
|
||||
{
|
||||
data.close(false);
|
||||
oneShot('closeContext');
|
||||
};
|
||||
useEffect(() =>
|
||||
{
|
||||
|
|
@ -161,7 +166,7 @@ export function ContextDialog (data: {
|
|||
}] : [], [data.open]);
|
||||
|
||||
return <dialog ref={ref} open={data.open} closedby="any" className={
|
||||
twMerge("fixed modal cursor-pointer bg-base-300/80 backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
||||
twMerge("fixed modal cursor-pointer bg-base-300/80 not-mobile:backdrop-blur-md backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
||||
classNames({ "opacity-0": !data.open }), data.backdropClassName)
|
||||
}
|
||||
onClick={handleClose}>
|
||||
|
|
@ -169,7 +174,7 @@ export function ContextDialog (data: {
|
|||
<ContextDialogContext value={{ id: data.id, close: handleClose }} >
|
||||
<div
|
||||
className={twMerge(
|
||||
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] cursor-auto backdrop-blur-2xl",
|
||||
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[20vw] max-h-[80vh] overflow-y-auto cursor-auto not-mobile:backdrop-blur-2xl",
|
||||
data.open ? "animate-scale-delayed" : "opacity-0",
|
||||
data.className)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,28 +3,28 @@ import { GameMetaExtra, CardList } from "./CardList";
|
|||
import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { JSX, useContext } from "react";
|
||||
import { GameCardFocusHandler } from "./CardElement";
|
||||
import { JSX, Ref, useContext, useEffect } from "react";
|
||||
import { useLocalSetting } from "../scripts/utils";
|
||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||
import { allGamesQuery } from "@queries/romm";
|
||||
|
||||
export interface GameListParams
|
||||
export interface GameListParams extends FocusParams
|
||||
{
|
||||
id: string,
|
||||
filters?: GameListFilterType,
|
||||
grid?: boolean,
|
||||
setBackground?: (url: string) => void;
|
||||
onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
|
||||
onFocus?: GameCardFocusHandler;
|
||||
focus?: string;
|
||||
className?: string;
|
||||
finalElement?: JSX.Element;
|
||||
saveChildFocus?: "session" | "local";
|
||||
setFilterValues?: (filters: FrontEndFilterLists) => void;
|
||||
}
|
||||
|
||||
export function GameList (data: GameListParams)
|
||||
{
|
||||
const games = useSuspenseQuery({ ...allGamesQuery(data.filters), staleTime: DefaultRommStaleTime });
|
||||
const games = useSuspenseQuery({ ...allGamesQuery(data.filters), queryKey: ['games', data.filters ?? 'all'], staleTime: DefaultRommStaleTime });
|
||||
const navigator = useNavigate();
|
||||
const blur = useLocalSetting('backgroundBlur');
|
||||
const backgroundContext = useContext(AnimatedBackgroundContext);
|
||||
|
|
@ -48,6 +48,11 @@ export function GameList (data: GameListParams)
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
data.setFilterValues?.(games.data.filters);
|
||||
}, [games.data.filters]);
|
||||
|
||||
function handleDefaultSelect (g: FrontEndGameType)
|
||||
{
|
||||
navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } });
|
||||
|
|
@ -60,9 +65,10 @@ export function GameList (data: GameListParams)
|
|||
type="game"
|
||||
grid={data.grid}
|
||||
className={data.className}
|
||||
onGameFocus={data.onFocus}
|
||||
onFocus={data.onFocus}
|
||||
finalElement={data.finalElement}
|
||||
saveChildFocus={data.saveChildFocus}
|
||||
focus={data.focus}
|
||||
games={games.data?.games
|
||||
.map(
|
||||
(g) =>
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
|||
});
|
||||
}
|
||||
|
||||
return <div onClick={handleSelect} ref={ref} style={{ viewTimelineName: "header-accounts" }} className="avatar-group cursor-pointer -space-x-6 w-fit flex items-center gap-2 drop-shadow-sm overflow-visible rounded-3xl focusable focusable-hover ">
|
||||
return <div onClick={handleSelect} ref={ref} style={{ viewTimelineName: "header-accounts" }} className="avatar-group cursor-pointer -space-x-6 w-fit flex items-center gap-2 drop-shadow-sm overflow-visible rounded-3xl focusable focusable-primary focusable-hover ">
|
||||
{accounts?.map(a => <HeaderAvatar
|
||||
key={`header-avatar-${a.id}`}
|
||||
id={`account-${a.id}`}
|
||||
|
|
@ -260,7 +260,8 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
|
|||
</div>
|
||||
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
||||
<div className="flex gap-2">
|
||||
{data.buttonElements ?? data.buttons?.map(b => <RoundButton
|
||||
{data.buttonElements}
|
||||
{data.buttons?.map(b => <RoundButton
|
||||
key={b.id}
|
||||
className={twMerge("header-icon sm:size-10 md:size-14", b.className)}
|
||||
id={b.id}
|
||||
|
|
@ -306,7 +307,7 @@ export function HeaderUI (data: HeaderUIParams)
|
|||
);
|
||||
}
|
||||
|
||||
export function StickyHeaderUI (data: { ref: RefObject<any>; className?: string; } & HeaderUIParams)
|
||||
export function StickyHeaderUI (data: { ref: RefObject<any>; className?: string; children?: any; } & HeaderUIParams)
|
||||
{
|
||||
const [isStuck, setIsStuck] = useState(false);
|
||||
const headerRef = useRef(null);
|
||||
|
|
@ -317,6 +318,7 @@ export function StickyHeaderUI (data: { ref: RefObject<any>; className?: string;
|
|||
<div ref={sentinelRef} className="h-0" />
|
||||
<div ref={headerRef} className={twMerge('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', data.className)}>
|
||||
<HeaderUI focusable={!isStuck} {...data} />
|
||||
{data.children}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
102
src/mainview/components/HeaderSearchField.tsx
Normal file
102
src/mainview/components/HeaderSearchField.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { Ref, RefObject, useEffect, useRef, useState } from "react";
|
||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||
import { oneShot } from "../scripts/audio/audio";
|
||||
import { Search } from "lucide-react";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
|
||||
function SearchInput (data: {
|
||||
id: string;
|
||||
autoSearch?: boolean;
|
||||
search: string | undefined;
|
||||
compact: boolean | undefined;
|
||||
onInputFocus: () => void;
|
||||
setShowInput: (show: boolean) => void;
|
||||
onSubmit: (search: string | undefined) => void;
|
||||
} & FocusParams)
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
onBlur: () => inputRef.current?.blur(),
|
||||
onFocus: (l, p, d) =>
|
||||
{
|
||||
data.onFocus?.(focusKey, ref.current, { ...d, inputRef });
|
||||
if (data.autoSearch) inputRef.current?.focus();
|
||||
},
|
||||
focusKey: data.id,
|
||||
onEnterPress: () =>
|
||||
{
|
||||
if (document.activeElement === inputRef.current)
|
||||
{
|
||||
if (inputRef.current)
|
||||
data.onSubmit?.(inputRef.current.value);
|
||||
} else
|
||||
{
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [localSearch, setLocalSearch] = useState(data.search);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setLocalSearch(data.search ?? "");
|
||||
}, [data.search]);
|
||||
|
||||
useShortcuts(focusKey, () => document.activeElement === inputRef.current ? [{
|
||||
label: "Cancel",
|
||||
button: GamePadButtonCode.B, action (e)
|
||||
{
|
||||
inputRef.current?.blur();
|
||||
oneShot('returnGeneric');
|
||||
},
|
||||
}] : [], [inputRef.current, document.activeElement]);
|
||||
|
||||
useEventListener('search' as any, e =>
|
||||
{
|
||||
data.onSubmit?.(undefined);
|
||||
}, inputRef as any);
|
||||
|
||||
return <label ref={ref} onFocus={data.onInputFocus} className='input rounded-full input-lg w-full max-w-xs has-focus:bg-base-300 ring-primary focused:ring-7 has-focus:ring-7 has-focus:ring-base-content'>
|
||||
<Search />
|
||||
<input
|
||||
onBlur={e =>
|
||||
{
|
||||
data.setShowInput(false);
|
||||
setLocalSearch(data.search);
|
||||
}}
|
||||
autoFocus={data.compact}
|
||||
ref={inputRef}
|
||||
value={localSearch ?? ""}
|
||||
onChange={v => setLocalSearch(v.target.value)}
|
||||
type='search'
|
||||
placeholder='Search'
|
||||
/>
|
||||
</label>;
|
||||
}
|
||||
|
||||
export default function HeaderSearchField (data: {
|
||||
id: string;
|
||||
autoSearch?: boolean;
|
||||
search: string | undefined,
|
||||
onSubmit: (search: string | undefined) => void;
|
||||
compact?: boolean;
|
||||
} & FocusParams)
|
||||
{
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: data.id,
|
||||
focusBoundaryDirections: ['left', "right"],
|
||||
isFocusBoundary: data.compact && showInput
|
||||
});
|
||||
|
||||
return <div ref={ref} className='flex items-center'>
|
||||
<FocusContext value={focusKey}>
|
||||
{(!data.compact || showInput) && <SearchInput autoSearch={data.autoSearch} onFocus={data.onFocus} id={`${data.id}-field`} search={data.search} onSubmit={data.onSubmit} compact={data.compact} setShowInput={setShowInput} onInputFocus={focusSelf} />}
|
||||
{data.compact && !showInput && <RoundButton onAction={e => setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} ><Search /></RoundButton>}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -4,9 +4,9 @@ import { useIntersectionObserver } from "usehooks-ts";
|
|||
|
||||
export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams)
|
||||
{
|
||||
const handleAction = (e?: Event) =>
|
||||
const handleAction = (event?: Event) =>
|
||||
{
|
||||
data.onAction?.(e);
|
||||
data.onAction?.({ event, focusKey });
|
||||
if (data.lastId && focused)
|
||||
setFocus(FOCUS_KEYS.GAME_CARD(data.lastId));
|
||||
};
|
||||
|
|
@ -18,8 +18,6 @@ export default function LoadMoreButton (data: { isFetching: boolean; lastId?: Fr
|
|||
onEnterPress: handleAction
|
||||
});
|
||||
|
||||
|
||||
|
||||
const { ref: intersct } = useIntersectionObserver({
|
||||
initialIsIntersecting: true,
|
||||
rootMargin: "20%",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { CardList, GameMetaExtra } from "./CardList";
|
|||
import { rommApi } from "../scripts/clientApi";
|
||||
import { JSX, useMemo } from "react";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { GameCardFocusHandler } from "./CardElement";
|
||||
import { mobileCheck } from "../scripts/utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
|
|
@ -13,11 +12,10 @@ export function PlatformsList (data: {
|
|||
id: string,
|
||||
setBackground: (url: string) => void;
|
||||
className?: string;
|
||||
onFocus?: GameCardFocusHandler;
|
||||
grid?: boolean;
|
||||
onSelect?: (source: string, id: string) => void;
|
||||
saveChildFocus?: "session" | "local";
|
||||
})
|
||||
} & FocusParams)
|
||||
{
|
||||
const isMobile = mobileCheck();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -88,7 +86,7 @@ export function PlatformsList (data: {
|
|||
id={data.id}
|
||||
grid={data.grid}
|
||||
className={twMerge('*:aspect-8/10! md:py-12', data.className)}
|
||||
onGameFocus={data.onFocus}
|
||||
onFocus={data.onFocus}
|
||||
games={platformsMapped}
|
||||
onSelectGame={(id) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import { twMerge } from "tailwind-merge";
|
|||
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams)
|
||||
{
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const { ref, focusSelf } = useFocusable({
|
||||
const { ref, focusSelf, focusKey } = useFocusable({
|
||||
focusKey: `screenshot-${data.index}`,
|
||||
onEnterPress: () => data.onAction?.(),
|
||||
onEnterPress: () => data.onAction?.({ focusKey }),
|
||||
onFocus: (e, p, details) =>
|
||||
{
|
||||
data.setFocused?.(data.index);
|
||||
|
|
@ -23,7 +23,7 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n
|
|||
}); 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" decoding="async" />
|
||||
<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={e => data.onAction?.(e.nativeEvent)}> <Fullscreen /> </div>
|
||||
<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={e => data.onAction?.({ event: e.nativeEvent, focusKey })}> <Fullscreen /> </div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { FOCUS_KEYS } from "../scripts/types";
|
|||
export default function SelectMenu (data: { rootFocusKey: string; })
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const routeState = useRouterState();
|
||||
const matchRoute = useMatchRoute();
|
||||
|
||||
const options: DialogEntry[] = [
|
||||
|
|
@ -85,7 +84,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
|
|||
];
|
||||
const { dialog, setOpen, open } = useContextDialog('select-menu', {
|
||||
content: <ContextList showCloseButton={false} options={options} />,
|
||||
className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none',
|
||||
className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none max-h-screen',
|
||||
preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '')
|
||||
});
|
||||
useShortcuts(data.rootFocusKey, () => [{
|
||||
|
|
|
|||
147
src/mainview/components/SideFilters.tsx
Normal file
147
src/mainview/components/SideFilters.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { GameListFilterType } from "@/shared/constants";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import classNames from "classnames";
|
||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||
import { useFocusable, FocusContext } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store } from "lucide-react";
|
||||
import { sourceIconMap } from "./Constants";
|
||||
import { useContextDialog, ContextList, DialogEntry } from "./ContextDialog";
|
||||
|
||||
function FilterButton (data: {
|
||||
id: string,
|
||||
filters?: GameListFilterType,
|
||||
tooltip: string,
|
||||
icon: any;
|
||||
dialog: {
|
||||
setToggle: (focNewSourceFocusKey?: string | undefined) => void;
|
||||
};
|
||||
isActive: boolean;
|
||||
})
|
||||
{
|
||||
const handleAction = () => data.dialog.setToggle(data.id);
|
||||
useShortcuts(data.id, () => [{ label: data.tooltip, action: handleAction, button: GamePadButtonCode.A }]);
|
||||
return <div className="tooltip tooltip-right" data-tip={data.tooltip}>
|
||||
<RoundButton
|
||||
id={data.id}
|
||||
onAction={handleAction}
|
||||
className={classNames('sm:p-2 md:p-3 drop-shadow-md!', { "border-4 border-primary": data.isActive })}
|
||||
>
|
||||
{data.icon}
|
||||
</RoundButton>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function SideFilters (data: {
|
||||
id: string,
|
||||
filters?: GameListFilterType;
|
||||
setLocalFilter: (filter: GameListFilterType) => void,
|
||||
localFilter: GameListFilterType,
|
||||
filterValues: FrontEndFilterLists | undefined;
|
||||
})
|
||||
{
|
||||
|
||||
const { ref, focusKey } = useFocusable({ focusKey: data.id });
|
||||
|
||||
const orderByDialog = useContextDialog('order-by-dialog', {
|
||||
content: <ContextList options={([
|
||||
{ stat: "name", icon: <ArrowDownAz /> },
|
||||
{ stat: "activity", icon: <ClockArrowDown /> },
|
||||
{ stat: "added", icon: <CalendarArrowDown /> },
|
||||
{ stat: "release", icon: <Rocket /> },
|
||||
] satisfies { stat: GameListFilterType['orderBy'], icon?: any; }[])
|
||||
.map(o => ({
|
||||
content: o.stat,
|
||||
icon: o.icon,
|
||||
selected: data.localFilter.orderBy === o.stat,
|
||||
id: `sort-by-${o.stat}`,
|
||||
type: 'primary',
|
||||
action (ctx)
|
||||
{
|
||||
data.setLocalFilter({ ...data.localFilter, orderBy: o.stat });
|
||||
ctx.close();
|
||||
},
|
||||
}))} />,
|
||||
preferredChildFocusKey: `sort-by-${data.localFilter.orderBy}`
|
||||
});
|
||||
|
||||
const sourceFilterDialog = useContextDialog('source-filter-dialog', {
|
||||
content: <ContextList options={["romm"]
|
||||
.map<DialogEntry>(o => ({
|
||||
content: o,
|
||||
icon: sourceIconMap[o],
|
||||
selected: data.localFilter.source === o,
|
||||
id: `source-filter-${o}`,
|
||||
type: 'primary',
|
||||
action (ctx)
|
||||
{
|
||||
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, source: undefined });
|
||||
else data.setLocalFilter({ ...data.localFilter, source: o });
|
||||
ctx.close();
|
||||
},
|
||||
})).concat({
|
||||
content: "Local Only",
|
||||
icon: <HardDrive />,
|
||||
selected: data.localFilter.localOnly === true,
|
||||
id: `source-filter-local`,
|
||||
type: 'primary',
|
||||
action (ctx)
|
||||
{
|
||||
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, localOnly: undefined });
|
||||
else data.setLocalFilter({ ...data.localFilter, localOnly: true });
|
||||
ctx.close();
|
||||
},
|
||||
})} />,
|
||||
preferredChildFocusKey: `source-filter-${data.localFilter.source}`
|
||||
});
|
||||
|
||||
const genreFilterDialog = useContextDialog('genre-filter-dialog', {
|
||||
content: <ContextList options={data.filterValues?.genres.map(g => ({
|
||||
content: g,
|
||||
selected: data.localFilter.genres?.includes(g),
|
||||
id: `genre-filter-${g}`,
|
||||
type: 'primary',
|
||||
action (ctx)
|
||||
{
|
||||
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres?.filter(genre => genre !== g) ?? []] });
|
||||
else data.setLocalFilter({ ...data.localFilter, genres: [...data.localFilter.genres ?? [], g] });
|
||||
ctx.close();
|
||||
},
|
||||
}))} />
|
||||
});
|
||||
|
||||
const ageRatingFilterDialog = useContextDialog('age-rating-filter-dialog', {
|
||||
content: <ContextList options={data.filterValues?.age_ratings.map(a => ({
|
||||
content: a,
|
||||
selected: data.localFilter.age_ratings?.includes(a),
|
||||
id: `age-rating-filter-${a}`,
|
||||
type: 'primary',
|
||||
action (ctx)
|
||||
{
|
||||
if (ctx.selected) data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings?.filter(age => age !== a) ?? []] });
|
||||
else data.setLocalFilter({ ...data.localFilter, age_ratings: [...data.localFilter.age_ratings ?? [], a] });
|
||||
ctx.close();
|
||||
},
|
||||
}))} />
|
||||
});
|
||||
|
||||
return <div className='flex flex-col gap-2' ref={ref}>
|
||||
<FocusContext value={focusKey} >
|
||||
<FilterButton tooltip='Sorting' id='filter-order-by' dialog={orderByDialog} isActive={!!data.localFilter.orderBy} icon={<SortDesc />} />
|
||||
<FilterButton tooltip='Age Rating' id='filter-age-ratings' dialog={ageRatingFilterDialog} isActive={!!data.localFilter.age_ratings && data.localFilter.age_ratings.length > 0} icon={<User />} />
|
||||
<FilterButton tooltip='Genre' id='filter-genre' dialog={genreFilterDialog} isActive={!!data.localFilter.genres && data.localFilter.genres.length > 0} icon={<Drama />} />
|
||||
{!data.filters?.source &&
|
||||
<FilterButton tooltip='Source' id='filter-source' dialog={sourceFilterDialog} isActive={!!data.localFilter.source || data.localFilter.localOnly !== undefined} icon={<Store />} />
|
||||
}
|
||||
{Object.values(data.localFilter).some(v => v !== undefined) &&
|
||||
<>
|
||||
<div className="divider m-0"></div>
|
||||
<RoundButton id={'filter-clear'} onAction={() => data.setLocalFilter({})} className='p-3 drop-shadow-md!' > <FunnelX /> </RoundButton>
|
||||
</>
|
||||
}
|
||||
{orderByDialog.dialog}
|
||||
{sourceFilterDialog.dialog}
|
||||
{genreFilterDialog.dialog}
|
||||
{ageRatingFilterDialog.dialog}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export default function StatList (data: {
|
|||
content = <div key={`label-items-${i}`} className="flex flex-wrap gap-2">{s.content.map((c, ci) => <span key={`label-items-${i}-${ci}`} className={twMerge("rounded-3xl bg-base-200 px-3 py-1", data.elementClassName)}>{c}</span>)}</div>;
|
||||
} else
|
||||
{
|
||||
content = <div key={`label-element-${i}`} className={twMerge("flex gap-2 rounded-2xl bg-base-200 px-3 py-2", data.elementClassName)}>{s.icon}{s.content}</div>;
|
||||
content = <div key={`label-element-${i}`} className={twMerge("flex break-after-all gap-2 rounded-2xl bg-base-200 px-3 py-2", data.elementClassName)}>{s.icon}{s.content}</div>;
|
||||
}
|
||||
return [<Label key={`label-${i}`} id={`${data.id}-label-${i}`} label={s.label} />, <div key={`content-${i}`}>{content}</div>];
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,10 @@ export default function ActionButton (data: {
|
|||
onFocus?: () => void;
|
||||
tooltip?: string,
|
||||
tooltip_type?: 'accent' | 'error';
|
||||
onAction?: () => void;
|
||||
disabled?: boolean;
|
||||
})
|
||||
} & InteractParams)
|
||||
{
|
||||
const { ref } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true });
|
||||
const { ref, focusKey } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: () => data.onAction?.({ focusKey }), focusable: data.disabled !== true });
|
||||
const styles = {
|
||||
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",
|
||||
|
|
@ -29,7 +28,7 @@ export default function ActionButton (data: {
|
|||
<button
|
||||
disabled={data.disabled}
|
||||
ref={ref}
|
||||
onClick={data.onAction}
|
||||
onClick={e => data.onAction?.({ event: e.nativeEvent, focusKey })}
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import ActionButtons from "./ActionButtons";
|
|||
import prettyMilliseconds from 'pretty-ms';
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { validateSourceQuery } from "@/mainview/scripts/queries/romm";
|
||||
import { sourceIconMap } from "../Constants";
|
||||
|
||||
export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; })
|
||||
{
|
||||
|
|
@ -20,12 +21,6 @@ export function DetailElement (data: { icon: JSX.Element; tooltip?: string | nul
|
|||
);
|
||||
}
|
||||
|
||||
const sourceIconMap: Record<string, any> = {
|
||||
store: <Store />,
|
||||
local: <HardDrive />,
|
||||
romm: <Gamepad2 />
|
||||
};
|
||||
|
||||
export default function Details (data: {
|
||||
game?: FrontEndGameTypeDetailed,
|
||||
source: string,
|
||||
|
|
@ -81,7 +76,7 @@ export default function Details (data: {
|
|||
<DetailElement icon={platformCoverImg ? <img className="size-6" src={platformCoverImg.href}></img> : <div className="skeleton size-6 rounded-full shrink-0"></div>} >{data.game?.platform_display_name ?? <div className="skeleton h-4 w-32"></div>}</DetailElement>
|
||||
{data.game?.emulators?.some(e => e.integrations.some(i => i.capabilities?.includes('saves'))) && <DetailElement tooltip={"Save Backup"} icon={<CloudUpload />} />}
|
||||
<DetailElement tooltip={validation?.reason} icon={
|
||||
validation ? validation.valid ? sourceIconMap[data.game?.source ?? ''] : <TriangleAlert className="text-error" /> : <span className="loading loading-spinner loading-lg"></span>
|
||||
validation ? validation.valid ? sourceIconMap[data.game?.source ?? data.game?.id.source ?? ''] : <TriangleAlert className="text-error" /> : <span className="loading loading-spinner loading-lg"></span>
|
||||
} >
|
||||
{data.game?.source ?? data.game?.id.source}
|
||||
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</DetailElement>
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ export function Button (data: {
|
|||
tooltipType?: "base" | "accent" | "error" | "warning";
|
||||
} & InteractParams & FocusParams)
|
||||
{
|
||||
const handleAction = (e?: any) =>
|
||||
const handleAction = (event?: Event) =>
|
||||
{
|
||||
data.onAction?.(e);
|
||||
data.onAction?.({ event, focusKey });
|
||||
oneShot('click');
|
||||
};
|
||||
const { ref, focused, focusKey } = useFocusable({
|
||||
|
|
@ -50,12 +50,12 @@ export function Button (data: {
|
|||
|
||||
if (data.shortcutLabel)
|
||||
{
|
||||
useShortcuts(focusKey, () => [{ label: data.shortcutLabel, action: data.onAction, button: GamePadButtonCode.A }], [data.shortcutLabel]);
|
||||
useShortcuts(focusKey, () => [{ label: data.shortcutLabel, action: handleAction, button: GamePadButtonCode.A }], [data.shortcutLabel]);
|
||||
}
|
||||
|
||||
return <button
|
||||
ref={ref}
|
||||
onClick={handleAction}
|
||||
onClick={e => handleAction(e.nativeEvent)}
|
||||
disabled={data.disabled}
|
||||
data-tooltip={data.tooltip}
|
||||
data-tooltip-type={data.tooltipType}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
|||
onBlur={handleInputBlur}
|
||||
onChange={(e) =>
|
||||
{
|
||||
data.setLocalValue(e);
|
||||
data.setLocalValue(String(e));
|
||||
}}
|
||||
value={data.localValue}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; l
|
|||
name={field.name}
|
||||
value={field.state.value}
|
||||
type={data.type}
|
||||
onChange={v => field.handleChange(v)}
|
||||
onChange={v => field.handleChange(String(v))}
|
||||
placeholder={data.placeholder}
|
||||
className={classNames({ " flex-3 ring-4 ring-accent": field.getMeta().isDirty })}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function SettingsOption (data: {
|
|||
})
|
||||
{
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [localValue, setLocalValue] = useState<string | boolean | undefined>();
|
||||
const [localValue, setLocalValue] = useState<string | number | boolean | undefined>();
|
||||
const { data: serverValue } = useQuery(getSettingQuery(data.id));
|
||||
const setMutation = useMutation(setSettingMutation(data.id));
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import
|
|||
useFocusable,
|
||||
FocusContext,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { ChevronRight, Joystick } from "lucide-react";
|
||||
import { ChevronRight, Joystick, LayoutGrid } from "lucide-react";
|
||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils";
|
||||
import FocusDots from "../FocusDots";
|
||||
|
|
@ -26,9 +26,9 @@ function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (detail
|
|||
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"}
|
||||
className={"flex focusable focusable-hover focusable-info bg-base-100 rounded-4xl transition-shadow focused:animate-scale-small p-4 justify-center items-center min-w-2xs gap-2 cursor-pointer"}
|
||||
>
|
||||
See All Emulators <ChevronRight />
|
||||
<LayoutGrid /> See All Emulators <ChevronRight />
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,9 +95,9 @@ export function StoreEmulatorCard (data: {
|
|||
>
|
||||
<div className="bg-primary in-data-[full-support=false]:bg-warning in-data-[full-support=false]:text-warning-content in-aria-disabled:bg-base-200 in-aria-disabled:text-base-content text-primary-content rounded-full p-1.5"><WandSparkles className="size-5" /></div>
|
||||
</div>}
|
||||
{data.emulator.validSources.slice(0, 3).map(s =>
|
||||
{data.emulator.validSources.slice(0, 3).map((s, i) =>
|
||||
{
|
||||
return <div className="tooltip" data-tip={s.type}>
|
||||
return <div key={i} className="tooltip" data-tip={s.type}>
|
||||
<div data-source={s.type} className="flex items-center justify-center rounded-full p-1 size-8 bg-base-300 text-base-content data-[source=store]:bg-success data-[source=store]:text-success-content">
|
||||
{emulatorStatusIcons[s.type]}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue