feat: Made design more responsive
fix: Made blurring server side to help with performance fix: Fixed shortcut useEffect loop
This commit is contained in:
parent
b4a89385d0
commit
9e4b2a02c1
38 changed files with 583 additions and 329 deletions
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import classNames from 'classnames';
|
||||
import React, { createContext, JSX, Ref, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, JSX, Ref, useContext, useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
|
||||
|
|
@ -8,47 +9,48 @@ export const AnimatedBackgroundContext = createContext({} as { setBackground: (u
|
|||
export function AnimatedBackground (data: {
|
||||
children?: any;
|
||||
backgroundKey?: string;
|
||||
backgroundUrl?: string;
|
||||
backgroundUrl?: string | URL;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
className?: string;
|
||||
animated?: boolean,
|
||||
scrolling?: boolean;
|
||||
})
|
||||
{
|
||||
const blurBackground = true;
|
||||
const animateBackground = true;
|
||||
|
||||
const [lastBackgroundUrl, setLastBackgroundUrl] = data.backgroundKey ? useSessionStorage<string | undefined>(
|
||||
`${data.backgroundKey!}-last`,
|
||||
data.backgroundUrl,
|
||||
) : useState<string | undefined>();
|
||||
|
||||
const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ? useSessionStorage<string | undefined>(
|
||||
data.backgroundKey!,
|
||||
data.backgroundUrl,
|
||||
data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined,
|
||||
) : useState<string | undefined>();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setBackgroundUrl(data.backgroundUrl);
|
||||
setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined);
|
||||
}, [data.backgroundUrl]);
|
||||
|
||||
const finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined;
|
||||
const blur = localStorage.getItem('background-blur') !== "false";
|
||||
if (blur)
|
||||
{
|
||||
if (!finalBackgroundUrl?.searchParams.has('blur'))
|
||||
{
|
||||
finalBackgroundUrl?.searchParams.set('blur', String(24));
|
||||
}
|
||||
|
||||
finalBackgroundUrl?.searchParams.set('height', String(320));
|
||||
}
|
||||
|
||||
function handleSetBackground (url: string)
|
||||
{
|
||||
setLastBackgroundUrl(backgroundUrl);
|
||||
setBackgroundUrl(url);
|
||||
}
|
||||
|
||||
const bgColor = "bg-base-content";
|
||||
|
||||
let backgroundStyle = (url: string) => `linear-gradient(
|
||||
color-mix(in srgb, var(--color-base-300) 60%, transparent),
|
||||
color-mix(in srgb, var(--color-base-100) 80%, transparent)
|
||||
), url('${url}') center / cover`;
|
||||
|
||||
let backgroundElements: JSX.Element | undefined = undefined;
|
||||
if (true)
|
||||
{
|
||||
backgroundElements = <div id="container" className='md:visible sm:invisible'>
|
||||
backgroundElements = <div id="container" className='sm:invisible md:visible'>
|
||||
<div id="container-inside">
|
||||
<div className={bgColor} id="circle-small"></div>
|
||||
<div className={bgColor} id="circle-medium"></div>
|
||||
|
|
@ -62,11 +64,25 @@ export function AnimatedBackground (data: {
|
|||
return (
|
||||
<AnimatedBackgroundContext value={{ setBackground: handleSetBackground }}>
|
||||
<div ref={data.ref}
|
||||
className={twMerge("w-full h-full flex flex-col overflow-hidden", data.className)}
|
||||
className={twMerge("w-full h-full flex flex-col", data.scrolling ? "overflow-y-scroll animate-bg-zoom-scroll" : "overflow-hidden", data.className)}
|
||||
style={data.scrolling ? {
|
||||
backgroundImage: `url('${finalBackgroundUrl?.href}')`,
|
||||
backgroundAttachment: 'local',
|
||||
backgroundSize: '100%',
|
||||
backgroundPositionY: 'bottom',
|
||||
backgroundPositionX: 'center',
|
||||
backgroundColor: "var(--color-base-300)",
|
||||
} : {}}
|
||||
>
|
||||
{!!lastBackgroundUrl && <div className='absolute w-full h-full' style={{ background: backgroundStyle(lastBackgroundUrl), zIndex: -4 }}></div>}
|
||||
{!!backgroundUrl && <div key={backgroundUrl} className='absolute w-full h-full animate__animated animate__fadeIn' style={{ background: backgroundStyle(backgroundUrl), zIndex: -3 }}></div>}
|
||||
{blurBackground && <div className={"absolute w-full h-full backdrop-blur-3xl md:visible sm:invisible"} style={{ zIndex: -2 }}></div>}
|
||||
{!data.scrolling && <div className='absolute top-0 left-0 overflow-hidden w-full h-full'>
|
||||
{<img
|
||||
key={finalBackgroundUrl?.href}
|
||||
className={classNames('absolute w-full h-full object-cover object-center opacity-0 -z-3')}
|
||||
src={finalBackgroundUrl?.href}
|
||||
onLoad={e => e.currentTarget.classList.add(blur ? "animate-bg-zoom-big" : "animate-bg-zoom")}
|
||||
></img>}
|
||||
<div className='absolute w-full h-full bg-linear-to-b from-base-100/60 to-base-300/80 -z-2' />
|
||||
</div>}
|
||||
{data.animated && animateBackground && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}>
|
||||
{backgroundElements}
|
||||
</div>}
|
||||
|
|
|
|||
|
|
@ -74,8 +74,9 @@ export function CardList (data: {
|
|||
id={`card-list-${data.id}`}
|
||||
ref={ref}
|
||||
save-child-focus="session"
|
||||
className={twMerge("my-6 items-center justify-center-safe h-(--game-card-height) ",
|
||||
data.grid ? "card-grid h-fit gap-5" : 'card-list md:gap-6 sm:gap-2',
|
||||
className={twMerge("items-center justify-center-safe landscape:h-(--game-card-height) ",
|
||||
data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-(--game-card-height) grid-cols-[repeat(auto-fill,var(--game-card-width))]" :
|
||||
'landscape:flex sm:gap-2 md:gap-6 portrait:grid portrait:auto-rows-(--game-card-height) portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))]',
|
||||
data.className
|
||||
)}
|
||||
onKeyDown={(e) =>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export default function CollectionList (data: {
|
|||
onGameFocus={(id, node, details) =>
|
||||
{
|
||||
data.setBackground(
|
||||
`https://picsum.photos/id/${10 + (id ?? 0)}/1920/1080.webp`,
|
||||
`https://picsum.photos/id/${10 + (id ?? 0)}/100/100.webp?blur=10`,
|
||||
);
|
||||
data.onFocus?.(id, node, details);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
<div className="px-3 w-full pt-2">
|
||||
<HeaderUI title={data.headerTitle} buttons={[{ id: "search", icon: <Search /> }, { id: "filter", icon: <Settings2 /> }]} />
|
||||
</div>
|
||||
<div className="w-full grow mt-4 rounded-2xl px-2 overflow-y-scroll justify-center mask-alpha mask-t-from-transparent mask-t-to-20 mask-t-to-black">
|
||||
<div className="h-fit w-full px-6 pt-4 pb-32">
|
||||
<div className="w-full grow mt-4 rounded-2xl px-2 overflow-y-scroll justify-center mask-alpha sm:portrait:mask-t-from-transparent md:landscape:mask-t-from-transparent mask-t-to-20 mask-t-to-black">
|
||||
<div className="h-fit w-full md:px-6 pt-4 pb-32">
|
||||
{data.title}
|
||||
<Suspense>
|
||||
<GameList
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
|||
return <li ref={ref}
|
||||
onClick={handleAction}
|
||||
className={
|
||||
twMerge("flex cursor-pointer")}>
|
||||
twMerge("flex cursor-pointer sm:text-sm md:text-base")}>
|
||||
<FocusContext value={focusKey}>
|
||||
<div className={twMerge("flex w-full h-14 items-center px-4 rounded-2xl transition-all gap-2",
|
||||
<div className={twMerge("flex w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl transition-all gap-2",
|
||||
colors[data.type],
|
||||
classNames({ "font-semibold": focused || hasFocusedChild }),
|
||||
data.className)}>
|
||||
|
|
@ -115,11 +115,11 @@ export function ContextDialog (data: {
|
|||
<ContextDialogContext value={{ id: data.id, close: data.close }} >
|
||||
<div
|
||||
className={twMerge(
|
||||
"bg-base-100/80 delay-200 rounded-4xl p-6 min-w-[30vw] cursor-auto",
|
||||
"bg-base-100/80 delay-200 rounded-4xl sm:p-4 md:p-6 sm:min-w-[80vw] md:min-w-[30vw] cursor-auto",
|
||||
data.open ? "animate-scale-delayed" : "opacity-0",
|
||||
data.className)
|
||||
}
|
||||
style={{ backdropFilter: 'blur(24px)' }}
|
||||
style={{ backdropFilter: 'md:blur(24px)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{data.children}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ function List (data: {
|
|||
action: handleReturn,
|
||||
id: `${data.id}...`,
|
||||
type: 'primary',
|
||||
content: <div className="flex justify-between w-full items-center">...<SvgIcon className="md:size-8 sm:size-6" icon={'steamdeck_button_l1_outline'} /> </div>,
|
||||
content: <div className="flex justify-between w-full items-center">...<SvgIcon className="sm:size-6 md:size-8" icon={'steamdeck_button_l1_outline'} /> </div>,
|
||||
icon: <FolderOutput />,
|
||||
shortcuts: [{ label: "Up", action: handleReturn, button: GamePadButtonCode.A }]
|
||||
},
|
||||
|
|
@ -149,10 +149,10 @@ function OptionButtons (data: {
|
|||
})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `options-${data.id}`, onEnterPress: data.onSelect });
|
||||
return <div ref={ref} className="flex h-12 w-full justify-end gap-2">
|
||||
return <div ref={ref} className="flex md:inline h-12 w-full justify-end gap-2">
|
||||
<FocusContext value={focusKey}>
|
||||
{data.showConfirm && <Button className="p-6 ring-accent-content" onAction={data.onSelect} id={`${data.id}-select`} focusClassName="ring-7" type="button" ><Check />Select</Button>}
|
||||
<Button className="p-6 ring-warning-content" onAction={data.onCancel} id={`${data.id}-cancel`} type="button" focusClassName="ring-7 btn-warning" ><X />Cancel</Button>
|
||||
<Button className="md:p-6 ring-warning-content" onAction={data.onCancel} id={`${data.id}-cancel`} type="button" focusClassName="ring-7 btn-warning" ><X />Cancel</Button>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -161,7 +161,7 @@ function DriveElement (data: { id: string, isActive: boolean, label: string; onS
|
|||
{
|
||||
const { ref, focused } = useFocusable({ focusKey: data.id, onEnterPress: data.onSelect });
|
||||
return <li ref={ref} onClick={data.onSelect} className={twMerge(
|
||||
"flex bg-base-200 text-base-content rounded-full gap-2 items-center p-2 px-4 overflow-hidden max-w-xs cursor-pointer text-nowrap hover:bg-primary/40",
|
||||
"flex bg-base-200 text-base-content rounded-full gap-2 sm:min-h-10 items-center p-2 min-w-fit px-4 overflow-hidden max-w-xs cursor-pointer text-nowrap hover:bg-primary/40",
|
||||
classNames({
|
||||
"bg-primary text-primary-content": data.isActive,
|
||||
"ring-7 ring-base-content": focused
|
||||
|
|
@ -185,7 +185,7 @@ function Drives (data: {
|
|||
autoRestoreFocus: false
|
||||
});
|
||||
|
||||
return <ul className="flex flex-col gap-2" ref={ref} >
|
||||
return <ul className="flex not-portrait:flex-col sm:gap-1 md:gap-2 overflow-auto" ref={ref} >
|
||||
<FocusContext value={focusKey}>
|
||||
{drives?.filter(d => d.mountPoint)
|
||||
.sort((a, b) => b.mountPoint!.length - a.mountPoint!.length)
|
||||
|
|
@ -208,7 +208,7 @@ function ListWithDrives (data: {
|
|||
focusKey: `main-${data.id}`,
|
||||
preferredChildFocusKey: `list-${data.id}`
|
||||
});
|
||||
return <div ref={ref} className="flex grow min-h-0 gap-2">
|
||||
return <div ref={ref} className="flex sm:portrait:flex-col grow min-h-0 gap-2">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Drives onSelect={p => setCurrentPath(p)} id={`drives-${data.id}`} />
|
||||
|
|
@ -264,7 +264,7 @@ export default function FilePicker (data: {
|
|||
activeDrive
|
||||
}}>
|
||||
{!!fullPath &&
|
||||
<div className="breadcrumbs flex items-center text-sm min-h-12 max-h-12 h-12 px-4 py-2 overflow-hidden bg-base-300 text-base-content rounded-full">
|
||||
<div className="breadcrumbs flex items-center text-sm sm:min-h-10 sm:max-h-10 sm:h-10 md:min-h-12 md:max-h-12 md:h-12 px-4 py-2 overflow-hidden bg-base-300 text-base-content rounded-full">
|
||||
<ul>
|
||||
{fullPathElements.map((p, i) => <li>
|
||||
<a onClick={() =>
|
||||
|
|
@ -272,7 +272,7 @@ export default function FilePicker (data: {
|
|||
}>{p}</a>
|
||||
</li>)}
|
||||
</ul>
|
||||
{(filesLoading || drivesLoading) && <span className="loading loading-spinner loading-lg"></span>}
|
||||
{(filesLoading || drivesLoading) && <span className="loading loading-spinner sm:loading-md md:loading-lg"></span>}
|
||||
</div>}
|
||||
|
||||
<ListWithDrives
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ function FilterCat (
|
|||
ref={ref}
|
||||
onClick={focusSelf}
|
||||
className={classNames(
|
||||
"sm:text-sm sm:px-2",
|
||||
"flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg",
|
||||
"sm:text-xs sm:px-2",
|
||||
{
|
||||
"bg-base-content px-3 text-base-300 drop-shadow cursor-default":
|
||||
focused || data.active,
|
||||
|
|
@ -74,13 +74,12 @@ export function FilterUI (data: {
|
|||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex items-center sm:justify-start md:justify-center sm:ml-[15%] md:ml-0 gap-2"
|
||||
save-child-focus="session"
|
||||
>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<ul className="flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm md:h-14 sm:h-8">
|
||||
<ul className="flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm sm:h-9 md:h-14">
|
||||
<li className=" flex px-4 items-center justify-center rounded-full">
|
||||
<SvgIcon className="sm:size-4 md:size-8" icon="steamdeck_button_l1_outline" />
|
||||
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_l1_outline" />
|
||||
</li>
|
||||
{Object.entries(data.options)?.map(([id, option]) => (
|
||||
<FilterCat
|
||||
|
|
@ -93,7 +92,7 @@ export function FilterUI (data: {
|
|||
/>
|
||||
))}
|
||||
<li className="flex px-4 items-center justify-center rounded-full">
|
||||
<SvgIcon className="sm:size-4 md:size-8" icon="steamdeck_button_r1_outline" />
|
||||
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_r1_outline" />
|
||||
</li>
|
||||
</ul>
|
||||
</FocusContext.Provider>
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default function GameCard (data: GameCardParams)
|
|||
"overflow-hidden transition-all duration-200 drop-shadow-lg cursor-pointer",
|
||||
classNames({
|
||||
"focused animate-wiggle ring-7 bg-base-content text-base-300 drop-shadow-xl drop-shadow-black/30 scale-102 z-10": focused && !isPointer,
|
||||
"group hover:focused hover:animate-wiggle hover:ring-7 hover:bg-base-content hover:text-base-300 hover:drop-shadow-xl hover:drop-shadow-black/30 hover:scale-102 hover:z-10": isPointer,
|
||||
"group hover:focused hover:animate-wiggle sm:hover:ring-4 md:hover:ring-7 hover:bg-base-content hover:text-base-300 hover:drop-shadow-xl hover:drop-shadow-black/30 hover:scale-102 hover:z-10": isPointer,
|
||||
"h-(--game-card-height)": typeof data.preview === "string"
|
||||
}),
|
||||
data.className
|
||||
|
|
@ -77,20 +77,20 @@ export default function GameCard (data: GameCardParams)
|
|||
>
|
||||
<div className={twMerge(
|
||||
"overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all",
|
||||
focused ? "md:mt-2 md:mx-2" : "md:mt-2 md:mx-2",
|
||||
focused ? "sm:mt-1 sm:mx-1" : "sm:mt-1 sm:mx-1",
|
||||
focused ? "md:mt-2 md:mx-2" : "md:mt-2 md:mx-2",
|
||||
)}>
|
||||
{typeof data.preview === "string" ? (
|
||||
<img className={classNames({ "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
|
||||
<img className={classNames("object-cover w-full h-full", { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
|
||||
) : (
|
||||
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
|
||||
)}</div>
|
||||
|
||||
<div className="h-0 flex pr-2 justify-end items-center gap-2">
|
||||
<div className="h-0 flex pr-2 justify-end items-center sm:gap-1 md:gap-2">
|
||||
{data.badges?.map((b, i) =>
|
||||
<div key={i}
|
||||
className={
|
||||
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 md:last:mr-4 transition-colors",
|
||||
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 sm:last:mr-1 md:last:mr-4 transition-colors",
|
||||
classNames({
|
||||
"bg-primary text-primary-content": focused && !isPointer,
|
||||
"group-hover:bg-primary group-hover:text-primary-content": isPointer
|
||||
|
|
@ -100,7 +100,7 @@ export default function GameCard (data: GameCardParams)
|
|||
</div>)
|
||||
}
|
||||
</div>
|
||||
<div className="flex flex-col md:p-4 sm:p-2">
|
||||
<div className="flex flex-col sm:p-2 md:p-4">
|
||||
<div className="md:text-xl sm:text-sm font-bold text-nowrap text-ellipsis overflow-hidden">
|
||||
{data.title}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { GameMetaExtra, CardList } from "./CardList";
|
||||
import { FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
|
@ -7,6 +7,7 @@ import { rommApi } from "../scripts/clientApi";
|
|||
import { HardDrive } from "lucide-react";
|
||||
import { JSX } from "react";
|
||||
import { GameCardFocusHandler } from "./GameCard";
|
||||
import { gameQuery } from "../scripts/queries";
|
||||
|
||||
export interface GameListParams
|
||||
{
|
||||
|
|
@ -28,15 +29,25 @@ export function GameList (data: GameListParams)
|
|||
}).then(d => d.data)
|
||||
});
|
||||
const navigator = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleFocus = (id: FrontEndId) =>
|
||||
const handleFocus = (id: FrontEndId, source: string | null, sourceId: number | null) =>
|
||||
{
|
||||
const game = games.data?.games.find((g) => g.id === id);
|
||||
if (game)
|
||||
{
|
||||
data.setBackground?.(
|
||||
`${RPC_URL(__HOST__)}${game.path_cover}`,
|
||||
);
|
||||
try
|
||||
{
|
||||
const screenshotUrl = new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`);
|
||||
const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_cover}`);
|
||||
const previewUrl = localStorage.getItem('background-blur') !== "false" ? coverUrl : screenshotUrl;
|
||||
previewUrl.searchParams.delete('ts');
|
||||
data.setBackground?.(previewUrl.href);
|
||||
queryClient.prefetchQuery(gameQuery(source ?? id.source, sourceId ?? id.id));
|
||||
} catch
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -61,8 +72,13 @@ export function GameList (data: GameListParams)
|
|||
const badges: JSX.Element[] = [];
|
||||
if (g.id.source === 'local')
|
||||
{
|
||||
badges.push(<HardDrive className="size-8 m-1" />);
|
||||
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
|
||||
}
|
||||
const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
|
||||
previewUrl.searchParams.delete('ts');
|
||||
previewUrl.searchParams.set('width', "640");
|
||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
|
||||
platformUrl.searchParams.set('width', "64");
|
||||
|
||||
return {
|
||||
id: `game-${g.id.source}-${g.id.id}`,
|
||||
|
|
@ -70,14 +86,14 @@ export function GameList (data: GameListParams)
|
|||
title: g.name ?? "",
|
||||
subtitle: (
|
||||
<div className="flex gap-1 items-center">
|
||||
{!!g.path_platform_cover && <img className="sm:hidden md:inline size-4" src={`${RPC_URL(__HOST__)}${g.path_platform_cover}`} />}
|
||||
{!!g.path_platform_cover && <img className="sm:hidden md:inline size-4" src={platformUrl.href} />}
|
||||
<p className="opacity-80">{g.platform_display_name}</p>
|
||||
</div>
|
||||
),
|
||||
previewUrl: `${RPC_URL(__HOST__)}${g.path_cover}`,
|
||||
previewUrl: previewUrl.href,
|
||||
badges: badges,
|
||||
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id) : handleDefaultSelect(g.id, g.source, g.source_id),
|
||||
onFocus: () => handleFocus(g.id)
|
||||
onFocus: () => handleFocus(g.id, g.source, g.source_id)
|
||||
} satisfies GameMetaExtra;
|
||||
},
|
||||
) ?? []}
|
||||
|
|
|
|||
|
|
@ -25,10 +25,9 @@ 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 { useNavigate } from "@tanstack/react-router";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Router } from "..";
|
||||
|
||||
function HeaderAvatar (data: {
|
||||
id: string;
|
||||
|
|
@ -56,13 +55,13 @@ function HeaderAvatar (data: {
|
|||
ref={ref}
|
||||
onClick={data.onSelect}
|
||||
className={classNames(
|
||||
`avatar indicator ring-base-100 ring-offset-base-100 size-14 rounded-full flex items-center justify-center`,
|
||||
`avatar indicator ring-base-100 ring-offset-base-100 sm:size-8 md:size-14 rounded-full flex items-center justify-center`,
|
||||
bgColors[data.type ?? "none"],
|
||||
"text-base-content cursor-pointer transition-all drop-shadow-md",
|
||||
"hover:ring-primary hover:ring-7",
|
||||
{
|
||||
"ring-5 hover:ring-offset-5": data.active,
|
||||
"ring-7 ring-primary ring-offset-base-100": focused,
|
||||
"sm:ring-4 md:ring-7 ring-primary ring-offset-base-100": focused,
|
||||
"ring-offset-5": focused && data.active,
|
||||
},
|
||||
data.className,
|
||||
|
|
@ -85,7 +84,7 @@ function HeaderAvatar (data: {
|
|||
) : (
|
||||
<User />
|
||||
)}
|
||||
<span className={classNames("indicator-item status left-1 top-1 ring-3 ring-base-100 z-1", data.status)}></span>
|
||||
<span className={classNames("indicator-item status md:left-1 top-1 sm:ring-2 md:ring-3 ring-base-100 z-1", data.status)}></span>
|
||||
|
||||
</div>
|
||||
);
|
||||
|
|
@ -113,7 +112,7 @@ function NotificationStatus ()
|
|||
{
|
||||
const hasUnread = false;
|
||||
return <div className={classNames("p-2 rounded-full", { "bg-warning text-warning-content": hasUnread })}>
|
||||
<Bell className="md:size-6 sm:size-4" />
|
||||
<Bell className="sm:size-4 md:size-8" />
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +149,7 @@ function ClockStatus ()
|
|||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
return <div className="flex gap-3"><span ref={ref}></span><Clock /></div>;
|
||||
return <div className="flex gap-3 sm:text-xs md:text-2xl items-center"><span ref={ref}></span><Clock className="sm:size-4 md:size-8" /></div>;
|
||||
}
|
||||
|
||||
function BluetoothStatus ()
|
||||
|
|
@ -227,10 +226,8 @@ function BatteryStatus ()
|
|||
</div>;
|
||||
}
|
||||
|
||||
export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[], buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; })
|
||||
export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "header-elements" });
|
||||
const navigate = useNavigate();
|
||||
const rommOnline = useQuery({
|
||||
...statsApiStatsGetOptions(),
|
||||
refetchInterval: 30000,
|
||||
|
|
@ -250,7 +247,6 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
|
|||
{
|
||||
indicator = "status-success";
|
||||
}
|
||||
|
||||
const accounts: HeaderAccount[] = [{
|
||||
id: 'romm', previewUrl: [
|
||||
`${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`,
|
||||
|
|
@ -258,52 +254,61 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
|
|||
action: () =>
|
||||
{
|
||||
SaveSource('settings');
|
||||
navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } });
|
||||
Router.navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } });
|
||||
},
|
||||
status: user.data ? "status-success" : 'status-error',
|
||||
type: 'secondary'
|
||||
}, ...data.accounts ?? []];
|
||||
|
||||
return <div className="flex items-center gap-2 drop-shadow-sm">
|
||||
{accounts?.map(a => <HeaderAvatar
|
||||
key={`header-avatar-${a.id}`}
|
||||
type={a.type}
|
||||
id={`account-${a.id}`}
|
||||
status={a.status}
|
||||
locked={a.locked}
|
||||
imageSrc={a.previewUrl}
|
||||
onSelect={a.action}
|
||||
/>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; })
|
||||
{
|
||||
return <div className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
|
||||
<div className="flex sm:gap-2 md:gap-5 items-center">
|
||||
<ClockStatus />
|
||||
<WiFiStatus />
|
||||
<BluetoothStatus />
|
||||
<NotificationStatus />
|
||||
<BatteryStatus />
|
||||
</div>
|
||||
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
||||
<div className="flex gap-2">
|
||||
{data.buttonElements ?? data.buttons?.map(b => <RoundButton
|
||||
key={b.id}
|
||||
className="header-icon sm:size-10 md:size-16"
|
||||
id={b.id}
|
||||
icon={b.icon}
|
||||
external={b.external}
|
||||
action={b.action}
|
||||
/>)}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[]; buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "header-elements" });
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<header
|
||||
ref={ref}
|
||||
className={twMerge("md:relative md:h-14 md:mt-2 flex items-center justify-between text-white",
|
||||
"sm:absolute sm:top-0 sm:right-0 sm:left-0"
|
||||
)}
|
||||
className={`flex items-center justify-between text-base-content`}
|
||||
>
|
||||
<div className="flex items-center gap-2 drop-shadow-sm">
|
||||
{accounts?.map(a => <HeaderAvatar
|
||||
key={`header-avatar-${a.id}`}
|
||||
type={a.type}
|
||||
id={`account-${a.id}`}
|
||||
status={a.status}
|
||||
locked={a.locked}
|
||||
imageSrc={a.previewUrl}
|
||||
onSelect={a.action}
|
||||
/>)}
|
||||
{data.title}
|
||||
</div>
|
||||
<div className="flex items-center md:gap-2 sm:gap-1 text drop-shadow-sm">
|
||||
<div className="flex md:gap-5 sm:gap-2 items-center">
|
||||
<ClockStatus />
|
||||
<WiFiStatus />
|
||||
<BluetoothStatus />
|
||||
<NotificationStatus />
|
||||
<BatteryStatus />
|
||||
</div>
|
||||
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
||||
<div className="flex gap-2">
|
||||
{data.buttonElements ?? data.buttons?.map(b => <RoundButton
|
||||
key={b.id}
|
||||
className="header-icon md:size-16 sm:size-10"
|
||||
id={b.id}
|
||||
icon={b.icon}
|
||||
external={b.external}
|
||||
action={b.action}
|
||||
/>)}
|
||||
</div>
|
||||
</div>
|
||||
<HeaderAccounts accounts={data.accounts} />
|
||||
{data.title}
|
||||
<HeaderStatusBar buttonElements={data.buttonElements} buttons={data.buttons} />
|
||||
</header>
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import { CardList, GameMetaExtra } from "./CardList";
|
|||
import classNames from "classnames";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { JSX } from "react";
|
||||
import { JSX, useMemo } from "react";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { GameCardFocusHandler } from "./GameCard";
|
||||
|
||||
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: GameCardFocusHandler; })
|
||||
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: GameCardFocusHandler; grid?: boolean; })
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { data: platforms } = useSuspenseQuery(
|
||||
|
|
@ -25,55 +25,60 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
|
|||
staleTime: DefaultRommStaleTime,
|
||||
});
|
||||
|
||||
const platformsMapped = useMemo(() => platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
|
||||
.map((g, i) =>
|
||||
{
|
||||
const badges: JSX.Element[] = [];
|
||||
badges.push(<span className="flex items-center justify-center sm:size-3 md:size-6 m-1 md:text-2xl font-semibold font-boldrounded-full">{g.game_count}</span>);
|
||||
if (g.hasLocal)
|
||||
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
|
||||
const coverUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
|
||||
coverUrl.searchParams.set('width', "320");
|
||||
const entry: GameMetaExtra = {
|
||||
id: g.slug,
|
||||
focusKey: g.slug,
|
||||
title: g.name,
|
||||
subtitle: g.family_name ?? "",
|
||||
previewUrl: "",
|
||||
badges,
|
||||
onFocus: () => data.setBackground(
|
||||
`https://picsum.photos/id/${10 + i}/100/100.webp?blur=10`,
|
||||
),
|
||||
onSelect: () =>
|
||||
{
|
||||
SaveSource('game-list');
|
||||
navigate({ to: `/platform/${g.id.source}/${g.id.id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
},
|
||||
preview:
|
||||
({ focused }) => <div
|
||||
className="flex p-6 bg-base-100 justify-center"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
color-mix(in srgb, var(--color-base-content) 60%, transparent),
|
||||
color-mix(in srgb, var(--color-base-300) 60%, transparent)
|
||||
), url(https://picsum.photos/id/${10 + i}/100/100.webp?blur=10) center / cover`,
|
||||
|
||||
backgroundBlendMode: "screen",
|
||||
boxShadow: 'inset 0 0 32px rgba(0,0,0,0.6)'
|
||||
}}
|
||||
>
|
||||
<img className={classNames("drop-shadow-2xl", { "animate-rotate": focused })}
|
||||
src={coverUrl.href}
|
||||
></img>
|
||||
</div>
|
||||
,
|
||||
};
|
||||
return entry;
|
||||
}), [platforms]);
|
||||
|
||||
return (
|
||||
<CardList
|
||||
type="platform"
|
||||
id={data.id}
|
||||
grid={data.grid}
|
||||
className={data.className}
|
||||
onGameFocus={data.onFocus}
|
||||
games={platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
|
||||
.map((g) =>
|
||||
{
|
||||
const badges: JSX.Element[] = [];
|
||||
badges.push(<span className="flex items-center justify-center size-6 m-1 text-2xl font-semibold font-boldrounded-full">{g.game_count}</span>);
|
||||
if (g.hasLocal)
|
||||
badges.push(<HardDrive className="size-8 m-1" />);
|
||||
const entry: GameMetaExtra = {
|
||||
id: g.slug,
|
||||
focusKey: g.slug,
|
||||
title: g.name,
|
||||
subtitle: g.family_name ?? "",
|
||||
previewUrl: "",
|
||||
badges,
|
||||
onFocus: () => data.setBackground(
|
||||
`https://picsum.photos/id/${10 + g.slug.length}/1920/1080.webp`,
|
||||
),
|
||||
onSelect: () =>
|
||||
{
|
||||
SaveSource('game-list');
|
||||
navigate({ to: `/platform/${g.id.source}/${g.id.id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
},
|
||||
preview:
|
||||
({ focused }) => <div
|
||||
className="flex h-60 p-6 bg-base-100 justify-center"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
color-mix(in srgb, var(--color-base-content) 60%, transparent),
|
||||
color-mix(in srgb, var(--color-base-300) 60%, transparent)
|
||||
), url(https://picsum.photos/id/${8 + g.slug.length}/300/300.webp?blur=10) center / cover`,
|
||||
|
||||
backgroundBlendMode: "screen",
|
||||
boxShadow: 'inset 0 0 32px rgba(0,0,0,0.6)'
|
||||
}}
|
||||
>
|
||||
<img className={classNames("drop-shadow-2xl", { "animate-rotate": focused })}
|
||||
src={`${RPC_URL(__HOST__)}${g.path_cover}`}
|
||||
></img>
|
||||
</div>
|
||||
,
|
||||
};
|
||||
return entry;
|
||||
})}
|
||||
games={platformsMapped}
|
||||
onSelectGame={(id) =>
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -15,17 +15,15 @@ export default function ShortcutPrompt (data: {
|
|||
<div
|
||||
onClick={data.onClick}
|
||||
style={{ viewTransitionName: data.id }}
|
||||
className={twMerge(
|
||||
className={twMerge("xs:text-xs sm:p-1 sm:text-sm",
|
||||
"flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm ring-[1px] ring-base-content/10 drop-shadow-black/30",
|
||||
"sm:text-sm sm:p-1",
|
||||
"xs:text-xs sm:p-1",
|
||||
data.className,
|
||||
classNames({
|
||||
"hover:bg-base-300 cursor-pointer": !!data.onClick,
|
||||
})
|
||||
)}
|
||||
>
|
||||
{data.icon && <SvgIcon className="md:size-8 sm:size-6 xs:size-2" icon={data.icon} />}
|
||||
{data.icon && <SvgIcon className="size-6 portrait:size-6 md:size-8" icon={data.icon} />}
|
||||
{data.label}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
|
|||
const { control } = useActiveControl();
|
||||
const showKeyboard = control === 'keyboard' || control === 'mouse';
|
||||
return (
|
||||
<div className="flex gap-2 z-1000">
|
||||
<div className="flex gap-2 z-1000 h-10">
|
||||
{data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt
|
||||
key={s.button}
|
||||
id={`shortcut-${s.button}`}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function OptionSpace (data: {
|
|||
<OptionContext value={{ focused, focus: focusSelf, eventTarget }}>
|
||||
<li
|
||||
ref={ref}
|
||||
className={twMerge("flex sm:p-2 md:p-4 pl-8! rounded-full bg-base-content/1", classNames(
|
||||
className={twMerge("flex portrait:flex-col portrait:gap-2 portrait:p-4 md:flex-row sm:p-2 md:p-4 md:pl-8! portrait:rounded-3xl landscape:rounded-full bg-base-content/1", classNames(
|
||||
{
|
||||
"text-primary-content bg-primary ": focused || hasFocusedChild,
|
||||
}),
|
||||
|
|
@ -87,7 +87,9 @@ export function OptionSpace (data: {
|
|||
data.label
|
||||
)}
|
||||
</div>
|
||||
{data.children}
|
||||
<div className="flex">
|
||||
{data.children}
|
||||
</div>
|
||||
</li>
|
||||
</OptionContext>
|
||||
</FocusContext>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue