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

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