refactor: moved queries to their own file
This commit is contained in:
parent
364bc9d0be
commit
cf6fff6fac
83 changed files with 1107 additions and 852 deletions
|
|
@ -47,7 +47,7 @@
|
|||
},
|
||||
{
|
||||
"type": "file",
|
||||
"path": "../src/mainview/assets/256x256.png"
|
||||
"path": "../src/mainview/public/256x256.png"
|
||||
},
|
||||
{
|
||||
"type": "script",
|
||||
|
|
|
|||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -30,9 +30,11 @@
|
|||
"cSpell.words": [
|
||||
"elysia",
|
||||
"elysiajs",
|
||||
"emulatorjs",
|
||||
"gameflow",
|
||||
"hackolade",
|
||||
"keytar",
|
||||
"mainview",
|
||||
"norigin",
|
||||
"noriginmedia",
|
||||
"romm"
|
||||
|
|
|
|||
|
|
@ -115,4 +115,4 @@
|
|||
"vite-static-assets-plugin": "^1.2.2",
|
||||
"vite-tsconfig-paths": "^6.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import { rmdir } from "node:fs";
|
|||
// ─────────────────────────────────────────────
|
||||
const APP_DIR = process.env.BUILD_DIR ?? `./build/${process.platform}`;
|
||||
const BINARY_NAME = pkg.bin;
|
||||
const ICON = "./src/mainview/assets/256x256.png";
|
||||
const ICON = "./src/mainview/public/256x256.png";
|
||||
const DESKTOP = "./flatpak/com.simeonradivoev.gameflow-deck.desktop";
|
||||
const TMP_FOLDER = ".";
|
||||
// ─────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Elysia, { sse, status } from "elysia";
|
||||
import Elysia, { status } from "elysia";
|
||||
import { config, events, jar, taskQueue } from "./app";
|
||||
import z from "zod";
|
||||
import { client } from "@clients/romm/client.gen";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import auth from "./auth";
|
|||
|
||||
export default new Elysia({ prefix: "/api/romm" })
|
||||
.use([games, platforms, auth])
|
||||
.all("/*", async ({ request, params, set }) =>
|
||||
.all("/*", async ({ request, set }) =>
|
||||
{
|
||||
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||
|
||||
|
|
|
|||
|
|
@ -401,7 +401,7 @@ export default new Elysia()
|
|||
const res = await fetch(`https://cdn.emulatorjs.org/latest/data/cores/${params['*']}`);
|
||||
return res;
|
||||
})
|
||||
.get('/emulatorjs/data/*', async ({ params }) =>
|
||||
.get('/emulatorjs/data/*', async () =>
|
||||
{
|
||||
return status("Not Found");
|
||||
});
|
||||
|
|
@ -181,7 +181,7 @@ export async function getValidLaunchCommands (data: {
|
|||
'%FILENAME%': $.escape(path.basename(validFiles[0]))
|
||||
};
|
||||
|
||||
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (subscring, injectFile: string) =>
|
||||
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (_, injectFile: string) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ export default async function buildStatusResponse (source: string, id: string)
|
|||
dispose.forEach(f => f());
|
||||
};
|
||||
},
|
||||
cancel (reason)
|
||||
cancel ()
|
||||
{
|
||||
cleanup?.();
|
||||
cleanup = undefined;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import getFolderSize from "get-folder-size";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { config, db, emulatorsDb } from "../../app";
|
||||
import { config, emulatorsDb } from "../../app";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as schema from "@schema/app";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed, StoreGameType } from "@shared/constants";
|
||||
|
|
@ -103,15 +103,6 @@ export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSel
|
|||
|
||||
export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameType>
|
||||
{
|
||||
let size: number | null = null;
|
||||
try
|
||||
{
|
||||
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||
size = Number(fileResponse.headers.get('content-length'));
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
}
|
||||
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
|
||||
where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm'))
|
||||
});
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ export class InstallJob implements IJob
|
|||
let bytesReceived = 0;
|
||||
|
||||
const progressStream = new Transform({
|
||||
transform (chunk, encoding, callback)
|
||||
transform (chunk, _, callback)
|
||||
{
|
||||
bytesReceived += chunk.length;
|
||||
if (totalBytes > 0)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { LoginJob } from "./login-job";
|
|||
import TwitchLoginJob from "./twitch-login-job";
|
||||
import UpdateStoreJob from "./update-store";
|
||||
|
||||
function registerJob<const Path extends string, TS, T extends { id: Path, dataSchema?: TS; }> (job: T, path: Path, dataSchema: TS)
|
||||
function registerJob<const Path extends string, TS, T extends { id: Path, dataSchema?: TS; }> (_job: T, path: Path, dataSchema: TS)
|
||||
{
|
||||
return new Elysia().ws(path, {
|
||||
body: z.discriminatedUnion('type', [
|
||||
|
|
@ -64,7 +64,7 @@ function registerJob<const Path extends string, TS, T extends { id: Path, dataSc
|
|||
{
|
||||
(ws.data as any).cleanup.forEach((d: Function) => d());
|
||||
},
|
||||
message (ws, message)
|
||||
message (_, message)
|
||||
{
|
||||
if (message.type === 'cancel')
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
|
||||
import * as appSchema from '@schema/app';
|
||||
import { findExec, findExecByName } from "../games/services/launchGameService";
|
||||
import { findExecByName } from "../games/services/launchGameService";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { customEmulators, db, emulatorsDb } from '../app';
|
||||
import fs from 'node:fs/promises';
|
||||
import { cores } from '../emulatorjs/emulatorjs';
|
||||
import { FrontEndEmulator } from '@/shared/constants';
|
||||
|
||||
/**
|
||||
* Get emulators based on local games. Only the ones we probably need.
|
||||
|
|
@ -77,117 +78,40 @@ export async function getRelevantEmulators ()
|
|||
systems.forEach(s => platformViability.set(s, true));
|
||||
}
|
||||
|
||||
return {
|
||||
emulator: emulator,
|
||||
path: execPath,
|
||||
const em: FrontEndEmulator & { isCritical: boolean; path?: { path: string, type: string; }; } = {
|
||||
name: emulator,
|
||||
exists: exists,
|
||||
logo: platform ? `/api/romm/platform/local/${platform}/cover` : '',
|
||||
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s).map(e => ({ icon: `/api/romm/image/romm/assets/platforms/${e.es_slug}.svg`, name: e.platform_name ?? 'Unknown', id: e.es_slug ?? '' })),
|
||||
gameCount: 0,
|
||||
description: '',
|
||||
homepage: '',
|
||||
type: 'emulator',
|
||||
os: [process.platform as any],
|
||||
isCritical: false,
|
||||
path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null,
|
||||
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s)
|
||||
path: execPath,
|
||||
};
|
||||
|
||||
return em;
|
||||
}));
|
||||
|
||||
finalEmulators.push({
|
||||
emulator: 'emulatorjs',
|
||||
name: 'emulatorjs',
|
||||
exists: true,
|
||||
path: { path: 'localhost', type: 'js' },
|
||||
path_cover: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
||||
isCritical: false,
|
||||
systems: []
|
||||
logo: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
||||
systems: [],
|
||||
gameCount: 0,
|
||||
type: 'emulator',
|
||||
description: '',
|
||||
homepage: '',
|
||||
os: [process.platform as any],
|
||||
isCritical: false
|
||||
});
|
||||
|
||||
return finalEmulators.map(e =>
|
||||
{
|
||||
e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!));
|
||||
e.isCritical = !e.systems.filter(s => s?.id).some(s => !!platformViability.get(s?.id!));
|
||||
return e;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Only emulators we strictly need based on local games. Emulator JS is included as bundled.
|
||||
* If there is even single emulator for a system don't include emulators for that system.
|
||||
*/
|
||||
/*export async function getMissingEmulators ()
|
||||
{
|
||||
const localGames = await db.query.games.findMany({
|
||||
columns: {
|
||||
platform_id: true,
|
||||
slug: true
|
||||
},
|
||||
with: {
|
||||
platform: {
|
||||
columns: {
|
||||
name: true,
|
||||
es_slug: true
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
const platformLookup = new Map(localGames.map(g => [g.platform.es_slug, g]));
|
||||
const platformViability = new Map(localGames.map(g => [g.platform.es_slug, false]));
|
||||
|
||||
// all commands based on the local games
|
||||
const commands = await emulatorsDb.query.commands.findMany({
|
||||
columns: { command: true },
|
||||
where: inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.platform.es_slug).map(s => s.platform.es_slug!)))),
|
||||
with: { system: { columns: { name: true } } }
|
||||
});
|
||||
|
||||
// get all emulators in said commands
|
||||
const emulators = commands
|
||||
.flatMap(command =>
|
||||
{
|
||||
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
|
||||
if (!matches)
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return matches?.map(m => ({ emulator: m, system: command.system?.name }));
|
||||
}
|
||||
).filter(c => !!c);
|
||||
|
||||
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
|
||||
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
|
||||
{
|
||||
let execPath: { path: string; type: string, } | undefined;
|
||||
if (customEmulators.has(emulator))
|
||||
{
|
||||
execPath = { path: customEmulators.get(emulator), type: 'custom' };
|
||||
} else
|
||||
{
|
||||
execPath = await findExecByName(emulator);
|
||||
}
|
||||
|
||||
let platform: number | null | undefined = null;
|
||||
if (system_slug.length <= 1)
|
||||
{
|
||||
platform = platformLookup.get(system_slug[0].system)?.platform_id;
|
||||
}
|
||||
|
||||
// check if automatic or custom path found existing binary.
|
||||
// This might not be the actual emulator but I don't care.
|
||||
const exists = !!execPath && await fs.exists(execPath.path);
|
||||
const systems = Array.from(new Set(system_slug.map(s => s.system)));
|
||||
if (exists)
|
||||
{
|
||||
systems.forEach(s => platformViability.set(s, true));
|
||||
}
|
||||
|
||||
return {
|
||||
emulator: emulator,
|
||||
path: execPath,
|
||||
exists: exists,
|
||||
isCritical: false,
|
||||
path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null,
|
||||
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s)
|
||||
};
|
||||
}));
|
||||
|
||||
return finalEmulators.map(e =>
|
||||
{
|
||||
e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!));
|
||||
return e;
|
||||
});
|
||||
}*/
|
||||
}
|
||||
|
|
@ -194,7 +194,8 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
source: execPath?.type,
|
||||
location: execPath?.path
|
||||
},
|
||||
screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`)
|
||||
screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`),
|
||||
gameCount: 0
|
||||
};
|
||||
|
||||
return emulator;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { SERVER_PORT } from "@shared/constants";
|
||||
import path from 'node:path';
|
||||
import appInfo from '~/package.json';
|
||||
import { host } from "./utils/host";
|
||||
import { appPath } from "./utils";
|
||||
import Elysia, { file } from "elysia";
|
||||
import Elysia from "elysia";
|
||||
import cors from "@elysiajs/cors";
|
||||
import staticPlugin from "@elysiajs/static";
|
||||
|
||||
|
|
@ -17,11 +15,11 @@ export function RunBunServer ()
|
|||
'cross-origin-opener-policy': 'same-origin',
|
||||
'cross-origin-resource-policy': 'cross-origin'
|
||||
})
|
||||
.get("/", ({ set }) =>
|
||||
.get("/", () =>
|
||||
{
|
||||
return Bun.file(appPath("./dist/index.html"));
|
||||
})
|
||||
.get('/emulatorjs', ({ set }) =>
|
||||
.get('/emulatorjs', () =>
|
||||
{
|
||||
return Bun.file(appPath('./dist/emulatorjs/index.html'));
|
||||
})
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ export async function BuildParams (data: BrowserParams)
|
|||
|
||||
args.push(`--app=${SERVER_URL(host)}`);
|
||||
args.push(`--app-id=gameflow`);
|
||||
args.push(`--force-app-mode`);
|
||||
args.push('--no-default-browser-check');
|
||||
args.push('--new-instance');
|
||||
args.push('--no-first-run');
|
||||
|
|
|
|||
|
|
@ -27,11 +27,6 @@ interface SpawnBrowserOptions
|
|||
ipc?: (message: string) => void;
|
||||
}
|
||||
|
||||
interface SpawnLastInfo
|
||||
{
|
||||
PID: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a browser process with proper handling for different installation types.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { CSSProperties, JSX, Ref, useEffect, useRef, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
import { mobileCheck, useLocalSetting } from '../scripts/utils';
|
||||
import { useLocalSetting } from '../scripts/utils';
|
||||
import { AnimatedBackgroundContext } from '../scripts/contexts';
|
||||
|
||||
export function AnimatedBackground (data: {
|
||||
|
|
@ -88,8 +88,6 @@ export function AnimatedBackground (data: {
|
|||
|
||||
}, [finalBackgroundUrl]);
|
||||
|
||||
const isMobile = mobileCheck();
|
||||
|
||||
function handleSetBackground (url: string)
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
|||
{
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: data.focusKey,
|
||||
onFocus: (l, p, detals) => data.onFocus?.(data.id, ref.current as any, detals),
|
||||
onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current as any, details),
|
||||
onEnterPress: () => data.onAction?.(),
|
||||
onBlur: () => data.onBlur?.(data.id)
|
||||
});
|
||||
const { isMouse, isPointer } = useActiveControl();
|
||||
const { isPointer } = useActiveControl();
|
||||
|
||||
return (
|
||||
<li
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export function CardList (data: {
|
|||
onSelectGame?: (id: string) => void;
|
||||
onGameFocus?: GameCardFocusHandler;
|
||||
className?: string;
|
||||
finalElement?: JSX.Element;
|
||||
saveChildFocus?: 'session' | 'local';
|
||||
})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
|
|
@ -72,7 +74,7 @@ export function CardList (data: {
|
|||
title="Games"
|
||||
id={`card-list-${data.id}`}
|
||||
ref={ref}
|
||||
save-child-focus="session"
|
||||
save-child-focus={data.saveChildFocus}
|
||||
className={twMerge("items-center justify-center-safe h-full",
|
||||
data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-min grid-cols-[repeat(auto-fill,var(--game-card-width))]" :
|
||||
'landscape:grid landscape:grid-flow-col landscape:auto-cols-min auto-rows-[1fr] sm:gap-2 md:gap-4 portrait:grid portrait:auto-rows-min portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))] *:portrait:aspect-8/10 *:landscape:aspect-8/12 sm:landscape:max-h-84 md:max-h-128!',
|
||||
|
|
@ -83,10 +85,10 @@ export function CardList (data: {
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
{data.games.map(BuildCard)}
|
||||
{data.finalElement}
|
||||
</FocusContext.Provider>
|
||||
</ul>
|
||||
);
|
||||
|
|
|
|||
72
src/mainview/components/Carousel.tsx
Normal file
72
src/mainview/components/Carousel.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { twMerge } from "tailwind-merge";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { CSSProperties, Ref, useEffect, useRef, useState } from "react";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
|
||||
export default function Carousel (data: {
|
||||
className?: string;
|
||||
rootClassName?: string;
|
||||
controlsClassName?: string;
|
||||
children?: any;
|
||||
scrollRef?: Ref<HTMLDivElement>;
|
||||
scrollHandler?: (direction: number, element: HTMLDivElement) => void;
|
||||
isScrollable?: boolean;
|
||||
style?: CSSProperties;
|
||||
})
|
||||
{
|
||||
const [scrollable, setScrollable] = useState(false);
|
||||
const localRef = useRef<HTMLDivElement>(null);
|
||||
const handleScroll = (dir: number) =>
|
||||
{
|
||||
if (!localRef.current) return;
|
||||
if (data.scrollHandler)
|
||||
{
|
||||
data.scrollHandler(dir, localRef.current);
|
||||
return;
|
||||
}
|
||||
localRef.current.scrollBy({ behavior: 'smooth', left: localRef.current.clientWidth / 2 * dir });
|
||||
};
|
||||
const { isMouse } = useActiveControl();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const el = localRef.current;
|
||||
if (!el) return;
|
||||
|
||||
setScrollable(el.scrollWidth > el.clientWidth);
|
||||
const observer = new ResizeObserver(() =>
|
||||
{
|
||||
setScrollable(el.scrollWidth > el.clientWidth);
|
||||
});
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [localRef.current, localRef.current?.clientWidth, localRef.current?.scrollWidth]);
|
||||
|
||||
return <div className={twMerge("relative scroll-smooth", data.rootClassName)}>
|
||||
<div style={{ ...data.style, scrollSnapType: 'x mandatory' }} ref={r =>
|
||||
{
|
||||
if (data.scrollRef instanceof Function)
|
||||
{
|
||||
data.scrollRef(r);
|
||||
} else if (data.scrollRef)
|
||||
{
|
||||
data.scrollRef.current = r;
|
||||
}
|
||||
localRef.current = r;
|
||||
|
||||
}} className={twMerge(data.className)}>
|
||||
{data.children}
|
||||
</div>
|
||||
{((scrollable || data.isScrollable) && isMouse) && <>
|
||||
<div className={twMerge("absolute flex items-center left-2 top-0 bottom-0", data.controlsClassName)}>
|
||||
<RoundButton onAction={() => handleScroll(-1)} id="move-left" className="p-2 border-base-content/40"><ChevronLeft /></RoundButton>
|
||||
</div>
|
||||
<div className={twMerge("absolute flex items-center justify-end right-2 top-0 bottom-0", data.controlsClassName)}>
|
||||
<RoundButton onAction={() => handleScroll(1)} id="move-left" className="p-2 border-base-content/40"><ChevronRight /></RoundButton>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { getCollectionsApiCollectionsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "@/shared/constants";
|
||||
import { RPC_URL } from "@/shared/constants";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { CardList, GameMetaExtra } from "./CardList";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { GameCardFocusHandler } from "./CardElement";
|
||||
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import queries from "../scripts/queries";
|
||||
|
||||
export default function CollectionList (data: {
|
||||
id: string,
|
||||
|
|
@ -13,14 +13,11 @@ export default function CollectionList (data: {
|
|||
className?: string;
|
||||
onFocus?: GameCardFocusHandler;
|
||||
onSelect?: (id: string) => void;
|
||||
saveChildFocus?: 'session' | 'local';
|
||||
})
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
const { data: collections } = useSuspenseQuery({
|
||||
...getCollectionsApiCollectionsGetOptions(),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: DefaultRommStaleTime
|
||||
});
|
||||
const { data: collections } = useSuspenseQuery(queries.romm.getCollectionsQuery());
|
||||
|
||||
const handleDefaultSelect = (id: string) =>
|
||||
{
|
||||
|
|
@ -33,6 +30,7 @@ export default function CollectionList (data: {
|
|||
type="collection"
|
||||
id={data.id}
|
||||
className={data.className}
|
||||
saveChildFocus={data.saveChildFocus}
|
||||
games={collections.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at))
|
||||
.map((g) => ({
|
||||
id: String(g.id),
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import { AnimatedBackground } from './AnimatedBackground';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { HeaderUI } from './Header';
|
||||
import { GameList } from './GameList';
|
||||
import { Search, Settings2 } from 'lucide-react';
|
||||
import { JSX, Suspense } from 'react';
|
||||
import { JSX, Suspense, useEffect } from 'react';
|
||||
import Shortcuts from './Shortcuts';
|
||||
import { AutoFocus } from './AutoFocus';
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||
import { Router } from '..';
|
||||
import { PopNavigateSource, PopSource } from '../scripts/spatialNavigation';
|
||||
import { PopNavigateSource } from '../scripts/spatialNavigation';
|
||||
import { GameListFilterType } from '@/shared/constants';
|
||||
import { GameCardFocusHandler } from './CardElement';
|
||||
|
||||
export interface CollectionsDetailParams
|
||||
{
|
||||
id?: string;
|
||||
setBackground: (url: string) => void;
|
||||
setBackground?: (url: string) => void;
|
||||
filters?: GameListFilterType;
|
||||
headerTitle?: JSX.Element;
|
||||
title?: JSX.Element;
|
||||
footer?: JSX.Element;
|
||||
focus?: string;
|
||||
}
|
||||
|
||||
export function CollectionsDetail (data: CollectionsDetailParams)
|
||||
|
|
@ -37,10 +37,21 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
{
|
||||
if (!(details.nativeEvent instanceof MouseEvent))
|
||||
{
|
||||
node.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
node.scrollIntoView({ block: 'center', behavior: details.instant ? 'instant' : 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (data.focus)
|
||||
setFocus(data.focus, { instant: true });
|
||||
}, [data.focus]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
return () => setFocus('');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FocusContext value={focusKey}>
|
||||
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className='flex'>
|
||||
|
|
@ -53,7 +64,6 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
<Suspense>
|
||||
<GameList
|
||||
grid
|
||||
setBackground={data.setBackground}
|
||||
filters={data.filters}
|
||||
onFocus={handleScroll}
|
||||
id={`${focusKey}-list`}>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ 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, focusKey, hasFocusedChild } = useFocusable({
|
||||
const { ref, focusSelf, focusKey } = useFocusable({
|
||||
focusKey: `${context.id}-list-option-${data.id}`,
|
||||
onEnterPress: data.shortcuts ? undefined : handleAction,
|
||||
onFocus: handleFocus,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import Shortcuts from "./Shortcuts";
|
|||
import { Button } from "./options/Button";
|
||||
import { useEffect } from "react";
|
||||
import { ErrorComponentProps } from "@tanstack/react-router";
|
||||
import { mobileCheck } from "../scripts/utils";
|
||||
|
||||
export default function Error (data: ErrorComponentProps)
|
||||
{
|
||||
|
|
@ -19,12 +18,15 @@ export default function Error (data: ErrorComponentProps)
|
|||
|
||||
return <div ref={ref} className="absolute flex flex-col justify-center items-center w-full h-full gap-4">
|
||||
<FocusContext value={focusKey}>
|
||||
<p className="flex gap-2 items-center text-4xl text-error text-shadow-lg">
|
||||
<p className="flex gap-2 items-center text-2xl text-error text-shadow-lg">
|
||||
<TriangleAlert className="size-12" />
|
||||
{data.error.message}
|
||||
</p>
|
||||
<p className="flex gap-2 text-lg text-base-content/50 text-shadow-lg">{window.location.href} </p>
|
||||
<Button className="text-2xl! p-6! focusable focusable-primary" id="return" onAction={handleReturn}><Home />Return Home</Button>
|
||||
<p className="flex gap-2 text-base-content/50 text-shadow-lg">{window.location.href} </p>
|
||||
|
||||
{import.meta.env.DEV && <div className="text-center text-base-content/50">{data.error.stack}</div>}
|
||||
|
||||
<Button className="text-2xl! focusable focusable-primary" id="return" onAction={handleReturn}><Home />Return Home</Button>
|
||||
<div className="mobile:hidden bg-gradient"></div>
|
||||
<div className="mobile:hidden bg-noise"></div>
|
||||
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||
import { ContextList, DialogEntry } from "./ContextDialog";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { useContext, useRef, useState } from "react";
|
||||
import path from "pathe";
|
||||
import path, { dirname } from "pathe";
|
||||
import { Check, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
|
||||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { DirType } from "@/shared/constants";
|
||||
|
|
@ -12,7 +12,7 @@ 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";
|
||||
import queries from "../scripts/queries";
|
||||
import { FilePickerContext } from "../scripts/contexts";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
|
||||
|
|
@ -113,12 +113,7 @@ 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;
|
||||
},
|
||||
...queries.system.createFolderMutation(data.id),
|
||||
onError: (e) => toast.error(e.message ?? 'Error Creating New Folder'),
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
|
|
@ -128,7 +123,7 @@ function NewFolderOption (data: { id: string, dirname: string; })
|
|||
});
|
||||
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={e => createMutation.mutate()} type="button" ><FolderPlus /></Button>
|
||||
<Button id={`${data.id}-create`} onAction={e => createMutation.mutate({ name, dirname: data.dirname })} type="button" ><FolderPlus /></Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
|
@ -233,8 +228,8 @@ export default function FilePicker (data: {
|
|||
{
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(data.startingPath);
|
||||
|
||||
const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(filesQuery(currentPath, data.id));
|
||||
const { data: drives, isLoading: drivesLoading } = useQuery(drivesQuery);
|
||||
const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(queries.system.filesQuery(currentPath, data.id));
|
||||
const { data: drives, isLoading: drivesLoading } = useQuery(queries.system.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];
|
||||
|
|
|
|||
|
|
@ -11,14 +11,13 @@ function FilterCat (
|
|||
id: string;
|
||||
children?: any;
|
||||
active: boolean;
|
||||
onFocus: () => void;
|
||||
hasFocusedPeer: boolean;
|
||||
} & FilterOption,
|
||||
} & FilterOption & FocusParams,
|
||||
)
|
||||
{
|
||||
const { ref, focusSelf, focused } = useFocusable({
|
||||
const { ref, focusSelf } = useFocusable({
|
||||
focusKey: data.id,
|
||||
onFocus: data.onFocus,
|
||||
onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current, details),
|
||||
onEnterPress: data.onAction
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,20 +2,75 @@ import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
|||
import classNames from "classnames";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useGlobalFocus } from "../scripts/spatialNavigation";
|
||||
import { JSX, RefObject, useMemo, useState } from "react";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
|
||||
function ScrollDot (data: { index: number; parent: RefObject<HTMLElement | null>, peers: HTMLElement[]; })
|
||||
{
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
useEventListener('scrollend', () =>
|
||||
{
|
||||
if (!data.parent.current) return;
|
||||
const center = data.parent.current.scrollLeft + data.parent.current.clientWidth / 2;
|
||||
|
||||
// find child closest to center
|
||||
const closest = data.peers.reduce((closest, child) =>
|
||||
{
|
||||
const childCenter = child.offsetLeft + child.offsetWidth / 2;
|
||||
const closestCenter = closest.offsetLeft + closest.offsetWidth / 2;
|
||||
return Math.abs(childCenter - center) < Math.abs(closestCenter - center)
|
||||
? child
|
||||
: closest;
|
||||
});
|
||||
|
||||
setFocused(closest === data.peers[data.index]);
|
||||
|
||||
}, data.parent as any);
|
||||
|
||||
return <button key={data.index} onClick={(e) =>
|
||||
{
|
||||
data.peers[data.index].scrollIntoView({ behavior: 'smooth', inline: 'center' });
|
||||
}}
|
||||
className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
|
||||
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
|
||||
}))}></button>;
|
||||
}
|
||||
|
||||
export default function FocusDots (data: {
|
||||
elements: string[];
|
||||
|
||||
elements?: string[] | undefined;
|
||||
scrollElement?: RefObject<HTMLElement | null>;
|
||||
})
|
||||
{
|
||||
const focusedKey = useGlobalFocus();
|
||||
|
||||
return <div className="divider opacity-20"><div className="flex gap-2 py-6 justify-center items-center h-3">{data.elements.map((em, i) =>
|
||||
const focusedKey = useGlobalFocus();
|
||||
let elements = useMemo(() =>
|
||||
{
|
||||
const focused = em === focusedKey;
|
||||
return <button key={i} onClick={(e) => setFocus(em, { nativeEvent: e.nativeEvent })}
|
||||
className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
|
||||
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
|
||||
}))}></button>;
|
||||
})}</div></div>;
|
||||
if (data.elements)
|
||||
{
|
||||
return data.elements.map((em, i) =>
|
||||
{
|
||||
const focused = em === focusedKey;
|
||||
return <button key={i} onClick={(e) => setFocus(em, { nativeEvent: e.nativeEvent })}
|
||||
className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
|
||||
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
|
||||
}))}></button>;
|
||||
});
|
||||
} else if (data.scrollElement?.current)
|
||||
{
|
||||
const childrenArray = Array.from(data.scrollElement.current.children);
|
||||
|
||||
return childrenArray.map((c, i) =>
|
||||
{
|
||||
return <ScrollDot parent={data.scrollElement!} index={i} peers={childrenArray as HTMLElement[]} />;
|
||||
});
|
||||
} else
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}, [data.elements, data.scrollElement?.current]);
|
||||
|
||||
return <div className="divider opacity-20">
|
||||
<div className="flex gap-2 py-6 justify-center items-center h-3">{elements}</div>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { GameMetaExtra, CardList } from "./CardList";
|
||||
import { FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants";
|
||||
import { FrontEndGameType, 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";
|
||||
import { JSX, useContext } from "react";
|
||||
import { GameCardFocusHandler } from "./CardElement";
|
||||
import { useLocalSetting } from "../scripts/utils";
|
||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||
import queries from "../scripts/queries";
|
||||
|
||||
export interface GameListParams
|
||||
{
|
||||
|
|
@ -18,19 +19,16 @@ export interface GameListParams
|
|||
onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
|
||||
onFocus?: GameCardFocusHandler;
|
||||
className?: string;
|
||||
finalElement?: JSX.Element;
|
||||
saveChildFocus?: "session" | "local";
|
||||
}
|
||||
|
||||
export function GameList (data: GameListParams)
|
||||
{
|
||||
const games = useSuspenseQuery({
|
||||
queryKey: ['games', data.filters ?? 'all'],
|
||||
queryFn: () => rommApi.api.romm.games.get({
|
||||
query: data.filters
|
||||
}).then(d => d.data)
|
||||
});
|
||||
const games = useSuspenseQuery(queries.romm.allGamesQuery(data.filters));
|
||||
const navigator = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const blur = useLocalSetting('backgroundBlur');
|
||||
const backgroundContext = useContext(AnimatedBackgroundContext);
|
||||
|
||||
const handleFocus = (id: FrontEndId, source: string | null, sourceId: string | null) =>
|
||||
{
|
||||
|
|
@ -39,11 +37,11 @@ export function GameList (data: GameListParams)
|
|||
{
|
||||
try
|
||||
{
|
||||
const screenshotUrl = new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`);
|
||||
const screenshotUrl = game.paths_screenshots && game.paths_screenshots.length > 0 ? new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`) : undefined;
|
||||
const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_cover}`);
|
||||
const previewUrl = blur ? coverUrl : screenshotUrl;
|
||||
const previewUrl = blur ? coverUrl : (screenshotUrl ?? coverUrl);
|
||||
previewUrl.searchParams.delete('ts');
|
||||
data.setBackground?.(previewUrl.href);
|
||||
data.setBackground?.(previewUrl.href) ?? backgroundContext.setBackground(previewUrl.href);
|
||||
} catch
|
||||
{
|
||||
|
||||
|
|
@ -51,10 +49,10 @@ export function GameList (data: GameListParams)
|
|||
}
|
||||
};
|
||||
|
||||
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null)
|
||||
function handleDefaultSelect (g: FrontEndGameType)
|
||||
{
|
||||
SaveSource('details');
|
||||
navigator({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
SaveSource('details', { search: { focus: g.slug ?? `game-${g.id}` } });
|
||||
navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -65,6 +63,8 @@ export function GameList (data: GameListParams)
|
|||
grid={data.grid}
|
||||
className={data.className}
|
||||
onGameFocus={data.onFocus}
|
||||
finalElement={data.finalElement}
|
||||
saveChildFocus={data.saveChildFocus}
|
||||
games={games.data?.games
|
||||
.map(
|
||||
(g) =>
|
||||
|
|
@ -92,7 +92,7 @@ export function GameList (data: GameListParams)
|
|||
),
|
||||
previewUrl: previewUrl.href,
|
||||
badges: badges,
|
||||
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g.id, g.source, g.source_id),
|
||||
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g),
|
||||
onFocus: () => handleFocus(g.id, g.source, g.source_id)
|
||||
} satisfies GameMetaExtra;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen";
|
||||
import { RPC_URL } from "../../shared/constants";
|
||||
import { JSX, useEffect, useRef } from "react";
|
||||
import { SaveSource, useFocusableDynamic } from "../scripts/spatialNavigation";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { Router } from "..";
|
||||
|
||||
|
|
@ -228,25 +228,12 @@ function BatteryStatus ()
|
|||
|
||||
export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
||||
{
|
||||
const rommOnline = useQuery({
|
||||
...statsApiStatsGetOptions(),
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
});
|
||||
const user = useQuery({
|
||||
...getCurrentUserApiUsersMeGetOptions(),
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1
|
||||
});
|
||||
|
||||
let indicator = "status-neutral";
|
||||
if (user.isError)
|
||||
{
|
||||
indicator = "status-error";
|
||||
} else if (!user.isPending && rommOnline.isSuccess)
|
||||
{
|
||||
indicator = "status-success";
|
||||
}
|
||||
const accounts: HeaderAccount[] = [{
|
||||
id: 'romm', previewUrl: [
|
||||
`${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`,
|
||||
|
|
|
|||
35
src/mainview/components/LoadMoreButton.tsx
Normal file
35
src/mainview/components/LoadMoreButton.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { FOCUS_KEYS } from "../scripts/types";
|
||||
import { useIntersectionObserver } from "usehooks-ts";
|
||||
|
||||
export default function LoadMoreButton (data: { isFetching: boolean; lastId?: string; } & FocusParams & InteractParams)
|
||||
{
|
||||
const handleAction = (e?: Event) =>
|
||||
{
|
||||
data.onAction?.(e);
|
||||
if (data.lastId && focused)
|
||||
setFocus(FOCUS_KEYS.GAME_CARD(data.lastId));
|
||||
};
|
||||
|
||||
const { ref, focusKey, focused } = useFocusable({
|
||||
focusKey: 'load-more-btn',
|
||||
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details),
|
||||
onEnterPress: handleAction
|
||||
});
|
||||
|
||||
const { ref: intersct } = useIntersectionObserver({
|
||||
onChange: (isIntersecting, entry) =>
|
||||
{
|
||||
if (isIntersecting)
|
||||
{
|
||||
handleAction();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return <div ref={(r) =>
|
||||
{
|
||||
ref.current = r;
|
||||
intersct(r);
|
||||
}} className='flex bg-base-100 game-card focusable focusable-accent focusable-hover text-2xl justify-center items-center cursor-pointer' onClick={e => handleAction(e.nativeEvent)} id='load-more-btn'>{data.isFetching ? <span className="loading loading-spinner loading-xl"></span> : "Load More"}</div>;
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ export function PlatformsList (data: {
|
|||
onFocus?: GameCardFocusHandler;
|
||||
grid?: boolean;
|
||||
onSelect?: (source: string, id: string) => void;
|
||||
saveChildFocus?: "session" | "local";
|
||||
})
|
||||
{
|
||||
const isMobile = mobileCheck();
|
||||
|
|
@ -85,6 +86,7 @@ export function PlatformsList (data: {
|
|||
return (
|
||||
<CardList
|
||||
type="platform"
|
||||
saveChildFocus={data.saveChildFocus}
|
||||
id={data.id}
|
||||
grid={data.grid}
|
||||
className={twMerge('*:aspect-8/10! md:py-12', data.className)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { CSSProperties, JSX } from "react";
|
||||
import { CSSProperties } from "react";
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { Button, ButtonStyle } from "./options/Button";
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ export function RoundButton (data: {
|
|||
} & InteractParams & FocusParams)
|
||||
{
|
||||
return (
|
||||
<Button cssStyle={data.cssStyle} onFocus={data.onFocus} id={data.id} style={data.style} className={twMerge("rounded-full", data.external && "focusable focusable-primary focusable-hover", data.className)} onAction={data.onAction}>
|
||||
<Button cssStyle={data.cssStyle} onFocus={data.onFocus} id={data.id} style={data.style} className={twMerge("rounded-full aspect-square", data.external && "focusable focusable-primary focusable-hover", data.className)} onAction={data.onAction}>
|
||||
{data.children}
|
||||
</Button>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { RPC_URL } from "@/shared/constants";
|
||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import FocusDots from "./FocusDots";
|
||||
import { scrollIntoNearestParent, useDragScroll } from "../scripts/utils";
|
||||
import { Fullscreen } from "lucide-react";
|
||||
import Carousel from "./Carousel";
|
||||
import { ContextDialog } from "./ContextDialog";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
|
||||
|
||||
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; })
|
||||
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; } & InteractParams)
|
||||
{
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
const { ref, focusSelf } = useFocusable({
|
||||
focusKey: `screenshot-${data.index}`,
|
||||
onEnterPress: () => (ref.current as HTMLElement).requestFullscreen(),
|
||||
onEnterPress: () => data.onAction?.(),
|
||||
onFocus: (e, p, details) =>
|
||||
{
|
||||
data.setFocused?.(data.index);
|
||||
|
|
@ -19,31 +22,109 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n
|
|||
}); 4096;
|
||||
return <div ref={ref} className="group relative flex min-w-fit aspect-video max-h-[60vh] rounded-3xl focusable focusable-accent not-focused:cursor-pointer overflow-hidden">
|
||||
<img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" />
|
||||
<div className="absolute flex justify-center items-center bottom-2 right-2 size-10 rounded-full bg-base-100 hover:bg-base-content hover:text-base-300 cursor-pointer opacity-60 not-control-mouse:hidden invisible group-has-hover:visible" onClick={() => imageRef.current?.requestFullscreen()}> <Fullscreen /> </div>
|
||||
<div className="absolute flex justify-center items-center bottom-2 right-2 size-10 rounded-full bg-base-100 hover:bg-base-content hover:text-base-300 cursor-pointer opacity-60 not-control-mouse:hidden invisible group-has-hover:visible" onClick={e => data.onAction?.(e.nativeEvent)}> <Fullscreen /> </div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function Screenshots (data: { screenshots: string[]; } & FocusParams)
|
||||
{
|
||||
const scrollRef = useRef(null);
|
||||
const { ref, focusKey } = useFocusable({
|
||||
const [preview, setPreview] = useState<number | undefined>(undefined);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { ref, focusKey, focused, hasFocusedChild } = useFocusable({
|
||||
focusKey: 'screenshot-list',
|
||||
trackChildren: true,
|
||||
onFocus: (e, p, details) =>
|
||||
{
|
||||
data.onFocus?.(focusKey, ref.current, details);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if ((focused || hasFocusedChild) && scrollRef.current)
|
||||
{
|
||||
const closest = findClosestElementToCenter(scrollRef.current);
|
||||
const closestIndex = Array.from(scrollRef.current.children).indexOf(closest);
|
||||
setFocus(`screenshot-${closestIndex}`);
|
||||
}
|
||||
}, [focused, hasFocusedChild, scrollRef.current]);
|
||||
|
||||
const findClosestElementToCenter = (element: HTMLDivElement) =>
|
||||
{
|
||||
const center = element.scrollLeft + element.clientWidth / 2;
|
||||
|
||||
const children = Array.from(element.children) as HTMLElement[];
|
||||
|
||||
// find child closest to center
|
||||
return children.reduce((closest, child) =>
|
||||
{
|
||||
const childCenter = child.offsetLeft + child.offsetWidth / 2;
|
||||
const closestCenter = closest.offsetLeft + closest.offsetWidth / 2;
|
||||
return Math.abs(childCenter - center) < Math.abs(closestCenter - center)
|
||||
? child
|
||||
: closest;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (preview !== undefined && scrollRef.current)
|
||||
{
|
||||
Array.from(scrollRef.current.children)[preview].scrollIntoView({ inline: 'center', behavior: 'instant' });
|
||||
}
|
||||
|
||||
}, [preview]);
|
||||
|
||||
const handleScroll = (dir: number, element: HTMLDivElement) =>
|
||||
{
|
||||
const current = findClosestElementToCenter(element);
|
||||
|
||||
const next = (dir > 0 ? current.nextElementSibling : current.previousElementSibling) as HTMLElement | null;
|
||||
if (!next) return;
|
||||
|
||||
// scroll so next element is centered
|
||||
element.scrollTo({
|
||||
left: next.offsetLeft - element.clientWidth / 2 + next.offsetWidth / 2,
|
||||
behavior: "smooth"
|
||||
});
|
||||
};
|
||||
|
||||
useShortcuts(`screenshots-context-dialog`, () => [
|
||||
{
|
||||
button: GamePadButtonCode.Left,
|
||||
label: "Left",
|
||||
action: () =>
|
||||
{
|
||||
if (preview === undefined) return;
|
||||
setPreview((data.screenshots.length + preview - 1) % data.screenshots.length);
|
||||
}
|
||||
},
|
||||
{
|
||||
button: GamePadButtonCode.Right,
|
||||
label: "Right",
|
||||
action: () =>
|
||||
{
|
||||
if (preview === undefined) return;
|
||||
setPreview((preview + 1) % data.screenshots.length);
|
||||
}
|
||||
}
|
||||
], [preview, focusKey]);
|
||||
|
||||
useDragScroll(scrollRef);
|
||||
|
||||
return <div ref={ref} className="flex flex-col w-full z-0 min-h-0">
|
||||
<FocusContext value={focusKey}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-6 px-16 py-2 sm:overflow-scroll md:overflow-hidden no-scrollbar justify-center-safe"
|
||||
>
|
||||
{data.screenshots.map((s, i) => <Screenshot key={s} index={i} path={s} />)}
|
||||
</div>
|
||||
<FocusDots elements={data.screenshots.map((_, i) => `screenshot-${i}`)} />
|
||||
<Carousel scrollHandler={handleScroll} scrollRef={scrollRef} rootClassName="h-full" className="flex gap-6 px-16 py-2 overflow-x-scroll no-scrollbar justify-center-safe h-full" >
|
||||
{data.screenshots.map((s, i) => <Screenshot key={s} index={i} path={s} onAction={() => setPreview(i)} />)}
|
||||
</Carousel>
|
||||
<FocusDots scrollElement={scrollRef} />
|
||||
</FocusContext>
|
||||
{preview !== undefined && <ContextDialog id="screenshots" close={() =>
|
||||
{
|
||||
setFocus(`screenshot-${preview}`);
|
||||
setPreview(undefined);
|
||||
}} open={true}>
|
||||
<img draggable={false} className="object-cover w-full h-full rounded-2xl" src={`${RPC_URL(__HOST__)}${data.screenshots[preview]}`} loading="lazy" />
|
||||
</ContextDialog>}
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import
|
|||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { CSSProperties } from "react";
|
||||
|
||||
export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error';
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { useState } from "react";
|
||||
import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { changeDownloadsMutation } from "@/mainview/scripts/queries";
|
||||
import queries 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,
|
||||
...queries.settings.changeDownloadsMutation,
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
setDirty(r !== localValue);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef, useState } from "react";
|
||||
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useOptionContext } from "./OptionSpace";
|
||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
|
@ -25,15 +24,9 @@ export function OptionDropdown (data: {
|
|||
setOpen(true);
|
||||
};
|
||||
const handleClose = () => setOpen(false);
|
||||
const { ref, focused, focusKey } = useFocusable({
|
||||
const { ref } = useFocusable({
|
||||
focusKey: data.name, onEnterPress: handlePress
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const option = useOptionContext({
|
||||
onOptionEnterPress: handlePress,
|
||||
});
|
||||
|
||||
const valueIndex = data.value ? data.values?.indexOf(data.value) : -1;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function OptionInput (data: {
|
|||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
const { ref, focused } = useFocusable({
|
||||
const { ref } = useFocusable({
|
||||
focusKey: data.name, onEnterPress: handlePress
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function OptionSpace (data: {
|
|||
})
|
||||
{
|
||||
const eventTarget = useMemo(() => new EventTarget(), []);
|
||||
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
|
||||
const { ref, focused, focusSelf, focusKey } = useFocusable({
|
||||
focusKey: data.id,
|
||||
focusable: data.focusable !== false,
|
||||
trackChildren: true,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { HTMLInputTypeAttribute, JSX, useCallback, useState } from "react";
|
||||
import { HTMLInputTypeAttribute, JSX, useEffect, 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";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
|
||||
type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
|
|
@ -32,14 +32,8 @@ 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;
|
||||
},
|
||||
const setMutation = useMutation({
|
||||
...queries.settings.setSettingMutation(data.id),
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
{
|
||||
setDirty(r !== localValue);
|
||||
|
|
@ -51,7 +45,7 @@ export function PathSettingsOption (data: PathSettingsOptionParams)
|
|||
label={data.label}
|
||||
id={data.id}
|
||||
type={data.type}
|
||||
save={setSettingMutation.mutate}
|
||||
save={setMutation.mutate}
|
||||
localValue={localValue}
|
||||
allowNewFolderCreation={data.allowNewFolderCreation}
|
||||
setLocalValue={(v) =>
|
||||
|
|
@ -69,22 +63,17 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
|||
})
|
||||
{
|
||||
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 { data: defaultValue } = useQuery(queries.settings.getSettingQuery(data.id));
|
||||
const changed = defaultValue !== data.localValue;
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!data.isDirty)
|
||||
{
|
||||
data.setLocalValue(String(defaultValue));
|
||||
}
|
||||
}, [data.isDirty, defaultValue]);
|
||||
|
||||
const handleSelectPath = (path: string) =>
|
||||
{
|
||||
data.setLocalValue(path);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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 queries from "@/mainview/scripts/queries";
|
||||
|
||||
type KeysWithValueAssignableTo<T, Value> = {
|
||||
[K in keyof T]: Exclude<T[K], undefined> extends Value ? K : never;
|
||||
|
|
@ -20,36 +20,15 @@ export function SettingsOption (data: {
|
|||
{
|
||||
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;
|
||||
}
|
||||
});
|
||||
useQuery(queries.settings.getSettingQuery(data.id));
|
||||
const setMutation = useMutation(queries.settings.setSettingMutation(data.id));
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
if (dirty)
|
||||
{
|
||||
setDirty(false);
|
||||
setSettingMutation.mutate(localValue);
|
||||
setMutation.mutate(localValue);
|
||||
}
|
||||
}, [dirty, setDirty, localValue]);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Router } from "@/mainview";
|
|||
import { StoreEmulatorCard } from "./StoreEmulatorCard";
|
||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||
import { FrontEndEmulator } from "@/shared/constants";
|
||||
import Carousel from "../Carousel";
|
||||
|
||||
function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant: boolean; }) => void; })
|
||||
{
|
||||
|
|
@ -34,7 +35,7 @@ function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (detail
|
|||
|
||||
export function EmulatorsSection (data: {
|
||||
id: string;
|
||||
emulators: FrontEndEmulator[];
|
||||
emulators?: FrontEndEmulator[];
|
||||
onSelect?: (id: string, focusKey: string) => void;
|
||||
header?: any;
|
||||
} & FocusParams)
|
||||
|
|
@ -60,17 +61,19 @@ export function EmulatorsSection (data: {
|
|||
</h2>
|
||||
</>}
|
||||
</div>
|
||||
<div ref={containerRef} className="flex *:min-w-[18rem] overflow-y-hidden overflow-x-scroll scrollbar-none py-2 px-4 gap-4 select-none">
|
||||
|
||||
<Carousel scrollRef={containerRef} className="flex *:min-w-[18rem] overflow-y-hidden overflow-x-scroll scrollbar-none py-2 px-4 gap-4 select-none">
|
||||
{data.emulators?.map((em) => (
|
||||
<StoreEmulatorCard id={`${data.id}-${em.name}`} key={em.name} emulator={em} onSelect={(id, focusKey) => data.onSelect?.(em.name, focusKey)} onFocus={({ node, details }) =>
|
||||
{
|
||||
scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' });
|
||||
}} />
|
||||
))}
|
||||
)) ?? Array.from({ length: 8 }).map((_, i) => <div key={i} className="skeleton h-38 w-full rounded-4xl" />)}
|
||||
<SeeAllCard id={`${FOCUS_KEYS.EMULATOR_SECTION}-see-all`} onAction={() => Router.navigate({ to: '/store/tab/emulators' })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} />
|
||||
</div>
|
||||
</Carousel>
|
||||
|
||||
</section>
|
||||
{!!data.emulators && <FocusDots elements={data.emulators.map(e => FOCUS_KEYS.EMULATOR_CARD(e.name))} />}
|
||||
<FocusDots elements={data.emulators?.map(e => FOCUS_KEYS.EMULATOR_CARD(e.name))} />
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,15 +4,16 @@ import
|
|||
useFocusable,
|
||||
FocusContext,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { Gamepad2 } from "lucide-react";
|
||||
import { Gamepad2, Star } from "lucide-react";
|
||||
import { useDragScroll } from "@/mainview/scripts/utils";
|
||||
import FocusDots from "../FocusDots";
|
||||
import { FrontEndGameType, FrontEndId } from "@/shared/constants";
|
||||
import FrontEndGameCard from "../FrontEndGameCard";
|
||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||
import Carousel from "../Carousel";
|
||||
|
||||
export function GamesSection ({ games, onSelect, onFocus }: {
|
||||
games: FrontEndGameType[];
|
||||
games?: FrontEndGameType[];
|
||||
onSelect?: (id: FrontEndId, focusKey: string) => void;
|
||||
} & FocusParams)
|
||||
{
|
||||
|
|
@ -33,17 +34,17 @@ export function GamesSection ({ games, onSelect, onFocus }: {
|
|||
<h2 className="font-bold uppercase tracking-widest text-accent grow">
|
||||
Featured Games
|
||||
</h2>
|
||||
<div className="badge badge-xl badge-accent badge-soft">Curated picks</div>
|
||||
<div className="flex gap-2 bg-accent text-accent-content rounded-full py-1 px-4 font-semibold opacity-80"><Star />Creator Picks</div>
|
||||
</div>
|
||||
<div ref={containerRef} className="grid grid-flow-col auto-cols-[18rem] overflow-y-hidden overflow-x-auto hide-scrollbar p-4 gap-4 justify-center-safe">
|
||||
{games.map((g, i) => <FrontEndGameCard
|
||||
<Carousel controlsClassName="z-20" scrollRef={containerRef} className="flex *:w-[18rem] *:min-w-[18rem] *:h-[21rem] overflow-y-hidden overflow-x-auto hide-scrollbar p-4 gap-4 justify-center-safe">
|
||||
{games?.map((g, i) => <FrontEndGameCard
|
||||
key={g.id.id}
|
||||
game={g}
|
||||
onAction={() => onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id.id))}
|
||||
index={i} />)}
|
||||
</div>
|
||||
index={i} />) ?? Array.from({ length: 8 }).map((_, i) => <div key={i} className="skeleton h-38 w-full" />)}
|
||||
</Carousel>
|
||||
</section>
|
||||
<FocusDots elements={games.map(e => FOCUS_KEYS.GAME_CARD(e.id.id))} />
|
||||
<FocusDots elements={games?.map(e => FOCUS_KEYS.GAME_CARD(e.id.id)) ?? []} />
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { storeApi } from "@/mainview/scripts/clientApi";
|
||||
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Joystick, LibraryBig, Save, TriangleAlert } from "lucide-react";
|
||||
|
||||
|
|
@ -14,14 +15,7 @@ export function StatsSection ({
|
|||
}: StatsSectionProps)
|
||||
{
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['store', 'stats'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.stats.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
const { data: stats } = useQuery(queries.store.storeGetStatsQuery);
|
||||
|
||||
return (
|
||||
<section className="px-6 pt-3 pb-4">
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './../routes/__root'
|
||||
import { Route as GamesRouteImport } from './../routes/games'
|
||||
import { Route as SettingsRouteRouteImport } from './../routes/settings/route'
|
||||
import { Route as IndexRouteImport } from './../routes/index'
|
||||
import { Route as SettingsInterfaceRouteImport } from './../routes/settings/interface'
|
||||
|
|
@ -27,6 +28,11 @@ import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id'
|
|||
import { Route as EmbeddedSourceIdRouteImport } from './../routes/embedded.$source.$id'
|
||||
import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id'
|
||||
|
||||
const GamesRoute = GamesRouteImport.update({
|
||||
id: '/games',
|
||||
path: '/games',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsRouteRoute = SettingsRouteRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
|
|
@ -116,6 +122,7 @@ const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({
|
|||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRouteRouteWithChildren
|
||||
'/games': typeof GamesRoute
|
||||
'/store/tab': typeof StoreTabRouteRouteWithChildren
|
||||
'/collection/$id': typeof CollectionIdRoute
|
||||
'/settings/about': typeof SettingsAboutRoute
|
||||
|
|
@ -135,6 +142,7 @@ export interface FileRoutesByFullPath {
|
|||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRouteRouteWithChildren
|
||||
'/games': typeof GamesRoute
|
||||
'/collection/$id': typeof CollectionIdRoute
|
||||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
|
|
@ -154,6 +162,7 @@ export interface FileRoutesById {
|
|||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRouteRouteWithChildren
|
||||
'/games': typeof GamesRoute
|
||||
'/store/tab': typeof StoreTabRouteRouteWithChildren
|
||||
'/collection/$id': typeof CollectionIdRoute
|
||||
'/settings/about': typeof SettingsAboutRoute
|
||||
|
|
@ -175,6 +184,7 @@ export interface FileRouteTypes {
|
|||
fullPaths:
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/games'
|
||||
| '/store/tab'
|
||||
| '/collection/$id'
|
||||
| '/settings/about'
|
||||
|
|
@ -194,6 +204,7 @@ export interface FileRouteTypes {
|
|||
to:
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/games'
|
||||
| '/collection/$id'
|
||||
| '/settings/about'
|
||||
| '/settings/accounts'
|
||||
|
|
@ -212,6 +223,7 @@ export interface FileRouteTypes {
|
|||
| '__root__'
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/games'
|
||||
| '/store/tab'
|
||||
| '/collection/$id'
|
||||
| '/settings/about'
|
||||
|
|
@ -232,6 +244,7 @@ export interface FileRouteTypes {
|
|||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
SettingsRouteRoute: typeof SettingsRouteRouteWithChildren
|
||||
GamesRoute: typeof GamesRoute
|
||||
StoreTabRouteRoute: typeof StoreTabRouteRouteWithChildren
|
||||
CollectionIdRoute: typeof CollectionIdRoute
|
||||
EmbeddedSourceIdRoute: typeof EmbeddedSourceIdRoute
|
||||
|
|
@ -243,6 +256,13 @@ export interface RootRouteChildren {
|
|||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/games': {
|
||||
id: '/games'
|
||||
path: '/games'
|
||||
fullPath: '/games'
|
||||
preLoaderRoute: typeof GamesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
|
|
@ -404,6 +424,7 @@ const StoreTabRouteRouteWithChildren = StoreTabRouteRoute._addFileChildren(
|
|||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
SettingsRouteRoute: SettingsRouteRouteWithChildren,
|
||||
GamesRoute: GamesRoute,
|
||||
StoreTabRouteRoute: StoreTabRouteRouteWithChildren,
|
||||
CollectionIdRoute: CollectionIdRoute,
|
||||
EmbeddedSourceIdRoute: EmbeddedSourceIdRoute,
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@ const assets = new Set<string>([
|
|||
]);
|
||||
|
||||
// Store basePath resolved from Vite config
|
||||
const BASE_PATH = "/";
|
||||
const BASE_PATH = "./";
|
||||
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,7 +3,14 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="./assets/favicon.ico" />
|
||||
<meta name="description" content="Retro Game Launcher" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/256x256.png" sizes="320x320" />
|
||||
<link rel="mask-icon" href="/icon.svg" color="#1d232a" />
|
||||
<meta name="theme-color" content="#605dff" />
|
||||
<link rel="manifest" href="./manifest.json" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Alan+Sans:wght@300..900&display=swap"
|
||||
rel="stylesheet"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import
|
|||
{
|
||||
createHashHistory,
|
||||
createRouter,
|
||||
Link,
|
||||
RouterProvider,
|
||||
} from "@tanstack/react-router";
|
||||
import { routeTree } from "./gen/routeTree.gen";
|
||||
|
|
@ -17,6 +16,12 @@ import { client as rommClient } from "../clients/romm/client.gen";
|
|||
import "./scripts/spatialNavigation";
|
||||
import NotFound from "./components/NotFound";
|
||||
import Error from "./components/Error";
|
||||
import serviceWorker from './scripts/serviceWorker?worker&url';
|
||||
|
||||
if ('serviceWorker' in navigator)
|
||||
{
|
||||
navigator.serviceWorker.register(serviceWorker);
|
||||
}
|
||||
|
||||
const hashHistory = createHashHistory({});
|
||||
|
||||
|
|
|
|||
17
src/mainview/manifest.json
Normal file
17
src/mainview/manifest.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"short_name": "GF",
|
||||
"name": "Gameflow Deck",
|
||||
"start_url": "/",
|
||||
"display": "fullscreen",
|
||||
"theme_color": "#605dff",
|
||||
"background_color": "#1d232a",
|
||||
"description": "Retro Game Launcher",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/256x256.png",
|
||||
"sizes": "320x320",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"lang": "en"
|
||||
}
|
||||
BIN
src/mainview/public/256x256.png
(Stored with Git LFS)
Normal file
BIN
src/mainview/public/256x256.png
(Stored with Git LFS)
Normal file
Binary file not shown.
|
|
@ -1,12 +1,11 @@
|
|||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||
import { getRomApiRomsIdGetOptions, getRomsApiRomsGetOptions } from "../clients/romm/@tanstack/react-query.gen";
|
||||
import { GameListFilter } from "./components/GameList";
|
||||
import { DefaultRommStaleTime } from "../shared/constants";
|
||||
import { DefaultRommStaleTime, GameListFilterType } from "../shared/constants";
|
||||
|
||||
export function gamesQueryOptions (filter?: GameListFilter)
|
||||
export function gamesQueryOptions (filter?: GameListFilterType)
|
||||
{
|
||||
return queryOptions({
|
||||
...getRomsApiRomsGetOptions({ query: { order_by: "updated_at", platform_ids: filter?.platformIds, collection_id: filter?.collectionId } }),
|
||||
...getRomsApiRomsGetOptions({ query: { order_by: "updated_at", platform_ids: filter?.platform_id ? [filter?.platform_id] : null, collection_id: filter?.collection_id } }),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: DefaultRommStaleTime
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { CollectionsDetail } from '../components/CollectionsDetail';
|
||||
import { getCollectionApiCollectionsIdGetOptions, getRomsApiRomsGetOptions } from '@clients/romm/@tanstack/react-query.gen';
|
||||
import { getRomsApiRomsGetOptions } from '@clients/romm/@tanstack/react-query.gen';
|
||||
import { DefaultRommStaleTime } from '@shared/constants';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useContext } from 'react';
|
||||
import { AnimatedBackgroundContext } from '../scripts/contexts';
|
||||
import queries from '../scripts/queries';
|
||||
|
||||
export const Route = createFileRoute('/collection/$id')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -17,7 +18,7 @@ export const Route = createFileRoute('/collection/$id')({
|
|||
function RouteComponent ()
|
||||
{
|
||||
const { id } = Route.useParams();
|
||||
const { data: collection } = useQuery({ ...getCollectionApiCollectionsIdGetOptions({ path: { id: Number(id) } }) });
|
||||
const { data: collection } = useQuery(queries.romm.getCollectionQuery(Number(id)));
|
||||
const animatedBgContext = useContext(AnimatedBackgroundContext);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,27 +1,26 @@
|
|||
import { EMULATORJS_URL, RPC_URL, SERVER_URL } from '@/shared/constants';
|
||||
import { RPC_URL, SERVER_URL } from '@/shared/constants';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { gameQuery } from '../scripts/queries';
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import z from 'zod';
|
||||
import { RefObject, useEffect, useRef, useState } from 'react';
|
||||
import { Router } from '..';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { Button, ButtonStyle } from '../components/options/Button';
|
||||
import { DoorOpen, Home, RefreshCw, Undo } from 'lucide-react';
|
||||
import { ButtonStyle } from '../components/options/Button';
|
||||
import { DoorOpen, RefreshCw, Undo } from 'lucide-react';
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||
import Shortcuts from '../components/Shortcuts';
|
||||
import { useEventListener, useTimeout } from 'usehooks-ts';
|
||||
import { GetFocusedElement, useGlobalFocus } from '../scripts/spatialNavigation';
|
||||
import { useEventListener } from 'usehooks-ts';
|
||||
import useActiveControl from '../scripts/gamepads';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { HeaderAccounts, HeaderStatusBar } from '../components/Header';
|
||||
import { RoundButton } from '../components/RoundButton';
|
||||
import queries from '../scripts/queries';
|
||||
|
||||
export const Route = createFileRoute('/embedded/$source/$id')({
|
||||
component: RouteComponent,
|
||||
loader: async (ctx) =>
|
||||
{
|
||||
const data = await ctx.context.queryClient.fetchQuery(gameQuery(ctx.params.source, ctx.params.id));
|
||||
const data = await ctx.context.queryClient.fetchQuery(queries.romm.gameQuery(ctx.params.source, ctx.params.id));
|
||||
return { data };
|
||||
},
|
||||
validateSearch: zodValidator(z.record(z.string(), z.string().optional().nullable()))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router";
|
||||
import { CommandEntry, FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants";
|
||||
import { twJoin, twMerge } from "tailwind-merge";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { JSX, RefObject, useEffect, useRef, useState } from "react";
|
||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
|
|
@ -11,20 +11,20 @@ import { PopSource, SaveSource, useFocusEventListener } from "../../scripts/spat
|
|||
import { AnimatedBackground } from "../../components/AnimatedBackground";
|
||||
import { rommApi } from "../../scripts/clientApi";
|
||||
import toast from "react-hot-toast";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Router } from "../..";
|
||||
import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog";
|
||||
import Shortcuts from "../../components/Shortcuts";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { gameQuery } from "@/mainview/scripts/queries";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
import Screenshots from "@/mainview/components/Screenshots";
|
||||
import { delay, useSticky, useStickyDataAttr } from "@/mainview/scripts/utils";
|
||||
import { useStickyDataAttr } from "@/mainview/scripts/utils";
|
||||
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||
|
||||
export const Route = createFileRoute("/game/$source/$id")({
|
||||
loader: async ({ params, context }) =>
|
||||
{
|
||||
const data = await context.queryClient.fetchQuery(gameQuery(params.source, params.id));
|
||||
const data = await context.queryClient.fetchQuery(queries.romm.gameQuery(params.source, params.id));
|
||||
return { data };
|
||||
},
|
||||
component: GameDetailsUI,
|
||||
|
|
@ -402,8 +402,7 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
|
|||
const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) });
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: ['delete', data.game.id],
|
||||
mutationFn: () => rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).delete(),
|
||||
...queries.romm.deleteGameMutation,
|
||||
onSuccess: () =>
|
||||
{
|
||||
location.reload();
|
||||
|
|
@ -493,7 +492,7 @@ function ActionButton (data: {
|
|||
disabled?: boolean;
|
||||
})
|
||||
{
|
||||
const { ref, focused } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true });
|
||||
const { ref } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true });
|
||||
const styles = {
|
||||
primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary",
|
||||
base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary",
|
||||
|
|
|
|||
21
src/mainview/routes/games.tsx
Normal file
21
src/mainview/routes/games.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { CollectionsDetail } from '../components/CollectionsDetail';
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import z from 'zod';
|
||||
|
||||
export const Route = createFileRoute('/games')({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(z.object({ focus: z.string().optional() }))
|
||||
});
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = Route.useSearch();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<CollectionsDetail focus={focus} id='all-games'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { JSX, Suspense, useContext, useState } from "react";
|
||||
import { JSX, Suspense, useContext, useEffect, useState } from "react";
|
||||
import
|
||||
{
|
||||
Gamepad2,
|
||||
|
|
@ -21,7 +21,6 @@ import
|
|||
{
|
||||
FocusContext,
|
||||
FocusDetails,
|
||||
getCurrentFocusKey,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
|
|
@ -38,7 +37,6 @@ import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
|
|||
import { twMerge } from "tailwind-merge";
|
||||
import Shortcuts from "../components/Shortcuts";
|
||||
import { PlatformsList } from "../components/PlatformsList";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
|
||||
import z from "zod";
|
||||
import { Router } from "..";
|
||||
|
|
@ -47,6 +45,8 @@ import { zodValidator } from '@tanstack/zod-adapter';
|
|||
import { mobileCheck, useDragScroll } from "../scripts/utils";
|
||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||
import { FrontEndId } from "@/shared/constants";
|
||||
import Carousel from "../components/Carousel";
|
||||
import queries from "../scripts/queries";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: ConsoleHomeUI,
|
||||
|
|
@ -90,6 +90,16 @@ function HomeListError (data: { focused: boolean; })
|
|||
</div></div>;
|
||||
}
|
||||
|
||||
function ShowAllGamesCard ()
|
||||
{
|
||||
const handleNavigate = () =>
|
||||
{
|
||||
Router.navigate({ to: '/games', viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
const { ref } = useFocusable({ focusKey: 'all-games-btn', onEnterPress: handleNavigate });
|
||||
return <div ref={ref} onClick={handleNavigate} className="flex focusable focusable-primary justify-center items-center bg-base-300/80 rounded-3xl font-semibold w-(--game-card-width) h-(--game-card-height) focusable-hover cursor-pointer">All Games</div>;
|
||||
}
|
||||
|
||||
function HomeList (data: {
|
||||
selectedFilter: string;
|
||||
})
|
||||
|
|
@ -104,8 +114,8 @@ function HomeList (data: {
|
|||
|
||||
const handleNodeFocus = (id: string, node: HTMLElement, details: FocusDetails) =>
|
||||
{
|
||||
const isMounseEvent = details.nativeEvent instanceof MouseEvent;
|
||||
if (!isMounseEvent)
|
||||
const isMouseEvent = details.nativeEvent instanceof MouseEvent;
|
||||
if (!isMouseEvent)
|
||||
{
|
||||
node?.scrollIntoView({ inline: 'center', block: 'center', behavior: initFocus ? 'smooth' : 'instant' });
|
||||
}
|
||||
|
|
@ -136,19 +146,29 @@ function HomeList (data: {
|
|||
{
|
||||
case 'consoles':
|
||||
activeList = <>
|
||||
<PlatformsList onSelect={handlePlatformSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />
|
||||
<PlatformsList saveChildFocus="session" onSelect={handlePlatformSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />
|
||||
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
|
||||
</>;
|
||||
break;
|
||||
case 'collections':
|
||||
activeList = <>
|
||||
<CollectionList onSelect={handleCollectionSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />
|
||||
<CollectionList saveChildFocus="session" onSelect={handleCollectionSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />
|
||||
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
|
||||
</>;
|
||||
break;
|
||||
default:
|
||||
activeList = <>
|
||||
<GameList onGameSelect={handleGameSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />
|
||||
<GameList
|
||||
onGameSelect={handleGameSelect}
|
||||
saveChildFocus="session"
|
||||
onFocus={handleNodeFocus}
|
||||
className="animate-slide-up"
|
||||
key="games-list"
|
||||
id="games-list"
|
||||
setBackground={bg.setBackground}
|
||||
filters={{ limit: 12 }}
|
||||
finalElement={<ShowAllGamesCard />}
|
||||
/>
|
||||
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
|
||||
</>;
|
||||
break;
|
||||
|
|
@ -182,7 +202,7 @@ function HomeList (data: {
|
|||
|
||||
return (
|
||||
<FocusContext value={focusKey}>
|
||||
<div ref={ref} className="flex h-full w-full landscape:overflow-x-scroll portrait:overflow-y-scroll overflow-hidden no-scrollbar justify-center-safe sm:py-2 md:py-6 md:pb-6 md:mb-1 not-mobile:sm:pb-4" style={{
|
||||
<Carousel scrollRef={ref} rootClassName="h-full w-full" className="flex h-full w-full landscape:overflow-x-scroll portrait:overflow-y-scroll overflow-hidden no-scrollbar justify-center-safe sm:py-2 md:py-6 md:pb-6 md:mb-1 not-mobile:sm:pb-4" style={{
|
||||
mask: `linear-gradient(to right, rgba(0,0,0,0.8) 0%, black 10%, black 90%, rgba(0,0,0,0.8) 100%)`
|
||||
}}>
|
||||
<div className="landscape:flex landscape:px-16 portrait:min-h-fit portrait:h-fit portrait:pb-32 portrait:w-full landscape:h-full landscape:items-center">
|
||||
|
|
@ -193,17 +213,16 @@ function HomeList (data: {
|
|||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</Carousel>
|
||||
</FocusContext>
|
||||
);
|
||||
}
|
||||
|
||||
function MainMenu (data: {})
|
||||
function MainMenu ()
|
||||
{
|
||||
const { ref, focusKey, hasFocusedChild } = useFocusable({
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: `main-menu`,
|
||||
trackChildren: true,
|
||||
onBlur: (layout, props, details) => { },
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
|
|
@ -214,7 +233,7 @@ function MainMenu (data: {})
|
|||
>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<CircleIcon
|
||||
action={() => navigate({ to: "/" })}
|
||||
action={() => navigate({ to: "/games", viewTransition: { types: ['zoom-in'] } })}
|
||||
icon={<Gamepad2 />}
|
||||
label="Home"
|
||||
type="secondary"
|
||||
|
|
@ -248,7 +267,7 @@ function CircleIcon (data: {
|
|||
icon?: JSX.Element;
|
||||
})
|
||||
{
|
||||
const { ref, focused, focusKey } = useFocusable({
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: `navigation-icon-${data.label}`,
|
||||
onEnterPress: data.action,
|
||||
});
|
||||
|
|
@ -275,15 +294,9 @@ export default function ConsoleHomeUI ()
|
|||
{
|
||||
const { filter } = Route.useSearch();
|
||||
|
||||
const closeMutation = useMutation({
|
||||
mutationKey: ['close'], mutationFn: async () =>
|
||||
{
|
||||
const { error } = await systemApi.api.system.exit.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
const close = useMutation(queries.system.closeMutation);
|
||||
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
const { ref, focusKey } = useFocusable({
|
||||
forceFocus: true,
|
||||
autoRestoreFocus: false,
|
||||
saveLastFocusedChild: false,
|
||||
|
|
@ -319,7 +332,7 @@ export default function ConsoleHomeUI ()
|
|||
const headerButtons = [];
|
||||
if (mobileCheck())
|
||||
headerButtons.push({ id: "fullscreen", icon: <Maximize />, action: handleFullscreen });
|
||||
headerButtons.push({ id: "search", icon: <Search /> }, { id: "power-button", icon: <Power />, external: true, action: () => closeMutation.mutate() });
|
||||
headerButtons.push({ id: "search", icon: <Search /> }, { id: "power-button", icon: <Power />, external: true, action: () => close.mutate() });
|
||||
|
||||
return (
|
||||
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className="grid grid-cols-3 sm:landscape:grid-rows-[3rem_minmax(var(--game-card-height-safe),1fr)_4rem] md:landscape:grid-rows-[5rem_4rem_minmax(var(--game-card-height-safe),1fr)_6rem_6rem] gap-1 portrait:grid-rows-[3rem_4rem_minmax(var(--game-card-height-safe),1fr)] max-h-screen overflow-clip">
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { GameInstallProgress, RPC_URL } from '@/shared/constants';
|
|||
import DotsLoading from '../components/backgrounds/dots';
|
||||
import { Router } from '..';
|
||||
import { useEffect } 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';
|
||||
import queries from '../scripts/queries';
|
||||
|
||||
export const Route = createFileRoute('/launcher/$source/$id')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -23,7 +23,7 @@ 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() });
|
||||
const { data } = useQuery(queries.romm.gameQuery(source, id));
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
|
|
@ -58,7 +58,7 @@ function RouteComponent ()
|
|||
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>
|
||||
<h1 className='font-semibold'>Launching {data?.name} ...</h1>
|
||||
</div>
|
||||
<div className='absolute bot'>
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { CollectionsDetail } from "../components/CollectionsDetail";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||
import { useContext } from "react";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||
import { RPC_URL } from "../../shared/constants";
|
||||
import queries from "../scripts/queries";
|
||||
|
||||
export const Route = createFileRoute("/platform/$source/$id")({
|
||||
component: RouteComponent
|
||||
|
|
@ -24,22 +22,12 @@ function PlatformTitle (data: { pathCover: string | null, platformName?: string;
|
|||
function RouteComponent ()
|
||||
{
|
||||
const { source, id } = Route.useParams();
|
||||
const { data: platform } = useQuery({
|
||||
queryKey: ['platform', source, id], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}, staleTime: DefaultRommStaleTime
|
||||
});
|
||||
|
||||
const animatedBgContext = useContext(AnimatedBackgroundContext);
|
||||
const { data: platform } = useQuery(queries.romm.platformQuery(source, id));
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
{!!platform && <CollectionsDetail
|
||||
title={<PlatformTitle pathCover={platform.path_cover} platformName={platform.name} />}
|
||||
setBackground={animatedBgContext.setBackground}
|
||||
filters={{ platform_id: Number(id), platform_slug: platform.slug, platform_source: source }}
|
||||
/>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { systemApi } from '@/mainview/scripts/clientApi';
|
||||
|
||||
import queries from '@/mainview/scripts/queries';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
|
|
@ -9,7 +10,7 @@ export const Route = createFileRoute('/settings/about')({
|
|||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { data: systemInfo } = useQuery({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() });
|
||||
const { data: systemInfo } = useQuery(queries.system.systemInfoQuery);
|
||||
return <table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -13,23 +13,17 @@ import
|
|||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { RPC_URL } from "@shared/constants";
|
||||
import
|
||||
{
|
||||
getCurrentUserApiUsersMeGetOptions,
|
||||
statsApiStatsGetOptions,
|
||||
} from "@clients/romm/@tanstack/react-query.gen";
|
||||
import { RommLoginDataSchema, RPC_URL } from "@shared/constants";
|
||||
import toast from "react-hot-toast";
|
||||
import z from "zod";
|
||||
import { OptionSpace } from "../../components/options/OptionSpace";
|
||||
import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm";
|
||||
import { rommApi, settingsApi } from "../../scripts/clientApi";
|
||||
import { Button } from "../../components/options/Button";
|
||||
import { ContextDialog } from "@/mainview/components/ContextDialog";
|
||||
import QRCode from "react-qr-code";
|
||||
import { useJobStatus } from "@/mainview/scripts/utils";
|
||||
import { useInterval } from "usehooks-ts";
|
||||
import { TwitchIcon } from "@/mainview/scripts/brandIcons";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
|
||||
export const Route = createFileRoute("/settings/accounts")({
|
||||
component: RouteComponent,
|
||||
|
|
@ -56,44 +50,16 @@ function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url:
|
|||
</ContextDialog>;
|
||||
}
|
||||
|
||||
function TwitchLogin (data: {})
|
||||
function TwitchLogin ()
|
||||
{
|
||||
|
||||
const loginStatus = useQuery({
|
||||
queryKey: ['twitch', 'login', 'status'],
|
||||
retry (failureCount, error)
|
||||
{
|
||||
if (error.status === 404)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return failureCount < 3;
|
||||
},
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error, status } = await rommApi.api.romm.login.twitch.get();
|
||||
if (error) throw { ...error, status };
|
||||
return data;
|
||||
}
|
||||
});
|
||||
const loginStatus = useQuery(queries.settings.twitchLoginVerificationQuery);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationKey: ['twitch', 'login'],
|
||||
mutationFn: (openInBrowser: boolean) =>
|
||||
{
|
||||
return rommApi.api.romm.login.twitch.post({ openInBrowser });
|
||||
},
|
||||
...queries.settings.twitchLoginMutation,
|
||||
onSuccess: () => loginStatus.refetch()
|
||||
});
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationKey: ['twitch', 'logout'],
|
||||
mutationFn: () =>
|
||||
{
|
||||
return rommApi.api.romm.logout.twitch.post();
|
||||
},
|
||||
onSuccess: () => loginStatus.refetch()
|
||||
});
|
||||
const logoutMutation = useMutation({ ...queries.settings.twitchLogoutMutation, onSuccess: () => loginStatus.refetch() });
|
||||
|
||||
const { data: loginData, wsRef } = useJobStatus('twitch-login-job', { onEnded: () => loginStatus.refetch() });
|
||||
|
||||
|
|
@ -118,22 +84,13 @@ function TwitchLogin (data: {})
|
|||
|
||||
function LoginControls (data: { hasPassword: boolean; })
|
||||
{
|
||||
const user = useQuery({
|
||||
...getCurrentUserApiUsersMeGetOptions(),
|
||||
queryKey: ['romm', 'auth', "login"],
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 0
|
||||
});
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationKey: ['login', 'qr', 'cancel'],
|
||||
mutationFn: () => rommApi.api.romm.login.romm.post()
|
||||
});
|
||||
const { data: statusValue, error: loginError, wsRef } = useJobStatus('login-job');
|
||||
const user = useQuery(queries.romm.rommUserQuery());
|
||||
const loginMutation = useMutation(queries.romm.rommQrLoginMutation);
|
||||
const { data: statusValue, wsRef } = useJobStatus('login-job');
|
||||
const context = useSettingsFormContext({});
|
||||
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
|
||||
const logoutMutation = useMutation({
|
||||
mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post(),
|
||||
...queries.romm.rommLogoutMutation,
|
||||
onSuccess: async (d, v, r, c) =>
|
||||
{
|
||||
user.refetch();
|
||||
|
|
@ -171,8 +128,6 @@ function LoginControls (data: { hasPassword: boolean; })
|
|||
</div>;
|
||||
}
|
||||
|
||||
const dataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() });
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = Route.useSearch();
|
||||
|
|
@ -181,9 +136,9 @@ function RouteComponent ()
|
|||
preferredChildFocusKey: focus
|
||||
});
|
||||
|
||||
const { data: hasPassword } = useQuery({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) });
|
||||
const { data: hostname } = useQuery({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) });
|
||||
const { data: username } = useQuery({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) });
|
||||
const { data: hasPassword } = useQuery(queries.romm.rommHasPasswordQuery);
|
||||
const { data: hostname } = useQuery(queries.romm.rommHostnameQuery);
|
||||
const { data: username } = useQuery(queries.romm.rommUsernameQuery);
|
||||
|
||||
const loginForm = useSettingsForm({
|
||||
defaultValues: {
|
||||
|
|
@ -201,15 +156,11 @@ function RouteComponent ()
|
|||
loginForm.reset();
|
||||
},
|
||||
validators: {
|
||||
onChange: dataSchema
|
||||
onChange: RommLoginDataSchema
|
||||
}
|
||||
});
|
||||
|
||||
const rommOnline = useQuery({
|
||||
...statsApiStatsGetOptions(),
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
});
|
||||
const rommOnline = useQuery(queries.romm.rommGetOptionsQuery());
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
|
@ -219,22 +170,7 @@ function RouteComponent ()
|
|||
}
|
||||
}, [focus]);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationKey: ["romm", "login"],
|
||||
mutationFn: async (data: z.infer<typeof dataSchema>) =>
|
||||
{
|
||||
const { error } = await rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: (d, v, r, c) =>
|
||||
{
|
||||
c.client.invalidateQueries({ queryKey: ['romm', 'auth'] });
|
||||
},
|
||||
onError: (e) =>
|
||||
{
|
||||
console.error(e);
|
||||
},
|
||||
});
|
||||
const loginMutation = useMutation(queries.romm.rommLoginMutation);
|
||||
|
||||
let indicator = "";
|
||||
if (rommOnline.isError)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-naviga
|
|||
import { Block, createFileRoute } 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 queries from '@/mainview/scripts/queries';
|
||||
import { DownloadsDrive } from '@/shared/constants';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import classNames from 'classnames';
|
||||
|
|
@ -24,11 +24,11 @@ function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; r
|
|||
focusKey: data.drive.device,
|
||||
onFocus: () => (ref.current as HTMLElement)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
});
|
||||
const isMoving = useIsMutating(changeDownloadsMutation);
|
||||
const isMoving = useIsMutating(queries.settings.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 changeDownloads = useMutation({ ...queries.settings.changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason;
|
||||
const shortcuts: Shortcut[] = [];
|
||||
const valid = !data.drive.unusableReason && isMoving <= 0;
|
||||
const handleAction = () => changeDownloads.mutate(data.drive.mountPoint);
|
||||
|
|
@ -74,16 +74,16 @@ function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; r
|
|||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = Route.useSearch();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: "directories",
|
||||
preferredChildFocusKey: focus
|
||||
});
|
||||
|
||||
const isMoving = useIsMutating(changeDownloadsMutation);
|
||||
const { data: drives, refetch } = useQuery({ ...downloadDrivesQuery, refetchInterval: isMoving > 0 ? 1000 : undefined });
|
||||
const isMoving = useIsMutating(queries.settings.changeDownloadsMutation);
|
||||
const { data: drives, refetch } = useQuery({ ...queries.system.downloadDrivesQuery, refetchInterval: isMoving > 0 ? 1000 : undefined });
|
||||
|
||||
return <FocusContext value={focusKey}>
|
||||
<Block shouldBlockFn={() => isMoving} withResolver={false} />
|
||||
<Block shouldBlockFn={() => isMoving > 0} withResolver={false} />
|
||||
<ul ref={ref} className="list rounded-box gap-2">
|
||||
<div className="divider text-2xl mt-0 md:mt-4">
|
||||
<Download className='size-16' /> Downloads ({drives?.downloadsSize ? prettyBytes(drives?.downloadsSize) : <span className="loading loading-spinner loading-lg size-6"></span>})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { createFileRoute } from '@tanstack/react-router';
|
|||
import { OptionSpace } from '../../components/options/OptionSpace';
|
||||
import { OptionInput } from '../../components/options/OptionInput';
|
||||
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, FolderSearch, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
|
||||
|
|
@ -15,7 +14,7 @@ import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spat
|
|||
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||
import FilePicker from '@/mainview/components/FilePicker';
|
||||
import { dirname } from 'pathe';
|
||||
import { autoEmulatorsQuery } from '@/mainview/scripts/queries';
|
||||
import queries from '@/mainview/scripts/queries';
|
||||
|
||||
export const Route = createFileRoute('/settings/emulators')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -33,7 +32,7 @@ function EmulatorsPending ()
|
|||
|
||||
function EmulatorListCat (data: { selected: string, set: (c: string) => void; })
|
||||
{
|
||||
const { ref, focused, focusKey } = useFocusable({ focusKey: 'categories' });
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'categories' });
|
||||
return <ul className='flex gap-1' ref={ref}>
|
||||
<FocusContext value={focusKey}>
|
||||
{[..."ABCDEFGHIJKLMNOPQRSTVWXYZ"].map(c =>
|
||||
|
|
@ -99,40 +98,13 @@ 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({
|
||||
enabled: !!data.id,
|
||||
queryKey: ["emulator", data.id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).get();
|
||||
if (error) throw error;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
const setSettingMutation = useMutation({
|
||||
mutationKey: ["emulator", data.id, 'set'],
|
||||
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: data.id }).put({ value }),
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
{
|
||||
ctx.client.invalidateQueries({ queryKey: ["emulator", data.id] });
|
||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
setLocalValue(v);
|
||||
setDirty(false);
|
||||
}
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: ["emulator", data.id, 'delete'],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).delete();
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
{
|
||||
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
|
||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
}
|
||||
});
|
||||
const { data: remoteValue } = useQuery(queries.settings.customEmulatorRemoveValueQuery(data.id));
|
||||
const setSettingMutation = useMutation(queries.settings.setCustomEmulatorMutation(data.id, (v) =>
|
||||
{
|
||||
setLocalValue(v);
|
||||
setDirty(false);
|
||||
}));
|
||||
const deleteMutation = useMutation(queries.settings.customEmulatorDeleteMutation(data.id));
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
|
|
@ -251,11 +223,11 @@ function EmulatorBadge (data: {
|
|||
|
||||
function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; })
|
||||
{
|
||||
const { data: autoEmulators } = useQuery(autoEmulatorsQuery);
|
||||
const { data: autoEmulators } = useQuery(queries.settings.autoEmulatorsQuery);
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators && autoEmulators.length > 0 });
|
||||
return <div ref={ref} className='grid grid-cols-[repeat(auto-fit,14rem)] auto-rows-[4rem] gap-2 justify-center-safe'>
|
||||
<FocusContext value={focusKey}>
|
||||
{autoEmulators?.map(e => <EmulatorBadge key={e.emulator} isCritical={e.isCritical} addOverride={data.addOverride} pathCover={e.path_cover ?? undefined} path={e.path?.path} exists={e.exists} emulator={e.emulator} />)}
|
||||
{autoEmulators?.map(e => <EmulatorBadge key={e.name} isCritical={e.isCritical} addOverride={data.addOverride} pathCover={e.logo} path={e.path?.path} exists={e.exists} emulator={e.name} />)}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -263,30 +235,14 @@ function EmulatorBadges (data: { path?: string; addOverride: (emulator: string)
|
|||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = Route.useSearch();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: "emulators-setting",
|
||||
preferredChildFocusKey: focus
|
||||
});
|
||||
|
||||
const { data: customEmulators } = useQuery({
|
||||
queryKey: ['custom-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
const { data: customEmulators } = useQuery(queries.settings.customEmulatorsQuery);
|
||||
|
||||
const addOverrideMutation = useMutation({
|
||||
mutationKey: ['emulator', 'custom', 'add'],
|
||||
mutationFn: async (id: string) =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
|
||||
});
|
||||
const addOverrideMutation = useMutation(queries.settings.customEmulatorAddMutation);
|
||||
|
||||
return <FocusContext value={focusKey}>
|
||||
<ul ref={ref} className="list rounded-box gap-2">
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const Route = createFileRoute('/settings/interface')({
|
|||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = Route.useSearch();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: "interface-settings",
|
||||
preferredChildFocusKey: focus
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ function MenuItem (data: {
|
|||
const { to, search } = PopSource('settings');
|
||||
navigate({ to: data.return ? to ?? data.route : data.route, viewTransition: data.viewTransition, search: data.return ? search : undefined });
|
||||
};
|
||||
const { ref, focusSelf, focused } = useFocusable({
|
||||
const { ref, focusSelf } = useFocusable({
|
||||
focusKey: `menu-item-${data.route}`,
|
||||
forceFocus: !!acitve,
|
||||
onFocus: () =>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import Shortcuts from "@/mainview/components/Shortcuts";
|
|||
import { AnimatedBackground } from "@/mainview/components/AnimatedBackground";
|
||||
import { PopSource } from "@/mainview/scripts/spatialNavigation";
|
||||
import { systemApi } from "@/mainview/scripts/clientApi";
|
||||
import { storeEmulatorDetailsQuery, storeEmulatorsRecommendedQuery } from "@/mainview/scripts/queries";
|
||||
import queries from "@/mainview/scripts/queries";
|
||||
import { Button } from "@/mainview/components/options/Button";
|
||||
import { ChevronDown, Download, Info, Settings } from "lucide-react";
|
||||
import { ContextDialog, ContextList, DialogEntry } from "@/mainview/components/ContextDialog";
|
||||
|
|
@ -27,7 +27,7 @@ export const Route = createFileRoute('/store/details/emulator/$id')({
|
|||
component: RouteComponent,
|
||||
async loader (ctx)
|
||||
{
|
||||
const emulator = await ctx.context.queryClient.fetchQuery(storeEmulatorDetailsQuery(ctx.params.id));
|
||||
const emulator = await ctx.context.queryClient.fetchQuery(queries.store.storeEmulatorDetailsQuery(ctx.params.id));
|
||||
return { emulator };
|
||||
}
|
||||
});
|
||||
|
|
@ -107,7 +107,7 @@ export function RouteComponent ()
|
|||
});
|
||||
|
||||
const { emulator } = Route.useLoaderData();
|
||||
const { data: recommended } = useQuery(storeEmulatorsRecommendedQuery);
|
||||
const { data: recommended } = useQuery(queries.store.storeEmulatorsRecommendedQuery);
|
||||
|
||||
useShortcuts(focusKey, () => [{
|
||||
label: "Return",
|
||||
|
|
@ -180,13 +180,7 @@ export function RouteComponent ()
|
|||
setFocus("title-area");
|
||||
Router.navigate({ to: '/store/details/emulator/$id', params: { id }, viewTransition: { types: ['zoom-in'] } });
|
||||
}}
|
||||
emulators={recommended.map(em => ({
|
||||
name: em.name,
|
||||
id: em.name,
|
||||
installed: em.exists,
|
||||
logo: em.logo,
|
||||
systems: em.systems
|
||||
} satisfies ShopFrontEndEmulator))} />}
|
||||
emulators={recommended} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex fixed bottom-4 left-4 right-4 justify-end z-10'>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
|
||||
import { storeEmulatorsQuery } from '@/mainview/scripts/queries';
|
||||
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router';
|
||||
import { Joystick } from 'lucide-react';
|
||||
import { useContext, useEffect } from 'react';
|
||||
|
|
@ -7,33 +7,13 @@ import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/no
|
|||
import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard';
|
||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import queries from '@/mainview/scripts/queries';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/emulators')({
|
||||
component: RouteComponent,
|
||||
pendingComponent: PendingComponent,
|
||||
async loader ({ context })
|
||||
{
|
||||
const emulators = await context.queryClient.fetchQuery(storeEmulatorsQuery);
|
||||
return { emulators };
|
||||
},
|
||||
});
|
||||
|
||||
function PendingComponent ()
|
||||
{
|
||||
return <section className="px-6 py-4">
|
||||
<div className="divider text-info">
|
||||
<Joystick className='size-12' />
|
||||
<h2 className="font-bold uppercase tracking-widest">
|
||||
Emulators
|
||||
</h2>
|
||||
</div>
|
||||
{/* Cards */}
|
||||
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[12rem] py-2 px-4 gap-4 justify-center-safe">
|
||||
{[1, 2, 3, 4, 5, 6].map(i => <div key={i} className="skeleton h-36 rounded-2xl" />)}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = useSearch({ from: '/store/tab' });
|
||||
|
|
@ -42,7 +22,7 @@ function RouteComponent ()
|
|||
preferredChildFocusKey: focus
|
||||
});
|
||||
const storeContext = useContext(StoreContext);
|
||||
const { emulators } = Route.useLoaderData();
|
||||
const { data: emulators } = useQuery(queries.store.storeEmulatorsQuery);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
|
@ -64,7 +44,7 @@ function RouteComponent ()
|
|||
</div>
|
||||
{/* Cards */}
|
||||
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[12rem] py-2 md:px-4 gap-4 justify-center-safe">
|
||||
{emulators && emulators.map((data) => (
|
||||
{emulators?.map((data) => (
|
||||
<StoreEmulatorCard
|
||||
id={data.name}
|
||||
key={data.name}
|
||||
|
|
@ -72,7 +52,7 @@ function RouteComponent ()
|
|||
onFocus={({ node, details }) => { node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' }); }}
|
||||
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
|
||||
/>
|
||||
))}
|
||||
)) ?? Array.from({ length: 10 }).map((_, i) => <div key={i} className="skeleton rounded-3xl" />)}
|
||||
</div>
|
||||
</FocusContext>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,95 +1,23 @@
|
|||
import { StoreGameCard } from '@/mainview/components/store/GamesSection';
|
||||
import { FocusContext, getCurrentFocusKey, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router';
|
||||
import { Gamepad, Gamepad2, HardDrive, Save } from 'lucide-react';
|
||||
import { JSX, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { Gamepad2 } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||
import { basename, dirname, extname } from 'pathe';
|
||||
import { rommApi } from '@/mainview/scripts/clientApi';
|
||||
import { FrontEndGameType, RPC_URL } from '@/shared/constants';
|
||||
import CardElement from '@/mainview/components/CardElement';
|
||||
import { FOCUS_KEYS } from '@/mainview/scripts/types';
|
||||
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
|
||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||
import { useIntersectionObserver } from 'usehooks-ts';
|
||||
|
||||
const staleTime = 24 * 60 * 60 * 1000;
|
||||
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
||||
import queries from '@/mainview/scripts/queries';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/games')({
|
||||
component: RouteComponent,
|
||||
async loader (ctx)
|
||||
{
|
||||
|
||||
/*const gamesManifest = await ctx.context.queryClient.fetchQuery({
|
||||
queryKey: ['store-games-manifest'], queryFn: async () =>
|
||||
{
|
||||
const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json());
|
||||
|
||||
return store.tree.filter((e: any) =>
|
||||
{
|
||||
if (e.type === 'blob' && e.path !== "featured.json")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}) as [];
|
||||
}, staleTime
|
||||
});
|
||||
|
||||
return { gamesManifest };*/
|
||||
},
|
||||
component: RouteComponent
|
||||
});
|
||||
|
||||
function LoadMoreButton (data: { isFetching: boolean; lastId?: string; } & FocusParams & InteractParams)
|
||||
{
|
||||
const handleAction = (e?: Event) =>
|
||||
{
|
||||
data.onAction?.(e);
|
||||
if (data.lastId && focused)
|
||||
setFocus(FOCUS_KEYS.GAME_CARD(data.lastId));
|
||||
};
|
||||
|
||||
const { ref, focusKey, focused } = useFocusable({
|
||||
focusKey: 'load-more-btn',
|
||||
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details),
|
||||
onEnterPress: handleAction
|
||||
});
|
||||
|
||||
const { ref: intersct } = useIntersectionObserver({
|
||||
onChange: (isIntersecting, entry) =>
|
||||
{
|
||||
if (isIntersecting)
|
||||
{
|
||||
handleAction();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return <div ref={(r) =>
|
||||
{
|
||||
ref.current = r;
|
||||
intersct(r);
|
||||
}} className='flex bg-base-100 game-card focusable focusable-accent focusable-hover text-2xl justify-center items-center cursor-pointer' onClick={handleAction} id='load-more-btn'>{data.isFetching ? <span className="loading loading-spinner loading-xl"></span> : "Load More"}</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = useSearch({ from: '/store/tab' });
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus });
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery<{ data: FrontEndGameType[], nextPage: number; }>({
|
||||
initialPageParam: 0,
|
||||
queryKey: ['store-games'],
|
||||
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
|
||||
queryFn: async (data) =>
|
||||
{
|
||||
const pageParam = data.pageParam as number;
|
||||
const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } });
|
||||
if (error) throw error;
|
||||
return { data: games.games, nextPage: pageParam + 1 };
|
||||
}
|
||||
});
|
||||
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(queries.store.storeGamesInfiniteQuery);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
|
@ -115,17 +43,21 @@ function RouteComponent ()
|
|||
Games
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[minmax(18rem,min-content)] py-2 md:px-4 gap-4 justify-center-safe">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,18rem)] auto-rows-[21rem] py-2 md:px-4 gap-4 justify-center-safe">
|
||||
{data?.pages.flatMap((page) => (
|
||||
page.data.map((g, i) => <FrontEndGameCard onFocus={handleFocus} key={g.id.id} game={g} index={i} />))
|
||||
)}
|
||||
) ?? Array.from({ length: 20 }).map((_, i) => <div key={i} className="flex flex-col gap-4">
|
||||
<div className="skeleton grow w-full"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
<div className="skeleton h-4 w-[40%]"></div>
|
||||
</div>)}
|
||||
<LoadMoreButton
|
||||
lastId={data?.pages.at(-1)?.data.at(-1)?.id.id}
|
||||
onFocus={handleFocus}
|
||||
isFetching={isFetchingNextPage}
|
||||
isFetching={isFetchingNextPage || isFetching}
|
||||
onAction={() =>
|
||||
{
|
||||
if (isFetchingNextPage)
|
||||
if (isFetchingNextPage || isFetching)
|
||||
return;
|
||||
fetchNextPage();
|
||||
}} />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { createFileRoute, ErrorComponentProps, useSearch } from '@tanstack/react-router';
|
||||
import { createFileRoute, useSearch } from '@tanstack/react-router';
|
||||
import { useFocusable, FocusContext, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { MissingEmulatorsSection } from "../../../components/store/MissingEmulatorsSection";
|
||||
import { EmulatorsSection } from "../../../components/store/EmulatorsSection";
|
||||
import { GamesSection } from "../../../components/store/GamesSection";
|
||||
import { StatsSection } from "../../../components/store/StatsSection";
|
||||
import { FrontEndGameTypeDetailed, RPC_URL } from '@/shared/constants';
|
||||
import { autoEmulatorsQuery, storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@/mainview/scripts/queries';
|
||||
import queries from '@/mainview/scripts/queries';
|
||||
import { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { scrollIntoViewHandler } from '@/mainview/scripts/utils';
|
||||
import { StoreContext } from '@/mainview/scripts/contexts';
|
||||
|
|
@ -13,66 +13,34 @@ import { useInterval } from 'usehooks-ts';
|
|||
import { Button } from '@/mainview/components/options/Button';
|
||||
import { HardDrive, Search } from 'lucide-react';
|
||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const Route = createFileRoute('/store/tab/')({
|
||||
component: RouteComponent,
|
||||
pendingComponent: LoadingSkeleton,
|
||||
errorComponent: ErrorComponent,
|
||||
loader: async ({ context }) =>
|
||||
{
|
||||
const autoEmulators = await context.queryClient.fetchQuery(autoEmulatorsQuery);
|
||||
const crutialEmulators = autoEmulators?.filter(e => !e.exists && e.isCritical);
|
||||
const featuredGames = await await context.queryClient.fetchQuery(storeFeaturedGamesQuery);
|
||||
const recommendedEmulators = await context.queryClient.fetchQuery(storeEmulatorsRecommendedQuery);
|
||||
return { crutialEmulators, recommendedEmulators, featuredGames };
|
||||
}
|
||||
component: RouteComponent
|
||||
});
|
||||
|
||||
function ErrorComponent (data: ErrorComponentProps)
|
||||
{
|
||||
return <div className="flex items-center justify-center h-64">
|
||||
<div role="alert" className="alert alert-error alert-soft max-w-sm">
|
||||
<span>Failed to load store data.</span>
|
||||
<p>{data.error.message}</p>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
// ── Loading skeleton ───────────────────────────────────────────────────────
|
||||
function LoadingSkeleton ()
|
||||
function Main (data: { games?: FrontEndGameTypeDetailed[]; })
|
||||
{
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-6 py-4 animate-pulse">
|
||||
{/* Missing section */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[1, 2, 3].map((i) => <div key={i} className="skeleton h-40 rounded-2xl" />)}
|
||||
</div>
|
||||
{/* Emulators */}
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => <div key={i} className="skeleton h-36 rounded-2xl" />)}
|
||||
</div>
|
||||
{/* Games */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[1, 2, 3, 4].map((i) => <div key={i} className="skeleton h-44 rounded-2xl" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
|
||||
{
|
||||
const [selectedGame, setSelectedGame] = useState(new Date().getSeconds() % data.games.length);
|
||||
const [selectedGame, setSelectedGame] = useState(0);
|
||||
const [nextSwitch, setNextSwitch] = useState(new Date().getTime() + 10000);
|
||||
const progressRef = useRef<HTMLProgressElement>(null);
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'main-featured-area' });
|
||||
const game = data.games[selectedGame];
|
||||
const game = data.games ? data.games[selectedGame] : undefined;
|
||||
|
||||
useInterval(() =>
|
||||
{
|
||||
setSelectedGame(current => (current + 1) % data.games.length);
|
||||
if (!data.games) return;
|
||||
setSelectedGame(current => (current + 1) % data.games!.length);
|
||||
setNextSwitch(new Date().getTime() + 10000);
|
||||
}, 10000);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!data.games) return;
|
||||
setSelectedGame(new Date().getSeconds() % data.games.length);
|
||||
}, [data.games]);
|
||||
|
||||
useInterval(() =>
|
||||
{
|
||||
var time = (nextSwitch - new Date().getTime()) / 10000;
|
||||
|
|
@ -81,18 +49,18 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
|
|||
}, 10);
|
||||
|
||||
const storeContext = useContext(StoreContext);
|
||||
const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`);
|
||||
previewUrl.searchParams.set('blur', '16');
|
||||
const previewUrl = data.games ? new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`) : undefined;
|
||||
previewUrl?.searchParams.set('blur', '16');
|
||||
|
||||
return <div ref={ref} className='flex sm:flex-wrap md:flex-nowrap group-focusable p-4 mt-4 gap-4'>
|
||||
|
||||
<FocusContext value={focusKey}>
|
||||
<div key={selectedGame} className="flex transition-all duration-500 flex-col sm:32 md:h-64 rounded-3xl overflow-hidden shadow-black/5 shadow-xl grow">
|
||||
{game ? <div key={selectedGame} className="flex transition-all duration-500 flex-col rounded-3xl overflow-hidden shadow-black/5 shadow-xl w-full">
|
||||
<div className='flex relative h-full overflow-hidden'>
|
||||
<div className='absolute w-full h-full z-0 bg-base-200'>
|
||||
<img key={selectedGame}
|
||||
className='w-full h-full object-cover transition-all duration-500 ease-out scale-110 opacity-0 z-0 mask-l-from-0'
|
||||
src={previewUrl.href}
|
||||
src={previewUrl?.href}
|
||||
onLoad={(e) =>
|
||||
{
|
||||
e.currentTarget.classList.toggle('opacity-0', false);
|
||||
|
|
@ -101,11 +69,11 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
|
|||
/>
|
||||
</div>
|
||||
<div key={selectedGame} className='flex sm:flex-wrap md:flex-nowrap grow z-1 p-8 opacity-0 animate-fade-in h-full items-end gap-4 sm:justify-end md:justify-between'>
|
||||
<div className='flex gap-4 max-h-full z-1 grow'>
|
||||
<div className='flex gap-4 max-h-full z-1 grow md:h-full'>
|
||||
<div className='flex sm:portrait:flex-wrap sm:portrait:grow gap-4 max-h-full justify-center'>
|
||||
<div className='relative rounded-3xl max-w-xs overflow-hidden'>
|
||||
<div className='relative rounded-3xl max-w-xs h-48 overflow-hidden'>
|
||||
<div className='flex absolute bottom-4 left-4 size-8 bg-base-content text-base-100 rounded-full items-center justify-center shadow-lg'><HardDrive /></div>
|
||||
<img className='object-cover w-full h-full' src={`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`} />
|
||||
{!!data.games && <img className='object-cover w-full h-full' src={`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`} />}
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 py-3 max-w-md'>
|
||||
<h1 className='font-semibold text-3xl'>{game.name}</h1>
|
||||
|
|
@ -117,21 +85,19 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
|
|||
<Button onAction={() => storeContext.showDetails('game', game.id.source, game.id.id, focusKey)} className='px-6 py-3 text-2xl! z-1 gap-2 focusable focusable-primary' id={'play-featured-btn'}> <Search /> Details</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.children}
|
||||
</div>
|
||||
</div> : <div className='skeleton w-full rounded-3xl grow sm:h-64 z-15' />}
|
||||
<div className='sm:flex sm:flex-wrap grow justify-stretch md:grid sm:landscape:grid-flow-col sm:auto-cols-[minmax(8rem,1fr)] md:grid-flow-row! auto-rows-fr landscape:min-w-xs gap-4'>
|
||||
{data.games.map((g, i) =>
|
||||
{data.games?.map((g, i) =>
|
||||
<div key={i} data-active={i === selectedGame} className='flex grow flex-col gap-1 transition-opacity duration-500 data-[active=true]:opacity-50 rounded-3xl bg-base-100 p-4 justify-center shadow-md'>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<img className='size-6' src={`${RPC_URL(__HOST__)}${game.path_platform_cover}`}></img>
|
||||
<img className='size-6' src={`${RPC_URL(__HOST__)}${g.path_platform_cover}`}></img>
|
||||
<div className='flex gap-2 items-center grow'>
|
||||
{g.name}
|
||||
</div>
|
||||
</div>
|
||||
{i === selectedGame && <progress ref={progressRef} className="progress progress-accent w-full" style={{ animationName: '' }} value={0} max="1"></progress>}
|
||||
</div>)}
|
||||
</div>) ?? Array.from({ length: 3 }).map((_, i) => <div key={i} className="skeleton rounded-3xl"></div>)}
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
|
|
@ -140,7 +106,9 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
|
|||
export function RouteComponent ()
|
||||
{
|
||||
const { focus } = useSearch({ from: '/store/tab' });
|
||||
const { crutialEmulators, recommendedEmulators, featuredGames } = Route.useLoaderData();
|
||||
const { data: crucialEmulators, isSuccess } = useQuery({ ...queries.settings.autoEmulatorsQuery, select: (data) => data.filter(e => !e.exists && e.isCritical) });
|
||||
const { data: featuredGames } = useQuery(queries.store.storeFeaturedGamesQuery);
|
||||
const { data: recommendedEmulators } = useQuery(queries.store.storeEmulatorsRecommendedQuery);
|
||||
|
||||
const { focusKey, ref, focusSelf } = useFocusable({ focusKey: 'main-area', preferredChildFocusKey: focus ?? "recommended-emulators" });
|
||||
const storeContext = useContext(StoreContext);
|
||||
|
|
@ -152,15 +120,15 @@ export function RouteComponent ()
|
|||
focusSelf({ instant: true });
|
||||
}
|
||||
|
||||
}, [focus]);
|
||||
}, [focus, isSuccess]);
|
||||
|
||||
return (
|
||||
<div className='animate-slide-up' ref={ref}>
|
||||
<FocusContext value={focusKey}>
|
||||
{!!featuredGames && <Main games={featuredGames} />}
|
||||
{crutialEmulators.length > 0 && <MissingEmulatorsSection
|
||||
{<Main games={featuredGames} />}
|
||||
{!!crucialEmulators && crucialEmulators?.length > 0 && <MissingEmulatorsSection
|
||||
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
|
||||
emulators={crutialEmulators} />}
|
||||
emulators={crucialEmulators} />}
|
||||
<div className='pt-4'>
|
||||
<EmulatorsSection
|
||||
id="recommended-emulators"
|
||||
|
|
@ -177,7 +145,7 @@ export function RouteComponent ()
|
|||
|
||||
<StatsSection
|
||||
romCount={1240}
|
||||
missingCount={crutialEmulators.length}
|
||||
missingCount={crucialEmulators?.length ?? 0}
|
||||
/>
|
||||
</FocusContext>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,18 @@ import { mobileCheck } from "./utils";
|
|||
let loopStarted = false;
|
||||
let isTouching = false;
|
||||
type ActiveControlType = 'keyboard' | 'gamepad' | 'mouse' | 'touch' | undefined;
|
||||
let activeControls: ActiveControlType = mobileCheck() ? 'touch' : 'mouse';
|
||||
let activeControls: ActiveControlType = sessionStorage.getItem('active-controls') as any;
|
||||
if (!activeControls)
|
||||
{
|
||||
if (mobileCheck())
|
||||
{
|
||||
activeControls = 'touch';
|
||||
} else
|
||||
{
|
||||
activeControls = 'mouse';
|
||||
}
|
||||
}
|
||||
let mouseUpdateTimeout: any | undefined = undefined;
|
||||
let touchStopTimeout: any | undefined = undefined;
|
||||
|
||||
const handleLoop = () =>
|
||||
{
|
||||
|
|
@ -109,6 +118,13 @@ function focusControl (control: typeof activeControls)
|
|||
if (activeControls != control)
|
||||
{
|
||||
activeControls = control;
|
||||
if (control)
|
||||
{
|
||||
sessionStorage.setItem('active-controls', control);
|
||||
} else
|
||||
{
|
||||
sessionStorage.removeItem('active-controls');
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('activecontrolschange', { detail: control }));
|
||||
if (control !== 'mouse')
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,108 +1,11 @@
|
|||
import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||
import { rommApi, settingsApi, storeApi, systemApi } from "./clientApi";
|
||||
import toast from "react-hot-toast";
|
||||
import { getErrorMessage } from "react-error-boundary";
|
||||
import system from "./queries/system";
|
||||
import settings from "./queries/settings";
|
||||
import romm from "./queries/romm";
|
||||
import store from "./queries/store";
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
export const gameQuery = (source: string, id: string) => queryOptions({
|
||||
queryKey: ['game', source, id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.game({ source })({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
export const autoEmulatorsQuery = queryOptions({
|
||||
queryKey: ['auto-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.automatic.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
export const storeEmulatorsQuery = queryOptions({
|
||||
queryKey: ['store-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.emulators.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
export const storeFeaturedGamesQuery = queryOptions({
|
||||
queryKey: ['store-emulators', 'recommended'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.games.featured.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
export const storeEmulatorsRecommendedQuery = queryOptions({
|
||||
queryKey: ['store-emulators', 'recommended'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.emulators.get({ query: { limit: 6, missing: true, orderBy: 'importance' } });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
export const storeEmulatorDetailsQuery = (id: string) => queryOptions({
|
||||
queryKey: ['store-emulator', id], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.details.emulator({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
export default {
|
||||
system,
|
||||
settings,
|
||||
romm,
|
||||
store
|
||||
};
|
||||
79
src/mainview/scripts/queries/romm.ts
Normal file
79
src/mainview/scripts/queries/romm.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { DefaultRommStaleTime, FrontEndId, GameListFilterType, RommLoginDataSchema, RPC_URL } from "@/shared/constants";
|
||||
import { rommApi, settingsApi } from "../clientApi";
|
||||
import { mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||
import z from "zod";
|
||||
import { getCollectionApiCollectionsIdGetOptions, getCollectionsApiCollectionsGetOptions, getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
|
||||
|
||||
export default {
|
||||
allGamesQuery: (filter?: GameListFilterType) => queryOptions({
|
||||
queryKey: ['games', filter ?? 'all'],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.games.get({ query: filter });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
gameQuery: (source: string, id: string) => queryOptions({
|
||||
queryKey: ['game', source, id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.game({ source })({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
}),
|
||||
rommLogoutMutation: mutationOptions({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post() }),
|
||||
rommQrLoginMutation: mutationOptions({
|
||||
mutationKey: ['login', 'qr', 'cancel'],
|
||||
mutationFn: () => rommApi.api.romm.login.romm.post()
|
||||
}),
|
||||
rommLoginMutation: mutationOptions({
|
||||
mutationKey: ["romm", "login"],
|
||||
mutationFn: async (data: z.infer<typeof RommLoginDataSchema>) =>
|
||||
{
|
||||
const { error } = await rommApi.api.romm.login.post({ username: data.username, password: data.password, host: data.hostname });
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: (d, v, r, c) =>
|
||||
{
|
||||
c.client.invalidateQueries({ queryKey: ['romm', 'auth'] });
|
||||
},
|
||||
onError: (e) =>
|
||||
{
|
||||
console.error(e);
|
||||
},
|
||||
}),
|
||||
rommUserQuery: () => queryOptions({
|
||||
...getCurrentUserApiUsersMeGetOptions(),
|
||||
queryKey: ['romm', 'auth', "login"],
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 0
|
||||
}),
|
||||
rommGetOptionsQuery: () => queryOptions({
|
||||
...statsApiStatsGetOptions(),
|
||||
refetchInterval: 30000,
|
||||
retry: false,
|
||||
}),
|
||||
rommHasPasswordQuery: queryOptions({ queryKey: ['romm', 'auth', 'passLength'], queryFn: () => rommApi.api.romm.login.get().then(d => d.data?.hasPassword as boolean) }),
|
||||
rommHostnameQuery: queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) }),
|
||||
rommUsernameQuery: queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) }),
|
||||
deleteGameMutation: (id: FrontEndId) => mutationOptions({
|
||||
mutationKey: ['delete', id],
|
||||
mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete()
|
||||
}),
|
||||
getCollectionsQuery: () => queryOptions({
|
||||
...getCollectionsApiCollectionsGetOptions(),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: DefaultRommStaleTime
|
||||
}),
|
||||
getCollectionQuery: (id: number) => queryOptions({ ...getCollectionApiCollectionsIdGetOptions({ path: { id } }) }),
|
||||
platformQuery: (source: string, id: string) => queryOptions({
|
||||
queryKey: ['platform', source, id], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}, staleTime: DefaultRommStaleTime
|
||||
})
|
||||
};
|
||||
134
src/mainview/scripts/queries/settings.ts
Normal file
134
src/mainview/scripts/queries/settings.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||
import { getErrorMessage } from "react-error-boundary";
|
||||
import toast from "react-hot-toast";
|
||||
import { rommApi, settingsApi } from "../clientApi";
|
||||
|
||||
export default {
|
||||
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;
|
||||
}
|
||||
}),
|
||||
autoEmulatorsQuery: queryOptions({
|
||||
queryKey: ['auto-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.automatic.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
twitchLogoutMutation: mutationOptions({
|
||||
mutationKey: ['twitch', 'logout'],
|
||||
mutationFn: () =>
|
||||
{
|
||||
return rommApi.api.romm.logout.twitch.post();
|
||||
}
|
||||
}),
|
||||
twitchLoginMutation: mutationOptions({
|
||||
mutationKey: ['twitch', 'login'],
|
||||
mutationFn: (openInBrowser: boolean) =>
|
||||
{
|
||||
return rommApi.api.romm.login.twitch.post({ openInBrowser });
|
||||
}
|
||||
}),
|
||||
twitchLoginVerificationQuery: queryOptions({
|
||||
queryKey: ['twitch', 'login', 'status'],
|
||||
retry (failureCount, error)
|
||||
{
|
||||
if ((error as any).status === 404)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return failureCount < 3;
|
||||
},
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error, status } = await rommApi.api.romm.login.twitch.get();
|
||||
if (error) throw { ...error, status };
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
customEmulatorsQuery: queryOptions({
|
||||
queryKey: ['custom-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
customEmulatorAddMutation: mutationOptions({
|
||||
mutationKey: ['emulator', 'custom', 'add'],
|
||||
mutationFn: async (id: string) =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
|
||||
}),
|
||||
customEmulatorDeleteMutation: (id: string) => mutationOptions({
|
||||
mutationKey: ["emulator", id, 'delete'],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await settingsApi.api.settings.emulators.custom({ id: id }).delete();
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
{
|
||||
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
|
||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
}
|
||||
}),
|
||||
setCustomEmulatorMutation: (id: string, onSuccess?: (value: string) => void) => mutationOptions({
|
||||
mutationKey: ["emulator", id, 'set'],
|
||||
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: id }).put({ value }),
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
{
|
||||
ctx.client.invalidateQueries({ queryKey: ["emulator", id] });
|
||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
onSuccess?.(v);
|
||||
}
|
||||
}),
|
||||
customEmulatorRemoveValueQuery: (id?: string) => queryOptions({
|
||||
enabled: !!id,
|
||||
queryKey: ["emulator", id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: id! }).get();
|
||||
if (error) throw error;
|
||||
return value;
|
||||
},
|
||||
}),
|
||||
setSettingMutation: (id?: string) => mutationOptions({
|
||||
mutationKey: ["setting", id],
|
||||
mutationFn: async (value: any) =>
|
||||
{
|
||||
const response = await settingsApi.api.settings({ id: id! }).post({ value });
|
||||
if (response.error) throw response.error;
|
||||
return response.data;
|
||||
}
|
||||
}),
|
||||
getSettingQuery: (id: string | undefined) => queryOptions({
|
||||
enabled: !!id,
|
||||
queryKey: ["setting", id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data: value, error } = await settingsApi.api.settings({ id: id! }).get();
|
||||
if (error) throw error;
|
||||
|
||||
return value.value;
|
||||
},
|
||||
})
|
||||
};
|
||||
58
src/mainview/scripts/queries/store.ts
Normal file
58
src/mainview/scripts/queries/store.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
|
||||
import { rommApi, storeApi } from "../clientApi";
|
||||
import { FrontEndGameType } from "@/shared/constants";
|
||||
|
||||
export default {
|
||||
storeEmulatorsQuery: queryOptions({
|
||||
queryKey: ['store-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.emulators.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
storeFeaturedGamesQuery: queryOptions({
|
||||
queryKey: ['store-emulators', 'featured'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.games.featured.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
storeEmulatorsRecommendedQuery: queryOptions({
|
||||
queryKey: ['store-emulators', 'recommended'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.emulators.get({ query: { limit: 6, missing: true, orderBy: 'importance' } });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
storeEmulatorDetailsQuery: (id: string) => queryOptions({
|
||||
queryKey: ['store-emulator', id], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.details.emulator({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
storeGamesInfiniteQuery: infiniteQueryOptions<{ data: FrontEndGameType[], nextPage: number; }>({
|
||||
initialPageParam: 0,
|
||||
queryKey: ['store-games'],
|
||||
getNextPageParam: (lastPage, pages) => lastPage.nextPage,
|
||||
queryFn: async (data) =>
|
||||
{
|
||||
const pageParam = data.pageParam as number;
|
||||
const { data: games, error } = await rommApi.api.romm.games.get({ query: { source: 'store', offset: pageParam * 10, limit: 10 } });
|
||||
if (error) throw error;
|
||||
return { data: games.games, nextPage: pageParam + 1 };
|
||||
}
|
||||
}),
|
||||
storeGetStatsQuery: queryOptions({
|
||||
queryKey: ['store', 'stats'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.stats.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
})
|
||||
};
|
||||
51
src/mainview/scripts/queries/system.ts
Normal file
51
src/mainview/scripts/queries/system.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query";
|
||||
import { systemApi } from "../clientApi";
|
||||
|
||||
export default {
|
||||
drivesQuery: queryOptions({
|
||||
queryKey: ['drives'],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await systemApi.api.system.drives.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
downloadDrivesQuery: queryOptions({
|
||||
queryKey: ['drives', 'download'],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await systemApi.api.system.drives.download.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
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
|
||||
}),
|
||||
systemInfoQuery: queryOptions({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() }),
|
||||
createFolderMutation: (id: string) => mutationOptions({
|
||||
|
||||
mutationKey: ['create', 'folder', id],
|
||||
mutationFn: async ({ name, dirname }: { name: string | undefined, dirname: string; }) =>
|
||||
{
|
||||
if (!name) return;
|
||||
const { error } = await systemApi.api.system.dirs.put({ name, dirname: dirname });
|
||||
if (error) throw error.value;
|
||||
},
|
||||
}),
|
||||
closeMutation: mutationOptions({
|
||||
mutationKey: ['close'], mutationFn: async () =>
|
||||
{
|
||||
const { error } = await systemApi.api.system.exit.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
})
|
||||
};
|
||||
60
src/mainview/scripts/serviceWorker.ts
Normal file
60
src/mainview/scripts/serviceWorker.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/// <reference lib="webworker" />
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
const SHELL = 'shell-v1';
|
||||
|
||||
async function cacheWithoutVary (cache: Cache, url: string)
|
||||
{
|
||||
const response = await fetch(url);
|
||||
const cleaned = new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: (() =>
|
||||
{
|
||||
const h = new Headers(response.headers);
|
||||
h.delete('Vary');
|
||||
return h;
|
||||
})()
|
||||
});
|
||||
await cache.put(url, cleaned);
|
||||
}
|
||||
|
||||
self.addEventListener('install', (event: ExtendableEvent) =>
|
||||
{
|
||||
event.waitUntil(
|
||||
caches.open(SHELL).then(cache => cacheWithoutVary(cache, '/'))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event: ExtendableEvent) =>
|
||||
{
|
||||
// Clean up old caches when you bump SHELL version
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== SHELL).map(k => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event: FetchEvent) =>
|
||||
{
|
||||
if (event.request.mode !== 'navigate') return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then(response =>
|
||||
{
|
||||
const vary = response.headers.get('Vary');
|
||||
if (!vary?.includes('*'))
|
||||
{
|
||||
caches.open(SHELL).then(cache => cache.put(event.request, response.clone()));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.match('/').then(cached => cached ?? Response.error())
|
||||
)
|
||||
);
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ import { GamepadButtonEvent } from "./gamepads";
|
|||
import { dispatchFocusedEvent, GetFocusedTree } from "./spatialNavigation";
|
||||
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
|
||||
const shortcutMap = new Map<string, Shortcut[]>();
|
||||
const shortcutMap = new Map<string, (() => Shortcut[])[]>();
|
||||
const conflictSet = new Set<number>();
|
||||
let hadEnterDown = false;
|
||||
|
||||
|
|
@ -66,7 +66,8 @@ export function useShortcutContext ()
|
|||
const focusKey = getCurrentFocusKey();
|
||||
const newArray = GetFocusedTree(focusKey)
|
||||
.filter(f => shortcutMap.has(f))
|
||||
.flatMap(f => shortcutMap.get(f)!.map(s => ({ key: f, ...s })))
|
||||
.flatMap(f => shortcutMap.get(f)!.map(s => ({ key: f, handler: s })))
|
||||
.flatMap(kvp => kvp.handler().map(s => ({ key: kvp.key, ...s })))
|
||||
.filter(s =>
|
||||
{
|
||||
const empty = !conflictSet.has(s.button);
|
||||
|
|
@ -193,12 +194,20 @@ export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps
|
|||
{
|
||||
useEffect(() =>
|
||||
{
|
||||
shortcutMap.set(focusKey, build());
|
||||
const array = shortcutMap.get(focusKey) ?? [];
|
||||
array.push(build);
|
||||
shortcutMap.set(focusKey, array);
|
||||
markDirtyThrottled();
|
||||
|
||||
return () =>
|
||||
{
|
||||
shortcutMap.delete(focusKey);
|
||||
const array = shortcutMap.get(focusKey);
|
||||
if (array)
|
||||
{
|
||||
const index = array.indexOf(build);
|
||||
array?.splice(index, 1);
|
||||
}
|
||||
|
||||
markDirtyThrottled();
|
||||
};
|
||||
}, [...deps, focusKey]);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants";
|
||||
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { Ref, RefObject, useEffect, useRef, useState } from "react";
|
||||
import { RefObject, useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import { jobsApi } from "./clientApi";
|
||||
import { EdenWS } from "@elysiajs/eden/treaty";
|
||||
import { InputSchema } from "elysia/types";
|
||||
import { Treaty } from "@elysiajs/eden";
|
||||
import { JobsAPIType } from "@/bun/api/rpc";
|
||||
|
||||
export function selfFocusSmart (shouldFocus: boolean, focusSelf: () => void)
|
||||
|
|
@ -67,7 +64,7 @@ export function useScrollSave (data: ScrollSaveParams)
|
|||
export function mobileCheck ()
|
||||
{
|
||||
let check = false;
|
||||
(function (a) { if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true; })(navigator.userAgent || navigator.vendor || window.opera);
|
||||
(function (a) { if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true; })(navigator.userAgent || navigator.vendor || (window as any).opera);
|
||||
return check;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ export const GameListFilterSchema = z.object({
|
|||
source: z.string().optional(),
|
||||
});
|
||||
|
||||
export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() });
|
||||
|
||||
export type GameListFilterType = z.infer<typeof GameListFilterSchema>;
|
||||
|
||||
export const DirSchema = z.object({ name: z.string(), parentPath: z.string(), isDirectory: z.boolean() });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { expect, test, mock } from 'bun:test';
|
||||
import { expect, test } from 'bun:test';
|
||||
|
||||
test("uses custom emulator", async () =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { defineConfig, Plugin } from "vite";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { tanstackRouter } from '@tanstack/router-plugin/vite';
|
||||
|
|
@ -8,6 +8,7 @@ import staticAssetsPlugin from 'vite-static-assets-plugin';
|
|||
import os from 'node:os';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { host } from "./src/bun/utils/host";
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig(({ command }) =>
|
||||
{
|
||||
|
|
@ -59,21 +60,22 @@ export default defineConfig(({ command }) =>
|
|||
manualChunks: (id
|
||||
) =>
|
||||
{
|
||||
if (id.includes('@emulatorjs'))
|
||||
{
|
||||
return 'emulatorjs';
|
||||
}
|
||||
if (id
|
||||
.includes
|
||||
('node_modules'))
|
||||
{
|
||||
return 'vendor';
|
||||
}
|
||||
|
||||
if (id.includes('@emulatorjs'))
|
||||
return 'emulatorjs';
|
||||
if (id.includes('clients/romm'))
|
||||
return 'clients';
|
||||
if (id.includes('node_modules/lucide-react'))
|
||||
return 'lucide';
|
||||
if (id.includes('node_modules/zod'))
|
||||
return 'zod';
|
||||
if (id.includes('node_modules/@tanstack'))
|
||||
return 'tanstack';
|
||||
console.log(id);
|
||||
if (id.includes('node_modules'))
|
||||
return 'vendor';
|
||||
if (id.endsWith('SvgIcon.tsx'))
|
||||
{
|
||||
return 'icons';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue