diff --git a/.config/flatpak/com.simeonradivoev.gameflow-deck.json b/.config/flatpak/com.simeonradivoev.gameflow-deck.json index dbaabd8..ccdc833 100644 --- a/.config/flatpak/com.simeonradivoev.gameflow-deck.json +++ b/.config/flatpak/com.simeonradivoev.gameflow-deck.json @@ -47,7 +47,7 @@ }, { "type": "file", - "path": "../src/mainview/assets/256x256.png" + "path": "../src/mainview/public/256x256.png" }, { "type": "script", diff --git a/.vscode/settings.json b/.vscode/settings.json index 8c7e283..2c6da05 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,9 +30,11 @@ "cSpell.words": [ "elysia", "elysiajs", + "emulatorjs", "gameflow", "hackolade", "keytar", + "mainview", "norigin", "noriginmedia", "romm" diff --git a/package.json b/package.json index ef6acb1..21b34f8 100644 --- a/package.json +++ b/package.json @@ -115,4 +115,4 @@ "vite-static-assets-plugin": "^1.2.2", "vite-tsconfig-paths": "^6.1.1" } -} +} \ No newline at end of file diff --git a/scripts/build-appimage.ts b/scripts/build-appimage.ts index 70100fe..496945a 100644 --- a/scripts/build-appimage.ts +++ b/scripts/build-appimage.ts @@ -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 = "."; // ───────────────────────────────────────────── diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index 501275d..c05749d 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -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"; diff --git a/src/bun/api/clients.ts b/src/bun/api/clients.ts index ef4741c..6a7c17f 100644 --- a/src/bun/api/clients.ts +++ b/src/bun/api/clients.ts @@ -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'; diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index ef1d8c9..152207b 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -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"); }); \ No newline at end of file diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 0bf8535..d554dc3 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -181,7 +181,7 @@ export async function getValidLaunchCommands (data: { '%FILENAME%': $.escape(path.basename(validFiles[0])) }; - cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (subscring, injectFile: string) => + cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (_, injectFile: string) => { try { diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index e022fb8..2acde4c 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -230,7 +230,7 @@ export default async function buildStatusResponse (source: string, id: string) dispose.forEach(f => f()); }; }, - cancel (reason) + cancel () { cleanup?.(); cleanup = undefined; diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index 66e4944..c2a1e8b 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -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 { - 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')) }); diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 89b1912..710d0e4 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -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) diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index b4680a6..317211b 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -5,7 +5,7 @@ import { LoginJob } from "./login-job"; import TwitchLoginJob from "./twitch-login-job"; import UpdateStoreJob from "./update-store"; -function registerJob (job: T, path: Path, dataSchema: TS) +function registerJob (_job: T, path: Path, dataSchema: TS) { return new Elysia().ws(path, { body: z.discriminatedUnion('type', [ @@ -64,7 +64,7 @@ function registerJob d()); }, - message (ws, message) + message (_, message) { if (message.type === 'cancel') { diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index cce32de..1f7b2d1 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -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; - }); -}*/ \ No newline at end of file +} \ No newline at end of file diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 3720c96..24dfba4 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -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; diff --git a/src/bun/server.ts b/src/bun/server.ts index c33dc51..86e5fac 100644 --- a/src/bun/server.ts +++ b/src/bun/server.ts @@ -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')); }) diff --git a/src/bun/utils/browser-params.ts b/src/bun/utils/browser-params.ts index 05efdf9..3ae3236 100644 --- a/src/bun/utils/browser-params.ts +++ b/src/bun/utils/browser-params.ts @@ -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'); diff --git a/src/bun/utils/browser-spawner.ts b/src/bun/utils/browser-spawner.ts index 0ab7419..5481580 100644 --- a/src/bun/utils/browser-spawner.ts +++ b/src/bun/utils/browser-spawner.ts @@ -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. * diff --git a/src/mainview/components/AnimatedBackground.tsx b/src/mainview/components/AnimatedBackground.tsx index 6b936fd..8aa67ab 100644 --- a/src/mainview/components/AnimatedBackground.tsx +++ b/src/mainview/components/AnimatedBackground.tsx @@ -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) { diff --git a/src/mainview/components/CardElement.tsx b/src/mainview/components/CardElement.tsx index f098c5f..8865d7a 100644 --- a/src/mainview/components/CardElement.tsx +++ b/src/mainview/components/CardElement.tsx @@ -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 (
  • 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" }} > {data.games.map(BuildCard)} + {data.finalElement} ); diff --git a/src/mainview/components/Carousel.tsx b/src/mainview/components/Carousel.tsx new file mode 100644 index 0000000..9b55993 --- /dev/null +++ b/src/mainview/components/Carousel.tsx @@ -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; + scrollHandler?: (direction: number, element: HTMLDivElement) => void; + isScrollable?: boolean; + style?: CSSProperties; +}) +{ + const [scrollable, setScrollable] = useState(false); + const localRef = useRef(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
    +
    + { + 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} +
    + {((scrollable || data.isScrollable) && isMouse) && <> +
    + handleScroll(-1)} id="move-left" className="p-2 border-base-content/40"> +
    +
    + handleScroll(1)} id="move-left" className="p-2 border-base-content/40"> +
    + } + +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index 67a1716..77722cf 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -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), diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index fa0d97a..dc55be1 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -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 ( @@ -53,7 +64,6 @@ export function CollectionsDetail (data: CollectionsDetailParams) diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index ff429c6..ad4f5f2 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -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, diff --git a/src/mainview/components/Error.tsx b/src/mainview/components/Error.tsx index fe824db..645d086 100644 --- a/src/mainview/components/Error.tsx +++ b/src/mainview/components/Error.tsx @@ -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
    -

    +

    {data.error.message}

    -

    {window.location.href}

    - +

    {window.location.href}

    + + {import.meta.env.DEV &&
    {data.error.stack}
    } + +
    diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index 2c369a6..4444641 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -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(); 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
    - +
    ; } @@ -233,8 +228,8 @@ export default function FilePicker (data: { { const [currentPath, setCurrentPath] = useState(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]; diff --git a/src/mainview/components/Filters.tsx b/src/mainview/components/Filters.tsx index 73796e6..3c7a2b6 100644 --- a/src/mainview/components/Filters.tsx +++ b/src/mainview/components/Filters.tsx @@ -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 }); diff --git a/src/mainview/components/FocusDots.tsx b/src/mainview/components/FocusDots.tsx index d42d945..807bdbe 100644 --- a/src/mainview/components/FocusDots.tsx +++ b/src/mainview/components/FocusDots.tsx @@ -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, 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 ; +} export default function FocusDots (data: { - elements: string[]; - + elements?: string[] | undefined; + scrollElement?: RefObject; }) { - const focusedKey = useGlobalFocus(); - return
    {data.elements.map((em, i) => + const focusedKey = useGlobalFocus(); + let elements = useMemo(() => { - const focused = em === focusedKey; - return ; - })}
    ; + if (data.elements) + { + return data.elements.map((em, i) => + { + const focused = em === focusedKey; + return ; + }); + } else if (data.scrollElement?.current) + { + const childrenArray = Array.from(data.scrollElement.current.children); + + return childrenArray.map((c, i) => + { + return ; + }); + } else + { + return []; + } + }, [data.elements, data.scrollElement?.current]); + + return
    +
    {elements}
    +
    ; } \ No newline at end of file diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index f1eb533..cbf125f 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -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; }, diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index a9affda..9ec4386 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -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`, diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx new file mode 100644 index 0000000..8747bfb --- /dev/null +++ b/src/mainview/components/LoadMoreButton.tsx @@ -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
    + { + 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 ? : "Load More"}
    ; +} \ No newline at end of file diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index a4503fb..78d3229 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -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 ( + diff --git a/src/mainview/components/Screenshots.tsx b/src/mainview/components/Screenshots.tsx index 3a760e8..f29e142 100644 --- a/src/mainview/components/Screenshots.tsx +++ b/src/mainview/components/Screenshots.tsx @@ -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(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
    focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" /> -
    imageRef.current?.requestFullscreen()}>
    +
    data.onAction?.(e.nativeEvent)}>
    ; } export default function Screenshots (data: { screenshots: string[]; } & FocusParams) { - const scrollRef = useRef(null); - const { ref, focusKey } = useFocusable({ + const [preview, setPreview] = useState(undefined); + const scrollRef = useRef(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
    -
    - {data.screenshots.map((s, i) => )} -
    - `screenshot-${i}`)} /> + + {data.screenshots.map((s, i) => setPreview(i)} />)} + +
    + {preview !== undefined && + { + setFocus(`screenshot-${preview}`); + setPreview(undefined); + }} open={true}> + + }
    ; } \ No newline at end of file diff --git a/src/mainview/components/options/Button.tsx b/src/mainview/components/options/Button.tsx index 8a1fd57..ce3c44c 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -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'; diff --git a/src/mainview/components/options/DownloadDirectoryOption.tsx b/src/mainview/components/options/DownloadDirectoryOption.tsx index 760ddd0..44c24d7 100644 --- a/src/mainview/components/options/DownloadDirectoryOption.tsx +++ b/src/mainview/components/options/DownloadDirectoryOption.tsx @@ -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(); const [dirty, setDirty] = useState(false); const setSettingMutation = useMutation({ - ...changeDownloadsMutation, + ...queries.settings.changeDownloadsMutation, onSuccess: (d, v, r, cx) => { setDirty(r !== localValue); diff --git a/src/mainview/components/options/OptionDropdown.tsx b/src/mainview/components/options/OptionDropdown.tsx index 093c664..3045e74 100644 --- a/src/mainview/components/options/OptionDropdown.tsx +++ b/src/mainview/components/options/OptionDropdown.tsx @@ -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(null); - const option = useOptionContext({ - onOptionEnterPress: handlePress, - }); - - const valueIndex = data.value ? data.values?.indexOf(data.value) : -1; return ( <> diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index d3de509..bd903c6 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -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(null); diff --git a/src/mainview/components/options/OptionSpace.tsx b/src/mainview/components/options/OptionSpace.tsx index ef21162..41f8620 100644 --- a/src/mainview/components/options/OptionSpace.tsx +++ b/src/mainview/components/options/OptionSpace.tsx @@ -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, diff --git a/src/mainview/components/options/PathSettingsOption.tsx b/src/mainview/components/options/PathSettingsOption.tsx index 054202a..a0c18c3 100644 --- a/src/mainview/components/options/PathSettingsOption.tsx +++ b/src/mainview/components/options/PathSettingsOption.tsx @@ -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 = { [K in keyof T]: Exclude extends Value ? K : never; @@ -32,14 +32,8 @@ export function PathSettingsOption (data: PathSettingsOptionParams) { const [localValue, setLocalValue] = useState(); 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); diff --git a/src/mainview/components/options/SettingsOption.tsx b/src/mainview/components/options/SettingsOption.tsx index 5022514..7f42948 100644 --- a/src/mainview/components/options/SettingsOption.tsx +++ b/src/mainview/components/options/SettingsOption.tsx @@ -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 = { [K in keyof T]: Exclude extends Value ? K : never; @@ -20,36 +20,15 @@ export function SettingsOption (data: { { const [dirty, setDirty] = useState(false); const [localValue, setLocalValue] = useState(); - 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]); diff --git a/src/mainview/components/store/EmulatorsSection.tsx b/src/mainview/components/store/EmulatorsSection.tsx index 2f1d463..8284137 100644 --- a/src/mainview/components/store/EmulatorsSection.tsx +++ b/src/mainview/components/store/EmulatorsSection.tsx @@ -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: { }
    -
    + + {data.emulators?.map((em) => ( data.onSelect?.(em.name, focusKey)} onFocus={({ node, details }) => { scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' }); }} /> - ))} + )) ?? Array.from({ length: 8 }).map((_, i) =>
    )} Router.navigate({ to: '/store/tab/emulators' })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} /> -
    +
    + - {!!data.emulators && FOCUS_KEYS.EMULATOR_CARD(e.name))} />} + FOCUS_KEYS.EMULATOR_CARD(e.name))} /> ); } \ No newline at end of file diff --git a/src/mainview/components/store/GamesSection.tsx b/src/mainview/components/store/GamesSection.tsx index 4322cb3..2d7a170 100644 --- a/src/mainview/components/store/GamesSection.tsx +++ b/src/mainview/components/store/GamesSection.tsx @@ -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 }: {

    Featured Games

    -
    Curated picks
    +
    Creator Picks
    -
    - {games.map((g, i) => + {games?.map((g, i) => onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id.id))} - index={i} />)} -
    + index={i} />) ?? Array.from({ length: 8 }).map((_, i) =>
    )} + - FOCUS_KEYS.GAME_CARD(e.id.id))} /> + FOCUS_KEYS.GAME_CARD(e.id.id)) ?? []} /> ); } \ No newline at end of file diff --git a/src/mainview/components/store/StatsSection.tsx b/src/mainview/components/store/StatsSection.tsx index 4c40491..f3925a7 100644 --- a/src/mainview/components/store/StatsSection.tsx +++ b/src/mainview/components/store/StatsSection.tsx @@ -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 (
    diff --git a/src/mainview/gen/routeTree.gen.ts b/src/mainview/gen/routeTree.gen.ts index edf83bc..38b4102 100644 --- a/src/mainview/gen/routeTree.gen.ts +++ b/src/mainview/gen/routeTree.gen.ts @@ -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, diff --git a/src/mainview/gen/static-icon-assets.gen.ts b/src/mainview/gen/static-icon-assets.gen.ts index 1d1a4aa..cb3fe1b 100644 --- a/src/mainview/gen/static-icon-assets.gen.ts +++ b/src/mainview/gen/static-icon-assets.gen.ts @@ -464,7 +464,7 @@ const assets = new Set([ ]); // Store basePath resolved from Vite config -const BASE_PATH = "/"; +const BASE_PATH = "./"; /** diff --git a/src/mainview/index.html b/src/mainview/index.html index 6332b4d..d468edc 100644 --- a/src/mainview/index.html +++ b/src/mainview/index.html @@ -3,7 +3,14 @@ - + + + + + + + + { - 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())) diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 1621de8..675388f 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -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", diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx new file mode 100644 index 0000000..b9ae196 --- /dev/null +++ b/src/mainview/routes/games.tsx @@ -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 ( +
    + +
    + ); +} \ No newline at end of file diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 421429d..0db1bd5 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -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; })
    ; } +function ShowAllGamesCard () +{ + const handleNavigate = () => + { + Router.navigate({ to: '/games', viewTransition: { types: ['zoom-in'] } }); + }; + const { ref } = useFocusable({ focusKey: 'all-games-btn', onEnterPress: handleNavigate }); + return
    All Games
    ; +} + 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 = <> - + ; break; case 'collections': activeList = <> - + ; break; default: activeList = <> - + } + /> ; break; @@ -182,7 +202,7 @@ function HomeList (data: { return ( -
    @@ -193,17 +213,16 @@ function HomeList (data: {
    -
    +
    ); } -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: {}) > navigate({ to: "/" })} + action={() => navigate({ to: "/games", viewTransition: { types: ['zoom-in'] } })} icon={} 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: , action: handleFullscreen }); - headerButtons.push({ id: "search", icon: }, { id: "power-button", icon: , external: true, action: () => closeMutation.mutate() }); + headerButtons.push({ id: "search", icon: }, { id: "power-button", icon: , external: true, action: () => close.mutate() }); return ( diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index c9fc871..6f987fa 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -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
    -

    Launching {data?.data?.name} ...

    +

    Launching {data?.name} ...

    diff --git a/src/mainview/routes/platform.$source.$id.tsx b/src/mainview/routes/platform.$source.$id.tsx index 17b7efa..0ae0c5a 100644 --- a/src/mainview/routes/platform.$source.$id.tsx +++ b/src/mainview/routes/platform.$source.$id.tsx @@ -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 (
    {!!platform && } - setBackground={animatedBgContext.setBackground} filters={{ platform_id: Number(id), platform_slug: platform.slug, platform_source: source }} />}
    diff --git a/src/mainview/routes/settings/about.tsx b/src/mainview/routes/settings/about.tsx index 7fe9b9f..fd0fede 100644 --- a/src/mainview/routes/settings/about.tsx +++ b/src/mainview/routes/settings/about.tsx @@ -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 diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index 39df364..ffd6026 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -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: ; } -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; }) ; } -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) => - { - 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) diff --git a/src/mainview/routes/settings/directories.tsx b/src/mainview/routes/settings/directories.tsx index 986c638..37e8984 100644 --- a/src/mainview/routes/settings/directories.tsx +++ b/src/mainview/routes/settings/directories.tsx @@ -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 - isMoving} withResolver={false} /> + isMoving > 0} withResolver={false} />
      Downloads ({drives?.downloadsSize ? prettyBytes(drives?.downloadsSize) : }) diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index b8c6d46..3fa94a8 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -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
        {[..."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(); - 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
        - {autoEmulators?.map(e => )} + {autoEmulators?.map(e => )}
        ; } @@ -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
          diff --git a/src/mainview/routes/settings/interface.tsx b/src/mainview/routes/settings/interface.tsx index a322b25..c1c94f9 100644 --- a/src/mainview/routes/settings/interface.tsx +++ b/src/mainview/routes/settings/interface.tsx @@ -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 }); diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index 3d00a05..8fb69a0 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -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: () => diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 77e74a6..302c799 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -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} />}
      diff --git a/src/mainview/routes/store/tab/emulators.tsx b/src/mainview/routes/store/tab/emulators.tsx index cbd00c1..ce30db4 100644 --- a/src/mainview/routes/store/tab/emulators.tsx +++ b/src/mainview/routes/store/tab/emulators.tsx @@ -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
      -
      - -

      - Emulators -

      -
      - {/* Cards */} -
      - {[1, 2, 3, 4, 5, 6].map(i =>
      )} -
      -
      ; -} - 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 ()
      {/* Cards */}
      - {emulators && emulators.map((data) => ( + {emulators?.map((data) => ( { node.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'center' }); }} onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)} /> - ))} + )) ?? Array.from({ length: 10 }).map((_, i) =>
      )}
      diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index 167d6fc..ddea718 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -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
      - { - 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 ? : "Load More"}
      ; -} - 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
      -
      +
      {data?.pages.flatMap((page) => ( page.data.map((g, i) => )) - )} + ) ?? Array.from({ length: 20 }).map((_, i) =>
      +
      +
      +
      +
      )} { - if (isFetchingNextPage) + if (isFetchingNextPage || isFetching) return; fetchNextPage(); }} /> diff --git a/src/mainview/routes/store/tab/index.tsx b/src/mainview/routes/store/tab/index.tsx index 89d00aa..94df52e 100644 --- a/src/mainview/routes/store/tab/index.tsx +++ b/src/mainview/routes/store/tab/index.tsx @@ -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
      -
      - Failed to load store data. -

      {data.error.message}

      -
      -
      ; -} -// ── Loading skeleton ─────────────────────────────────────────────────────── -function LoadingSkeleton () +function Main (data: { games?: FrontEndGameTypeDetailed[]; }) { - return ( -
      - {/* Missing section */} -
      - {[1, 2, 3].map((i) =>
      )} -
      - {/* Emulators */} -
      - {[1, 2, 3, 4, 5, 6].map((i) =>
      )} -
      - {/* Games */} -
      - {[1, 2, 3, 4].map((i) =>
      )} -
      -
      - ); -} - -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(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
      -
      + {game ?
      { e.currentTarget.classList.toggle('opacity-0', false); @@ -101,11 +69,11 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; }) />
      -
      +
      -
      +
      - + {!!data.games && }

      {game.name}

      @@ -117,21 +85,19 @@ function Main (data: { children?: any; games: FrontEndGameTypeDetailed[]; })
      - - {data.children} -
      +
      :
      }
      - {data.games.map((g, i) => + {data.games?.map((g, i) =>
      - +
      {g.name}
      {i === selectedGame && } -
      )} +
      ) ?? Array.from({ length: 3 }).map((_, i) =>
      )}
      ; @@ -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 (
      - {!!featuredGames &&
      } - {crutialEmulators.length > 0 && } + {!!crucialEmulators && crucialEmulators?.length > 0 && storeContext.showDetails('emulator', 'store', id, focus)} - emulators={crutialEmulators} />} + emulators={crucialEmulators} />}
      diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts index 5ca8ff1..fed3f3d 100644 --- a/src/mainview/scripts/gamepads.ts +++ b/src/mainview/scripts/gamepads.ts @@ -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') { diff --git a/src/mainview/scripts/queries.ts b/src/mainview/scripts/queries.ts index 9f07adc..f45ce51 100644 --- a/src/mainview/scripts/queries.ts +++ b/src/mainview/scripts/queries.ts @@ -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; - } -}); \ No newline at end of file +export default { + system, + settings, + romm, + store +}; \ No newline at end of file diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts new file mode 100644 index 0000000..406fb03 --- /dev/null +++ b/src/mainview/scripts/queries/romm.ts @@ -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) => + { + 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 + }) +}; \ No newline at end of file diff --git a/src/mainview/scripts/queries/settings.ts b/src/mainview/scripts/queries/settings.ts new file mode 100644 index 0000000..7fa9c6a --- /dev/null +++ b/src/mainview/scripts/queries/settings.ts @@ -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; + }, + }) +}; \ No newline at end of file diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts new file mode 100644 index 0000000..8adde23 --- /dev/null +++ b/src/mainview/scripts/queries/store.ts @@ -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; + } + }) +}; \ No newline at end of file diff --git a/src/mainview/scripts/queries/system.ts b/src/mainview/scripts/queries/system.ts new file mode 100644 index 0000000..8ac3224 --- /dev/null +++ b/src/mainview/scripts/queries/system.ts @@ -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; + } + }) +}; \ No newline at end of file diff --git a/src/mainview/scripts/serviceWorker.ts b/src/mainview/scripts/serviceWorker.ts new file mode 100644 index 0000000..0a3e391 --- /dev/null +++ b/src/mainview/scripts/serviceWorker.ts @@ -0,0 +1,60 @@ +/// +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()) + ) + ); +}); \ No newline at end of file diff --git a/src/mainview/scripts/shortcuts.ts b/src/mainview/scripts/shortcuts.ts index 0440a3c..5defdf7 100644 --- a/src/mainview/scripts/shortcuts.ts +++ b/src/mainview/scripts/shortcuts.ts @@ -3,7 +3,7 @@ import { GamepadButtonEvent } from "./gamepads"; import { dispatchFocusedEvent, GetFocusedTree } from "./spatialNavigation"; import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; -const shortcutMap = new Map(); +const shortcutMap = new Map Shortcut[])[]>(); const conflictSet = new Set(); 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]); diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index 7ed4168..05f6d28 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -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; }; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 504ca15..094f467 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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; export const DirSchema = z.object({ name: z.string(), parentPath: z.string(), isDirectory: z.boolean() }); diff --git a/src/tests/game-launching.test.ts b/src/tests/game-launching.test.ts index b8295e8..7618096 100644 --- a/src/tests/game-launching.test.ts +++ b/src/tests/game-launching.test.ts @@ -1,4 +1,4 @@ -import { expect, test, mock } from 'bun:test'; +import { expect, test } from 'bun:test'; test("uses custom emulator", async () => { diff --git a/tsconfig.json b/tsconfig.json index fe0da14..c39bbcd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,6 @@ "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, - "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "paths": { "@/*": [ diff --git a/vite.config.ts b/vite.config.ts index 7ab665c..e87e1c7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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; },