feat: Implemented launching and downloading of roms

This is just an initial implementation lots of kings to iron out
This commit is contained in:
Simeon Radivoev 2026-02-19 16:10:29 +02:00
parent ef08fa6114
commit f15bf9a1e0
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
117 changed files with 37776 additions and 1073 deletions

View file

@ -1,4 +1,5 @@
import React, { createContext, Ref, useContext, useState } from 'react';
import classNames from 'classnames';
import React, { createContext, JSX, Ref, useContext, useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { useSessionStorage } from 'usehooks-ts';
@ -13,15 +14,23 @@ export function AnimatedBackground (data: {
animated?: boolean,
})
{
const [lastBackgroundUrl, setLastBackgroundUrl] = data.backgroundUrl ? useSessionStorage<string | undefined>(
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.backgroundUrl ? useSessionStorage<string | undefined>(
const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ? useSessionStorage<string | undefined>(
data.backgroundKey!,
data.backgroundUrl,
) : useState(data.backgroundUrl);
) : useState<string | undefined>();
useEffect(() =>
{
setBackgroundUrl(data.backgroundUrl);
}, [data.backgroundUrl]);
function handleSetBackground (url: string)
{
@ -36,6 +45,20 @@ export function AnimatedBackground (data: {
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">
<div id="container-inside">
<div className={bgColor} id="circle-small"></div>
<div className={bgColor} id="circle-medium"></div>
<div className={bgColor} id="circle-large"></div>
<div className={bgColor} id="circle-xlarge"></div>
<div className={bgColor} id="circle-xxlarge"></div>
</div>
</div>;
}
return (
<AnimatedBackgroundContext value={{ setBackground: handleSetBackground }}>
<div ref={data.ref}
@ -43,17 +66,9 @@ export function AnimatedBackground (data: {
>
{!!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>}
<div className="absolute w-full h-full backdrop-blur-3xl" style={{ zIndex: -2 }}></div>
{data.animated && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}>
<div id="container">
<div id="container-inside">
<div className={bgColor} id="circle-small"></div>
<div className={bgColor} id="circle-medium"></div>
<div className={bgColor} id="circle-large"></div>
<div className={bgColor} id="circle-xlarge"></div>
<div className={bgColor} id="circle-xxlarge"></div>
</div>
</div>
{blurBackground && <div className={"absolute w-full h-full backdrop-blur-3xl"} style={{ zIndex: -2 }}></div>}
{data.animated && animateBackground && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}>
{backgroundElements}
</div>}
{data.children}
</div>

View file

@ -3,17 +3,16 @@ import
FocusContext,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import { GameMeta } from "../../shared/constants";
import GameCard, { GameCardSkeleton } from "./GameCard";
import { JSX, useEffect, useMemo, useState } from "react";
import { useLocalStorage } from "usehooks-ts";
import { useScrollSave } from "../scripts/utils";
import { FrontEndId, GameMeta } from "../../shared/constants";
import GameCard, { GameCardParams } from "./GameCard";
import { JSX, useState } from "react";
import classNames from "classnames";
import { twMerge } from "tailwind-merge";
export interface GameMetaExtra extends GameMeta
{
preview?: JSX.Element;
badge?: JSX.Element;
preview?: GameCardParams['preview'];
badges?: JSX.Element[];
focusKey: string;
}
@ -22,8 +21,9 @@ export function CardList (data: {
type?: string;
games: GameMetaExtra[];
grid?: boolean;
onSelectGame?: (id: number) => void;
onGameFocus?: (id: number) => void;
onSelectGame?: (id: string) => void;
onGameFocus?: (id: string) => void;
className?: string;
})
{
const { ref, focusKey } = useFocusable({
@ -32,7 +32,7 @@ export function CardList (data: {
function BuildGame (g: GameMetaExtra, i: number)
{
let preview: JSX.Element | string | undefined = g.preview;
let preview: GameCardParams['preview'] = g.preview;
if (!preview && g.previewUrl)
{
preview = g.previewUrl;
@ -48,11 +48,17 @@ export function CardList (data: {
subtitle={g.subtitle ?? ""}
onFocus={() =>
{
g.onFocus?.();
data.onGameFocus?.(g.id);
(document.querySelector(":root") as HTMLElement).style.setProperty('--selected-card-offset', `${i}s`);
}}
onAction={() =>
{
g.onSelect?.();
data.onSelectGame?.(g.id);
}}
onAction={() => data.onSelectGame?.(g.id)}
preview={preview}
badge={g.badge}
badges={g.badges}
id={g.id}
/>
);
@ -64,8 +70,9 @@ export function CardList (data: {
id={`card-list-${data.id}`}
ref={ref}
save-child-focus="session"
className={classNames("my-6 items-center justify-center-safe h-(--game-card-height) ",
data.grid ? "card-grid h-fit gap-5" : 'card-list gap-6'
className={twMerge("my-6 items-center justify-center-safe h-(--game-card-height) ",
data.grid ? "card-grid h-fit gap-5" : 'card-list gap-6',
data.className
)}
onKeyDown={(e) =>
{

View file

@ -3,9 +3,7 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga
import { HeaderUI } from './Header';
import { GameList, GameListFilter } from './GameList';
import { Search, Settings2 } from 'lucide-react';
import ShortcutPrompt from './ShortcutPrompt';
import { selfFocusSmart } from '../scripts/utils';
import { JSX, Suspense, useEffect, useState } from 'react';
import { JSX, Suspense } from 'react';
import Shortcuts from './Shortcuts';
import { AutoFocus } from './AutoFocus';
@ -21,7 +19,7 @@ export interface CollectionsDetailParams
export function CollectionsDetail (data: CollectionsDetailParams)
{
const focusKey = `game-list-${data.id}-${data.filters.platformIds?.join()}-${data.filters.collectionId}`;
const focusKey = `game-list-${data.id}-${data.filters.platformId}-${data.filters.collectionId}`;
const { ref, focusSelf } = useFocusable({
focusKey,
preferredChildFocusKey: `${focusKey}-list`,
@ -46,7 +44,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
<div>
{data.footer}
</div>
<Shortcuts />
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_b', label: 'Back' }]} />
</footer>
</AnimatedBackground>
</FocusContext>

View file

@ -0,0 +1,107 @@
import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { createContext, JSX, useContext, useEffect } from "react";
import { twMerge } from "tailwind-merge";
import { useEventListener } from "usehooks-ts";
import { X } from "lucide-react";
const ContextDialogContext = createContext({} as {
close: () => void,
id: string;
});
export function ContextList (data: { options: DialogEntry[]; className?: string; showCloseButton?: boolean; })
{
const context = useContext(ContextDialogContext);
return <ul className={twMerge("list max-h-[70vh] overflow-y-auto", data.className)}>
{data.options.map(o => <OptionElement className="list-row" key={o.id} {...o} />)}
{data.showCloseButton !== false && <OptionElement className="list-row" type='accent' icon={<X />} action={context.close} id="close" content="Close" />}
</ul>;
}
export function OptionElement (data: DialogEntry & { onFocus?: () => void; className?: string; })
{
const context = useContext(ContextDialogContext);
const handleFocus = () =>
{
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
data.onFocus?.();
};
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
const { ref, focused, focusSelf } = useFocusable({
focusKey: `${context.id}-list-option-${data.id}`,
onEnterPress: handleAction,
onFocus: handleFocus
});
const colors = {
primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused }),
secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused }),
accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused }),
info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused }),
warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused }),
error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused })
};
return <li ref={ref}
onClick={handleAction}
className={
twMerge("flex cursor-pointer")}>
<p className={twMerge("flex w-full h-14 items-center px-5 rounded-2xl transition-all gap-2",
colors[data.type],
classNames({ "font-semibold": focused }),
data.className)}>
{data.icon}
{data.content}
</p>
</li>;
}
export interface DialogEntry
{
id: string,
content: string | JSX.Element;
icon?: string | JSX.Element;
type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error';
action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void;
}
export function ContextDialog (data: { id: string, children: any | any[], open: boolean, close: () => void; })
{
const { ref, focusKey, focusSelf } = useFocusable({ focusable: data.open, focusKey: `${data.id}-context-dialog`, isFocusBoundary: true });
useEffect(() =>
{
if (data.open)
{
focusSelf();
}
}, [data.open]);
useEventListener('cancel', (e) =>
{
if (data.open)
{
e.stopPropagation();
data.close();
}
}, ref);
return <dialog ref={ref} open={data.open} closedby="any" className={
twMerge("absolute 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();
}}>
<FocusContext value={focusKey}>
<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", data.open ? "animate-scale-delayed" : "opacity-0")}
style={{ backdropFilter: 'blur(24px)' }}
onClick={(e) => e.stopPropagation()}
>
{data.children}
</div>
</ContextDialogContext>
</FocusContext>
</dialog>;
}

View file

@ -27,10 +27,10 @@ function FilterCat (
className={classNames(
"flex px-4 h-12 items-center justify-center rounded-full transition-all",
{
"bg-primary text-primary-content drop-shadow-sm cursor-default":
"bg-base-content px-3 text-base-300 drop-shadow cursor-default":
focused || data.active,
"ring-base-content ring-7": focused,
"hover:bg-base-300 cursor-pointer": !focused,
"ring-primary ring-7": focused,
"hover:bg-base-content/40 cursor-pointer": !focused,
},
)}
>

View file

@ -16,23 +16,30 @@ export function GameCardSkeleton ()
);
}
export default function GameCard (data: {
export interface GameCardParams
{
title: string;
type?: string;
subtitle: string;
preview?: string | JSX.Element;
subtitle: string | JSX.Element;
preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element);
focusKey: string;
index: number;
id: number;
badge?: JSX.Element;
onFocus?: (id: number) => void;
id: string;
badges?: JSX.Element[];
className?: string;
onFocus?: (id: string) => void;
onBlur?: (id: string) => void;
onAction?: () => void;
})
clickFocuses?: boolean;
}
export default function GameCard (data: GameCardParams)
{
const { ref, focused, focusSelf } = useFocusable({
focusKey: data.focusKey,
onFocus: () => data.onFocus?.(data.id),
onEnterPress: () => data.onAction?.(),
onBlur: () => data.onBlur?.(data.id)
});
useEffect(() =>
@ -59,33 +66,48 @@ export default function GameCard (data: {
}}
onFocus={focusSelf}
onDoubleClick={data.onAction}
onClick={focused ? data.onAction : focusSelf}
onClick={() =>
{
focusSelf();
data.onAction?.();
}}
className={twMerge(
`game-card game-card-height flex flex-col justify-end`,
`game-card game-card-height flex flex-col justify-end z-5`,
'max-h-(--game-card-height) min-w-(--game-card-width) w-(--game-card-width)',
"overflow-hidden transition-all duration-200 drop-shadow-lg cursor-pointer",
focused ?
`animate-wiggle ring-7 bg-base-content text-base-300 ring-primary drop-shadow-xl drop-shadow-base-300/60 scale-102 z-10` :
"bg-base-300 text-base-content",
`focused animate-wiggle ring-7 bg-base-content text-base-300 ring-primary drop-shadow-xl drop-shadow-black/30 scale-102 z-10` :
"bg-base-300 hover:bg-base-100 hover:scale-102 text-base-content",
classNames({
"h-(--game-card-height)": typeof data.preview === "string"
})
}),
data.className
)}
>
<div className={twMerge("overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all", focused ? "mt-2 mx-2" : "mt-2 mx-2")}>
{typeof data.preview === "string" ? (
<img src={data.preview}></img>
<img width={5192} height={5192} className={classNames({ "animate-rotate-small": focused })} src={data.preview} ></img>
) : (
data.preview
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
)}</div>
<div className="h-0 flex pr-2 justify-end items-center">{data.badge}</div>
<div className="h-0 flex pr-2 justify-end items-center">
{data.badges?.map(b =>
<div
className={
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 mr-4 transition-colors",
classNames({ "bg-primary text-primary-content": focused }))}
>
{b}
</div>)
}
</div>
<div className="flex flex-col p-4">
<div className="text-xl font-bold text-nowrap text-ellipsis overflow-hidden">
{data.title}
</div>
<div className="text-s">{data.subtitle}</div>
</div>
</li>
</li >
);
}

View file

@ -1,15 +1,15 @@
import { keepPreviousData, useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { getRomsApiRomsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
import { useSuspenseQuery } from "@tanstack/react-query";
import { GameMetaExtra, CardList } from "./CardList";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { FrontEndId, RPC_URL } from "../../shared/constants";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { Suspense, useEffect } from "react";
import { SaveSource } from "../scripts/spatialNavigation";
import { gamesQueryOptions } from "../query-options";
import { rommApi } from "../scripts/clientApi";
import { HardDrive } from "lucide-react";
import { JSX } from "react";
export interface GameListFilter
{
platformIds?: number[];
platformId?: number;
collectionId?: number;
}
@ -19,30 +19,39 @@ export interface GameListParams
filters?: GameListFilter,
grid?: boolean,
setBackground?: (url: string) => void;
onGameSelect?: (id: number) => void;
onGameSelect?: (id: FrontEndId) => void;
className?: string;
}
export function GameList (data: GameListParams)
{
const games = useSuspenseQuery(gamesQueryOptions(data.filters));
const games = useSuspenseQuery({
queryKey: ['games', data.filters ?? 'all'],
queryFn: () => rommApi.api.romm.games.get({
query: {
platform_id: data.filters?.platformId,
collection_id: data.filters?.collectionId
}
}).then(d => d.data)
});
const navigator = useNavigate();
const location = useLocation();
const handleFocus = (id: number) =>
const handleFocus = (id: FrontEndId) =>
{
const game = games.data?.items.find((g) => g.id === id);
const game = games.data?.games.find((g) => g.id === id);
if (game)
{
data.setBackground?.(
`${RPC_URL(__HOST__)}/api/romm${game.path_cover_small}`,
`${RPC_URL(__HOST__)}${game.path_cover}`,
);
}
};
function handleDefaultSelect (id: number)
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: number | null)
{
SaveSource('details', location.pathname);
navigator({ to: '/game/$id', params: { id: String(id) }, viewTransition: { types: ['zoom-in'] } });
SaveSource('details');
navigator({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
};
return (
@ -51,23 +60,34 @@ export function GameList (data: GameListParams)
id={data.id}
type="game"
grid={data.grid}
games={games.data.items.sort(
(a, b) =>
Date.parse(b.rom_user.last_played ?? b.updated_at) -
Date.parse(a.rom_user.last_played ?? a.updated_at),
)
className={data.className}
games={games.data?.games
.map(
(g) =>
({
id: g.id,
{
const badges: JSX.Element[] = [];
if (g.id.source === 'local')
{
badges.push(<HardDrive className="size-8 m-1" />);
}
return {
id: `game-${g.id.source}-${g.id.id}`,
focusKey: g.slug ?? `game-${g.id}`,
title: g.name ?? "",
subtitle: g.platform_display_name ?? "",
previewUrl: `${RPC_URL(__HOST__)}/api/romm${g.path_cover_large}`,
}) satisfies GameMetaExtra,
)}
onGameFocus={handleFocus}
onSelectGame={id => data.onGameSelect ? data.onGameSelect(id) : handleDefaultSelect(id)}
subtitle: (
<div className="flex gap-1 items-center">
{!!g.path_platform_cover && <img className="size-4" src={`${RPC_URL(__HOST__)}${g.path_platform_cover}`} />}
<p className="opacity-80">{g.platform_display_name}</p>
</div>
),
previewUrl: `${RPC_URL(__HOST__)}${g.path_cover}`,
badges: badges,
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id) : handleDefaultSelect(g.id, g.source, g.source_id),
onFocus: () => handleFocus(g.id)
} satisfies GameMetaExtra;
},
) ?? []}
/>
</>
);

View file

@ -50,7 +50,6 @@ function HeaderAvatar (data: {
id={data.id}
ref={ref}
onClick={data.onSelect}
style={{ viewTransitionName: data.id }}
className={classNames(
`avatar indicator ring-base-100 ring-offset-base-100 size-14 rounded-full flex items-center justify-center`,
bgColors[data.type ?? "none"],
@ -92,6 +91,7 @@ export interface HeaderButton
id: string;
icon: JSX.Element;
external?: boolean;
action?: () => void;
}
export interface HeaderAccount
@ -135,7 +135,7 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
],
action: () =>
{
SaveSource('settings', location.pathname);
SaveSource('settings');
navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } });
},
status: user.data ? "status-success" : 'status-error',
@ -182,6 +182,7 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
id={b.id}
icon={b.icon}
external={b.external}
action={b.action}
/>)}
</div>
</div>

View file

@ -0,0 +1,72 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { getPlatformsApiPlatformsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
import { DefaultRommStaleTime, GameMeta, RPC_URL } from "../../shared/constants";
import { CardList, GameMetaExtra } from "./CardList";
import classNames from "classnames";
import { rommApi } from "../scripts/clientApi";
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; })
{
const navigate = useNavigate();
const { data: platforms } = useSuspenseQuery(
{
queryKey: ['platform', 'all'],
queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.platforms.get();
if (error) throw error;
return data.platforms;
},
refetchOnWindowFocus: false,
staleTime: DefaultRommStaleTime,
});
return (
<CardList
type="platform"
id={data.id}
className={data.className}
games={platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
.map((g) => ({
id: g.slug,
focusKey: g.slug,
title: g.name,
subtitle: g.family_name ?? "",
previewUrl: "",
badges: [(<span className="text-lg font-bold p-2 rounded-full">
{g.game_count}
</span>)],
onFocus: () => data.setBackground(
`https://picsum.photos/id/${10 + g.slug.length}/1920/1080.webp`,
),
onSelect: () =>
{
navigate({ to: `/platform/${g.source ?? g.id.source}/${g.source_id ?? 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>
,
} satisfies GameMetaExtra))}
onSelectGame={(id) =>
{
}}
/>
);
}

View file

@ -14,7 +14,7 @@ export default function ShortcutPrompt (data: {
<span
onClick={data.onClick}
className={twMerge(
"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",
"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",
data.className,
classNames({

View file

@ -1,14 +1,18 @@
import React from 'react';
import ShortcutPrompt from './ShortcutPrompt';
import { IconType } from './SvgIcon';
export default function Shortcuts ()
export interface Shortcut
{
icon: IconType;
label: string;
action?: () => void;
}
export default function Shortcuts (data: { shortcuts: Shortcut[]; })
{
return (
<div style={{ viewTransitionName: 'shortcuts' }} className="flex gap-2">
<ShortcutPrompt icon="steamdeck_button_a" label="Continue" />
<ShortcutPrompt icon="steamdeck_button_b" label="Back" />
<ShortcutPrompt icon="steamdeck_button_x" label="Close" />
<ShortcutPrompt icon="steamdeck_button_y" label="Options" />
{data.shortcuts.map((s, i) => <ShortcutPrompt key={i} onClick={s.action} icon={s.icon} label={s.label} />)}
</div>
);
}

View file

@ -0,0 +1,29 @@
.ball {
border-radius: 50%;
animation: bounce 0.6s 32 alternate;
}
.ball:nth-child(1) {
background: var(--color-accent);
animation-delay: 0s;
}
.ball:nth-child(2) {
background: var(--color-secondary);
animation-delay: 0.2s;
}
.ball:nth-child(3) {
background: var(--color-primary);
animation-delay: 0.4s;
}
@keyframes bounce {
from {
transform: translateY(0);
}
to {
transform: translateY(-30px);
}
}

View file

@ -0,0 +1,10 @@
import './dots.css';
export default function DotsLoading ()
{
return <div className="flex gap-3 justify-center animation_alternate items-center pt-8">
<div className="ball size-6"></div>
<div className="ball size-6"></div>
<div className="ball size-6"></div>
</div>;
}

View file

@ -0,0 +1,28 @@
import { twMerge } from "tailwind-merge";
import
{
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
export function Button (data: { id: string, children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
{
const { ref, focused } = useFocusable({
focusKey: data.id,
onEnterPress: data.onAction,
onFocus: data.onFocus,
focusable: !data.disabled
});
return <button
ref={ref}
onClick={data.onAction}
disabled={data.disabled}
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg", classNames({
"btn-accent": focused
}, data.className))}
type={data.type}
>
{data.children}
</button>;
}

View file

@ -1,7 +1,9 @@
import classNames from "classnames";
import { ChangeEventHandler, FocusEventHandler, HTMLInputTypeAttribute, JSX, useRef } from "react";
import { ChangeEventHandler, FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react";
import { twMerge } from "tailwind-merge";
import { useOptionContext } from "./OptionSpace";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
export function OptionInput (data: {
name: string;
@ -11,10 +13,18 @@ export function OptionInput (data: {
icon?: JSX.Element;
value?: string;
defaultValue?: string;
autocomplete?: HTMLInputAutoCompleteAttribute;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChange?: ChangeEventHandler<HTMLInputElement>;
})
{
const { ref, focused } = useFocusable({
focusKey: data.name, onEnterPress: () =>
{
inputRef.current?.focus();
systemApi.api.system.show_keyboard.post();
}
});
const inputRef = useRef<HTMLInputElement>(null);
const option = useOptionContext({
onOptionEnterPress ()
@ -24,10 +34,11 @@ export function OptionInput (data: {
});
return (
<label className="flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent">
<span className={twMerge("text-base-content/80", classNames({
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
classNames({ "[&_input]:not-focus:ring-7 [&_input]:not-focus:ring-accent": focused }))}>
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
"text-primary-content": option.focused
}))}>{data.icon}</span>
}))}>{data.icon}</span>}
<input
ref={inputRef}
id={data.name}
@ -35,12 +46,13 @@ export function OptionInput (data: {
value={data.value}
defaultValue={data.defaultValue}
type={data.type}
autoComplete={data.autocomplete}
onFocus={() => option.focus()}
placeholder={data.placeholder}
onChange={data.onChange}
onBlur={data.onBlur}
className={twMerge(
"input grow rounded-full ring-primary-content focus:ring-3",
"input grow rounded-full ring-primary-content focus:ring-7",
data.className,
)}
/>

View file

@ -42,8 +42,9 @@ export function OptionSpace (data: {
id?: string;
className?: string;
focusable?: boolean;
children: JSX.Element;
children?: any | any[];
label?: string | JSX.Element;
saveLastFocusedChild?: boolean;
})
{
const eventTarget = useMemo(() => new EventTarget(), []);
@ -51,6 +52,11 @@ export function OptionSpace (data: {
focusKey: data.id,
focusable: data.focusable !== false,
trackChildren: true,
saveLastFocusedChild: data.saveLastFocusedChild ?? false,
onFocus ()
{
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
},
onEnterPress ()
{
eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));

View file

@ -0,0 +1,72 @@
import { HTMLInputTypeAttribute, JSX, useCallback, useState } from "react";
import { SettingsType } from "../../../shared/constants";
import { useMutation, useQuery } from "@tanstack/react-query";
import { OptionSpace } from "./OptionSpace";
import { OptionInput } from "./OptionInput";
import { settingsApi } from "../../scripts/clientApi";
type KeysWithValueAssignableTo<T, Value> = {
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
}[keyof T];
export function SettingsOption (data: {
label: string;
id: KeysWithValueAssignableTo<SettingsType, string>;
type: HTMLInputTypeAttribute;
placeholder?: string;
icon?: JSX.Element;
})
{
const [dirty, setDirty] = useState(false);
const [localValue, setLocalValue] = useState<string | undefined>();
useQuery({
enabled: !!data.id,
queryKey: ["setting", data.id],
queryFn: async () =>
{
const { data: value, error } = await settingsApi.api.settings({ id: data.id! }).get();
if (error) throw error;
if (!dirty)
{
setLocalValue(String(value.value));
}
return value.value;
},
});
const setSettingMutation = useMutation({
mutationKey: ["setting", data.id],
mutationFn: async (value: any) =>
{
const response = await settingsApi.api.settings({ id: data.id! }).post({ value });
if (response.error) throw response.error;
return response.data;
}
});
const handleSave = useCallback(() =>
{
if (dirty)
{
setDirty(false);
setSettingMutation.mutate(localValue);
}
}, [dirty, setDirty, localValue]);
return (
<OptionSpace label={data.label}>
<OptionInput
icon={data.icon}
name={data.id ?? ""}
type={data.type}
placeholder={data.placeholder}
onBlur={handleSave}
onChange={(e) =>
{
setLocalValue(e.currentTarget.value);
setDirty(true);
}}
value={localValue}
/>
</OptionSpace>
);
}