feat: Made design more responsive

fix: Made blurring server side to help with performance
fix: Fixed shortcut useEffect loop
This commit is contained in:
Simeon Radivoev 2026-02-26 00:28:14 +02:00
parent b4a89385d0
commit 9e4b2a02c1
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
38 changed files with 583 additions and 329 deletions

View file

@ -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>}

View file

@ -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) =>

View file

@ -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);
}}

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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;
},
) ?? []}

View file

@ -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>
);

View file

@ -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) =>
{

View file

@ -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>
);

View file

@ -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}`}

View file

@ -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>