more game page stuff

This commit is contained in:
roormonger
2026-03-25 20:34:53 -04:00
parent 4e6ce29f0b
commit 2efd6cae9a
7 changed files with 912 additions and 143 deletions

372
package-lock.json generated
View File

@@ -16,10 +16,12 @@
"@tanstack/react-query-persist-client": "^5.94.5",
"clsx": "^2.1.1",
"framer-motion": "^11.5.0",
"pdfjs-dist": "^5.5.207",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-gamepad": "^1.0.3",
"react-intersection-observer": "^10.0.3",
"react-pdf": "^10.4.1",
"react-router-dom": "^6.26.0",
"tailwind-merge": "^2.5.2"
},
@@ -2660,6 +2662,256 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.97",
"@napi-rs/canvas-darwin-arm64": "0.1.97",
"@napi-rs/canvas-darwin-x64": "0.1.97",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -5397,6 +5649,15 @@
"node": ">=0.10.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -6274,6 +6535,41 @@
"yallist": "^3.0.2"
}
},
"node_modules/make-cancellable-promise": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
"integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
}
},
"node_modules/make-event-props": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
}
},
"node_modules/merge-refs": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz",
"integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -6374,6 +6670,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-readable-to-web-readable-stream": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
"license": "MIT",
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -6501,6 +6804,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "5.5.207",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz",
"integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0 || >=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.95",
"node-readable-to-web-readable-stream": "^0.4.2"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6794,6 +7110,47 @@
}
}
},
"node_modules/react-pdf": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.4.1.tgz",
"integrity": "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"dequal": "^2.0.3",
"make-cancellable-promise": "^2.0.0",
"make-event-props": "^2.0.0",
"merge-refs": "^2.0.0",
"pdfjs-dist": "5.4.296",
"tiny-invariant": "^1.0.0",
"warning": "^4.0.0"
},
"funding": {
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-pdf/node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -7212,6 +7569,12 @@
"node": ">=0.8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -7470,6 +7833,15 @@
}
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -18,10 +18,12 @@
"@tanstack/react-query-persist-client": "^5.94.5",
"clsx": "^2.1.1",
"framer-motion": "^11.5.0",
"pdfjs-dist": "^5.5.207",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-gamepad": "^1.0.3",
"react-intersection-observer": "^10.0.3",
"react-pdf": "^10.4.1",
"react-router-dom": "^6.26.0",
"tailwind-merge": "^2.5.2"
},

View File

@@ -32,6 +32,7 @@ export interface DetailedGame extends Game {
collections?: string[];
favorite?: boolean;
manualUrl?: string;
platformId?: number;
}
export interface RommCollection {
@@ -311,7 +312,22 @@ export const rommApiClient = {
screenshots: (json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''),
fsName: json.fs_name,
regions: regions.length > 0 ? regions : ['Global'],
players: json.players || getFirst('total_players') || getFirst('players'),
players: (() => {
let val = json.metadatum?.player_count || json.players || getFirst('player_count') || getFirst('total_players') || getFirst('players') || getFirst('max_players');
if (val && (String(val) === '1' || String(val).toLowerCase() === 'single player')) {
const modes = getAll('game_modes').map(sanitize).map(m => m.toLowerCase());
if (modes.includes('multiplayer') || modes.includes('co-operative') || modes.includes('split screen')) {
val = 'Multiplayer';
}
}
if (val) {
val = String(val);
if (!val.toLowerCase().includes('player')) {
val = `${val} ${val === '1' ? 'Player' : 'Players'}`;
}
}
return val;
})(),
rating: ratingVal ? Math.round(Number(ratingVal)) : undefined,
esrbRating: ageRatingStr,
sha1: json.hashes?.sha1,
@@ -321,6 +337,7 @@ export const rommApiClient = {
])).filter(Boolean),
favorite: (json.user_collections || json.rom_collections || []).some((c: any) => c.is_favorite || c.name === 'Favorites'),
manualUrl: getFullImageUrl(json.url_manual),
platformId: json.platform_id,
};
},
@@ -422,6 +439,19 @@ export const rommApiClient = {
return res.json();
},
getPlayUrl(gameId: string): string {
return `${this.apiBase}/roms/${gameId}/play`;
},
getManualUrl(gameId: string, platformId?: number): string {
// Constructed manual URL: /assets/romm/resources/roms/[platform_id]/[rom_id]/manual/[rom_id].pdf
// We return a relative path to ensure the request goes through our local origin (bypassing CORS via Vite proxy).
if (platformId) {
return `/assets/romm/resources/roms/${platformId}/${gameId}/manual/${gameId}.pdf`;
}
return `${this.apiBase}/roms/${gameId}/manual`;
},
async fetchCurrentUser(): Promise<UserProfile> {
const res = await fetch(`${this.apiBase}/users/me`, { headers: this.headers });
if (!res.ok) throw new Error('Failed to fetch user profile.');

View File

@@ -0,0 +1,77 @@
import { useEffect } from 'react';
import { useFocusable, FocusContext, pause, resume } from '@noriginmedia/norigin-spatial-navigation';
import { rommApiClient } from '../api/client';
interface EmulatorOverlayProps {
gameId: string;
onClose: () => void;
}
export const EmulatorOverlay = ({ gameId, onClose }: EmulatorOverlayProps) => {
const { ref, focusKey } = useFocusable({
isFocusBoundary: true,
focusKey: 'EMULATOR_OVERLAY'
});
const playUrl = rommApiClient.getPlayUrl(gameId);
useEffect(() => {
// Pause spatial navigation for the browser during gameplay
// This prevents d-pad inputs from moving focus behind the iframe
pause();
const handleKeyDown = (e: KeyboardEvent) => {
// Allow ESC to exit the emulator
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
// Auto-focus the iframe area for immediate controller interaction
const timer = setTimeout(() => {
const iframe = document.querySelector('iframe');
iframe?.focus();
}, 500);
return () => {
window.removeEventListener('keydown', handleKeyDown);
clearTimeout(timer);
resume();
};
}, [onClose]);
return (
<FocusContext.Provider value={focusKey}>
<div
ref={ref}
className="fixed inset-0 z-[200] bg-black animate-in fade-in duration-500 flex flex-col"
>
{/* HUD - Minimalist Atelier style overlay */}
<div className="absolute top-6 right-6 z-[210] flex items-center gap-4 group">
<div className="px-4 py-2 rounded-full bg-black/60 backdrop-blur-xl border border-white/10 flex items-center gap-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
<span className="text-[10px] font-black uppercase tracking-[0.4em] text-white/40 geist-mono">Session Active</span>
<div className="w-1.5 h-1.5 rounded-full bg-[#2563eb] shadow-[0_0_8px_#2563eb] animate-pulse" />
</div>
<button
onClick={onClose}
className="w-12 h-12 flex items-center justify-center rounded-[14px] bg-black/60 backdrop-blur-xl border border-white/10 text-white/40 hover:text-white hover:bg-red-500/80 hover:border-red-500/50 transition-all hover:scale-110 active:scale-95"
title="Exit to Browser (ESC)"
>
<span className="material-symbols-outlined">close</span>
</button>
</div>
{/* Emulator Iframe */}
<iframe
src={playUrl}
className="w-full h-full border-none shadow-[0_0_128px_rgba(0,0,0,1)]"
allow="autoplay; gamepad; fullscreen; keyboard"
title="Game Player"
/>
</div>
</FocusContext.Provider>
);
};

View File

@@ -2,8 +2,10 @@ import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { FocusContext, useFocusable, setFocus } from '@noriginmedia/norigin-spatial-navigation';
import { CollectionModal } from './CollectionModal';
import { ManualModal } from './ManualModal';
import { rommApiClient, Platform, Game } from '../api/client';
import { useInputMode } from '../context/InputModeContext';
import { EmulatorOverlay } from './EmulatorOverlay';
interface FocusableItemProps {
onFocus: () => void;
@@ -149,7 +151,9 @@ export const GamesPage = () => {
const { mode } = useInputMode();
const [selectedPlatformId, setSelectedPlatformId] = useState<number | null>(null);
const [selectedGameId, setSelectedGameId] = useState<string | null>(null);
const [playingGameId, setPlayingGameId] = useState<string | null>(null);
const [isCollectionModalOpen, setIsCollectionModalOpen] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [activeZone, setActiveZone] = useState<'platforms' | 'games' | 'alphabet' | 'details' | null>(null);
const detailTimeoutRef = React.useRef<any>(null);
@@ -476,178 +480,181 @@ export const GamesPage = () => {
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent opacity-60"></div>
</div>
{/* Info Pane */}
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className="flex items-center gap-3 mb-4">
<span className="px-3 py-1 bg-[#2563eb] text-white text-[9px] font-black uppercase tracking-widest rounded-sm">PLAYABLE</span>
<span className="text-white/40 text-[10px] geist-mono uppercase tracking-widest">ID: ROM-{detailsQuery.data.id}</span>
{/* Metadata Content */}
<div className="flex-1 flex flex-col h-full min-w-0">
{/* Badges Row */}
<div className="flex items-center gap-3 mb-0 uppercase geist-mono font-black text-[10px] tracking-widest">
<div className="bg-[#2563eb] text-white px-3 py-1 rounded-[4px] shadow-lg shadow-[#2563eb]/20">
Playable
</div>
<div className="bg-white/10 text-white/60 px-3 py-1 rounded-[4px] border border-white/5">
{detailsQuery.data.players || '1 Player'}
</div>
</div>
<h1 className="text-5xl font-black text-white tracking-tighter leading-none mb-6 truncate whitespace-nowrap overflow-hidden">
{/* Title */}
<h1 className="text-[54px] font-black text-white leading-[0.9] tracking-tighter uppercase mb-3 geist-mono truncate" title={detailsQuery.data.title}>
{detailsQuery.data.title}
</h1>
{/* Metadata rows with consistent spacing (mb-2) */}
<div className="flex items-center gap-6 mb-2">
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Region:</span>
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
{detailsQuery.data.regions?.join(', ') || 'Global'}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Release Date:</span>
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
{detailsQuery.data.releaseDate || 'Unknown'}
</span>
</div>
{detailsQuery.data.rating && (
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Rating:</span>
<span className="text-[11px] font-bold text-white/60 geist-mono tracking-wider">{detailsQuery.data.rating}%</span>
</div>
)}
</div>
<div className="flex items-center gap-6 mb-2">
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Companies:</span>
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
{[...(detailsQuery.data.developers || []), ...(detailsQuery.data.publishers || [])].filter((val, idx, self) => self.indexOf(val) === idx).join(' / ') || 'Unknown Company'}
</span>
{/* Divider */}
<div className="border-t border-white/10 w-full mb-3"></div>
{/* Metadata List - Three Combined Rows */}
<div className="space-y-2 mb-4 text-[11px] font-black tracking-[0.2em] geist-mono uppercase">
{/* Combined Row 1: Region & Release Date */}
<div className="flex items-center gap-10 overflow-hidden">
<div className="flex gap-2 min-w-0 shrink-0">
<span className="text-[#2563eb] w-[140px] shrink-0">Region:</span>
<span className="text-white truncate max-w-[200px]">{detailsQuery.data.regions?.join(', ') || 'N/A'}</span>
</div>
<div className="flex gap-2 shrink-0">
<span className="text-[#2563eb] shrink-0">Release date:</span>
<span className="text-white">{detailsQuery.data.releaseDate || 'N/A'}</span>
</div>
</div>
{(detailsQuery.data.franchises?.length ?? 0) > 0 && (
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Franchise:</span>
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
{detailsQuery.data.franchises?.join(' & ')}
{/* Combined Row 2: Franchise & Companies */}
<div className="flex items-center gap-10 overflow-hidden">
<div className="flex gap-2 min-w-0 shrink-0">
<span className="text-[#2563eb] w-[140px] shrink-0">Franchise:</span>
<span className="text-white truncate max-w-[200px]">{detailsQuery.data.collections?.join(', ') || 'N/A'}</span>
</div>
<div className="flex gap-2 min-w-0">
<span className="text-[#2563eb] shrink-0">Companies:</span>
<span className="text-white truncate font-bold">
{[...(detailsQuery.data.developers || []), ...(detailsQuery.data.publishers || [])].slice(0, 3).join(', ') || 'Unknown Company'}
</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-6 mb-2">
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Genres:</span>
<span className="text-[11px] font-bold text-white/40 uppercase geist-mono tracking-wider italic">
{detailsQuery.data.genres?.join(' • ') || 'No Genres'}
</span>
{/* Combined Row 3: Age Rating & Genres */}
<div className="flex items-center gap-10 overflow-hidden">
<div className="flex gap-2 min-w-0 shrink-0">
<span className="text-[#2563eb] w-[140px] shrink-0">Age Rating:</span>
<span className="text-white">{detailsQuery.data.esrbRating || 'NR'}</span>
</div>
<div className="flex gap-2 min-w-0">
<span className="text-[#2563eb] shrink-0">Genres:</span>
<span className="text-white truncate">{detailsQuery.data.genres?.slice(0, 4).join(', ') || 'N/A'}</span>
</div>
</div>
</div>
{detailsQuery.data.esrbRating && (
<div className="flex items-center gap-2 mb-6">
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Age Rating:</span>
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
{detailsQuery.data.esrbRating}
</span>
</div>
)}
{/* Summary Section - Fixed Height & Scrollable */}
<div className="bg-white/5 rounded-[8px] border border-white/5 p-4 flex-1 mb-6 overflow-hidden flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto scrollbar-thin pr-2">
<p className="text-white/60 text-sm leading-relaxed font-medium">
{detailsQuery.data.summary || "This title does not have a comprehensive analysis in our local database yet. Our researchers are working to archive more metadata for this specific entry."}
</p>
</div>
{/* Brief Summary Container */}
<div className="bg-white/5 rounded-[8px] p-5 mb-8 flex-1 overflow-y-auto border border-white/5 scrollbar-hide">
<p className="text-[15px] text-white/80 leading-[1.8] font-medium">
{detailsQuery.data.summary || 'No summary available for this entry.'}
</p>
</div>
{/* Action Buttons - Pushed to bottom, aligned with poster bottom */}
<div className="flex gap-4 shrink-0">
<div className="flex items-center gap-3">
<FocusableItem
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
onEnterPress={() => console.log("Play Game", detailsQuery.data?.id)}
onClick={() => console.log("Play Game", detailsQuery.data?.id)}
className="shrink-0"
focusKey="DETAILS_PLAY"
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
onEnterPress={() => {
if (detailsQuery.data) {
setPlayingGameId(detailsQuery.data.id);
}
}}
onClick={() => {
if (detailsQuery.data) {
setPlayingGameId(detailsQuery.data.id);
}
}}
className="shrink-0"
focusKey="DETAILS_PLAY"
>
{(focused) => (
<button className={`flex items-center gap-3 px-8 h-[54px] rounded-[12px] font-black uppercase tracking-tighter transition-all duration-300 shadow-xl ${focused ? 'bg-[#2563eb] text-white scale-105 shadow-[#2563eb]/40' : 'bg-white text-black hover:bg-white/90'}`}>
<span className="material-symbols-outlined filled">play_arrow</span>
<button className={`h-[54px] px-8 rounded-[12px] font-black uppercase text-[12px] tracking-[0.1em] flex items-center gap-3 transition-all duration-300 shadow-2xl ${focused ? 'bg-white text-black scale-110 ring-4 ring-[#2563eb] shadow-[0_0_25px_rgba(37,99,235,0.4)]' : 'bg-white text-black hover:scale-105 hover:bg-white/90'}`}>
<span className="material-symbols-outlined text-[24px] filled" style={{ fontVariationSettings: "'FILL' 1" }}>play_arrow</span>
Start Game
</button>
)}
</FocusableItem>
<FocusableItem
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
onEnterPress={() => console.log("Download", detailsQuery.data?.id)}
onClick={() => console.log("Download", detailsQuery.data?.id)}
className="shrink-0"
focusKey="DETAILS_DOWNLOAD"
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
onEnterPress={() => console.log("Download", detailsQuery.data?.id)}
onClick={() => console.log("Download", detailsQuery.data?.id)}
className="shrink-0"
focusKey="DETAILS_DOWNLOAD"
>
{(focused) => (
<button className={`flex items-center gap-3 px-8 h-[54px] rounded-[12px] font-black uppercase tracking-tighter border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white hover:border-white/20 bg-white/5'}`}>
<span className="material-symbols-outlined">download</span>
<button className={`h-[54px] px-8 rounded-[12px] font-black uppercase text-[12px] tracking-[0.1em] flex items-center gap-3 transition-all duration-300 border-2 ${focused ? 'scale-110 border-[#2563eb] bg-[#2563eb]/20 text-white ring-4 ring-[#2563eb]/20 shadow-[0_0_20px_rgba(37,99,235,0.3)]' : 'bg-white/5 border-white/10 text-white hover:bg-white/10 hover:border-white/20'}`}>
<span className="material-symbols-outlined text-[24px]">download</span>
Download
</button>
)}
</FocusableItem>
<div className="flex gap-2.5">
<FocusableItem
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
onEnterPress={() => {
if (detailsQuery.data) {
favoriteMutation.mutate({
gameId: detailsQuery.data.id,
favorite: !detailsQuery.data.favorite
});
}
}}
onClick={() => {
if (detailsQuery.data) {
favoriteMutation.mutate({
gameId: detailsQuery.data.id,
favorite: !detailsQuery.data.favorite
});
}
}}
className="shrink-0"
focusKey="DETAILS_FAVORITE"
>
{(focused) => (
<button
title="Favorite"
className={`w-[54px] h-[54px] flex items-center justify-center rounded-[12px] border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white/40 hover:text-white hover:border-white/20 bg-white/5'} ${detailsQuery.data?.favorite ? '!border-[#2563eb] !text-[#2563eb] !bg-[#2563eb]/10' : ''}`}
>
<span className={`material-symbols-outlined ${detailsQuery.data?.favorite ? 'filled' : ''}`} style={detailsQuery.data?.favorite ? { fontVariationSettings: "'FILL' 1" } : {}}>
favorite
</span>
</button>
)}
</FocusableItem>
<div className="flex gap-3 ml-4">
<FocusableItem
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
onEnterPress={() => {
if (detailsQuery.data) {
favoriteMutation.mutate({
gameId: detailsQuery.data.id,
favorite: !detailsQuery.data.favorite
});
}
}}
onClick={() => {
if (detailsQuery.data) {
favoriteMutation.mutate({
gameId: detailsQuery.data.id,
favorite: !detailsQuery.data.favorite
});
}
}}
className="shrink-0"
focusKey="DETAILS_FAVORITE"
>
{(focused) => (
<button
title="Favorite"
className={`w-[54px] h-[54px] flex items-center justify-center transition-all duration-300 rounded-[12px] border-2
${focused ? 'scale-110 border-[#2563eb] bg-[#2563eb]/20 ring-4 ring-[#2563eb]/20 shadow-[0_0_15px_rgba(37,99,235,0.3)]' : 'bg-white/5 border-white/10 hover:bg-white/10'}
`}
>
<span
className={`material-symbols-outlined text-[24px]
${detailsQuery.data?.favorite ? 'filled text-[#2563eb]' : 'text-white/40'}
${focused ? '!text-white' : ''}
`}
style={detailsQuery.data?.favorite ? { fontVariationSettings: "'FILL' 1" } : {}}
>
star
</span>
</button>
)}
</FocusableItem>
<FocusableItem
onEnterPress={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
onClick={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
className="shrink-0"
focusKey="DETAILS_COLLECTION"
>
{(focused) => (
<button title="Add to Collection" className={`w-[54px] h-[54px] flex items-center justify-center rounded-[12px] border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white/40 hover:text-white hover:border-white/20 bg-white/5'}`}>
<span className="material-symbols-outlined">library_add</span>
</button>
)}
</FocusableItem>
<FocusableItem
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
onEnterPress={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
onClick={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
className="shrink-0"
focusKey="DETAILS_COLLECTION"
>
{(focused) => (
<button title="Add to Collection" className={`w-[54px] h-[54px] flex items-center justify-center transition-all duration-300 rounded-[12px] border-2 ${focused ? 'scale-110 border-[#2563eb] text-white bg-[#2563eb]/20 ring-4 ring-[#2563eb]/20 shadow-[0_0_15px_rgba(37,99,235,0.3)]' : 'bg-white/5 border-white/10 text-white/40 hover:bg-white/10 hover:text-white'}`}>
<span className="material-symbols-outlined text-[24px]">library_add</span>
</button>
)}
</FocusableItem>
{detailsQuery.data?.manualUrl && (
<FocusableItem
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
onEnterPress={() => window.open(detailsQuery.data?.manualUrl, '_blank')}
onClick={() => window.open(detailsQuery.data?.manualUrl, '_blank')}
className="shrink-0"
focusKey="DETAILS_MANUAL"
>
{(focused) => (
<button title="Open Manual" className={`w-[54px] h-[54px] flex items-center justify-center rounded-[12px] border-2 transition-all duration-300 ${focused ? 'border-[#2563eb] text-[#2563eb] bg-[#2563eb]/10 scale-105' : 'border-white/10 text-white/40 hover:text-white hover:border-white/20 bg-white/5'}`}>
<span className="material-symbols-outlined">menu_book</span>
</button>
)}
</FocusableItem>
)}
<FocusableItem
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
onEnterPress={() => { if (detailsQuery.data?.manualUrl) setIsManualModalOpen(true); }}
onClick={() => { if (detailsQuery.data?.manualUrl) setIsManualModalOpen(true); }}
className="shrink-0"
focusKey="DETAILS_MANUAL"
>
{(focused) => (
<button title="Manual" className={`w-[54px] h-[54px] flex items-center justify-center transition-all duration-300 rounded-[12px] border-2 ${focused ? 'scale-110 border-[#2563eb] text-white bg-[#2563eb]/20 ring-4 ring-[#2563eb]/20 shadow-[0_0_15px_rgba(37,99,235,0.3)]' : 'bg-white/5 border-white/10 text-white/40 hover:bg-white/10 hover:text-white'}`}>
<span className="material-symbols-outlined text-[24px]">menu_book</span>
</button>
)}
</FocusableItem>
</div>
</div>
@@ -671,6 +678,7 @@ export const GamesPage = () => {
)}
</div>
</FocusContext.Provider>
{/* Collection Management Modal Overlay */}
{isCollectionModalOpen && detailsQuery.data && (
<CollectionModal
@@ -687,6 +695,21 @@ export const GamesPage = () => {
})}
/>
)}
{playingGameId && (
<EmulatorOverlay
gameId={playingGameId}
onClose={() => setPlayingGameId(null)}
/>
)}
{isManualModalOpen && detailsQuery.data && (
<ManualModal
gameId={detailsQuery.data.id}
platformId={detailsQuery.data.platformId}
onClose={() => setIsManualModalOpen(false)}
/>
)}
</div>
</div>
);

View File

@@ -0,0 +1,258 @@
import React, { useState, useEffect } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import { rommApiClient } from '../api/client';
// Worker setup using CDN for maximum compatibility
pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
interface ManualModalProps {
gameId: string;
platformId?: number;
onClose: () => void;
}
const FocusableControl = ({
onFocus,
onEnterPress,
onClick,
children,
className,
focusKey
}: {
onFocus: () => void;
onEnterPress?: () => void;
onClick?: () => void;
children: (focused: boolean) => React.ReactNode;
className?: string;
focusKey?: string;
}) => {
const { ref, focused } = useFocusable({
onFocus,
onEnterPress,
focusKey
});
return (
<div
ref={ref}
className={`${className} ${focused ? 'focused' : ''}`}
onClick={onClick}
>
{children(focused)}
</div>
);
};
export const ManualModal = ({ gameId, platformId, onClose }: ManualModalProps) => {
const url = rommApiClient.getManualUrl(gameId, platformId);
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(1);
const [scale, setScale] = useState(1.0);
const [error, setError] = useState<string | null>(null);
const { ref, focusKey } = useFocusable({
focusKey: 'MANUAL_MODAL',
isFocusBoundary: true,
autoRestoreFocus: true
});
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
setNumPages(numPages);
setPageNumber(1);
setError(null);
};
const onDocumentLoadError = (err: Error) => {
console.error('PDF Load Error:', err);
setError('Failed to load manual. The archive might be inaccessible or corrupted.');
};
const changePage = (offset: number) => {
setPageNumber(prevPageNumber => {
const next = prevPageNumber + offset;
if (numPages && next >= 1 && next <= numPages) {
return next;
}
return prevPageNumber;
});
};
const changeScale = (delta: number) => {
setScale(prev => Math.min(Math.max(prev + delta, 0.5), 3.0));
};
// Keyboard/Gamepad shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft' || e.key === 'PageUp') changePage(-1);
if (e.key === 'ArrowRight' || e.key === 'PageDown') changePage(1);
};
const handleGamepadButton = (e: Event) => {
const { button } = (e as CustomEvent<{ button: string }>).detail;
if (button === 'B') onClose();
if (button === 'LB') changePage(-1);
if (button === 'RB') changePage(1);
if (button === 'Up') changeScale(0.1);
if (button === 'Down') changeScale(-0.1);
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('gamepadbutton', handleGamepadButton);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('gamepadbutton', handleGamepadButton);
};
}, [numPages, onClose]);
return (
<FocusContext.Provider value={focusKey}>
<div
ref={ref}
className="fixed inset-0 z-[100] bg-black/95 flex flex-col items-center justify-center p-8 backdrop-blur-xl animate-in fade-in duration-500"
>
{/* Header / Background branding */}
<div className="absolute top-10 left-10 opacity-20 user-select-none pointer-events-none">
<div className="text-4xl font-black geist-mono text-[#2563eb] tracking-tighter uppercase leading-none">
Digital Archive<br />Technical Manual
</div>
</div>
{/* PDF Container */}
<div className="flex-1 w-full max-w-5xl overflow-auto scrollbar-hide flex justify-center items-start rounded-xl border border-white/10 bg-black/40 shadow-2xl relative shadow-black/80">
{error ? (
<div className="h-full flex flex-col items-center justify-center text-center p-20">
<span className="material-symbols-outlined text-6xl text-red-500/50 mb-6">error</span>
<p className="text-white/60 font-medium text-lg max-w-md leading-relaxed">{error}</p>
<FocusableControl onFocus={() => {}} onEnterPress={onClose} onClick={onClose} className="mt-8">
{(focused) => (
<button className={`px-10 py-4 rounded-full font-black uppercase text-[12px] tracking-widest transition-all ${focused ? 'bg-white text-black scale-110' : 'bg-white/10 text-white hover:bg-white/20'}`}>
Return to Library
</button>
)}
</FocusableControl>
</div>
) : (
<Document
file={url}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={
<div className="h-full flex flex-col items-center justify-center">
<div className="material-symbols-outlined text-4xl text-[#2563eb] animate-spin mb-4">progress_activity</div>
<div className="text-[10px] geist-mono text-white/40 uppercase tracking-widest">Parsing Archive...</div>
</div>
}
>
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={true}
renderAnnotationLayer={true}
className="shadow-[0_0_50px_rgba(0,0,0,0.8)]"
/>
</Document>
)}
{/* Page Info Overlay */}
{!error && numPages && (
<div className="absolute top-6 right-6 px-4 py-2 bg-black/60 backdrop-blur-md rounded-lg border border-white/10 text-[10px] geist-mono font-black text-white/60 uppercase tracking-widest flex items-center gap-4">
<div>Page <span className="text-white text-sm">{pageNumber}</span> / <span className="text-white/40">{numPages}</span></div>
<div className="w-px h-3 bg-white/10"></div>
<div>Zoom <span className="text-white text-sm">{Math.round(scale * 100)}%</span></div>
</div>
)}
</div>
{/* Control Bar */}
<div className="shrink-0 mt-8 mb-4 flex items-center gap-4">
<div className="flex bg-black/40 backdrop-blur-md p-1 rounded-2xl border border-white/10">
<FocusableControl
onFocus={() => {}}
onEnterPress={() => changePage(-1)}
onClick={() => changePage(-1)}
focusKey="MANUAL_PREV"
>
{(focused) => (
<button
className={`w-[64px] h-[64px] flex items-center justify-center transition-all rounded-xl ${focused ? 'bg-[#2563eb] text-white scale-110 shadow-lg shadow-[#2563eb]/30' : 'text-white/30 hover:text-white/70'}`}
disabled={pageNumber <= 1}
>
<span className="material-symbols-outlined text-[32px]">navigate_before</span>
</button>
)}
</FocusableControl>
<FocusableControl
onFocus={() => {}}
onEnterPress={() => changeScale(-0.2)}
onClick={() => changeScale(-0.2)}
focusKey="MANUAL_ZOUT"
>
{(focused) => (
<button className={`w-[64px] h-[64px] flex items-center justify-center transition-all rounded-xl ${focused ? 'bg-[#2563eb] text-white scale-110 shadow-lg shadow-[#2563eb]/30' : 'text-white/30 hover:text-white/70'}`}>
<span className="material-symbols-outlined text-[32px]">zoom_out</span>
</button>
)}
</FocusableControl>
<FocusableControl
onFocus={() => {}}
onEnterPress={() => changeScale(0.2)}
onClick={() => changeScale(0.2)}
focusKey="MANUAL_ZIN"
>
{(focused) => (
<button className={`w-[64px] h-[64px] flex items-center justify-center transition-all rounded-xl ${focused ? 'bg-[#2563eb] text-white scale-110 shadow-lg shadow-[#2563eb]/30' : 'text-white/30 hover:text-white/70'}`}>
<span className="material-symbols-outlined text-[32px]">zoom_in</span>
</button>
)}
</FocusableControl>
<FocusableControl
onFocus={() => {}}
onEnterPress={() => changePage(1)}
onClick={() => changePage(1)}
focusKey="MANUAL_NEXT"
>
{(focused) => (
<button
className={`w-[64px] h-[64px] flex items-center justify-center transition-all rounded-xl ${focused ? 'bg-[#2563eb] text-white scale-110 shadow-lg shadow-[#2563eb]/30' : 'text-white/30 hover:text-white/70'}`}
disabled={numPages ? pageNumber >= numPages : true}
>
<span className="material-symbols-outlined text-[32px]">navigate_next</span>
</button>
)}
</FocusableControl>
</div>
<div className="w-px h-10 bg-white/10 mx-2"></div>
<FocusableControl
onFocus={() => {}}
onEnterPress={onClose}
onClick={onClose}
focusKey="MANUAL_CLOSE"
>
{(focused) => (
<button className={`px-10 h-[64px] flex items-center gap-3 transition-all rounded-2xl font-black uppercase text-[12px] tracking-widest border-2 ${focused ? 'bg-white text-black scale-110 border-white ring-4 ring-white/20' : 'bg-white/5 text-white/60 border-white/5 hover:bg-white/10 hover:border-white/10 hover:text-white'}`}>
<span className="material-symbols-outlined text-[24px]">close</span>
Close Reader
</button>
)}
</FocusableControl>
</div>
{/* Hint text */}
<div className="text-[9px] geist-mono text-white/20 uppercase tracking-[0.3em] font-black mt-2">
Paging: LB / RB Zoom: D-Pad Up / Down Close: B Button
</div>
</div>
</FocusContext.Provider>
);
};

View File

@@ -9,5 +9,12 @@ export default defineConfig({
watch: {
usePolling: true,
},
proxy: {
'/assets/romm': {
target: 'https://retro.chieflix.com',
changeOrigin: true,
secure: false,
}
}
},
})