feat: Implemented emulator installation
feat: Updated romm API version feat: Updated es-de rules feat: Added tabs to game details refactor: returned to global query definitions to help with typescript performance
This commit is contained in:
parent
cf6fff6fac
commit
3750e9ed8f
103 changed files with 4888 additions and 1632 deletions
|
|
@ -33,6 +33,7 @@ export interface GameCardParams
|
|||
onFocus?: GameCardFocusHandler;
|
||||
onBlur?: (id: string) => void;
|
||||
clickFocuses?: boolean;
|
||||
previewClassName?: string;
|
||||
}
|
||||
|
||||
export default function CardElement (data: GameCardParams & InteractParams)
|
||||
|
|
@ -53,7 +54,7 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
|||
role="button"
|
||||
ref={ref}
|
||||
style={{
|
||||
scrollSnapAlign: "center"
|
||||
scrollSnapAlign: isPointer ? "center" : "none"
|
||||
}}
|
||||
onFocus={focusSelf}
|
||||
onDoubleClick={e => data.onAction?.(e.nativeEvent)}
|
||||
|
|
@ -74,7 +75,7 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
|||
classNames({ "h-full": typeof data.preview === "string" })
|
||||
)}>
|
||||
{typeof data.preview === "string" ? (
|
||||
<img draggable={false} className={classNames("object-cover w-full h-full", { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
|
||||
<img draggable={false} className={classNames("object-cover w-full h-full", data.previewClassName, { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
|
||||
) : (
|
||||
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@ 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";
|
||||
import { getCollectionsQuery } from "@queries/romm";
|
||||
|
||||
export default function CollectionList (data: {
|
||||
id: string,
|
||||
|
|
@ -17,12 +15,11 @@ export default function CollectionList (data: {
|
|||
})
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { data: collections } = useSuspenseQuery(queries.romm.getCollectionsQuery());
|
||||
const { data: collections } = useSuspenseQuery(getCollectionsQuery());
|
||||
|
||||
const handleDefaultSelect = (id: string) =>
|
||||
{
|
||||
SaveSource('game-list', { search: { focus: getCurrentFocusKey() } });
|
||||
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
navigate({ to: `/collection/${id}` });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -36,7 +33,7 @@ export default function CollectionList (data: {
|
|||
id: String(g.id),
|
||||
title: g.name,
|
||||
focusKey: `collection-${g.id}`,
|
||||
subtitle: g.user__username,
|
||||
subtitle: g.owner_username,
|
||||
previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`,
|
||||
badges: [
|
||||
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/
|
|||
import { PopNavigateSource } from '../scripts/spatialNavigation';
|
||||
import { GameListFilterType } from '@/shared/constants';
|
||||
import { GameCardFocusHandler } from './CardElement';
|
||||
import { Router } from '..';
|
||||
import { HandleGoBack } from '../scripts/utils';
|
||||
|
||||
export interface CollectionsDetailParams
|
||||
{
|
||||
|
|
@ -30,7 +32,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
preferredChildFocusKey: `${focusKey}-list`,
|
||||
});
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => PopNavigateSource('game-list', '/') }]);
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
|
||||
const handleScroll: GameCardFocusHandler = (id, node, details) =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { FocusContext, FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { JSX, useContext, useEffect } from "react";
|
||||
import { JSX, useContext, useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { X } from "lucide-react";
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
||||
|
|
@ -67,21 +67,61 @@ export interface DialogEntry
|
|||
shortcuts?: Shortcut[];
|
||||
}
|
||||
|
||||
export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; })
|
||||
{
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sourceFocusKey, setSourceFocusKey] = useState<string | undefined>(undefined);
|
||||
const dialog = <ContextDialog id={id} open={open} close={() =>
|
||||
{
|
||||
setOpen(false);
|
||||
data.onClose?.();
|
||||
}} className={data.className} sourceFocusKey={sourceFocusKey} preferredChildFocusKey={data.preferredChildFocusKey}>
|
||||
{data.content}
|
||||
</ContextDialog>;
|
||||
return {
|
||||
dialog,
|
||||
open,
|
||||
setOpen: (value: boolean, sourceFocusKey?: string) =>
|
||||
{
|
||||
if (value === open) return;
|
||||
if (value)
|
||||
{
|
||||
setOpen(true);
|
||||
setSourceFocusKey(sourceFocusKey);
|
||||
} else
|
||||
{
|
||||
setOpen(false);
|
||||
if (sourceFocusKey)
|
||||
{
|
||||
setFocus(sourceFocusKey);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function ContextDialog (data: {
|
||||
id: string,
|
||||
children: any | any[],
|
||||
open: boolean,
|
||||
close: () => void;
|
||||
close: (open: boolean) => void;
|
||||
className?: string;
|
||||
preferredChildFocusKey?: string;
|
||||
sourceFocusKey?: string;
|
||||
})
|
||||
{
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
focusable: data.open,
|
||||
focusKey: `${data.id}-context-dialog`,
|
||||
isFocusBoundary: true,
|
||||
saveLastFocusedChild: !data.preferredChildFocusKey,
|
||||
preferredChildFocusKey: data.preferredChildFocusKey
|
||||
});
|
||||
const handleClose = () =>
|
||||
{
|
||||
data.close(false);
|
||||
};
|
||||
useEffect(() =>
|
||||
{
|
||||
if (data.open)
|
||||
|
|
@ -93,22 +133,16 @@ export function ContextDialog (data: {
|
|||
useShortcuts(focusKey, () => data.open ? [{
|
||||
label: "Close",
|
||||
button: GamePadButtonCode.B,
|
||||
action: () =>
|
||||
{
|
||||
data.close();
|
||||
}
|
||||
action: handleClose
|
||||
}] : [], [data.open]);
|
||||
|
||||
return <dialog ref={ref} open={data.open} closedby="any" className={
|
||||
twMerge("fixed modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
||||
classNames({ "opacity-0": !data.open }))
|
||||
}
|
||||
onClick={() =>
|
||||
{
|
||||
if (data.open) data.close();
|
||||
}}>
|
||||
onClick={handleClose}>
|
||||
<FocusContext value={focusKey}>
|
||||
<ContextDialogContext value={{ id: data.id, close: data.close }} >
|
||||
<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",
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"
|
|||
import SvgIcon from "./SvgIcon";
|
||||
import { Button } from "./options/Button";
|
||||
import toast from "react-hot-toast";
|
||||
import queries from "../scripts/queries";
|
||||
import { FilePickerContext } from "../scripts/contexts";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
import { createFolderMutation, drivesQuery, filesQuery } from "@queries/system";
|
||||
|
||||
function List (data: {
|
||||
id: string,
|
||||
|
|
@ -113,7 +113,7 @@ function NewFolderOption (data: { id: string, dirname: string; })
|
|||
const { refetchFiles } = useContext(FilePickerContext);
|
||||
const [name, setName] = useState<string | undefined>();
|
||||
const createMutation = useMutation({
|
||||
...queries.system.createFolderMutation(data.id),
|
||||
...createFolderMutation(data.id),
|
||||
onError: (e) => toast.error(e.message ?? 'Error Creating New Folder'),
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
|
|
@ -228,8 +228,8 @@ export default function FilePicker (data: {
|
|||
{
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(data.startingPath);
|
||||
|
||||
const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(queries.system.filesQuery(currentPath, data.id));
|
||||
const { data: drives, isLoading: drivesLoading } = useQuery(queries.system.drivesQuery);
|
||||
const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(filesQuery(currentPath, data.id));
|
||||
const { data: drives, isLoading: drivesLoading } = useQuery(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];
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import
|
||||
{
|
||||
FocusContext,
|
||||
setFocus,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import SvgIcon from "./SvgIcon";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEffect } from "react";
|
||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||
|
||||
function FilterCat (
|
||||
data: {
|
||||
id: string;
|
||||
children?: any;
|
||||
active: boolean;
|
||||
hasFocusedPeer: boolean;
|
||||
} & FilterOption & FocusParams,
|
||||
)
|
||||
{
|
||||
|
|
@ -26,9 +28,10 @@ function FilterCat (
|
|||
aria-selected={data.active}
|
||||
ref={ref}
|
||||
onClick={focusSelf}
|
||||
className={"sm:text-sm sm:px-2 flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg focusable focusable-primary hover:not-focused:not-aria-selected:bg-base-content/40 not-focused:cursor-pointer aria-selected:bg-base-content aria-selected:text-base-300 aria-selected:drop-shadow aria-selected:cursor-default active:bg-accent! active:text-accent-content! active:ring-offset-7 active:ring-offset-base-content select-none"}
|
||||
className={"sm:text-sm sm:px-2 flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg focusable focusable-primary hover:not-focused:not-aria-selected:bg-base-content/40 not-focused:cursor-pointer aria-selected:bg-base-content aria-selected:text-base-300 aria-selected:drop-shadow aria-selected:cursor-default active:bg-accent! active:text-accent-content! active:ring-offset-7 active:ring-offset-base-content select-none gap-1"}
|
||||
>
|
||||
{data.children ?? data.label}
|
||||
{data.icon ? <><div className="sm:portrait:px-2">{data.icon}</div><div className="sm:portrait:hidden md:inline">{data.children ?? data.label}</div></> : <div>{data.children ?? data.label}</div>}
|
||||
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -39,6 +42,8 @@ export function FilterUI (data: {
|
|||
setSelected: (id: string) => void;
|
||||
containerClassName?: string;
|
||||
className?: string;
|
||||
rootFocusKey?: string;
|
||||
showShortcuts?: boolean;
|
||||
})
|
||||
{
|
||||
const defaultFocus = Object.entries(data.options).filter(o => o[1].selected)[0]?.[0];
|
||||
|
|
@ -50,29 +55,72 @@ export function FilterUI (data: {
|
|||
trackChildren: true
|
||||
});
|
||||
|
||||
if (data.rootFocusKey)
|
||||
{
|
||||
useShortcuts(data.rootFocusKey, () => [
|
||||
{
|
||||
action: (e) =>
|
||||
{
|
||||
const filterKeys = Object.keys(data.options);
|
||||
const filterIndex = Math.max(0, filterKeys.findIndex(f => data.options[f].selected));
|
||||
const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1);
|
||||
const newFilter = filterKeys[selectedFilterIndex];
|
||||
if (!data.options[newFilter].selected)
|
||||
{
|
||||
data.setSelected(newFilter);
|
||||
}
|
||||
},
|
||||
button: GamePadButtonCode.R1
|
||||
},
|
||||
{
|
||||
action: (e) =>
|
||||
{
|
||||
const filterKeys = Object.keys(data.options);
|
||||
const filterIndex = Math.max(0, filterKeys.findIndex(f => data.options[f as any].selected));
|
||||
const selectedFilterIndex = Math.max(0, filterIndex - 1,);
|
||||
const newFilter = filterKeys[selectedFilterIndex];
|
||||
if (!data.options[newFilter].selected)
|
||||
data.setSelected(newFilter);
|
||||
},
|
||||
button: GamePadButtonCode.L1
|
||||
}], [data.options]);
|
||||
}
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (hasFocusedChild)
|
||||
{
|
||||
setFocus(`${data.id}-${defaultFocus}`);
|
||||
}
|
||||
}, [hasFocusedChild, defaultFocus, data.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={data.containerClassName}
|
||||
style={{ viewTransitionName: `filter-${data.id}` }}
|
||||
>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<ul className={twMerge("flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm sm:portrait:h-12 sm:landscape:h-9 md:h-14!", data.className)}>
|
||||
<li className=" flex px-4 items-center justify-center rounded-full">
|
||||
{!!data.rootFocusKey && (data.showShortcuts ?? true) && <li className=" flex px-4 items-center justify-center rounded-full">
|
||||
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_l1_outline" />
|
||||
</li>
|
||||
</li>}
|
||||
{Object.entries(data.options)?.map(([id, option]) => (
|
||||
<FilterCat
|
||||
hasFocusedPeer={hasFocusedChild}
|
||||
id={`${data.id}-${id}`}
|
||||
key={id}
|
||||
onFocus={() => data.setSelected(id)}
|
||||
onFocus={() =>
|
||||
{
|
||||
if (!option.selected)
|
||||
data.setSelected(id);
|
||||
}}
|
||||
active={option.selected}
|
||||
{...option}
|
||||
/>
|
||||
))}
|
||||
<li className="flex px-4 items-center justify-center rounded-full">
|
||||
{!!data.rootFocusKey && (data.showShortcuts ?? true) && <li className="flex px-4 items-center justify-center rounded-full">
|
||||
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_r1_outline" />
|
||||
</li>
|
||||
</li>}
|
||||
</ul>
|
||||
</FocusContext.Provider>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ export default function FocusDots (data: {
|
|||
scrollElement?: RefObject<HTMLElement | null>;
|
||||
})
|
||||
{
|
||||
|
||||
const focusedKey = useGlobalFocus();
|
||||
let elements = useMemo(() =>
|
||||
{
|
||||
|
|
@ -62,7 +61,7 @@ export default function FocusDots (data: {
|
|||
|
||||
return childrenArray.map((c, i) =>
|
||||
{
|
||||
return <ScrollDot parent={data.scrollElement!} index={i} peers={childrenArray as HTMLElement[]} />;
|
||||
return <ScrollDot key={i} parent={data.scrollElement!} index={i} peers={childrenArray as HTMLElement[]} />;
|
||||
});
|
||||
} else
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,18 +1,15 @@
|
|||
import { FrontEndGameType, FrontEndId, RPC_URL } from "@/shared/constants";
|
||||
import CardElement from "./CardElement";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { Router } from "..";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { FileQuestion, HardDrive, Store } from "lucide-react";
|
||||
import { JSX } from "react";
|
||||
import { FOCUS_KEYS } from "../scripts/types";
|
||||
|
||||
export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; } & FocusParams & InteractParams)
|
||||
export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; showSource?: boolean; } & FocusParams & InteractParams)
|
||||
{
|
||||
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null)
|
||||
{
|
||||
SaveSource('details', { search: { focus: FOCUS_KEYS.GAME_CARD(data.game.id.id) } });
|
||||
console.log({ id: String(sourceId ?? id.id), source: source ?? id.source });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } });
|
||||
};
|
||||
|
||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`);
|
||||
|
|
@ -27,7 +24,26 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG
|
|||
previewUrl.searchParams.set('width', "640");
|
||||
|
||||
const badges: JSX.Element[] = [];
|
||||
if (data.game.id.source === 'local')
|
||||
|
||||
if (data.showSource)
|
||||
{
|
||||
switch (data.game.id.source)
|
||||
{
|
||||
case "local":
|
||||
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
|
||||
break;
|
||||
case "romm":
|
||||
badges.push(<img className="sm:size-4 md:size-8 m-1 rounded-full" src={`${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`} />);
|
||||
break;
|
||||
case "store":
|
||||
badges.push(<Store className="sm:size-4 md:size-8 m-1" />);
|
||||
break;
|
||||
default:
|
||||
badges.push(<FileQuestion className="sm:size-4 md:size-8 m-1" />);
|
||||
break;
|
||||
}
|
||||
|
||||
} else if (data.game.id.source === 'local')
|
||||
{
|
||||
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
|
||||
}
|
||||
|
|
@ -39,7 +55,9 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG
|
|||
preview={previewUrl.href}
|
||||
title={data.game.name ?? ""}
|
||||
subtitle={subtitle}
|
||||
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id.id)}
|
||||
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)}
|
||||
className={data.game.id.source === 'local' ? 'ring-offset-info/40 ring-offset-2' : ""}
|
||||
previewClassName={data.game.id.source === 'local' ? "not-in-focused:opacity-40" : ""}
|
||||
index={data.index}
|
||||
id={`game-${data.game.id.source}-${data.game.id.id}`}
|
||||
/>;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@ import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
|||
import { GameMetaExtra, CardList } from "./CardList";
|
||||
import { FrontEndGameType, FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { FileQuestion, HardDrive, Store } from "lucide-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";
|
||||
import { allGamesQuery } from "@queries/romm";
|
||||
|
||||
export interface GameListParams
|
||||
{
|
||||
|
|
@ -25,7 +24,7 @@ export interface GameListParams
|
|||
|
||||
export function GameList (data: GameListParams)
|
||||
{
|
||||
const games = useSuspenseQuery(queries.romm.allGamesQuery(data.filters));
|
||||
const games = useSuspenseQuery(allGamesQuery(data.filters));
|
||||
const navigator = useNavigate();
|
||||
const blur = useLocalSetting('backgroundBlur');
|
||||
const backgroundContext = useContext(AnimatedBackgroundContext);
|
||||
|
|
@ -51,8 +50,7 @@ export function GameList (data: GameListParams)
|
|||
|
||||
function handleDefaultSelect (g: FrontEndGameType)
|
||||
{
|
||||
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'] } });
|
||||
navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -74,6 +72,7 @@ export function GameList (data: GameListParams)
|
|||
{
|
||||
badges.push(<HardDrive className="sm:size-4 md:size-8 md:p-1 m-1" />);
|
||||
}
|
||||
|
||||
const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
|
||||
previewUrl.searchParams.delete('ts');
|
||||
previewUrl.searchParams.set('width', "16");
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@ import { RoundButton } from "./RoundButton";
|
|||
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 } from "../scripts/spatialNavigation";
|
||||
import { JSX, Ref, RefObject, useEffect, useRef, useState } from "react";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { Router } from "..";
|
||||
import { useStickyDataAttr } from "../scripts/utils";
|
||||
|
||||
function HeaderAvatar (data: {
|
||||
id: string;
|
||||
|
|
@ -240,8 +240,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
|||
],
|
||||
action: () =>
|
||||
{
|
||||
SaveSource('settings');
|
||||
Router.navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } });
|
||||
Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } });
|
||||
},
|
||||
status: user.data ? "status-success" : 'status-error',
|
||||
type: 'secondary'
|
||||
|
|
@ -284,15 +283,19 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
|
|||
</div>;
|
||||
}
|
||||
|
||||
export function HeaderUI (data: {
|
||||
interface HeaderUIParams
|
||||
{
|
||||
buttons?: HeaderButton[];
|
||||
accounts?: HeaderAccount[];
|
||||
buttonElements?: JSX.Element[] | JSX.Element;
|
||||
title?: JSX.Element;
|
||||
preferredChildFocusKey?: string;
|
||||
})
|
||||
focusable?: boolean;
|
||||
}
|
||||
|
||||
export function HeaderUI (data: HeaderUIParams)
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "header-elements", preferredChildFocusKey: data.preferredChildFocusKey });
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "header-elements", focusable: data.focusable, preferredChildFocusKey: data.preferredChildFocusKey });
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<header
|
||||
|
|
@ -307,3 +310,18 @@ export function HeaderUI (data: {
|
|||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function StickyHeaderUI (data: { ref: RefObject<any>; } & HeaderUIParams)
|
||||
{
|
||||
const [isStuck, setIsStuck] = useState(false);
|
||||
const headerRef = useRef(null);
|
||||
const sentinelRef = useRef(null);
|
||||
useStickyDataAttr(headerRef, sentinelRef, data.ref, setIsStuck);
|
||||
|
||||
return <>
|
||||
<div ref={sentinelRef} className="h-0" />
|
||||
<div ref={headerRef} className='sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15'>
|
||||
<HeaderUI focusable={!isStuck} {...data} />
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { FOCUS_KEYS } from "../scripts/types";
|
||||
import { useIntersectionObserver } from "usehooks-ts";
|
||||
import { FrontEndId } from "@/shared/constants";
|
||||
|
||||
export default function LoadMoreButton (data: { isFetching: boolean; lastId?: string; } & FocusParams & InteractParams)
|
||||
export default function LoadMoreButton (data: { isFetching: boolean; lastId?: FrontEndId; } & FocusParams & InteractParams)
|
||||
{
|
||||
const handleAction = (e?: Event) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Notification, RPC_URL } from "@/shared/constants";
|
||||
import { useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import toast, { ToastOptions } from "react-hot-toast";
|
||||
|
||||
export default function Notifications (data: {})
|
||||
{
|
||||
|
|
@ -10,15 +10,16 @@ export default function Notifications (data: {})
|
|||
es.addEventListener('notification', (e) =>
|
||||
{
|
||||
const notification = JSON.parse(e.data) as Notification;
|
||||
const options: ToastOptions = { removeDelay: notification.duration };
|
||||
if (notification.type === 'error')
|
||||
{
|
||||
toast.error(notification.message);
|
||||
toast.error(notification.message, options);
|
||||
} else if (notification.type === 'success')
|
||||
{
|
||||
toast.success(notification.message);
|
||||
toast.success(notification.message, options);
|
||||
} else
|
||||
{
|
||||
toast.custom(notification.message);
|
||||
toast.custom(notification.message, options);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useNavigate } from "@tanstack/react-router";
|
|||
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
|
||||
import { CardList, GameMetaExtra } from "./CardList";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { JSX, useMemo } from "react";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { GameCardFocusHandler } from "./CardElement";
|
||||
|
|
@ -37,8 +36,7 @@ export function PlatformsList (data: {
|
|||
|
||||
const handleDefaultSelect = (source: string, id: string) =>
|
||||
{
|
||||
SaveSource('game-list');
|
||||
navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
navigate({ to: `/platform/${source}/${id}` });
|
||||
};
|
||||
|
||||
const platformsMapped = useMemo(() => platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { RPC_URL } from "@/shared/constants";
|
||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Dispatch, SetStateAction, 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";
|
||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams)
|
||||
{
|
||||
|
|
@ -26,7 +27,43 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n
|
|||
</div>;
|
||||
}
|
||||
|
||||
export default function Screenshots (data: { screenshots: string[]; } & FocusParams)
|
||||
function Preview (data: { id: string; screenshots?: string[]; preview: number; setPreview: Dispatch<SetStateAction<number | undefined>>; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: data.id });
|
||||
|
||||
useShortcuts(focusKey, () => [
|
||||
{
|
||||
button: GamePadButtonCode.Left,
|
||||
label: "Left",
|
||||
action: () =>
|
||||
{
|
||||
if (data.preview === undefined || !data.screenshots) return;
|
||||
data.setPreview(p =>
|
||||
{
|
||||
if (!data.screenshots) return p;
|
||||
return (data.screenshots.length + (p ?? 0) - 1) % data.screenshots.length;
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
button: GamePadButtonCode.Right,
|
||||
label: "Right",
|
||||
action: () =>
|
||||
{
|
||||
if (data.preview === undefined || !data.screenshots) return;
|
||||
data.setPreview(p =>
|
||||
{
|
||||
if (!data.screenshots) return p;
|
||||
return (p ?? 0 + 1) % data.screenshots.length;
|
||||
});
|
||||
}
|
||||
}
|
||||
], [data.preview, focusKey, data.screenshots?.length ?? 0]);
|
||||
|
||||
return <img ref={ref} draggable={false} className="object-cover w-full h-full rounded-2xl" src={`${RPC_URL(__HOST__)}${data.screenshots?.[data.preview]}`} loading="lazy" />;
|
||||
}
|
||||
|
||||
export default function Screenshots (data: { screenshots?: string[]; className?: string; } & FocusParams)
|
||||
{
|
||||
const [preview, setPreview] = useState<number | undefined>(undefined);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -41,9 +78,10 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
|
|||
|
||||
useEffect(() =>
|
||||
{
|
||||
if ((focused || hasFocusedChild) && scrollRef.current)
|
||||
if ((focused || hasFocusedChild) && scrollRef.current && data.screenshots)
|
||||
{
|
||||
const closest = findClosestElementToCenter(scrollRef.current);
|
||||
if (!closest) return;
|
||||
const closestIndex = Array.from(scrollRef.current.children).indexOf(closest);
|
||||
setFocus(`screenshot-${closestIndex}`);
|
||||
}
|
||||
|
|
@ -54,6 +92,7 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
|
|||
const center = element.scrollLeft + element.clientWidth / 2;
|
||||
|
||||
const children = Array.from(element.children) as HTMLElement[];
|
||||
if (children.length <= 0) return undefined;
|
||||
|
||||
// find child closest to center
|
||||
return children.reduce((closest, child) =>
|
||||
|
|
@ -78,7 +117,7 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
|
|||
const handleScroll = (dir: number, element: HTMLDivElement) =>
|
||||
{
|
||||
const current = findClosestElementToCenter(element);
|
||||
|
||||
if (!current) return;
|
||||
const next = (dir > 0 ? current.nextElementSibling : current.previousElementSibling) as HTMLElement | null;
|
||||
if (!next) return;
|
||||
|
||||
|
|
@ -89,42 +128,21 @@ export default function Screenshots (data: { screenshots: string[]; } & FocusPar
|
|||
});
|
||||
};
|
||||
|
||||
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">
|
||||
return <div ref={ref} className={twMerge("flex flex-col w-full z-0 min-h-0", data.className)}>
|
||||
<FocusContext value={focusKey}>
|
||||
<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)} />)}
|
||||
{data.screenshots?.map((s, i) => <Screenshot key={s} index={i} path={s} onAction={() => setPreview(i)} />) ?? <div className="skeleton w-32 h-32"></div>}
|
||||
</Carousel>
|
||||
<FocusDots scrollElement={scrollRef} />
|
||||
</FocusContext>
|
||||
{preview !== undefined && <ContextDialog id="screenshots" close={() =>
|
||||
{
|
||||
setFocus(`screenshot-${preview}`);
|
||||
setFocus(`screenshot-${preview}`, { instant: true });
|
||||
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" />
|
||||
<Preview id="screenshot-preview" screenshots={data.screenshots} preview={preview} setPreview={setPreview} />
|
||||
</ContextDialog>}
|
||||
</div>;
|
||||
}
|
||||
50
src/mainview/components/StatList.tsx
Normal file
50
src/mainview/components/StatList.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { JSX } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export interface StatEntry
|
||||
{
|
||||
icon?: JSX.Element,
|
||||
label: string | JSX.Element,
|
||||
content: string | JSX.Element | string[];
|
||||
}
|
||||
|
||||
function Label (data: { id: string, label: string | JSX.Element; })
|
||||
{
|
||||
return <div className="font-semibold focused:text-accent">{data.label}:</div>;
|
||||
}
|
||||
|
||||
export default function StatList (data: {
|
||||
id: string;
|
||||
stats: StatEntry[];
|
||||
elementClassName?: string;
|
||||
focusable?: boolean;
|
||||
} & FocusParams)
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: data.id,
|
||||
focusable: data.focusable,
|
||||
onFocus: (l, p, details) => data.onFocus?.(focusKey, ref.current, details)
|
||||
});
|
||||
|
||||
return <ul ref={ref} className="grid md:grid-cols-[8rem_1fr] sm:px-8 md:px-16 py-4 gap-2 focused:border-y focused:border-dashed focused:border-base-content/40">
|
||||
<FocusContext value={focusKey}>
|
||||
{data.stats.map((s, i) =>
|
||||
{
|
||||
let content: any = undefined;
|
||||
if (s.content instanceof Array)
|
||||
{
|
||||
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-full 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-full bg-base-200 px-3 py-1", data.elementClassName)}>{s.icon}{s.content}</div>;
|
||||
}
|
||||
const element = <>
|
||||
<Label id={`${data.id}-label-${i}`} key={`label-${i}`} label={s.label} />
|
||||
{content}
|
||||
</>;
|
||||
return element;
|
||||
})}
|
||||
</FocusContext>
|
||||
</ul>;
|
||||
}
|
||||
35
src/mainview/components/game/Achievements.tsx
Normal file
35
src/mainview/components/game/Achievements.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement } from "@/shared/constants";
|
||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { Medal } from "lucide-react";
|
||||
|
||||
function Achievement (data: { index: number, achievement: FrontEndGameTypeDetailedAchievement; } & FocusParams)
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `achievement-${data.index}`, onFocus: (l, p, details) => data.onFocus?.(focusKey, ref.current, details) });
|
||||
return <div ref={ref} className="flex focusable focusable-primary gap-4 p-4 bg-base-300 rounded-3xl items-center scroll-mb-16 scroll-mt-32">
|
||||
<div data-unlocked={!!data.achievement.date} data-hardcore={!!data.achievement.date_hardcode} className="data-[unlocked=true]:ring-4 aspect-square data-[unlocked=true]:ring-offset-4 ring-accent ring-offset-warning rounded-2xl overflow-hidden">
|
||||
<img className="scale-110" src={data.achievement.badge_url} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 sm:flex-col md:flex-row grow justify-between sm:items-start md:items-center">
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
{data.achievement.type === 'win_condition' && <Medal />}
|
||||
<p className="font-semibold">{data.achievement.title}</p>
|
||||
</div>
|
||||
<p className="text-base-content/60">{data.achievement.description}</p>
|
||||
</div>
|
||||
{!!data.achievement.date && <div className="bg-base-100 rounded-3xl px-4 p-1">{data.achievement.date.toDateString()}</div>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function Achievements (data: { game: FrontEndGameTypeDetailed; })
|
||||
{
|
||||
const handleFocus = (key: string, node: HTMLElement, details: any) =>
|
||||
{
|
||||
node.scrollIntoView({ behavior: details?.instant ? 'instant' : 'smooth', block: 'nearest' });
|
||||
};
|
||||
return <div className="grid sm:grid-cols-1 md:grid-cols-3 px-4 gap-2">
|
||||
{data.game.achievements?.entires.map((a, i) => <Achievement index={i} onFocus={handleFocus} key={i} achievement={a} />)}
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { useState } from "react";
|
||||
import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
import { changeDownloadsMutation } from "@queries/settings";
|
||||
|
||||
export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
|
||||
{
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const setSettingMutation = useMutation({
|
||||
...queries.settings.changeDownloadsMutation,
|
||||
...changeDownloadsMutation,
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
setDirty(r !== localValue);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ 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";
|
||||
import { getSettingQuery, setSettingMutation } from "@queries/settings";
|
||||
|
||||
type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
|
|
@ -33,7 +33,7 @@ export function PathSettingsOption (data: PathSettingsOptionParams)
|
|||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const setMutation = useMutation({
|
||||
...queries.settings.setSettingMutation(data.id),
|
||||
...setSettingMutation(data.id),
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
setDirty(r !== localValue);
|
||||
|
|
@ -63,7 +63,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
|||
})
|
||||
{
|
||||
const [isBrowsing, setIsBrowsing] = useState(false);
|
||||
const { data: defaultValue } = useQuery(queries.settings.getSettingQuery(data.id));
|
||||
const { data: defaultValue } = useQuery(getSettingQuery(data.id));
|
||||
const changed = defaultValue !== data.localValue;
|
||||
|
||||
useEffect(() =>
|
||||
|
|
|
|||
|
|
@ -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 queries from "@/mainview/scripts/queries";
|
||||
import { getSettingQuery, setSettingMutation } from "@queries/settings";
|
||||
|
||||
type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
|
|
@ -20,8 +20,8 @@ export function SettingsOption (data: {
|
|||
{
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
useQuery(queries.settings.getSettingQuery(data.id));
|
||||
const setMutation = useMutation(queries.settings.setSettingMutation(data.id));
|
||||
useQuery(getSettingQuery(data.id));
|
||||
const setMutation = useMutation(setSettingMutation(data.id));
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export function EmulatorsSection (data: {
|
|||
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' })} />
|
||||
<SeeAllCard id={`${FOCUS_KEYS.EMULATOR_SECTION}-see-all`} onAction={() => Router.navigate({ to: '/store/tab/emulators', viewTransition: { types: ['zoom-in'] } })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} />
|
||||
</Carousel>
|
||||
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,50 +1,58 @@
|
|||
import { useRef } from "react";
|
||||
import { CSSProperties, Ref, RefObject, useEffect, useRef } from "react";
|
||||
import
|
||||
{
|
||||
useFocusable,
|
||||
FocusContext,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { Gamepad2, Star } from "lucide-react";
|
||||
import { useDragScroll } from "@/mainview/scripts/utils";
|
||||
import { scrollIntoNearestParent, 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";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function GamesSection ({ games, onSelect, onFocus }: {
|
||||
export function GamesSection (data: {
|
||||
games?: FrontEndGameType[];
|
||||
onSelect?: (id: FrontEndId, focusKey: string) => void;
|
||||
className?: string;
|
||||
showSources?: boolean;
|
||||
ref?: Ref<any>;
|
||||
} & FocusParams)
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
const { ref, focusKey, focused, focusSelf } = useFocusable({
|
||||
focusKey: FOCUS_KEYS.GAME_SECTION,
|
||||
trackChildren: true,
|
||||
onFocus: (_l, _p, details) => onFocus?.(focusKey, ref.current, details)
|
||||
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details)
|
||||
});
|
||||
const containerRef = useRef(null);
|
||||
useDragScroll(containerRef);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (focused)
|
||||
focusSelf();
|
||||
}, [!!data.games]);
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<section ref={ref} className="px-6 py-3 select-none">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
|
||||
<Gamepad2 className="text-accent" />
|
||||
<h2 className="font-bold uppercase tracking-widest text-accent grow">
|
||||
Featured Games
|
||||
</h2>
|
||||
<div className="flex gap-2 bg-accent text-accent-content rounded-full py-1 px-4 font-semibold opacity-80"><Star />Creator Picks</div>
|
||||
</div>
|
||||
<section ref={(r) =>
|
||||
{
|
||||
ref.current = r;
|
||||
if (data.ref instanceof Function) data.ref(r);
|
||||
else if (data.ref) data.ref.current = r;
|
||||
}} className={twMerge("select-none", data.className)}>
|
||||
<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
|
||||
{data.games?.map((g, i) => <FrontEndGameCard
|
||||
showSource={data.showSources}
|
||||
key={g.id.id}
|
||||
game={g}
|
||||
onAction={() => onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id.id))}
|
||||
onAction={() => data.onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id))}
|
||||
onFocus={(key, node, details) => scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' })}
|
||||
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={data.games?.map(e => FOCUS_KEYS.GAME_CARD(e.id)) ?? []} />
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
|
||||
import { storeGetStatsQuery } from "@queries/store";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Joystick, LibraryBig, Save, TriangleAlert } from "lucide-react";
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ export function StatsSection ({
|
|||
}: StatsSectionProps)
|
||||
{
|
||||
|
||||
const { data: stats } = useQuery(queries.store.storeGetStatsQuery);
|
||||
const { data: stats } = useQuery(storeGetStatsQuery);
|
||||
|
||||
return (
|
||||
<section className="px-6 pt-3 pb-4">
|
||||
|
|
|
|||
|
|
@ -5,8 +5,18 @@ import { Button } from "../options/Button";
|
|||
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { ChevronRight, EllipsisVertical, HardDrive } from "lucide-react";
|
||||
import { ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Package, Store } from "lucide-react";
|
||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||
import { FlatpackIcon } from "@/mainview/scripts/brandIcons";
|
||||
import { JSX } from "react";
|
||||
|
||||
export const emulatorStatusIcons: Record<string, JSX.Element> = {
|
||||
store: <Store />,
|
||||
custom: <FileQuestion />,
|
||||
flatpak: FlatpackIcon,
|
||||
winget: <Package />,
|
||||
scoop: <IceCream2 />
|
||||
};
|
||||
|
||||
export function StoreEmulatorCard (data: {
|
||||
id: string;
|
||||
|
|
@ -35,7 +45,7 @@ export function StoreEmulatorCard (data: {
|
|||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-installed={data.emulator.exists ? true : undefined}
|
||||
data-installed={!!data.emulator.validSource}
|
||||
onClick={isTouch ? handleSelect : undefined}
|
||||
className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)}
|
||||
>
|
||||
|
|
@ -44,14 +54,14 @@ export function StoreEmulatorCard (data: {
|
|||
<div className="flex gap-2">
|
||||
<div className="flex items-start">
|
||||
<div
|
||||
data-installed={data.emulator.exists}
|
||||
data-installed={!!data.emulator.validSource}
|
||||
className={`size-14 p-2 rounded-full bg-info flex items-center justify-center text-xl shadow-lg data-[installed=true]:bg-success`}
|
||||
>
|
||||
<img draggable={false} src={data.emulator.logo}></img>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p data-installed={data.emulator.exists} className="font-bold text-base-content text-xl leading-snug data-[installed=true]:text-success">{data.emulator.name}</p>
|
||||
<p data-installed={!!data.emulator.validSource} className="font-bold text-base-content text-xl leading-snug data-[installed=true]:text-success">{data.emulator.name}</p>
|
||||
<ul className="flex flex-wrap gap-1">
|
||||
{data.emulator.systems.map(({ id, name, icon }) =>
|
||||
{
|
||||
|
|
@ -66,10 +76,12 @@ export function StoreEmulatorCard (data: {
|
|||
</div>
|
||||
|
||||
<div className="flex gap-0.5 mt-1 h-10 items-center">
|
||||
{data.emulator.exists && <div className="tooltip" data-tip="Installed">
|
||||
<div className="flex items-center justify-center rounded-full p-1 size-8 bg-success text-success-content"><HardDrive /></div>
|
||||
{!!data.emulator.validSource && <div className="tooltip" data-tip={data.emulator.validSource.type}>
|
||||
<div className="flex items-center justify-center rounded-full p-1 size-8 bg-success text-success-content">
|
||||
{emulatorStatusIcons[data.emulator.validSource?.type ?? '']}
|
||||
</div>
|
||||
</div>}
|
||||
{<div className="tooltip" data-tip="Game Count">
|
||||
{data.emulator.gameCount > 0 && <div className="tooltip" data-tip="Game Count">
|
||||
<div className="flex items-center justify-center rounded-full font-semibold size-9 p-2 bg-base-200 text-base-content/40">{data.emulator.gameCount}</div>
|
||||
</div>}
|
||||
{isMouse && <>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue