refactor: moved queries to their own file
This commit is contained in:
parent
364bc9d0be
commit
cf6fff6fac
83 changed files with 1107 additions and 852 deletions
|
|
@ -1,9 +1,9 @@
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { CSSProperties, JSX, Ref, useEffect, useRef, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
import { mobileCheck, useLocalSetting } from '../scripts/utils';
|
||||
import { useLocalSetting } from '../scripts/utils';
|
||||
import { AnimatedBackgroundContext } from '../scripts/contexts';
|
||||
|
||||
export function AnimatedBackground (data: {
|
||||
|
|
@ -88,8 +88,6 @@ export function AnimatedBackground (data: {
|
|||
|
||||
}, [finalBackgroundUrl]);
|
||||
|
||||
const isMobile = mobileCheck();
|
||||
|
||||
function handleSetBackground (url: string)
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
|||
{
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: data.focusKey,
|
||||
onFocus: (l, p, detals) => data.onFocus?.(data.id, ref.current as any, detals),
|
||||
onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current as any, details),
|
||||
onEnterPress: () => data.onAction?.(),
|
||||
onBlur: () => data.onBlur?.(data.id)
|
||||
});
|
||||
const { isMouse, isPointer } = useActiveControl();
|
||||
const { isPointer } = useActiveControl();
|
||||
|
||||
return (
|
||||
<li
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export function CardList (data: {
|
|||
onSelectGame?: (id: string) => void;
|
||||
onGameFocus?: GameCardFocusHandler;
|
||||
className?: string;
|
||||
finalElement?: JSX.Element;
|
||||
saveChildFocus?: 'session' | 'local';
|
||||
})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
|
|
@ -72,7 +74,7 @@ export function CardList (data: {
|
|||
title="Games"
|
||||
id={`card-list-${data.id}`}
|
||||
ref={ref}
|
||||
save-child-focus="session"
|
||||
save-child-focus={data.saveChildFocus}
|
||||
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!',
|
||||
|
|
@ -83,10 +85,10 @@ export function CardList (data: {
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
{data.games.map(BuildCard)}
|
||||
{data.finalElement}
|
||||
</FocusContext.Provider>
|
||||
</ul>
|
||||
);
|
||||
|
|
|
|||
72
src/mainview/components/Carousel.tsx
Normal file
72
src/mainview/components/Carousel.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { twMerge } from "tailwind-merge";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { CSSProperties, Ref, useEffect, useRef, useState } from "react";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
|
||||
export default function Carousel (data: {
|
||||
className?: string;
|
||||
rootClassName?: string;
|
||||
controlsClassName?: string;
|
||||
children?: any;
|
||||
scrollRef?: Ref<HTMLDivElement>;
|
||||
scrollHandler?: (direction: number, element: HTMLDivElement) => void;
|
||||
isScrollable?: boolean;
|
||||
style?: CSSProperties;
|
||||
})
|
||||
{
|
||||
const [scrollable, setScrollable] = useState(false);
|
||||
const localRef = useRef<HTMLDivElement>(null);
|
||||
const handleScroll = (dir: number) =>
|
||||
{
|
||||
if (!localRef.current) return;
|
||||
if (data.scrollHandler)
|
||||
{
|
||||
data.scrollHandler(dir, localRef.current);
|
||||
return;
|
||||
}
|
||||
localRef.current.scrollBy({ behavior: 'smooth', left: localRef.current.clientWidth / 2 * dir });
|
||||
};
|
||||
const { isMouse } = useActiveControl();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const el = localRef.current;
|
||||
if (!el) return;
|
||||
|
||||
setScrollable(el.scrollWidth > el.clientWidth);
|
||||
const observer = new ResizeObserver(() =>
|
||||
{
|
||||
setScrollable(el.scrollWidth > el.clientWidth);
|
||||
});
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [localRef.current, localRef.current?.clientWidth, localRef.current?.scrollWidth]);
|
||||
|
||||
return <div className={twMerge("relative scroll-smooth", data.rootClassName)}>
|
||||
<div style={{ ...data.style, scrollSnapType: 'x mandatory' }} ref={r =>
|
||||
{
|
||||
if (data.scrollRef instanceof Function)
|
||||
{
|
||||
data.scrollRef(r);
|
||||
} else if (data.scrollRef)
|
||||
{
|
||||
data.scrollRef.current = r;
|
||||
}
|
||||
localRef.current = r;
|
||||
|
||||
}} className={twMerge(data.className)}>
|
||||
{data.children}
|
||||
</div>
|
||||
{((scrollable || data.isScrollable) && isMouse) && <>
|
||||
<div className={twMerge("absolute flex items-center left-2 top-0 bottom-0", data.controlsClassName)}>
|
||||
<RoundButton onAction={() => handleScroll(-1)} id="move-left" className="p-2 border-base-content/40"><ChevronLeft /></RoundButton>
|
||||
</div>
|
||||
<div className={twMerge("absolute flex items-center justify-end right-2 top-0 bottom-0", data.controlsClassName)}>
|
||||
<RoundButton onAction={() => handleScroll(1)} id="move-left" className="p-2 border-base-content/40"><ChevronRight /></RoundButton>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { getCollectionsApiCollectionsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "@/shared/constants";
|
||||
import { RPC_URL } from "@/shared/constants";
|
||||
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 "./CardElement";
|
||||
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import queries from "../scripts/queries";
|
||||
|
||||
export default function CollectionList (data: {
|
||||
id: string,
|
||||
|
|
@ -13,14 +13,11 @@ export default function CollectionList (data: {
|
|||
className?: string;
|
||||
onFocus?: GameCardFocusHandler;
|
||||
onSelect?: (id: string) => void;
|
||||
saveChildFocus?: 'session' | 'local';
|
||||
})
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { data: collections } = useSuspenseQuery({
|
||||
...getCollectionsApiCollectionsGetOptions(),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: DefaultRommStaleTime
|
||||
});
|
||||
const { data: collections } = useSuspenseQuery(queries.romm.getCollectionsQuery());
|
||||
|
||||
const handleDefaultSelect = (id: string) =>
|
||||
{
|
||||
|
|
@ -33,6 +30,7 @@ export default function CollectionList (data: {
|
|||
type="collection"
|
||||
id={data.id}
|
||||
className={data.className}
|
||||
saveChildFocus={data.saveChildFocus}
|
||||
games={collections.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at))
|
||||
.map((g) => ({
|
||||
id: String(g.id),
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import { AnimatedBackground } from './AnimatedBackground';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { HeaderUI } from './Header';
|
||||
import { GameList } from './GameList';
|
||||
import { Search, Settings2 } from 'lucide-react';
|
||||
import { JSX, Suspense } from 'react';
|
||||
import { JSX, Suspense, useEffect } from 'react';
|
||||
import Shortcuts from './Shortcuts';
|
||||
import { AutoFocus } from './AutoFocus';
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||
import { Router } from '..';
|
||||
import { PopNavigateSource, PopSource } from '../scripts/spatialNavigation';
|
||||
import { PopNavigateSource } from '../scripts/spatialNavigation';
|
||||
import { GameListFilterType } from '@/shared/constants';
|
||||
import { GameCardFocusHandler } from './CardElement';
|
||||
|
||||
export interface CollectionsDetailParams
|
||||
{
|
||||
id?: string;
|
||||
setBackground: (url: string) => void;
|
||||
setBackground?: (url: string) => void;
|
||||
filters?: GameListFilterType;
|
||||
headerTitle?: JSX.Element;
|
||||
title?: JSX.Element;
|
||||
footer?: JSX.Element;
|
||||
focus?: string;
|
||||
}
|
||||
|
||||
export function CollectionsDetail (data: CollectionsDetailParams)
|
||||
|
|
@ -37,10 +37,21 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
{
|
||||
if (!(details.nativeEvent instanceof MouseEvent))
|
||||
{
|
||||
node.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
node.scrollIntoView({ block: 'center', behavior: details.instant ? 'instant' : 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (data.focus)
|
||||
setFocus(data.focus, { instant: true });
|
||||
}, [data.focus]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
return () => setFocus('');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FocusContext value={focusKey}>
|
||||
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className='flex'>
|
||||
|
|
@ -53,7 +64,6 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
<Suspense>
|
||||
<GameList
|
||||
grid
|
||||
setBackground={data.setBackground}
|
||||
filters={data.filters}
|
||||
onFocus={handleScroll}
|
||||
id={`${focusKey}-list`}>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
|||
data.onFocus?.();
|
||||
};
|
||||
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
|
||||
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
|
||||
const { ref, focusSelf, focusKey } = useFocusable({
|
||||
focusKey: `${context.id}-list-option-${data.id}`,
|
||||
onEnterPress: data.shortcuts ? undefined : handleAction,
|
||||
onFocus: handleFocus,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ 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)
|
||||
{
|
||||
|
|
@ -19,12 +18,15 @@ export default function Error (data: ErrorComponentProps)
|
|||
|
||||
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">
|
||||
<p className="flex gap-2 items-center text-2xl 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>
|
||||
<p className="flex gap-2 text-base-content/50 text-shadow-lg">{window.location.href} </p>
|
||||
|
||||
{import.meta.env.DEV && <div className="text-center text-base-content/50">{data.error.stack}</div>}
|
||||
|
||||
<Button className="text-2xl! 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>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||
import { ContextList, DialogEntry } from "./ContextDialog";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { useContext, useRef, useState } from "react";
|
||||
import path from "pathe";
|
||||
import path, { dirname } from "pathe";
|
||||
import { Check, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
|
||||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { DirType } from "@/shared/constants";
|
||||
|
|
@ -12,7 +12,7 @@ import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"
|
|||
import SvgIcon from "./SvgIcon";
|
||||
import { Button } from "./options/Button";
|
||||
import toast from "react-hot-toast";
|
||||
import { drivesQuery, filesQuery } from "../scripts/queries";
|
||||
import queries from "../scripts/queries";
|
||||
import { FilePickerContext } from "../scripts/contexts";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
|
||||
|
|
@ -113,12 +113,7 @@ function NewFolderOption (data: { id: string, dirname: string; })
|
|||
const { refetchFiles } = useContext(FilePickerContext);
|
||||
const [name, setName] = useState<string | undefined>();
|
||||
const createMutation = useMutation({
|
||||
mutationKey: ['create', 'folder', data.id], mutationFn: async () =>
|
||||
{
|
||||
if (!name) return;
|
||||
const { error } = await systemApi.api.system.dirs.put({ name, dirname: data.dirname });
|
||||
if (error) throw error.value;
|
||||
},
|
||||
...queries.system.createFolderMutation(data.id),
|
||||
onError: (e) => toast.error(e.message ?? 'Error Creating New Folder'),
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
|
|
@ -128,7 +123,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={e => createMutation.mutate()} type="button" ><FolderPlus /></Button>
|
||||
<Button id={`${data.id}-create`} onAction={e => createMutation.mutate({ name, dirname: data.dirname })} type="button" ><FolderPlus /></Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
|
@ -233,8 +228,8 @@ export default function FilePicker (data: {
|
|||
{
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(data.startingPath);
|
||||
|
||||
const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(filesQuery(currentPath, data.id));
|
||||
const { data: drives, isLoading: drivesLoading } = useQuery(drivesQuery);
|
||||
const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(queries.system.filesQuery(currentPath, data.id));
|
||||
const { data: drives, isLoading: drivesLoading } = useQuery(queries.system.drivesQuery);
|
||||
|
||||
const fullPath = files ? path.join(files.parentPath, files.name) : '';
|
||||
const activeDrive = drives?.filter(d => !!d.mountPoint).sort((a, b) => b.mountPoint!.length - a.mountPoint!.length).filter(d => fullPath.startsWith(d.mountPoint!))[0];
|
||||
|
|
|
|||
|
|
@ -11,14 +11,13 @@ function FilterCat (
|
|||
id: string;
|
||||
children?: any;
|
||||
active: boolean;
|
||||
onFocus: () => void;
|
||||
hasFocusedPeer: boolean;
|
||||
} & FilterOption,
|
||||
} & FilterOption & FocusParams,
|
||||
)
|
||||
{
|
||||
const { ref, focusSelf, focused } = useFocusable({
|
||||
const { ref, focusSelf } = useFocusable({
|
||||
focusKey: data.id,
|
||||
onFocus: data.onFocus,
|
||||
onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current, details),
|
||||
onEnterPress: data.onAction
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,20 +2,75 @@ import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
|||
import classNames from "classnames";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useGlobalFocus } from "../scripts/spatialNavigation";
|
||||
import { JSX, RefObject, useMemo, useState } from "react";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
|
||||
function ScrollDot (data: { index: number; parent: RefObject<HTMLElement | null>, peers: HTMLElement[]; })
|
||||
{
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
useEventListener('scrollend', () =>
|
||||
{
|
||||
if (!data.parent.current) return;
|
||||
const center = data.parent.current.scrollLeft + data.parent.current.clientWidth / 2;
|
||||
|
||||
// find child closest to center
|
||||
const closest = data.peers.reduce((closest, child) =>
|
||||
{
|
||||
const childCenter = child.offsetLeft + child.offsetWidth / 2;
|
||||
const closestCenter = closest.offsetLeft + closest.offsetWidth / 2;
|
||||
return Math.abs(childCenter - center) < Math.abs(closestCenter - center)
|
||||
? child
|
||||
: closest;
|
||||
});
|
||||
|
||||
setFocused(closest === data.peers[data.index]);
|
||||
|
||||
}, data.parent as any);
|
||||
|
||||
return <button key={data.index} onClick={(e) =>
|
||||
{
|
||||
data.peers[data.index].scrollIntoView({ behavior: 'smooth', inline: 'center' });
|
||||
}}
|
||||
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>;
|
||||
}
|
||||
|
||||
export default function FocusDots (data: {
|
||||
elements: string[];
|
||||
|
||||
elements?: string[] | undefined;
|
||||
scrollElement?: RefObject<HTMLElement | null>;
|
||||
})
|
||||
{
|
||||
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 focusedKey = useGlobalFocus();
|
||||
let elements = useMemo(() =>
|
||||
{
|
||||
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>;
|
||||
if (data.elements)
|
||||
{
|
||||
return 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>;
|
||||
});
|
||||
} else if (data.scrollElement?.current)
|
||||
{
|
||||
const childrenArray = Array.from(data.scrollElement.current.children);
|
||||
|
||||
return childrenArray.map((c, i) =>
|
||||
{
|
||||
return <ScrollDot parent={data.scrollElement!} index={i} peers={childrenArray as HTMLElement[]} />;
|
||||
});
|
||||
} else
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}, [data.elements, data.scrollElement?.current]);
|
||||
|
||||
return <div className="divider opacity-20">
|
||||
<div className="flex gap-2 py-6 justify-center items-center h-3">{elements}</div>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { GameMetaExtra, CardList } from "./CardList";
|
||||
import { FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants";
|
||||
import { FrontEndGameType, FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { JSX } from "react";
|
||||
import { JSX, useContext } from "react";
|
||||
import { GameCardFocusHandler } from "./CardElement";
|
||||
import { useLocalSetting } from "../scripts/utils";
|
||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||
import queries from "../scripts/queries";
|
||||
|
||||
export interface GameListParams
|
||||
{
|
||||
|
|
@ -18,19 +19,16 @@ export interface GameListParams
|
|||
onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
|
||||
onFocus?: GameCardFocusHandler;
|
||||
className?: string;
|
||||
finalElement?: JSX.Element;
|
||||
saveChildFocus?: "session" | "local";
|
||||
}
|
||||
|
||||
export function GameList (data: GameListParams)
|
||||
{
|
||||
const games = useSuspenseQuery({
|
||||
queryKey: ['games', data.filters ?? 'all'],
|
||||
queryFn: () => rommApi.api.romm.games.get({
|
||||
query: data.filters
|
||||
}).then(d => d.data)
|
||||
});
|
||||
const games = useSuspenseQuery(queries.romm.allGamesQuery(data.filters));
|
||||
const navigator = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const blur = useLocalSetting('backgroundBlur');
|
||||
const backgroundContext = useContext(AnimatedBackgroundContext);
|
||||
|
||||
const handleFocus = (id: FrontEndId, source: string | null, sourceId: string | null) =>
|
||||
{
|
||||
|
|
@ -39,11 +37,11 @@ export function GameList (data: GameListParams)
|
|||
{
|
||||
try
|
||||
{
|
||||
const screenshotUrl = new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`);
|
||||
const screenshotUrl = game.paths_screenshots && game.paths_screenshots.length > 0 ? new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`) : undefined;
|
||||
const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_cover}`);
|
||||
const previewUrl = blur ? coverUrl : screenshotUrl;
|
||||
const previewUrl = blur ? coverUrl : (screenshotUrl ?? coverUrl);
|
||||
previewUrl.searchParams.delete('ts');
|
||||
data.setBackground?.(previewUrl.href);
|
||||
data.setBackground?.(previewUrl.href) ?? backgroundContext.setBackground(previewUrl.href);
|
||||
} catch
|
||||
{
|
||||
|
||||
|
|
@ -51,10 +49,10 @@ export function GameList (data: GameListParams)
|
|||
}
|
||||
};
|
||||
|
||||
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null)
|
||||
function handleDefaultSelect (g: FrontEndGameType)
|
||||
{
|
||||
SaveSource('details');
|
||||
navigator({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
SaveSource('details', { search: { focus: g.slug ?? `game-${g.id}` } });
|
||||
navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -65,6 +63,8 @@ export function GameList (data: GameListParams)
|
|||
grid={data.grid}
|
||||
className={data.className}
|
||||
onGameFocus={data.onFocus}
|
||||
finalElement={data.finalElement}
|
||||
saveChildFocus={data.saveChildFocus}
|
||||
games={games.data?.games
|
||||
.map(
|
||||
(g) =>
|
||||
|
|
@ -92,7 +92,7 @@ export function GameList (data: GameListParams)
|
|||
),
|
||||
previewUrl: previewUrl.href,
|
||||
badges: badges,
|
||||
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g.id, g.source, g.source_id),
|
||||
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g),
|
||||
onFocus: () => handleFocus(g.id, g.source, g.source_id)
|
||||
} satisfies GameMetaExtra;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen";
|
||||
import { RPC_URL } from "../../shared/constants";
|
||||
import { JSX, useEffect, useRef } from "react";
|
||||
import { SaveSource, useFocusableDynamic } from "../scripts/spatialNavigation";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { Router } from "..";
|
||||
|
||||
|
|
@ -228,25 +228,12 @@ function BatteryStatus ()
|
|||
|
||||
export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
||||
{
|
||||
const rommOnline = useQuery({
|
||||
...statsApiStatsGetOptions(),
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
});
|
||||
const user = useQuery({
|
||||
...getCurrentUserApiUsersMeGetOptions(),
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1
|
||||
});
|
||||
|
||||
let indicator = "status-neutral";
|
||||
if (user.isError)
|
||||
{
|
||||
indicator = "status-error";
|
||||
} else if (!user.isPending && rommOnline.isSuccess)
|
||||
{
|
||||
indicator = "status-success";
|
||||
}
|
||||
const accounts: HeaderAccount[] = [{
|
||||
id: 'romm', previewUrl: [
|
||||
`${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`,
|
||||
|
|
|
|||
35
src/mainview/components/LoadMoreButton.tsx
Normal file
35
src/mainview/components/LoadMoreButton.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { FOCUS_KEYS } from "../scripts/types";
|
||||
import { useIntersectionObserver } from "usehooks-ts";
|
||||
|
||||
export default 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={e => handleAction(e.nativeEvent)} id='load-more-btn'>{data.isFetching ? <span className="loading loading-spinner loading-xl"></span> : "Load More"}</div>;
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ export function PlatformsList (data: {
|
|||
onFocus?: GameCardFocusHandler;
|
||||
grid?: boolean;
|
||||
onSelect?: (source: string, id: string) => void;
|
||||
saveChildFocus?: "session" | "local";
|
||||
})
|
||||
{
|
||||
const isMobile = mobileCheck();
|
||||
|
|
@ -85,6 +86,7 @@ export function PlatformsList (data: {
|
|||
return (
|
||||
<CardList
|
||||
type="platform"
|
||||
saveChildFocus={data.saveChildFocus}
|
||||
id={data.id}
|
||||
grid={data.grid}
|
||||
className={twMerge('*:aspect-8/10! md:py-12', data.className)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { CSSProperties, JSX } from "react";
|
||||
import { CSSProperties } from "react";
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { Button, ButtonStyle } from "./options/Button";
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ export function RoundButton (data: {
|
|||
} & InteractParams & FocusParams)
|
||||
{
|
||||
return (
|
||||
<Button cssStyle={data.cssStyle} 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}>
|
||||
<Button cssStyle={data.cssStyle} onFocus={data.onFocus} id={data.id} style={data.style} className={twMerge("rounded-full aspect-square", data.external && "focusable focusable-primary focusable-hover", data.className)} onAction={data.onAction}>
|
||||
{data.children}
|
||||
</Button>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { RPC_URL } from "@/shared/constants";
|
||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import FocusDots from "./FocusDots";
|
||||
import { scrollIntoNearestParent, useDragScroll } from "../scripts/utils";
|
||||
import { Fullscreen } from "lucide-react";
|
||||
import Carousel from "./Carousel";
|
||||
import { ContextDialog } from "./ContextDialog";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
|
||||
|
||||
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; })
|
||||
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams)
|
||||
{
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
const { ref, focusSelf } = useFocusable({
|
||||
focusKey: `screenshot-${data.index}`,
|
||||
onEnterPress: () => (ref.current as HTMLElement).requestFullscreen(),
|
||||
onEnterPress: () => data.onAction?.(),
|
||||
onFocus: (e, p, details) =>
|
||||
{
|
||||
data.setFocused?.(data.index);
|
||||
|
|
@ -19,31 +22,109 @@ 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" />
|
||||
<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 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>;
|
||||
}
|
||||
|
||||
export default function Screenshots (data: { screenshots: string[]; } & FocusParams)
|
||||
{
|
||||
const scrollRef = useRef(null);
|
||||
const { ref, focusKey } = useFocusable({
|
||||
const [preview, setPreview] = useState<number | undefined>(undefined);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { ref, focusKey, focused, hasFocusedChild } = useFocusable({
|
||||
focusKey: 'screenshot-list',
|
||||
trackChildren: true,
|
||||
onFocus: (e, p, details) =>
|
||||
{
|
||||
data.onFocus?.(focusKey, ref.current, details);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if ((focused || hasFocusedChild) && scrollRef.current)
|
||||
{
|
||||
const closest = findClosestElementToCenter(scrollRef.current);
|
||||
const closestIndex = Array.from(scrollRef.current.children).indexOf(closest);
|
||||
setFocus(`screenshot-${closestIndex}`);
|
||||
}
|
||||
}, [focused, hasFocusedChild, scrollRef.current]);
|
||||
|
||||
const findClosestElementToCenter = (element: HTMLDivElement) =>
|
||||
{
|
||||
const center = element.scrollLeft + element.clientWidth / 2;
|
||||
|
||||
const children = Array.from(element.children) as HTMLElement[];
|
||||
|
||||
// find child closest to center
|
||||
return children.reduce((closest, child) =>
|
||||
{
|
||||
const childCenter = child.offsetLeft + child.offsetWidth / 2;
|
||||
const closestCenter = closest.offsetLeft + closest.offsetWidth / 2;
|
||||
return Math.abs(childCenter - center) < Math.abs(closestCenter - center)
|
||||
? child
|
||||
: closest;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (preview !== undefined && scrollRef.current)
|
||||
{
|
||||
Array.from(scrollRef.current.children)[preview].scrollIntoView({ inline: 'center', behavior: 'instant' });
|
||||
}
|
||||
|
||||
}, [preview]);
|
||||
|
||||
const handleScroll = (dir: number, element: HTMLDivElement) =>
|
||||
{
|
||||
const current = findClosestElementToCenter(element);
|
||||
|
||||
const next = (dir > 0 ? current.nextElementSibling : current.previousElementSibling) as HTMLElement | null;
|
||||
if (!next) return;
|
||||
|
||||
// scroll so next element is centered
|
||||
element.scrollTo({
|
||||
left: next.offsetLeft - element.clientWidth / 2 + next.offsetWidth / 2,
|
||||
behavior: "smooth"
|
||||
});
|
||||
};
|
||||
|
||||
useShortcuts(`screenshots-context-dialog`, () => [
|
||||
{
|
||||
button: GamePadButtonCode.Left,
|
||||
label: "Left",
|
||||
action: () =>
|
||||
{
|
||||
if (preview === undefined) return;
|
||||
setPreview((data.screenshots.length + preview - 1) % data.screenshots.length);
|
||||
}
|
||||
},
|
||||
{
|
||||
button: GamePadButtonCode.Right,
|
||||
label: "Right",
|
||||
action: () =>
|
||||
{
|
||||
if (preview === undefined) return;
|
||||
setPreview((preview + 1) % data.screenshots.length);
|
||||
}
|
||||
}
|
||||
], [preview, focusKey]);
|
||||
|
||||
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}`)} />
|
||||
<Carousel scrollHandler={handleScroll} scrollRef={scrollRef} rootClassName="h-full" className="flex gap-6 px-16 py-2 overflow-x-scroll no-scrollbar justify-center-safe h-full" >
|
||||
{data.screenshots.map((s, i) => <Screenshot key={s} index={i} path={s} onAction={() => setPreview(i)} />)}
|
||||
</Carousel>
|
||||
<FocusDots scrollElement={scrollRef} />
|
||||
</FocusContext>
|
||||
{preview !== undefined && <ContextDialog id="screenshots" close={() =>
|
||||
{
|
||||
setFocus(`screenshot-${preview}`);
|
||||
setPreview(undefined);
|
||||
}} open={true}>
|
||||
<img draggable={false} className="object-cover w-full h-full rounded-2xl" src={`${RPC_URL(__HOST__)}${data.screenshots[preview]}`} loading="lazy" />
|
||||
</ContextDialog>}
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import
|
|||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { CSSProperties } from "react";
|
||||
|
||||
export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error';
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { useState } from "react";
|
||||
import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { changeDownloadsMutation } from "@/mainview/scripts/queries";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
|
||||
export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
|
||||
{
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const setSettingMutation = useMutation({
|
||||
...changeDownloadsMutation,
|
||||
...queries.settings.changeDownloadsMutation,
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
setDirty(r !== localValue);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef, useState } from "react";
|
||||
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useOptionContext } from "./OptionSpace";
|
||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
|
@ -25,15 +24,9 @@ export function OptionDropdown (data: {
|
|||
setOpen(true);
|
||||
};
|
||||
const handleClose = () => setOpen(false);
|
||||
const { ref, focused, focusKey } = useFocusable({
|
||||
const { ref } = useFocusable({
|
||||
focusKey: data.name, onEnterPress: handlePress
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const option = useOptionContext({
|
||||
onOptionEnterPress: handlePress,
|
||||
});
|
||||
|
||||
const valueIndex = data.value ? data.values?.indexOf(data.value) : -1;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function OptionInput (data: {
|
|||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
const { ref, focused } = useFocusable({
|
||||
const { ref } = useFocusable({
|
||||
focusKey: data.name, onEnterPress: handlePress
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function OptionSpace (data: {
|
|||
})
|
||||
{
|
||||
const eventTarget = useMemo(() => new EventTarget(), []);
|
||||
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
|
||||
const { ref, focused, focusSelf, focusKey } = useFocusable({
|
||||
focusKey: data.id,
|
||||
focusable: data.focusable !== false,
|
||||
trackChildren: true,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { HTMLInputTypeAttribute, JSX, useCallback, useState } from "react";
|
||||
import { HTMLInputTypeAttribute, JSX, useEffect, 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";
|
||||
import { Button } from "./Button";
|
||||
import { FileSearchCorner, FolderSearch, Pen, Save } from "lucide-react";
|
||||
import { ContextDialog } from "../ContextDialog";
|
||||
import FilePicker from "../FilePicker";
|
||||
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
|
||||
type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
|
|
@ -32,14 +32,8 @@ export function PathSettingsOption (data: PathSettingsOptionParams)
|
|||
{
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
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 setMutation = useMutation({
|
||||
...queries.settings.setSettingMutation(data.id),
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
setDirty(r !== localValue);
|
||||
|
|
@ -51,7 +45,7 @@ export function PathSettingsOption (data: PathSettingsOptionParams)
|
|||
label={data.label}
|
||||
id={data.id}
|
||||
type={data.type}
|
||||
save={setSettingMutation.mutate}
|
||||
save={setMutation.mutate}
|
||||
localValue={localValue}
|
||||
allowNewFolderCreation={data.allowNewFolderCreation}
|
||||
setLocalValue={(v) =>
|
||||
|
|
@ -69,22 +63,17 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
|||
})
|
||||
{
|
||||
const [isBrowsing, setIsBrowsing] = useState(false);
|
||||
const { data: defaultValue } = 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 (!data.isDirty)
|
||||
{
|
||||
data.setLocalValue(String(value.value));
|
||||
}
|
||||
return value.value;
|
||||
},
|
||||
});
|
||||
const { data: defaultValue } = useQuery(queries.settings.getSettingQuery(data.id));
|
||||
const changed = defaultValue !== data.localValue;
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!data.isDirty)
|
||||
{
|
||||
data.setLocalValue(String(defaultValue));
|
||||
}
|
||||
}, [data.isDirty, defaultValue]);
|
||||
|
||||
const handleSelectPath = (path: string) =>
|
||||
{
|
||||
data.setLocalValue(path);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
|
||||
type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
|
|
@ -20,36 +20,15 @@ export function SettingsOption (data: {
|
|||
{
|
||||
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;
|
||||
}
|
||||
});
|
||||
useQuery(queries.settings.getSettingQuery(data.id));
|
||||
const setMutation = useMutation(queries.settings.setSettingMutation(data.id));
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
if (dirty)
|
||||
{
|
||||
setDirty(false);
|
||||
setSettingMutation.mutate(localValue);
|
||||
setMutation.mutate(localValue);
|
||||
}
|
||||
}, [dirty, setDirty, localValue]);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Router } from "@/mainview";
|
|||
import { StoreEmulatorCard } from "./StoreEmulatorCard";
|
||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||
import { FrontEndEmulator } from "@/shared/constants";
|
||||
import Carousel from "../Carousel";
|
||||
|
||||
function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant: boolean; }) => void; })
|
||||
{
|
||||
|
|
@ -34,7 +35,7 @@ function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (detail
|
|||
|
||||
export function EmulatorsSection (data: {
|
||||
id: string;
|
||||
emulators: FrontEndEmulator[];
|
||||
emulators?: FrontEndEmulator[];
|
||||
onSelect?: (id: string, focusKey: string) => void;
|
||||
header?: any;
|
||||
} & FocusParams)
|
||||
|
|
@ -60,17 +61,19 @@ export function EmulatorsSection (data: {
|
|||
</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">
|
||||
|
||||
<Carousel scrollRef={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' });
|
||||
}} />
|
||||
))}
|
||||
)) ?? Array.from({ length: 8 }).map((_, i) => <div key={i} className="skeleton h-38 w-full rounded-4xl" />)}
|
||||
<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>
|
||||
</Carousel>
|
||||
|
||||
</section>
|
||||
{!!data.emulators && <FocusDots elements={data.emulators.map(e => FOCUS_KEYS.EMULATOR_CARD(e.name))} />}
|
||||
<FocusDots elements={data.emulators?.map(e => FOCUS_KEYS.EMULATOR_CARD(e.name))} />
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,15 +4,16 @@ import
|
|||
useFocusable,
|
||||
FocusContext,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { Gamepad2 } from "lucide-react";
|
||||
import { Gamepad2, Star } 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";
|
||||
import Carousel from "../Carousel";
|
||||
|
||||
export function GamesSection ({ games, onSelect, onFocus }: {
|
||||
games: FrontEndGameType[];
|
||||
games?: FrontEndGameType[];
|
||||
onSelect?: (id: FrontEndId, focusKey: string) => void;
|
||||
} & FocusParams)
|
||||
{
|
||||
|
|
@ -33,17 +34,17 @@ export function GamesSection ({ games, onSelect, onFocus }: {
|
|||
<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 className="flex gap-2 bg-accent text-accent-content rounded-full py-1 px-4 font-semibold opacity-80"><Star />Creator 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
|
||||
<Carousel controlsClassName="z-20" scrollRef={containerRef} className="flex *:w-[18rem] *:min-w-[18rem] *:h-[21rem] 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>
|
||||
index={i} />) ?? Array.from({ length: 8 }).map((_, i) => <div key={i} className="skeleton h-38 w-full" />)}
|
||||
</Carousel>
|
||||
</section>
|
||||
<FocusDots elements={games.map(e => FOCUS_KEYS.GAME_CARD(e.id.id))} />
|
||||
<FocusDots elements={games?.map(e => FOCUS_KEYS.GAME_CARD(e.id.id)) ?? []} />
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { storeApi } from "@/mainview/scripts/clientApi";
|
||||
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Joystick, LibraryBig, Save, TriangleAlert } from "lucide-react";
|
||||
|
||||
|
|
@ -14,14 +15,7 @@ export function StatsSection ({
|
|||
}: 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;
|
||||
}
|
||||
});
|
||||
const { data: stats } = useQuery(queries.store.storeGetStatsQuery);
|
||||
|
||||
return (
|
||||
<section className="px-6 pt-3 pb-4">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue