more game page stuff
This commit is contained in:
372
package-lock.json
generated
372
package-lock.json
generated
@@ -16,10 +16,12 @@
|
|||||||
"@tanstack/react-query-persist-client": "^5.94.5",
|
"@tanstack/react-query-persist-client": "^5.94.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^11.5.0",
|
"framer-motion": "^11.5.0",
|
||||||
|
"pdfjs-dist": "^5.5.207",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-gamepad": "^1.0.3",
|
"react-gamepad": "^1.0.3",
|
||||||
"react-intersection-observer": "^10.0.3",
|
"react-intersection-observer": "^10.0.3",
|
||||||
|
"react-pdf": "^10.4.1",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"tailwind-merge": "^2.5.2"
|
"tailwind-merge": "^2.5.2"
|
||||||
},
|
},
|
||||||
@@ -2660,6 +2662,256 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -5397,6 +5649,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@@ -6274,6 +6535,41 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -6374,6 +6670,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.36",
|
"version": "2.0.36",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||||
@@ -6501,6 +6804,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -7212,6 +7569,12 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -18,10 +18,12 @@
|
|||||||
"@tanstack/react-query-persist-client": "^5.94.5",
|
"@tanstack/react-query-persist-client": "^5.94.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^11.5.0",
|
"framer-motion": "^11.5.0",
|
||||||
|
"pdfjs-dist": "^5.5.207",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-gamepad": "^1.0.3",
|
"react-gamepad": "^1.0.3",
|
||||||
"react-intersection-observer": "^10.0.3",
|
"react-intersection-observer": "^10.0.3",
|
||||||
|
"react-pdf": "^10.4.1",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"tailwind-merge": "^2.5.2"
|
"tailwind-merge": "^2.5.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface DetailedGame extends Game {
|
|||||||
collections?: string[];
|
collections?: string[];
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
manualUrl?: string;
|
manualUrl?: string;
|
||||||
|
platformId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RommCollection {
|
export interface RommCollection {
|
||||||
@@ -311,7 +312,22 @@ export const rommApiClient = {
|
|||||||
screenshots: (json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''),
|
screenshots: (json.screenshots || []).map((s: any) => s.url ? getFullImageUrl(s.url) || '' : ''),
|
||||||
fsName: json.fs_name,
|
fsName: json.fs_name,
|
||||||
regions: regions.length > 0 ? regions : ['Global'],
|
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,
|
rating: ratingVal ? Math.round(Number(ratingVal)) : undefined,
|
||||||
esrbRating: ageRatingStr,
|
esrbRating: ageRatingStr,
|
||||||
sha1: json.hashes?.sha1,
|
sha1: json.hashes?.sha1,
|
||||||
@@ -321,6 +337,7 @@ export const rommApiClient = {
|
|||||||
])).filter(Boolean),
|
])).filter(Boolean),
|
||||||
favorite: (json.user_collections || json.rom_collections || []).some((c: any) => c.is_favorite || c.name === 'Favorites'),
|
favorite: (json.user_collections || json.rom_collections || []).some((c: any) => c.is_favorite || c.name === 'Favorites'),
|
||||||
manualUrl: getFullImageUrl(json.url_manual),
|
manualUrl: getFullImageUrl(json.url_manual),
|
||||||
|
platformId: json.platform_id,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -422,6 +439,19 @@ export const rommApiClient = {
|
|||||||
return res.json();
|
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> {
|
async fetchCurrentUser(): Promise<UserProfile> {
|
||||||
const res = await fetch(`${this.apiBase}/users/me`, { headers: this.headers });
|
const res = await fetch(`${this.apiBase}/users/me`, { headers: this.headers });
|
||||||
if (!res.ok) throw new Error('Failed to fetch user profile.');
|
if (!res.ok) throw new Error('Failed to fetch user profile.');
|
||||||
|
|||||||
77
src/components/EmulatorOverlay.tsx
Normal file
77
src/components/EmulatorOverlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,8 +2,10 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { FocusContext, useFocusable, setFocus } from '@noriginmedia/norigin-spatial-navigation';
|
import { FocusContext, useFocusable, setFocus } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import { CollectionModal } from './CollectionModal';
|
import { CollectionModal } from './CollectionModal';
|
||||||
|
import { ManualModal } from './ManualModal';
|
||||||
import { rommApiClient, Platform, Game } from '../api/client';
|
import { rommApiClient, Platform, Game } from '../api/client';
|
||||||
import { useInputMode } from '../context/InputModeContext';
|
import { useInputMode } from '../context/InputModeContext';
|
||||||
|
import { EmulatorOverlay } from './EmulatorOverlay';
|
||||||
|
|
||||||
interface FocusableItemProps {
|
interface FocusableItemProps {
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
@@ -149,7 +151,9 @@ export const GamesPage = () => {
|
|||||||
const { mode } = useInputMode();
|
const { mode } = useInputMode();
|
||||||
const [selectedPlatformId, setSelectedPlatformId] = useState<number | null>(null);
|
const [selectedPlatformId, setSelectedPlatformId] = useState<number | null>(null);
|
||||||
const [selectedGameId, setSelectedGameId] = useState<string | null>(null);
|
const [selectedGameId, setSelectedGameId] = useState<string | null>(null);
|
||||||
|
const [playingGameId, setPlayingGameId] = useState<string | null>(null);
|
||||||
const [isCollectionModalOpen, setIsCollectionModalOpen] = useState(false);
|
const [isCollectionModalOpen, setIsCollectionModalOpen] = useState(false);
|
||||||
|
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
||||||
const [activeZone, setActiveZone] = useState<'platforms' | 'games' | 'alphabet' | 'details' | null>(null);
|
const [activeZone, setActiveZone] = useState<'platforms' | 'games' | 'alphabet' | 'details' | null>(null);
|
||||||
const detailTimeoutRef = React.useRef<any>(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 className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent opacity-60"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Pane */}
|
{/* Metadata Content */}
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
<div className="flex-1 flex flex-col h-full min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
{/* Badges Row */}
|
||||||
<span className="px-3 py-1 bg-[#2563eb] text-white text-[9px] font-black uppercase tracking-widest rounded-sm">PLAYABLE</span>
|
<div className="flex items-center gap-3 mb-0 uppercase geist-mono font-black text-[10px] tracking-widest">
|
||||||
<span className="text-white/40 text-[10px] geist-mono uppercase tracking-widest">ID: ROM-{detailsQuery.data.id}</span>
|
<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>
|
</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}
|
{detailsQuery.data.title}
|
||||||
</h1>
|
</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">
|
{/* Divider */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="border-t border-white/10 w-full mb-3"></div>
|
||||||
<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">
|
{/* Metadata List - Three Combined Rows */}
|
||||||
{[...(detailsQuery.data.developers || []), ...(detailsQuery.data.publishers || [])].filter((val, idx, self) => self.indexOf(val) === idx).join(' / ') || 'Unknown Company'}
|
<div className="space-y-2 mb-4 text-[11px] font-black tracking-[0.2em] geist-mono uppercase">
|
||||||
</span>
|
{/* 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>
|
</div>
|
||||||
{(detailsQuery.data.franchises?.length ?? 0) > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Combined Row 2: Franchise & Companies */}
|
||||||
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Franchise:</span>
|
<div className="flex items-center gap-10 overflow-hidden">
|
||||||
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
|
<div className="flex gap-2 min-w-0 shrink-0">
|
||||||
{detailsQuery.data.franchises?.join(' & ')}
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6 mb-2">
|
{/* Combined Row 3: Age Rating & Genres */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-10 overflow-hidden">
|
||||||
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Genres:</span>
|
<div className="flex gap-2 min-w-0 shrink-0">
|
||||||
<span className="text-[11px] font-bold text-white/40 uppercase geist-mono tracking-wider italic">
|
<span className="text-[#2563eb] w-[140px] shrink-0">Age Rating:</span>
|
||||||
{detailsQuery.data.genres?.join(' • ') || 'No Genres'}
|
<span className="text-white">{detailsQuery.data.esrbRating || 'NR'}</span>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detailsQuery.data.esrbRating && (
|
{/* Brief Summary Container */}
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="bg-white/5 rounded-[8px] p-5 mb-8 flex-1 overflow-y-auto border border-white/5 scrollbar-hide">
|
||||||
<span className="text-[10px] font-black text-[#2563eb] uppercase tracking-widest geist-mono">Age Rating:</span>
|
<p className="text-[15px] text-white/80 leading-[1.8] font-medium">
|
||||||
<span className="text-[11px] font-bold text-white/60 uppercase geist-mono tracking-wider">
|
{detailsQuery.data.summary || 'No summary available for this entry.'}
|
||||||
{detailsQuery.data.esrbRating}
|
</p>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{/* Action Buttons - Pushed to bottom, aligned with poster bottom */}
|
|
||||||
<div className="flex gap-4 shrink-0">
|
|
||||||
<FocusableItem
|
<FocusableItem
|
||||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||||
onEnterPress={() => console.log("Play Game", detailsQuery.data?.id)}
|
onEnterPress={() => {
|
||||||
onClick={() => console.log("Play Game", detailsQuery.data?.id)}
|
if (detailsQuery.data) {
|
||||||
className="shrink-0"
|
setPlayingGameId(detailsQuery.data.id);
|
||||||
focusKey="DETAILS_PLAY"
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (detailsQuery.data) {
|
||||||
|
setPlayingGameId(detailsQuery.data.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="shrink-0"
|
||||||
|
focusKey="DETAILS_PLAY"
|
||||||
>
|
>
|
||||||
{(focused) => (
|
{(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'}`}>
|
<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 filled">play_arrow</span>
|
<span className="material-symbols-outlined text-[24px] filled" style={{ fontVariationSettings: "'FILL' 1" }}>play_arrow</span>
|
||||||
Start Game
|
Start Game
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</FocusableItem>
|
</FocusableItem>
|
||||||
|
|
||||||
<FocusableItem
|
<FocusableItem
|
||||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||||
onEnterPress={() => console.log("Download", detailsQuery.data?.id)}
|
onEnterPress={() => console.log("Download", detailsQuery.data?.id)}
|
||||||
onClick={() => console.log("Download", detailsQuery.data?.id)}
|
onClick={() => console.log("Download", detailsQuery.data?.id)}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
focusKey="DETAILS_DOWNLOAD"
|
focusKey="DETAILS_DOWNLOAD"
|
||||||
>
|
>
|
||||||
{(focused) => (
|
{(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'}`}>
|
<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">download</span>
|
<span className="material-symbols-outlined text-[24px]">download</span>
|
||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</FocusableItem>
|
</FocusableItem>
|
||||||
|
|
||||||
<div className="flex gap-2.5">
|
<div className="flex gap-3 ml-4">
|
||||||
<FocusableItem
|
<FocusableItem
|
||||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||||
onEnterPress={() => {
|
onEnterPress={() => {
|
||||||
if (detailsQuery.data) {
|
if (detailsQuery.data) {
|
||||||
favoriteMutation.mutate({
|
favoriteMutation.mutate({
|
||||||
gameId: detailsQuery.data.id,
|
gameId: detailsQuery.data.id,
|
||||||
favorite: !detailsQuery.data.favorite
|
favorite: !detailsQuery.data.favorite
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (detailsQuery.data) {
|
if (detailsQuery.data) {
|
||||||
favoriteMutation.mutate({
|
favoriteMutation.mutate({
|
||||||
gameId: detailsQuery.data.id,
|
gameId: detailsQuery.data.id,
|
||||||
favorite: !detailsQuery.data.favorite
|
favorite: !detailsQuery.data.favorite
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
focusKey="DETAILS_FAVORITE"
|
focusKey="DETAILS_FAVORITE"
|
||||||
>
|
>
|
||||||
{(focused) => (
|
{(focused) => (
|
||||||
<button
|
<button
|
||||||
title="Favorite"
|
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' : ''}`}
|
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 ${detailsQuery.data?.favorite ? 'filled' : ''}`} style={detailsQuery.data?.favorite ? { fontVariationSettings: "'FILL' 1" } : {}}>
|
`}
|
||||||
favorite
|
>
|
||||||
</span>
|
<span
|
||||||
</button>
|
className={`material-symbols-outlined text-[24px]
|
||||||
)}
|
${detailsQuery.data?.favorite ? 'filled text-[#2563eb]' : 'text-white/40'}
|
||||||
</FocusableItem>
|
${focused ? '!text-white' : ''}
|
||||||
|
`}
|
||||||
|
style={detailsQuery.data?.favorite ? { fontVariationSettings: "'FILL' 1" } : {}}
|
||||||
|
>
|
||||||
|
star
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</FocusableItem>
|
||||||
|
|
||||||
<FocusableItem
|
<FocusableItem
|
||||||
onEnterPress={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
|
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||||
onClick={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
|
onEnterPress={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
|
||||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
onClick={() => { if (detailsQuery.data) setIsCollectionModalOpen(true); }}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
focusKey="DETAILS_COLLECTION"
|
focusKey="DETAILS_COLLECTION"
|
||||||
>
|
>
|
||||||
{(focused) => (
|
{(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'}`}>
|
<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">library_add</span>
|
<span className="material-symbols-outlined text-[24px]">library_add</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</FocusableItem>
|
</FocusableItem>
|
||||||
|
|
||||||
{detailsQuery.data?.manualUrl && (
|
<FocusableItem
|
||||||
<FocusableItem
|
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
||||||
onFocus={() => { if (mode === 'gamepad') setActiveZone('details'); }}
|
onEnterPress={() => { if (detailsQuery.data?.manualUrl) setIsManualModalOpen(true); }}
|
||||||
onEnterPress={() => window.open(detailsQuery.data?.manualUrl, '_blank')}
|
onClick={() => { if (detailsQuery.data?.manualUrl) setIsManualModalOpen(true); }}
|
||||||
onClick={() => window.open(detailsQuery.data?.manualUrl, '_blank')}
|
className="shrink-0"
|
||||||
className="shrink-0"
|
focusKey="DETAILS_MANUAL"
|
||||||
focusKey="DETAILS_MANUAL"
|
>
|
||||||
>
|
{(focused) => (
|
||||||
{(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'}`}>
|
||||||
<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 text-[24px]">menu_book</span>
|
||||||
<span className="material-symbols-outlined">menu_book</span>
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
</FocusableItem>
|
||||||
</FocusableItem>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -671,6 +678,7 @@ export const GamesPage = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</FocusContext.Provider>
|
</FocusContext.Provider>
|
||||||
|
|
||||||
{/* Collection Management Modal Overlay */}
|
{/* Collection Management Modal Overlay */}
|
||||||
{isCollectionModalOpen && detailsQuery.data && (
|
{isCollectionModalOpen && detailsQuery.data && (
|
||||||
<CollectionModal
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
258
src/components/ManualModal.tsx
Normal file
258
src/components/ManualModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,5 +9,12 @@ export default defineConfig({
|
|||||||
watch: {
|
watch: {
|
||||||
usePolling: true,
|
usePolling: true,
|
||||||
},
|
},
|
||||||
|
proxy: {
|
||||||
|
'/assets/romm': {
|
||||||
|
target: 'https://retro.chieflix.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user