feat: implemented storage management

fix: Enabled fallback secrets
feat: Made header stats actually work
feat: Made steam deck keyboard auto open for some inputs
fix: Made keybaord also work with shortcuts (no tooltips yet)
This commit is contained in:
Simeon Radivoev 2026-02-24 00:30:16 +02:00
parent 62f16cbcc1
commit e4df8fb9fb
Signed by: simeonradivoev
GPG key ID: C16C2132A7660C8E
55 changed files with 1675 additions and 398 deletions

View file

@ -9,12 +9,13 @@ import { AutoFocus } from './AutoFocus';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { Router } from '..';
import { PopSource } from '../scripts/spatialNavigation';
import { GameListFilterType } from '@/shared/constants';
export interface CollectionsDetailParams
{
id?: string;
setBackground: (url: string) => void;
filters: GameListFilter;
filters?: GameListFilterType;
headerTitle?: JSX.Element;
title?: JSX.Element;
footer?: JSX.Element;
@ -32,7 +33,7 @@ function HandleGoBack ()
export function CollectionsDetail (data: CollectionsDetailParams)
{
const focusKey = `game-list-${data.id}-${data.filters.platformId}-${data.filters.collectionId}`;
const focusKey = `game-list-${data.id}-${data.filters ? Object.values(data.filters).map(f => String(f)).join(",") : ''}`;
const { ref, focusSelf } = useFocusable({
focusKey,
preferredChildFocusKey: `${focusKey}-list`,
@ -51,7 +52,14 @@ export function CollectionsDetail (data: CollectionsDetailParams)
<div className="h-fit w-full px-6 pt-4 pb-32">
{data.title}
<Suspense>
<GameList grid setBackground={data.setBackground} filters={data.filters} id={`${focusKey}-list`}></GameList>
<GameList
grid
setBackground={data.setBackground}
filters={data.filters}
onFocus={(node) => node.scrollIntoView({ block: 'center', behavior: 'smooth' })}
id={`${focusKey}-list`}>
</GameList>
<AutoFocus focus={focusSelf} />
</Suspense>
</div>

View file

@ -2,20 +2,19 @@ import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedi
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";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
const ContextDialogContext = createContext({} as {
close: () => void,
id: string;
});
export function ContextList (data: { options: DialogEntry[]; className?: string; showCloseButton?: boolean; })
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} />)}
return <ul className={twMerge("list", 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>;
}
@ -29,30 +28,37 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
data.onFocus?.();
};
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
const { ref, focused, focusSelf } = useFocusable({
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
focusKey: `${context.id}-list-option-${data.id}`,
onEnterPress: handleAction,
onFocus: handleFocus
onFocus: handleFocus,
trackChildren: typeof data.content !== 'string'
});
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 })
primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused || hasFocusedChild }),
secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused || hasFocusedChild }),
accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused || hasFocusedChild }),
info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused || hasFocusedChild }),
warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused || hasFocusedChild }),
error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused || hasFocusedChild })
};
if (data.shortcuts)
{
useShortcuts(focusKey, () => data.shortcuts!, [data.shortcuts]);
}
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>
<FocusContext value={focusKey}>
<div className={twMerge("flex w-full h-14 items-center px-4 rounded-2xl transition-all gap-2",
colors[data.type],
classNames({ "font-semibold": focused || hasFocusedChild }),
data.className)}>
{data.icon}
{data.content}
</div>
</FocusContext>
</li>;
}
@ -63,11 +69,23 @@ export interface DialogEntry
icon?: string | JSX.Element;
type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error';
action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void;
shortcuts?: Shortcut[];
}
export function ContextDialog (data: { id: string, children: any | any[], open: boolean, close: () => void; })
export function ContextDialog (data: {
id: string,
children: any | any[],
open: boolean, close: () => void;
className?: string;
preferredChildFocusKey?: string;
})
{
const { ref, focusKey, focusSelf } = useFocusable({ focusable: data.open, focusKey: `${data.id}-context-dialog`, isFocusBoundary: true });
const { ref, focusKey, focusSelf } = useFocusable({
focusable: data.open,
focusKey: `${data.id}-context-dialog`,
isFocusBoundary: true,
preferredChildFocusKey: data.preferredChildFocusKey
});
useEffect(() =>
{
if (data.open)
@ -76,14 +94,14 @@ export function ContextDialog (data: { id: string, children: any | any[], open:
}
}, [data.open]);
useShortcuts(focusKey, () => [{
useShortcuts(focusKey, () => data.open ? [{
label: "Close",
button: GamePadButtonCode.B,
action: () =>
{
data.close();
}
}], []);
}] : [], [data.open]);
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",
@ -96,7 +114,11 @@ export function ContextDialog (data: { id: string, children: any | any[], open:
<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")}
className={twMerge(
"bg-base-100/80 delay-200 rounded-4xl p-6 min-w-[30vw] cursor-auto",
data.open ? "animate-scale-delayed" : "opacity-0",
data.className)
}
style={{ backdropFilter: 'blur(24px)' }}
onClick={(e) => e.stopPropagation()}
>

View file

@ -0,0 +1,286 @@
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { ContextList, DialogEntry, OptionElement } from "./ContextDialog";
import { systemApi } from "../scripts/clientApi";
import { createContext, useContext, useRef, useState } from "react";
import path from "pathe";
import { Check, File, Folder, FolderClosed, FolderInput, FolderOutput, FolderPlus, HardDrive, Plus, Save, Undo, Usb, X } from "lucide-react";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { DirType, Drive } from "@/shared/constants";
import classNames from "classnames";
import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
import SvgIcon from "./SvgIcon";
import { Button } from "./options/Button";
import toast from "react-hot-toast";
import { drivesQuery, filesQuery } from "../scripts/queries";
const FilePickerContext = createContext<{
allowNewFolderCreation: boolean;
isDirectoryPicker: boolean;
setCurrentPath: (path: string) => void;
currentPath: string | undefined,
startingPath: string | undefined;
refetchFiles: () => void;
drives: Drive[],
activeDrive: Drive | undefined;
}>({} as any);
function List (data: {
id: string,
parentPath: string,
dirs: DirType[],
select: (path: string) => void;
})
{
const { setCurrentPath, startingPath, allowNewFolderCreation, currentPath, isDirectoryPicker } = useContext(FilePickerContext);
const { ref, focusKey } = useFocusable({ focusKey: data.id, preferredChildFocusKey: `${data.id}...` });
const handleReturn = () => setCurrentPath(data.parentPath);
useShortcuts(focusKey, () => [{ label: "Directoy Up", button: GamePadButtonCode.L1, action: handleReturn }], [handleReturn]);
return <div ref={ref}>
<FocusContext value={focusKey}>
<ContextList showCloseButton={false}
options={[
{
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>,
icon: <FolderOutput />,
shortcuts: [{ label: "Up", action: handleReturn, button: GamePadButtonCode.A }]
},
...data.dirs.map(f =>
{
const fullPath = path.join(f.parentPath, f.name);
const isDefaultPath = fullPath === startingPath;
let icon = <Folder />;
if (isDefaultPath)
{
icon = <FolderInput />;
} else if (!f.isDirectory)
{
icon = <></>;
}
const shortcuts: Shortcut[] = [];
if (f.isDirectory)
{
shortcuts.push({ label: "Enter", button: GamePadButtonCode.A, action: () => setCurrentPath(fullPath) });
if (isDirectoryPicker)
shortcuts.push({ label: "Select", button: GamePadButtonCode.X, action: () => data.select(fullPath) });
} else
{
shortcuts.push({ label: "Select", button: GamePadButtonCode.A, action: () => data.select(fullPath) });
}
const entry: DialogEntry = {
content: f.name,
id: `${data.id}-${f.name}`,
type: 'primary',
icon,
shortcuts
};
return entry;
}), ...(allowNewFolderCreation && currentPath ? [{
content: <NewFolderOption id={`${data.id}-new-folder-content`} dirname={currentPath} />,
id: `${data.id}-new-folder`,
type: 'primary'
} satisfies DialogEntry] : [])]
} />
</FocusContext>
</div>;
}
function NewFolderInput (data: { id: string, name: string | undefined, setName: (name: string) => void; className?: string; })
{
const inputRef = useRef<HTMLInputElement>(null);
const { ref, focused, focusSelf } = useFocusable({
focusKey: data.id,
onEnterPress: () => inputRef.current?.focus(),
onBlur: () => inputRef.current?.blur(),
});
const handleFocus = () =>
{
focusSelf();
systemApi.api.system.show_keyboard.post();
};
return <div className={data.className} ref={ref}>
<input ref={inputRef}
className={twMerge("input rounded-xl focus:ring-base-content w-full", classNames({ "ring-4 ring-accent": focused }))}
onFocus={handleFocus}
value={data.name}
placeholder="New Folder"
onChange={e => data.setName(e.target.value)}
/>
</div>;
}
function NewFolderOption (data: { id: string, dirname: string; })
{
const { refetchFiles } = useContext(FilePickerContext);
const [name, setName] = useState<string | undefined>();
const createMutation = useMutation({
mutationKey: ['create', 'folder', data.id], mutationFn: async () =>
{
if (!name) return;
const { error } = await systemApi.api.system.dirs.put({ name, dirname: data.dirname });
if (error) throw error.value;
},
onError: (e) => toast.error(e.message ?? 'Error Creating New Folder'),
onSuccess: (d, v, r, cx) =>
{
toast.success(`Folder ${name} created`);
refetchFiles();
}
});
return <div className="flex gap-2 grow -ml-2">
<NewFolderInput className="grow" id={`${data.id}-input`} setName={setName} name={name} />
<Button id={`${data.id}-create`} onAction={createMutation.mutate} type="button" ><FolderPlus /></Button>
</div>;
}
function OptionButtons (data: {
id: string;
onCancel: () => void;
onSelect: () => void;
showConfirm: boolean;
})
{
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">
<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>
</FocusContext>
</div>;
}
function DriveElement (data: { id: string, isActive: boolean, label: string; onSelect: () => void; isRemovable: boolean; })
{
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 overflow-hidden max-w-xs",
classNames({
"bg-primary text-primary-content": data.isActive,
"ring-7 ring-base-content": focused
})
)}>
{data.isRemovable ? <Usb /> : <HardDrive />}
{data.label}
</li>;
}
function Drives (data: {
id: string,
onSelect: (path: string) => void;
})
{
const { drives, activeDrive } = useContext(FilePickerContext);
const { focusKey, ref } = useFocusable({
focusKey: data.id,
preferredChildFocusKey: activeDrive?.mountPoint ?? undefined,
saveLastFocusedChild: false,
autoRestoreFocus: false
});
return <ul className="flex flex-col gap-2" ref={ref} >
<FocusContext value={focusKey}>
{drives?.filter(d => d.mountPoint)
.sort((a, b) => b.mountPoint!.length - a.mountPoint!.length)
.map(d =>
<DriveElement isRemovable={d.isRemovable} onSelect={() => data.onSelect(d.mountPoint!)} id={d.mountPoint!} isActive={activeDrive?.mountPoint === d.mountPoint} label={d.label} />
)}
</FocusContext>
</ul>;
}
function ListWithDrives (data: {
id: string,
files: DirType[],
onSelect: (path: string) => void,
parentPath: string;
})
{
const { setCurrentPath, isDirectoryPicker } = useContext(FilePickerContext);
const { focusKey, ref } = useFocusable({
focusKey: `main-${data.id}`,
preferredChildFocusKey: `list-${data.id}`
});
return <div ref={ref} className="flex 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}`} />
<div className="divider divider-horizontal m-1"></div>
</div>
<div className="divider divider-horizontal m-0"></div>
<div className="overflow-y-auto w-full">
<List
id={`list-${data.id}`}
dirs={data.files.filter(d =>
{
if (isDirectoryPicker && !d.isDirectory)
{
return false;
}
return true;
})} parentPath={data.parentPath} select={data.onSelect} />
</div>
</FocusContext>
</div>;
}
export default function FilePicker (data: {
id: string;
startingPath?: string;
onSelect: (path: string) => void;
isDirectoryPicker?: boolean;
cancel: () => void;
allowNewFolderCreation?: boolean;
})
{
const [currentPath, setCurrentPath] = useState<string | undefined>(data.startingPath);
const { data: files, refetch: refetchFiles } = useQuery(filesQuery(currentPath, data.id));
const { data: drives } = 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];
const activeDriveMount = activeDrive?.mountPoint;
const fullPathElements = activeDrive?.label ?
[<><HardDrive />{activeDrive?.label}</>, ...fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep)] :
fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep);
return <div className="flex flex-col h-full max-h-full gap-3">
<FilePickerContext value={{
setCurrentPath,
currentPath,
isDirectoryPicker: data.isDirectoryPicker ?? false,
refetchFiles,
startingPath: data.startingPath,
allowNewFolderCreation: data.allowNewFolderCreation ?? false,
drives: drives ?? [],
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">
<ul>
{fullPathElements.map((p, i) => <li>
<a onClick={() =>
setCurrentPath(path.join(...fullPath.slice(-i)))
}>{p}</a>
</li>)}
</ul>
</div>}
<ListWithDrives
id={data.id}
files={files?.dirs ?? []}
onSelect={data.onSelect}
parentPath={files?.parentPath ?? ''}
/>
<OptionButtons
showConfirm={!!data.isDirectoryPicker}
onCancel={data.cancel}
onSelect={() => currentPath ? data.onSelect(currentPath) : undefined}
id={data.id} />
</FilePickerContext>
</div>;
}

View file

@ -79,11 +79,11 @@ export default function GameCard (data: GameCardParams)
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
)}</div>
<div className="h-0 flex pr-2 justify-end items-center">
<div className="h-0 flex pr-2 justify-end items-center 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 mr-4 transition-colors",
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 last:mr-4 transition-colors",
classNames({ "bg-primary text-primary-content": focused }))}
>
{b}

View file

@ -1,22 +1,16 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { GameMetaExtra, CardList } from "./CardList";
import { FrontEndId, RPC_URL } from "../../shared/constants";
import { FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants";
import { useNavigate } from "@tanstack/react-router";
import { SaveSource } from "../scripts/spatialNavigation";
import { rommApi } from "../scripts/clientApi";
import { HardDrive } from "lucide-react";
import { JSX } from "react";
export interface GameListFilter
{
platformId?: number;
collectionId?: number;
}
export interface GameListParams
{
id: string,
filters?: GameListFilter,
filters?: GameListFilterType,
grid?: boolean,
setBackground?: (url: string) => void;
onGameSelect?: (id: FrontEndId) => void;
@ -29,10 +23,7 @@ export function GameList (data: GameListParams)
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
}
query: data.filters
}).then(d => d.data)
});
const navigator = useNavigate();

View file

@ -6,7 +6,11 @@ import
import classNames from "classnames";
import
{
BatteryCharging,
BatteryFull,
BatteryLow,
BatteryMedium,
BatteryWarning,
Bell,
Bluetooth,
Clock,
@ -16,14 +20,18 @@ import
Sun,
User,
Wifi,
WifiHigh,
WifiLow,
WifiZero,
} from "lucide-react";
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 } from "react";
import { JSX, useEffect, useRef } from "react";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { SaveSource } from "../scripts/spatialNavigation";
import { systemApi } from "../scripts/clientApi";
function HeaderAvatar (data: {
id: string;
@ -104,11 +112,128 @@ export interface HeaderAccount
action?: () => void;
}
function NotificationStatus ()
{
const hasUnread = false;
return <div className={classNames("p-2 rounded-full", { "bg-warning text-warning-content": hasUnread })}>
<Bell className="w-6 h-6" />
</div>;
}
function ClockStatus ()
{
const ref = useRef<HTMLSpanElement>(null);
useEffect(() =>
{
function update ()
{
if (ref.current)
{
ref.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
// Update immediately
update();
// Wait until next minute boundary
const now = new Date();
const msUntilNextMinute =
(60 - now.getSeconds()) * 1000 - now.getMilliseconds();
const timeout = setTimeout(() =>
{
update();
// Then update every minute
const interval = setInterval(update, 60_000);
return () => clearInterval(interval);
}, msUntilNextMinute);
return () => clearTimeout(timeout);
}, []);
return <div className="flex gap-3"><span ref={ref}></span><Clock /></div>;
}
function BluetoothStatus ()
{
const { data: bluetooth } = useQuery({
queryKey: ['wifi'],
queryFn: () => systemApi.api.system.info.bluetooth.get().then(d => d.data),
refetchInterval: 3000
});
return bluetooth && bluetooth.find(b => b.connected) && <div>
<Bluetooth className="w-6 h-6" />
</div>;
}
function WiFiStatus ()
{
const { data: wifi } = useQuery({
queryKey: ['wifi'],
queryFn: () => systemApi.api.system.info.wifi.get().then(d => d.data),
refetchInterval: 3000
});
return <div>
{wifi?.map(w =>
{
const className = "w-6 h-6";
let icon = <Wifi className={className} />;
if (w.signalLevel >= -60)
icon = <Wifi className={className} />;
else if (w.signalLevel >= -70)
icon = <WifiHigh className={className} />;
else if (w.signalLevel >= -80)
icon = <WifiLow className={className} />;
else if (w.signalLevel >= -90)
icon = <WifiZero className={className} />;
return <div className="tooltip" data-tip={w.signalLevel}>
{icon}
</div>;
})}
</div>;
}
function BatteryStatus ()
{
const { data: battery } = useQuery({
queryKey: ['battery'],
queryFn: () => systemApi.api.system.info.battery.get().then(d => d.data),
refetchInterval: 3000
});
const batteryClassName = "w-6 h-6";
let batteryIcon = <BatteryFull className={batteryClassName} />;
if (battery?.isCharging || battery?.acConnected)
{
batteryIcon = <BatteryCharging className={batteryClassName} />;
} else if (battery?.percent)
{
if (battery.percent < 5)
{
batteryIcon = <BatteryWarning className={batteryClassName} />;
}
else if (battery.percent < 15)
{
batteryIcon = <BatteryLow className={batteryClassName} />;
} else if (battery.percent < 50)
{
batteryIcon = <BatteryMedium className={batteryClassName} />;
}
}
return <div className="flex gap-2 items-center">
{batteryIcon}
<span className="font-semibold">{battery?.percent} %</span>
</div>;
}
export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[], buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; })
{
const { ref, focusKey } = useFocusable({ focusKey: "header-elements" });
const navigate = useNavigate();
const location = useLocation();
const rommOnline = useQuery({
...statsApiStatsGetOptions(),
refetchInterval: 30000,
@ -161,18 +286,12 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
{data.title}
</div>
<div className="flex items-center gap-2 text drop-shadow-sm">
<div className="flex gap-5">
<Clock />
<Wifi className="w-6 h-6" />
<Bluetooth className="w-6 h-6" />
<div className="indicator">
<span className="indicator-item status status-error"></span>
<Bell className="w-6 h-6" />
</div>
<div className="flex gap-2 items-center">
<BatteryFull className="w-6 h-6" />
<span className="font-semibold">100%</span>
</div>
<div className="flex 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">

View file

@ -5,6 +5,8 @@ import { CardList, GameMetaExtra } from "./CardList";
import classNames from "classnames";
import { rommApi } from "../scripts/clientApi";
import { SaveSource } from "../scripts/spatialNavigation";
import { JSX } from "react";
import { HardDrive } from "lucide-react";
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: (node: HTMLElement) => void; })
{
@ -29,42 +31,48 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
className={data.className}
onGameFocus={(id, node) => data.onFocus?.(node)}
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: () =>
{
SaveSource('game-list');
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(
.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>
,
} satisfies GameMetaExtra))}
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;
})}
onSelectGame={(id) =>
{

View file

@ -26,11 +26,11 @@ const iconMap: Record<GamePadButtonCode, IconType> = {
export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
{
return (
<div className="flex gap-2">
<div className="flex gap-2 z-1000">
{data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt
key={s.button}
id={`shortcut-${s.button}`}
onClick={e => s.action(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={iconMap[s.button]}
label={s.label} />
)}

View file

@ -5,22 +5,39 @@ import
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
export function Button (data: { id: string, children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams)
export function Button (data: {
id: string,
children?: any,
className?: string,
disabled?: boolean,
type: "reset" | "button" | "submit" | undefined;
shortcutLabel?: string;
focusClassName?: string;
} & InteractParams & FocusParams)
{
const { ref, focused } = useFocusable({
const { ref, focused, focusKey } = useFocusable({
focusKey: data.id,
onEnterPress: data.onAction,
onFocus: data.onFocus,
focusable: !data.disabled
});
if (data.shortcutLabel)
{
useShortcuts(focusKey, () => [{ label: data.shortcutLabel, action: data.onAction, button: GamePadButtonCode.A }], [data.shortcutLabel]);
}
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))}
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg",
focused ? data.focusClassName : undefined,
classNames({
"btn-accent": focused,
}, data.className))}
type={data.type}
>
{data.children}

View file

@ -0,0 +1,32 @@
import { useState } from "react";
import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
import { useMutation } from "@tanstack/react-query";
import { changeDownloadsMutation } from "@/mainview/scripts/queries";
export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
{
const [localValue, setLocalValue] = useState<string | undefined>();
const [dirty, setDirty] = useState(false);
const setSettingMutation = useMutation({
...changeDownloadsMutation,
onSuccess: (d, v, r, cx) =>
{
setDirty(r !== localValue);
}
});
return <PathSettingsOptionBase
isDirty={dirty}
label={data.label}
id={data.id}
type={data.type}
save={setSettingMutation.mutate}
allowNewFolderCreation={data.allowNewFolderCreation}
isDirectoryPicker={true}
localValue={localValue}
setLocalValue={(v) =>
{
setLocalValue(v);
setDirty(true);
}} />;
}

View file

@ -22,7 +22,6 @@ export function OptionInput (data: {
focusKey: data.name, onEnterPress: () =>
{
inputRef.current?.focus();
systemApi.api.system.show_keyboard.post();
}
});
const inputRef = useRef<HTMLInputElement>(null);
@ -32,6 +31,21 @@ export function OptionInput (data: {
inputRef.current?.focus();
},
});
const handleFocus = () =>
{
option.focus();
if (inputRef.current)
{
var rect = inputRef.current?.getBoundingClientRect();
systemApi.api.system.show_keyboard.post({
XPosition: rect.x,
YPosition: rect.y,
Width: rect.width,
Height: rect.height
});
}
};
return (
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
@ -47,7 +61,7 @@ export function OptionInput (data: {
defaultValue={data.defaultValue}
type={data.type}
autoComplete={data.autocomplete}
onFocus={() => option.focus()}
onFocus={handleFocus}
placeholder={data.placeholder}
onChange={data.onChange}
onBlur={data.onBlur}

View file

@ -0,0 +1,156 @@
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";
import { Button } from "./Button";
import { FileSearchCorner, FolderSearch, Pen, Save } from "lucide-react";
import { ContextDialog } from "../ContextDialog";
import FilePicker from "../FilePicker";
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
type KeysWithValueAssignableTo<T, Value> = {
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
}[keyof T];
export interface PathSettingsOptionParams
{
label: string;
id: KeysWithValueAssignableTo<SettingsType, string>;
type: HTMLInputTypeAttribute;
placeholder?: string;
icon?: JSX.Element;
children?: any;
onBrowseAction?: (path: string | undefined) => void;
requireConfirmation?: boolean;
isDirectoryPicker?: boolean;
allowNewFolderCreation?: boolean;
}
export function PathSettingsOption (data: PathSettingsOptionParams)
{
const [localValue, setLocalValue] = useState<string | undefined>();
const [dirty, setDirty] = useState(false);
const setSettingMutation = useMutation({
mutationKey: ["setting", data.id],
mutationFn: async (value: any) =>
{
const response = await settingsApi.api.settings({ id: data.id! }).post({ value });
if (response.error) throw response.error;
return response.data;
},
onSuccess: (d, v, r, cx) =>
{
setDirty(r !== localValue);
}
});
return <PathSettingsOptionBase
isDirty={dirty}
label={data.label}
id={data.id}
type={data.type}
save={setSettingMutation.mutate}
localValue={localValue}
allowNewFolderCreation={data.allowNewFolderCreation}
setLocalValue={(v) =>
{
setLocalValue(v);
setDirty(true);
}} />;
}
export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
save: (value: string | undefined) => void;
localValue: string | undefined;
setLocalValue: (value: string | undefined) => void;
isDirty: boolean;
})
{
const [isBrowsing, setIsBrowsing] = useState(false);
const { data: defaultValue } = useQuery({
enabled: !!data.id,
queryKey: ["setting", data.id],
queryFn: async () =>
{
const { data: value, error } = await settingsApi.api.settings({ id: data.id! }).get();
if (error) throw error;
if (!data.isDirty)
{
data.setLocalValue(String(value.value));
}
return value.value;
},
});
const changed = defaultValue !== data.localValue;
const handleSelectPath = (path: string) =>
{
data.setLocalValue(path);
handleCloseSeatch();
if (data.requireConfirmation !== true)
{
data.save(path);
}
};
const handleCloseSeatch = () =>
{
setIsBrowsing(false);
setFocus(`${data.id}-browse`);
};
const handleInputBlur = () =>
{
if (data.requireConfirmation !== true)
{
data.save(data.localValue);
}
};
return (
<OptionSpace id={data.id} className="gap-2" label={<>{data.label}{changed && <Pen />}</>}>
<OptionInput
icon={data.icon}
name={`${data.id}-input`}
type={data.type}
placeholder={data.placeholder}
onBlur={handleInputBlur}
onChange={(e) =>
{
data.setLocalValue(e.currentTarget.value);
}}
value={data.localValue}
/>
<Button id={`${data.id}-browse`} className="ring-accent-content" focusClassName="ring-7" onAction={() =>
{
setIsBrowsing(true);
data.onBrowseAction?.(data.localValue);
}} type="button">
{data.isDirectoryPicker ? <FolderSearch /> : <FileSearchCorner />}
</Button>
{data.requireConfirmation === true && <Button
disabled={defaultValue === data.localValue}
id={`${data.id}-save`}
onAction={() => data.save(data.localValue)}
type="button">
<Save />
</Button>}
<ContextDialog className="h-[80vh] w-[60vw]" id={`file-picker-${data.id}`} open={isBrowsing} close={handleCloseSeatch} >
{isBrowsing && <FilePicker
isDirectoryPicker={data.isDirectoryPicker}
onSelect={handleSelectPath}
key={`download-path-${data.id}`}
startingPath={data.localValue}
id={`download-path-${data.id}`}
cancel={handleCloseSeatch}
allowNewFolderCreation={data.allowNewFolderCreation}
/>
}
</ContextDialog>
{data.children}
</OptionSpace>
);
}

View file

@ -15,6 +15,7 @@ export function SettingsOption (data: {
type: HTMLInputTypeAttribute;
placeholder?: string;
icon?: JSX.Element;
children?: any;
})
{
const [dirty, setDirty] = useState(false);
@ -67,6 +68,7 @@ export function SettingsOption (data: {
}}
value={localValue}
/>
{data.children}
</OptionSpace>
);
}

View file

@ -9,12 +9,11 @@ import
RouterProvider,
} from "@tanstack/react-router";
import { routeTree } from "./gen/routeTree.gen";
import { QueryClient } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RPC_URL } from "../shared/constants";
import "./scripts/gamepads";
import "./scripts/windowEvents";
import { client as rommClient } from "../clients/romm/client.gen";
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
import "./scripts/spatialNavigation";
const hashHistory = createHashHistory({});
@ -50,6 +49,8 @@ export const Router = createRouter({
},
});
// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register
@ -58,12 +59,6 @@ declare module "@tanstack/react-router" {
}
}
setupRouterSsrQueryIntegration({
router: Router,
queryClient,
wrapQueryClient: true,
});
const rootElement = document.getElementById("root")!;
if (!rootElement.innerHTML)
@ -71,7 +66,9 @@ if (!rootElement.innerHTML)
const root = createRoot(rootElement);
root.render(
<StrictMode>
<RouterProvider router={Router} />
<QueryClientProvider client={queryClient}>
<RouterProvider router={Router} />
</QueryClientProvider>
</StrictMode>,
);
}

View file

@ -1,8 +1,9 @@
import { createFileRoute } from '@tanstack/react-router';
import { useSessionStorage } from 'usehooks-ts';
import { CollectionsDetail } from '../components/CollectionsDetail';
import { getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen';
import { getCollectionApiCollectionsIdGetOptions, getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen';
import { DefaultRommStaleTime } from '../../shared/constants';
import { useQuery } from '@tanstack/react-query';
export const Route = createFileRoute('/collection/$id')({
component: RouteComponent,
@ -15,12 +16,13 @@ export const Route = createFileRoute('/collection/$id')({
function RouteComponent ()
{
const { id } = Route.useParams();
const { data: collection } = useQuery({ ...getCollectionApiCollectionsIdGetOptions({ path: { id: Number(id) } }) });
const [, setBackground] = useSessionStorage<string | undefined>(
"home-background",
undefined,
);
return (
<CollectionsDetail setBackground={setBackground} filters={{ collectionId: Number(id) }} />
<CollectionsDetail setBackground={setBackground} title={<div className="divider font-semibold text-2xl">{collection?.name}</div>} filters={{ collection_id: Number(id) }} />
);
}

View file

@ -205,7 +205,15 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
mutationFn: async () =>
{
const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).play.post();
if (error) throw error;
if (error)
{
if (error.value.message)
{
toast.error(error.value.message);
}
throw error;
};
}
});
const [progress, setProgress] = useState<number | undefined>(undefined);

View file

@ -13,23 +13,16 @@ import
import
{
createFileRoute,
useLocation,
useNavigate,
} from "@tanstack/react-router";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import
{
FocusContext,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { useEventListener } from "usehooks-ts";
import
{
getCollectionsApiCollectionsGetOptions,
} from "../../clients/romm/@tanstack/react-query.gen";
import { CardList, GameMetaExtra } from "../components/CardList";
import { HeaderUI } from "../components/Header";
import { FilterUI } from "../components/Filters";
import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground";
@ -47,10 +40,11 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/
import z from "zod";
import { Router } from "..";
import CollectionList from "../components/CollectionList";
import { zodValidator } from '@tanstack/zod-adapter';
export const Route = createFileRoute("/")({
component: ConsoleHomeUI,
validateSearch: z.object({ filter: z.string().optional().default('games') })
validateSearch: zodValidator(z.object({ filter: z.string().optional().default('games') }))
});
const filters = {

View file

@ -7,6 +7,9 @@ import { Router } from '..';
import { useEffect, useState } from 'react';
import { rommApi } from '../scripts/clientApi';
import { useQuery } from '@tanstack/react-query';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import Shortcuts from '../components/Shortcuts';
export const Route = createFileRoute('/launcher/$source/$id')({
component: RouteComponent,
@ -20,13 +23,11 @@ function RouteComponent ()
}
const { source, id } = Route.useParams();
const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` });
const { data } = useQuery({ queryKey: ['romm', 'game'], queryFn: () => rommApi.api.romm.game({ source })({ id }).get() });
useEventListener("cancel", (e) =>
{
e.stopPropagation();
HandleGoBack();
});
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
useEffect(() =>
{
@ -41,18 +42,27 @@ function RouteComponent ()
}
};
es.addEventListener('refresh', HandleGoBack);
es.addEventListener('refresh', () =>
{
HandleGoBack();
});
es.onerror = HandleGoBack;
es.onerror = () =>
{
HandleGoBack();
};
return () => es.close();
}, []);
return <AnimatedBackground backgroundKey='game-details'>
return <AnimatedBackground ref={ref} backgroundKey='game-details'>
<div className='flex shadow-2xs shadow-black flex-col absolute w-screen h-screen overflow-hidden justify-center items-center gap-4'>
<DotsLoading />
<h1 className='font-semibold'>Launching {data?.data?.name} ...</h1>
</div>
<div className='absolute bot'>
<Shortcuts shortcuts={shortcuts} />
</div>
</AnimatedBackground>;
}

View file

@ -1,7 +1,7 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEventListener, useSessionStorage } from "usehooks-ts";
import { CollectionsDetail } from "../components/CollectionsDetail";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { Suspense } from "react";
import { rommApi } from "../scripts/clientApi";
@ -10,10 +10,21 @@ export const Route = createFileRoute("/platform/$source/$id")({
component: RouteComponent
});
function PlatformTitle ()
function PlatformTitle (data: { platformSlug?: string, platformName?: string; })
{
return <div className="flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
<div className="divider mb-6 mt-0">
{!!data.platformSlug && <img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${data.platformSlug.toLocaleLowerCase()}.svg`} ></img>}
{data.platformName}
</div>
</div>;
}
function RouteComponent ()
{
const { source, id } = Route.useParams();
const { data: platform } = useSuspenseQuery({
const { data: platform } = useQuery({
queryKey: ['platform', source, id], queryFn: async () =>
{
const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get();
@ -22,33 +33,18 @@ function PlatformTitle ()
}, staleTime: DefaultRommStaleTime
});
return <div className="flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
<div className="divider mb-6 mt-0">
<img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${platform.slug.toLocaleLowerCase()}.svg`} ></img>
{platform.display_name}
</div>
</div>;
}
function RouteComponent ()
{
const { id } = Route.useParams();
const [, setBackground] = useSessionStorage<string | undefined>(
"home-background",
undefined,
);
const navigate = useNavigate();
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ['zoom-out'] } }));
return (
<div className="w-full h-full">
<CollectionsDetail
title={<Suspense><PlatformTitle /></Suspense>}
{!!platform && <CollectionsDetail
title={<PlatformTitle platformSlug={platform.slug} platformName={platform.name} />}
setBackground={setBackground}
filters={{ platformId: Number(id) }}
/>
filters={{ platform_id: Number(id), platform_slug: platform.slug, platform_source: source }}
/>}
</div>
);
}

View file

@ -51,10 +51,6 @@ function RouteComponent ()
<th>Machine</th>
<td>{systemInfo?.data?.machine}</td>
</tr>
<tr>
<th>Space</th>
<td>{!!systemInfo?.data && `${prettyBytes(systemInfo?.data?.freeSpace)} Free / ${prettyBytes(systemInfo?.data?.totalSpace)} Total | ${(1 - (systemInfo?.data?.freeSpace / systemInfo?.data?.totalSpace)).toLocaleString('en-GB', { style: "percent" })}`}</td>
</tr>
<tr>
<th>Steam Deck</th>
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>

View file

@ -1,11 +1,68 @@
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { createFileRoute } from '@tanstack/react-router';
import { SettingsOption } from '../../components/options/SettingsOption';
import { Block, createFileRoute, useBlocker } from '@tanstack/react-router';
import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption';
import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query';
import { changeDownloadsMutation, downloadDrivesQuery } from '@/mainview/scripts/queries';
import { DownloadsDrive } from '@/shared/constants';
import prettyBytes from 'pretty-bytes';
import classNames from 'classnames';
import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts';
import { Download, FolderOpen, HardDrive, Usb } from 'lucide-react';
import { twMerge } from 'tailwind-merge';
import { OptionSpace } from '@/mainview/components/options/OptionSpace';
import data from '@emulators';
import { Button } from '@/mainview/components/options/Button';
import { systemApi } from '@/mainview/scripts/clientApi';
export const Route = createFileRoute('/settings/directories')({
component: RouteComponent,
});
function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; refetchDrives: () => void; })
{
const { ref, focused, focusKey } = useFocusable({ focusKey: data.drive.device });
const isMoving = useIsMutating(changeDownloadsMutation);
const usedWithoutDownlods = data.drive.used - (data.drive.isCurrentlyUsed ? data.downloadsSize : 0);
const usedPercent = usedWithoutDownlods / data.drive.size;
const usedPercentRaw = data.drive.used / data.drive.size;
const changeDownloads = useMutation({ ...changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason;
const shortcuts: Shortcut[] = [];
if (!data.drive.unusableReason && isMoving <= 0)
{
shortcuts.push({ label: "Move Downloads", button: GamePadButtonCode.A, action: () => changeDownloads.mutate(data.drive.mountPoint) });
}
useShortcuts(focusKey, () => shortcuts, [shortcuts]);
return <li ref={ref} className={twMerge('flex flex-col p-4 bg-base-300 rounded-2xl gap-1',
classNames({
"ring-7": focused,
"border-dashed border-primary border-7": data.drive.isCurrentlyUsed,
"border-solid": data.drive.unusableReason === 'already_used',
"ring-error": data.drive.unusableReason === 'not_enough_space',
}))}>
<div className='flex gap-2 font-semibold'>{data.drive.isRemovable ? <Usb /> : <HardDrive />}{data.drive.label}</div>
<small className='opacity-60'>{data.drive.mountPoint}</small>
<div className='flex gap-2'>
{prettyBytes(data.drive.size - data.drive.used)} Free
{data.drive.unusableReason === 'not_enough_space' && <p className='text-error'>(Not Enough Space)</p>}
{data.drive.unusableReason === 'already_used' && <p>(Currently Used)</p>}
{data.drive.unusableReason !== 'already_used' && data.drive.isCurrentlyUsed && <p className='opacity-60'>(Custom Path)</p>}
</div>
<div className={twMerge("progress", classNames({
"progress-warning": usedPercent > 0.8,
"progress-error": data.drive.unusableReason === 'not_enough_space',
}))}>
<div className={twMerge('h-full bg-primary', classNames({
"bg-warning": usedPercent > 0.8,
"bg-error": data.drive.unusableReason === 'not_enough_space',
}))} style={{ width: usedPercent.toLocaleString('en-US', { style: 'percent' }) }}></div>
{!!data.drive.isCurrentlyUsed && <div className="h-full bg-base-content" style={{ width: usedPercentRaw.toLocaleString('en-US', { style: 'percent' }) }}></div>}
</div>
</li>;
}
function RouteComponent ()
{
const { focus } = Route.useSearch();
@ -13,14 +70,34 @@ function RouteComponent ()
preferredChildFocusKey: focus
});
const isMoving = useIsMutating(changeDownloadsMutation);
const { data: drives, refetch } = useQuery({ ...downloadDrivesQuery, refetchInterval: isMoving > 0 ? 1000 : undefined });
return <FocusContext value={focusKey}>
<Block shouldBlockFn={() => isMoving} withResolver={false} />
<ul ref={ref} className="list rounded-box gap-2">
<div className="divider text-2xl mt-0 md:mt-4">
<div className="flex flex-col">
<h3>Romm</h3>
</div>
<Download className='size-16' /> Downloads ({drives?.downloadsSize ? prettyBytes(drives?.downloadsSize) : '?'})
</div>
<SettingsOption label="Download Path" id="downloadPath" type="text" />
<ul className='p-2 grid grid-cols-2 gap-3'>
{drives?.drives.filter(d => d.mountPoint).map(d => <DriveComponent refetchDrives={refetch} downloadsSize={drives.downloadsSize} drive={d} />)}
</ul>
<DownloadDirectoryOption
isDirectoryPicker
requireConfirmation
allowNewFolderCreation
label="Custom Download Path"
id="downloadPath"
type="text" >
</DownloadDirectoryOption>
<OptionSpace label="Config Path" id='config'>
<div className='flex gap-2 items-center'>
{drives?.configPath}
<Button id='open-config' type='button' onAction={() => systemApi.api.system.open.post({ url: drives?.configPath ?? '' })} ><FolderOpen /></Button>
</div>
</OptionSpace>
</ul>
</FocusContext>;
</FocusContext >;
}

View file

@ -5,7 +5,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { settingsApi } from '../../scripts/clientApi';
import { useCallback, useState } from 'react';
import { Button } from '../../components/options/Button';
import { Check, ChevronDown, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
import { Check, ChevronDown, FolderSearch, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
import classNames from 'classnames';
import { twMerge } from 'tailwind-merge';
@ -13,6 +13,8 @@ import { RPC_URL } from '../../../shared/constants';
import emulators from '@emulators';
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
import FilePicker from '@/mainview/components/FilePicker';
import { dirname } from 'pathe';
export const Route = createFileRoute('/settings/emulators')({
component: RouteComponent,
@ -90,6 +92,7 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd
function EmulatorPath (data: { id: string; })
{
const [isSearching, setIsSearching] = useState(false);
const [dirty, setDirty] = useState(false);
const [localValue, setLocalValue] = useState<string | undefined>();
const { data: remoteValue } = useQuery({
@ -109,6 +112,8 @@ function EmulatorPath (data: { id: string; })
{
ctx.client.invalidateQueries({ queryKey: ["emulator", data.id] });
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
setLocalValue(v);
setDirty(false);
}
});
const deleteMutation = useMutation({
@ -129,11 +134,23 @@ function EmulatorPath (data: { id: string; })
{
if (dirty)
{
setDirty(false);
setSettingMutation.mutate(localValue ?? '');
}
}, [dirty, setDirty, localValue]);
const handleCloseSearch = () =>
{
setIsSearching(false);
setFocus(`search-${data.id}`);
};
const handleSelectPath = (path: string) =>
{
setIsSearching(false);
setSettingMutation.mutate(path);
setFocus(`search-${data.id}`);
};
return (
<OptionSpace label={<><p className='font-semibold'>{data.id}</p><small className='text-base-content/40'>{emulators[data.id]}</small></>}>
<div className='flex gap-2'>
@ -150,9 +167,33 @@ function EmulatorPath (data: { id: string; })
}}
value={localValue}
/>
<Button id={`delete-${data.id}`} className='p-2' onAction={() => deleteMutation.mutate()} type='button' >
<Button shortcutLabel="Remove" id={`delete-${data.id}`} className='p-2' onAction={() => deleteMutation.mutate()} type='button' >
<Trash />
</Button>
<Button
id={`search-${data.id}`}
className='p-2'
onAction={() => setIsSearching(true)}
shortcutLabel={"Search"}
type='button' >
<FolderSearch />
</Button>
<ContextDialog
className='h-[80vh] w-[60vw]'
id={`file-picker-${data.id}`}
open={isSearching}
close={handleCloseSearch}
preferredChildFocusKey={`main-download-path-${data.id}`}
>
{isSearching && <FilePicker
onSelect={handleSelectPath}
key={`download-path-${data.id}`}
startingPath={remoteValue ? dirname(remoteValue) : undefined}
id={`download-path-${data.id}`}
cancel={handleCloseSearch}
/>
}
</ContextDialog>
</div>
</OptionSpace>
);

View file

@ -53,7 +53,7 @@ function MenuItem (data: {
const acitve = matchRoute({ to: data.route });
const handleNonFocusSelect = () => navigate({ to: data.return ? PopSource('settings') ?? data.route : data.route, viewTransition: data.viewTransition });
const { ref, focusSelf, focused } = useFocusable({
focusKey: data.route,
focusKey: `menu-item-${data.route}`,
forceFocus: !!acitve,
onFocus: () =>
{
@ -119,8 +119,8 @@ function SettingsMenu (data: {})
/>
<MenuItem
focusSelect
route="/settings/visual"
label="Visual"
route="/settings/interface"
label="Interface"
icon={<MonitorCog />}
/>
<MenuItem
@ -156,18 +156,12 @@ function SettingsMenu (data: {})
function HandleGoBack ()
{
if (document.activeElement && document.activeElement !== document.body && document.activeElement instanceof HTMLElement)
const source = PopSource('settings');
if (source)
{
document.activeElement.blur();
} else
{
const source = PopSource('settings');
if (source)
{
console.log("Found source ", source, " to go back to");
}
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
console.log("Found source ", source, " to go back to");
}
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
}

View file

@ -8,12 +8,15 @@ export const rommApi = treaty<RommAPIType>(RPC_URL(__HOST__), {
credentials: 'include',
}
});
export const settingsApi = treaty<SettingsAPIType>(RPC_URL(__HOST__), {
keepDomain: true,
fetch: {
credentials: 'include',
}
});
export const systemApi = treaty<SystemAPIType>(RPC_URL(__HOST__), {
keepDomain: true,
fetch: {

View file

@ -3,20 +3,16 @@ import { dispatchFocusedEvent, GetFocusedElement } from "./spatialNavigation";
let loopStarted = false;
window.addEventListener("gamepadconnected", (evt) =>
const handleLoop = () =>
{
if (!loopStarted)
{
requestAnimationFrame(updateStatus);
loopStarted = true;
}
});
window.addEventListener("gamepaddisconnected", (evt) =>
{
});
};
window.addEventListener("gamepadconnected", handleLoop);
import.meta.hot.dispose(() => window.addEventListener('gamepaddisconnected', handleLoop));
const throttleMap = new Map<string, number>();
const throttleAcceleration = new Map<string, number>();
@ -36,7 +32,7 @@ function throttleNav (key: string, dir: string, event: Event)
}
}
window.addEventListener('keydown', e =>
/*window.addEventListener('keydown', e =>
{
if (e.key === 'Escape')
{
@ -45,7 +41,7 @@ window.addEventListener('keydown', e =>
const evn = new Event('cancel', { bubbles: true, cancelable: true });
finalTarget.dispatchEvent(evn);
}
});
});*/
export class GamepadButtonEvent extends Event
{

View file

@ -0,0 +1,54 @@
import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query";
import { settingsApi, systemApi } from "./clientApi";
import toast from "react-hot-toast";
import { getErrorMessage } from "react-error-boundary";
export const drivesQuery = queryOptions({
queryKey: ['drives'],
queryFn: async () =>
{
const { data, error } = await systemApi.api.system.drives.get();
if (error) throw error;
return data;
}
});
export const downloadDrivesQuery = queryOptions({
queryKey: ['drives', 'download'],
queryFn: async () =>
{
const { data, error } = await systemApi.api.system.drives.download.get();
if (error) throw error;
return data;
}
});
export const filesQuery = (currentPath: string | undefined, id: string) => queryOptions({
queryKey: ['files', currentPath ?? '', id],
queryFn: async () =>
{
const { data, error } = await systemApi.api.system.dirs.get({ query: { path: currentPath } });
if (error) throw error;
return data;
},
placeholderData: keepPreviousData
});
export const changeDownloadsMutation = mutationOptions({
mutationKey: ["setting", "downloads"],
mutationFn: async (value: any) =>
{
const response = await toast.promise(settingsApi.api.settings.path.download.put({ manualPath: value }).then(d =>
{
if (d.error) throw d.error;
return d.data;
}), {
success: e => `Download Moved to ${e}`,
loading: "Moving Download",
error: e => getErrorMessage(e) ?? "Error Moving Download"
});
return response;
}
});

View file

@ -32,9 +32,25 @@ export interface Shortcut
{
label?: string;
button: GamePadButtonCode;
action: (e: GamepadButtonEvent) => void;
action?: (e: GamepadButtonEvent) => void;
}
let isDirty = false;
const shortcutChangeDispatcher = setInterval(() =>
{
window.dispatchEvent(new Event('shortcutsChanged'));
isDirty = false;
}, 100);
import.meta.hot.dispose(() => clearInterval(shortcutChangeDispatcher));
function markDirtyThrottled ()
{
isDirty = true;
}
window.addEventListener('focuschanged', markDirtyThrottled);
import.meta.hot.dispose(() => window.removeEventListener('focuschanged', markDirtyThrottled));
export function useShortcutContext ()
{
const [array, setArray] = useState<Shortcut[] | undefined>();
@ -44,7 +60,8 @@ export function useShortcutContext ()
const handleShortcutRebuild = () =>
{
conflictSet.clear();
const newArray = GetFocusedTree(getCurrentFocusKey())
const focusKey = getCurrentFocusKey();
const newArray = GetFocusedTree(focusKey)
.filter(f => shortcutMap.has(f))
.flatMap(f => shortcutMap.get(f)!)
.filter(s =>
@ -65,7 +82,7 @@ export function useShortcutContext ()
const event = e as GamepadButtonEvent;
if (shortcuts.has(event.button))
{
shortcuts.get(event.button)?.action(event);
shortcuts.get(event.button)?.action?.(event);
}
else if (event.button === GamePadButtonCode.A)
{
@ -74,6 +91,20 @@ export function useShortcutContext ()
}
};
const handleKeyPress = (e: KeyboardEvent) =>
{
if (e.key === 'Escape')
{
shortcuts.get(GamePadButtonCode.B)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.B }));
} else if (e.key === 'Backspace')
{
shortcuts.get(GamePadButtonCode.X)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.X }));
} else if (e.key === ' ')
{
shortcuts.get(GamePadButtonCode.Y)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.Y }));
}
};
const handleGamepadButtonUp = (e: Event) =>
{
const event = e as GamepadButtonEvent;
@ -108,14 +139,16 @@ export function useShortcutContext ()
handleShortcutRebuild();
}
window.addEventListener('gamepadbuttondown', handleGamepadButtonDown);
window.addEventListener('keydown', handleKeyPress);
window.addEventListener('gamepadbuttonup', handleGamepadButtonUp);
window.addEventListener('focuschanged', handleShortcutRebuild);
window.addEventListener('shortcutsChanged', handleShortcutRebuild);
return () =>
{
window.removeEventListener('focuschanged', handleShortcutRebuild);
window.removeEventListener('gamepadbuttondown', handleGamepadButtonDown);
window.removeEventListener('gamepadbuttonup', handleGamepadButtonUp);
window.removeEventListener('shortcutsChanged', handleShortcutRebuild);
window.removeEventListener('keydown', handleKeyPress);
};
}, [array]);
@ -127,6 +160,7 @@ export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps
useEffect(() =>
{
shortcutMap.set(focusKey, build());
markDirtyThrottled();
return () =>
{

View file

@ -1,5 +1,6 @@
import
{
FocusDetails,
getCurrentFocusKey,
init,
SpatialNavigation,
@ -13,7 +14,7 @@ init({
let addFocusable = SpatialNavigation.addFocusable.bind(SpatialNavigation);
let removeFocusable = SpatialNavigation.removeFocusable.bind(SpatialNavigation);
let setCurrentFocusedKey = SpatialNavigation.setCurrentFocusedKey.bind(SpatialNavigation);
let setFocus = SpatialNavigation.setFocus.bind(SpatialNavigation);
type SaveFocusType = "session" | "local";
@ -27,7 +28,6 @@ export function SaveSource (id: HistorySourceType, url?: string)
{
historySourceMap.set(id, finalUrl);
}
}
export function HasSource (id: HistorySourceType)
@ -95,10 +95,10 @@ export function useFocusEventListener<K extends keyof FocusEventMap, O extends H
}, [eventName, handler, element?.current]);
}
SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) =>
SpatialNavigation.setFocus = (newFocusKey, focusDetails) =>
{
setCurrentFocusedKey(newFocusKey, focusDetails);
dispatchFocusedEvent(new Event('focuschanged', { bubbles: true }));
setFocus(newFocusKey, focusDetails);
dispatchFocusedEvent(new CustomEvent<FocusDetails>('focuschanged', { bubbles: true, detail: focusDetails }));
};
SpatialNavigation.addFocusable = (toAdd) =>
@ -174,8 +174,6 @@ SpatialNavigation.removeFocusable = ({ focusKey }) =>
removeFocusable(component);
}
};
SpatialNavigation.saveLastFocusedChildKey = (component, focusKey) =>

View file

@ -1,9 +1,11 @@
import { settingsApi } from "./clientApi";
window.addEventListener("resize", () =>
const handleResize = () =>
{
settingsApi.api.settings({ id: 'windowSize' }).post({ value: { width: window.innerWidth, height: window.innerHeight } });
});
};
window.addEventListener("resize", handleResize);
import.meta.hot.dispose(() => window.removeEventListener('resize', handleResize));
let lastWindowPosX: number = window.screenX;
let lastWindowPosY: number = window.screenY;
@ -16,4 +18,5 @@ var screenPositionInternal: NodeJS.Timeout = setInterval(() =>
lastWindowPosX = window.screenX;
lastWindowPosY = window.screenY;
}, 1000);
}, 1000);
import.meta.hot.dispose(() => clearInterval(screenPositionInternal));