feat: implemented a basic store and emulatorjs
This commit is contained in:
parent
2f32cbc730
commit
7286541822
121 changed files with 5900 additions and 1092 deletions
|
|
@ -4,6 +4,8 @@ on:
|
|||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
env:
|
||||
TWITCH_CLIENT_ID: ${{ env.TWITCH_CLIENT_ID }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -54,6 +56,8 @@ jobs:
|
|||
|
||||
- name: Build Canary
|
||||
run: bun run package:Linux
|
||||
env:
|
||||
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
|
|
|
|||
1
.github/workflows/build.yml
vendored
1
.github/workflows/build.yml
vendored
|
|
@ -59,6 +59,7 @@ jobs:
|
|||
run: bun run package:${{ runner.os }}
|
||||
env:
|
||||
BUILD_DIR: ./build/${{ runner.os }}
|
||||
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
|
||||
|
||||
- name: Install 7zip (minimal)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -26,3 +26,4 @@ trace
|
|||
downloads
|
||||
.flatpak-builder
|
||||
gameflow-deck.code-workspace
|
||||
.env.local
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
|
@ -9,7 +9,7 @@
|
|||
"internalConsoleOptions": "neverOpen",
|
||||
"request": "attach",
|
||||
"name": "Attach Bun",
|
||||
"url": "ws://127.0.0.1:9229/fixed-session",
|
||||
"url": "ws://127.0.0.1:9228/fixed-session",
|
||||
"localRoot": "${workspaceFolder}",
|
||||
"stopOnEntry": false,
|
||||
},
|
||||
|
|
|
|||
5
.vscode/tasks.json
vendored
5
.vscode/tasks.json
vendored
|
|
@ -38,6 +38,11 @@
|
|||
"label": "Start Dev (Hot Reload)",
|
||||
"type": "shell",
|
||||
"command": "bun run dev:hmr",
|
||||
"options": {
|
||||
"env": {
|
||||
"FORCE_BROWSER": "false"
|
||||
}
|
||||
},
|
||||
"isBackground": true,
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
|
|
|
|||
327
bun.lock
327
bun.lock
|
|
@ -9,6 +9,7 @@
|
|||
"@elysiajs/cors": "^1.4.1",
|
||||
"@elysiajs/eden": "^1.4.6",
|
||||
"@elysiajs/static": "^1.4.7",
|
||||
"@jimp/wasm-webp": "^1.6.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"conf": "^15.0.2",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
"systeminformation": "^5.31.1",
|
||||
"tough-cookie": "^6.0.0",
|
||||
"tough-cookie-file-store": "^3.3.0",
|
||||
"ts-igdb-client": "^0.4.2",
|
||||
"unzip-stream": "^0.3.4",
|
||||
"webview-bun": "^2.4.0",
|
||||
"zod": "^4.3.6",
|
||||
|
|
@ -31,6 +33,7 @@
|
|||
"devDependencies": {
|
||||
"@ap0nia/eden": "^1.0.0-next.22",
|
||||
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
|
||||
"@emulatorjs/emulatorjs": "^4.2.3",
|
||||
"@hey-api/openapi-ts": "^0.91.0",
|
||||
"@noriginmedia/norigin-spatial-navigation": "^2.3.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
|
@ -140,6 +143,106 @@
|
|||
|
||||
"@elysiajs/static": ["@elysiajs/static@1.4.7", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Go4kIXZ0G3iWfkAld07HmLglqIDMVXdyRKBQK/sVEjtpDdjHNb+rUIje73aDTWpZYg4PEVHUpi9v4AlNEwrQug=="],
|
||||
|
||||
"@emulatorjs/core-81": ["@emulatorjs/core-81@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-oPQEqjpR3z7Yedte4u3sOXDZ4NXAykNcbENjYcB+x3QshF8I+3MQCo8kINOT2lsqqgx91WR4kmEaYQqU39YsDA=="],
|
||||
|
||||
"@emulatorjs/core-a5200": ["@emulatorjs/core-a5200@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-/9yS0/MKHp/wO9iuxWfWTGUwiVNKykEOb7fEN5UM9BfIVQ1SAqep4Ji+TigmYW4weH/mASvYzON9ett3dmD6oQ=="],
|
||||
|
||||
"@emulatorjs/core-beetle_vb": ["@emulatorjs/core-beetle_vb@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-/e6qkA/spYw27qDmzxMiMW5ic36N5W1AVU3Qki8BTZGWKXDseZtNuV/zlPts9rwtTuH8k4+fsQLF+dHp905+/Q=="],
|
||||
|
||||
"@emulatorjs/core-cap32": ["@emulatorjs/core-cap32@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-RkEpGVsOo3S0ZEdDzhfZ5qjeYWYEeK6rGjyvJhYVUnmpPGSQFXvPu0POtIKFyTk9XQrlicllep8ZHzEC7Nngsg=="],
|
||||
|
||||
"@emulatorjs/core-crocods": ["@emulatorjs/core-crocods@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-p536fgB5ROd2CjOeDxGxB8YVKrZ1sGS9X0lYBahCmk6unkz4FL8bU20/TQk4XdKMIsSkhiyonvBg8JZZbs5mWg=="],
|
||||
|
||||
"@emulatorjs/core-desmume": ["@emulatorjs/core-desmume@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-3WgGPBWzjZJWPDKVOaK/Jmg34as0nMY9ClfmrGEiMFUfa3tocpQvL9unJ8Oh4ofWEDTf+45bak/PZ7wp2xwekA=="],
|
||||
|
||||
"@emulatorjs/core-desmume2015": ["@emulatorjs/core-desmume2015@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-GpSuu7/q4yIEbBGrZUlJV3CHoH/b+cNs9Xn0PnC2WTkIuQgEcD5FxUFLup+v208tEKB3bPxXeaLX72i13bkspQ=="],
|
||||
|
||||
"@emulatorjs/core-dosbox_pure": ["@emulatorjs/core-dosbox_pure@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-48CT0ztvnh/M+NRLtHS+pSysdnvH+p+6tgMLJU3+jvfPXdX1dlksiq8PvHPwtpuEF3d9mt2yECCBY9Vq6nkgdw=="],
|
||||
|
||||
"@emulatorjs/core-fbalpha2012_cps1": ["@emulatorjs/core-fbalpha2012_cps1@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-GeAEX/mJcWskcMnEZ1OjjefftL2vznmE+Lms19XyRRSwfJ23/wc8p1mt7P82QCR/aB0925gksjJqOX2WBvKV6A=="],
|
||||
|
||||
"@emulatorjs/core-fbalpha2012_cps2": ["@emulatorjs/core-fbalpha2012_cps2@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-RqmLd0J/Gh8dkyjnpGN7YGIRS1fJjtCKqBs8r54Uz35KmHxw4Qs/ghCc836DRXP/IrIYX871jMxVvsOIOdCB/g=="],
|
||||
|
||||
"@emulatorjs/core-fbneo": ["@emulatorjs/core-fbneo@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-54Yx8Kcq8DH42caTakws3pMUZc6UkBNqtZgg7Q04lNmWWc/W0wPGN0gHxG90xmlOFGHHtxp9QnicBb1NXMjGWg=="],
|
||||
|
||||
"@emulatorjs/core-fceumm": ["@emulatorjs/core-fceumm@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-XX9Vv2N/hzp0TstNMCTSppEs+sg+1lpJpPdSDuRqIO/cwdt7dUcF+WjNX1yQJLRbP5+XwcNHZ6K4BKy8CJpndQ=="],
|
||||
|
||||
"@emulatorjs/core-fuse": ["@emulatorjs/core-fuse@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-LIu2Zc9ALYWoLN5iSanA0LJWsDx9w/OeIu6oueVEpKpej064vf2OKIYYlQz9v6CwwmhsnmY0bURbBPS70gkgCQ=="],
|
||||
|
||||
"@emulatorjs/core-gambatte": ["@emulatorjs/core-gambatte@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-Oq3AJL9SKnMsNAmtAP2eN934TQts3NHeaAe0z1BO9Lx/L3xDZKLpxL+XwgcSXIrl4Sx1oXxYEUwD7JjPEQ0DGg=="],
|
||||
|
||||
"@emulatorjs/core-gearcoleco": ["@emulatorjs/core-gearcoleco@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-todwg9FhUzIBe1xkut+HOKmXvwIHgLSNKwERJm2yfJMY7gx/S1MHITHWWUHL3qxSmApacEucr9nZfpuTqVcjpA=="],
|
||||
|
||||
"@emulatorjs/core-genesis_plus_gx": ["@emulatorjs/core-genesis_plus_gx@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-D1e2XA2CPRAjfr0JurpXJgR9dePxl/xHtaE8v1T9BqD9A3DLfobIFWsWU9jCFS4NfUxCvcLdTYdY/We3QzvcTw=="],
|
||||
|
||||
"@emulatorjs/core-handy": ["@emulatorjs/core-handy@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-miv2nSSVIIHFcGeEdeO7BpYKsljL1j+0an4BO7xw44s9iUp2PZnHiY1mHWUIOzf4o22VuiXd/TvHOKUGaEYMNw=="],
|
||||
|
||||
"@emulatorjs/core-mame2003": ["@emulatorjs/core-mame2003@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-ZfcNooiHvkY45ZDgBkWlIFUY2RRQ1U+aesTvmsWUdDJBWMkyIdK0HUQLWUY7kKdz52sjL97cBqG0dL8xM+MAig=="],
|
||||
|
||||
"@emulatorjs/core-mame2003_plus": ["@emulatorjs/core-mame2003_plus@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-z37RxbT4R6dJe4r+/+GGN0gDW7d3K+DACJPowqJDSYO8TQK9RQBWPl0nsgp1aiLYkRL7yspbJn2tu2inBMWZow=="],
|
||||
|
||||
"@emulatorjs/core-mednafen_ngp": ["@emulatorjs/core-mednafen_ngp@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-q/BhrhIk1+yGQ8zXPHaRXMm7Bqgu+7RkRTwbnUSlH3E/ybj8npA3SOP8tvA8TK2ceZU2F5pWdDAwp3dzR3iikg=="],
|
||||
|
||||
"@emulatorjs/core-mednafen_pce": ["@emulatorjs/core-mednafen_pce@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-PwnQAKKxWT8m8dUscH4VX8jcClvgxgR/NkE6FxNE8qiRDyBH3Jze7+tF9+UdWLoJRBwssR5knd7o/Es9xIlU6A=="],
|
||||
|
||||
"@emulatorjs/core-mednafen_pcfx": ["@emulatorjs/core-mednafen_pcfx@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-o7AfEktQ7xjH8Dj6Np9J4EyGfXgHCcsKqc32vCL6Wts1luCsSApMR+ZcGX5n0u231GtZMU3UJaesNKmSocFXLw=="],
|
||||
|
||||
"@emulatorjs/core-mednafen_psx_hw": ["@emulatorjs/core-mednafen_psx_hw@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-SzNrmaqMqBknf/0hTvPg3pIACQeLb6PVSfaJKjYI4i6u0mpAhgsIW4PJiLOHn5RfLtTpy+eIWQJvC1DQGApfeQ=="],
|
||||
|
||||
"@emulatorjs/core-mednafen_wswan": ["@emulatorjs/core-mednafen_wswan@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-ePj0OoSTErXS171PCUsiZl3OrfPJVwupvRYFiMSzu6K2UaQjK3ESThWf8Rh1niJt1F1OQyjQENAeElSm0IcojQ=="],
|
||||
|
||||
"@emulatorjs/core-melonds": ["@emulatorjs/core-melonds@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-IjsEBNLPPbqU2GSeWEoEkOgAonK3JEyzvoYR3ucgOXu6InzDNdfuA5kP/mMh3d6DwCUbOwfnqGZuZXMTyNE2wA=="],
|
||||
|
||||
"@emulatorjs/core-mgba": ["@emulatorjs/core-mgba@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-daiHzZQKEr+P9fra7j5YoEAXiyYUEtBhFQ8EAV/SeCtrkvqtayU7GQ9LYgoSgzkKSwsbNSskApqGuA9EGARYPA=="],
|
||||
|
||||
"@emulatorjs/core-mupen64plus_next": ["@emulatorjs/core-mupen64plus_next@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-HnnEXbOEpYxD7f2wUJQLxWUpEB0bT2kC56u5s7PLyj/xNI5ckQKka5+ay1QhU2fOv2BuPRwjjhlzmVJmk5e3hw=="],
|
||||
|
||||
"@emulatorjs/core-nestopia": ["@emulatorjs/core-nestopia@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-Flz0+Sebpn5sgjJvghhQydZ5OM+Jf4jwa+E6tG1TuZGQ4+5sYeJFB3FA5zHAIXZuFBS91cWnIXRbpOUennf8Fw=="],
|
||||
|
||||
"@emulatorjs/core-opera": ["@emulatorjs/core-opera@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-cJSZXUAtHF2DLznDEFfo5Owuc9YQh5pg1qCb1TDhtkDZ4Q4za1dTIM0WZYbARd7IN83OX1UB2wd03BSDmCQAFQ=="],
|
||||
|
||||
"@emulatorjs/core-parallel_n64": ["@emulatorjs/core-parallel_n64@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-leRDMzJ36HdLyJ7gpvocaT2gA8SHiPALhEVlR8tHOpiV2vph9hPpNCGmiJL2CbP2eI5DW6A1PY1zFuIXbJnEJw=="],
|
||||
|
||||
"@emulatorjs/core-pcsx_rearmed": ["@emulatorjs/core-pcsx_rearmed@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-mMbl/NszCryFI15X5QniOqLNL7YGZ6tLP24gr5y1q2ekFOIbtc39BcVTMDEwRndNGovh2nPoCcMjB+W3fgQCog=="],
|
||||
|
||||
"@emulatorjs/core-picodrive": ["@emulatorjs/core-picodrive@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-FUxeHeJIIKi4XG+UdOoCARDjH7hV11XLb6LFUO644F7Ri08veGqMQkOJAoCP8uXGxa/JdgS7SXqc0a40+vLv2w=="],
|
||||
|
||||
"@emulatorjs/core-ppsspp": ["@emulatorjs/core-ppsspp@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-jSCvK+74PYFwpqbEWzAkuDalK1TQXYogVXUxs24wn0SJcMhykQuziNggn4QQgM7+4wy60Jkh0Xb00PM9fLlWvA=="],
|
||||
|
||||
"@emulatorjs/core-prboom": ["@emulatorjs/core-prboom@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-raqb5bZE1xrhnZkwY+NoiGyd9MG9pOlXsl2x0+2499NrEqow1946BB8xxzz1HHkbAkZ9fGxu+Xif2cbm9toVlw=="],
|
||||
|
||||
"@emulatorjs/core-prosystem": ["@emulatorjs/core-prosystem@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-Dd+gkjtXzzO4vNRKh1AtwUL8iKveQgz3Oy+ENx7uUvpUYAoQc/UT5aDfKP5vwgJJOg6ErwDH1ShC0WW100YvZQ=="],
|
||||
|
||||
"@emulatorjs/core-puae": ["@emulatorjs/core-puae@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-at/pvoBtpVsMlUyG3LCFOX6iGP4wQzbSaiATrJe8ydiuJVTH/2Eta6veMX6VUsEKd6ClU8SnTMOjqXn70qIqPg=="],
|
||||
|
||||
"@emulatorjs/core-same_cdi": ["@emulatorjs/core-same_cdi@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-Rh0TgLmpr8MnTyJtIsUYjj8CtAO3hitkZ/iDuvgnCDHSuVZrUo0oA1lnWgbzIPg16ThahB+X1t8kxIOmZapiiw=="],
|
||||
|
||||
"@emulatorjs/core-smsplus": ["@emulatorjs/core-smsplus@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-93G2pY9HJVl5pImoIQbDhtswVkX9aIgrrihdGTFHqurhisYHl74XCpzmCoMzdB5UMx09nFW12S+n+bLTIuiLrA=="],
|
||||
|
||||
"@emulatorjs/core-snes9x": ["@emulatorjs/core-snes9x@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-JTe9Rv9eOuCkgkG4ILwuCZUqNcVI3m9ju1NHTTjridUlFgJSqL4DUqF/vLKN9olCpsqlJFfcCayAOoikJxefwA=="],
|
||||
|
||||
"@emulatorjs/core-stella2014": ["@emulatorjs/core-stella2014@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-bs5az26pMrM6jswhZ5YxBXLpIwclwfdnWVbaEpip7yjJFQHactL7KYH7hVLts0JWDnfEK2lNxh4zlTfI6HAHmQ=="],
|
||||
|
||||
"@emulatorjs/core-vice_x128": ["@emulatorjs/core-vice_x128@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-3wmM8O764qdrWHzpHIcihYEYFH6n/BhzB+6Cng8epa298KObF4uklW3jPG/HJyDjWWJsdTlSgo52h6NQC3j/rw=="],
|
||||
|
||||
"@emulatorjs/core-vice_x64": ["@emulatorjs/core-vice_x64@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-y9ea8mPNS9rWBIBYwBuG/rzDApndW9JOL2Bs3qrBRhUeRPFEkQlGPaXKgOCyaV2Iuhy3Zrb3SmtoG0+hn67vOw=="],
|
||||
|
||||
"@emulatorjs/core-vice_x64sc": ["@emulatorjs/core-vice_x64sc@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-gNnVRa/aXmzMXp1+7WXdlW7nIx21O2lS2j4eFt5dun8U6XnSKsqSpsyguJiotlKxzEGtzuE0BZRdSnh/YJQdFA=="],
|
||||
|
||||
"@emulatorjs/core-vice_xpet": ["@emulatorjs/core-vice_xpet@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-jCGjMuU7CXAlxbGHYgVSGLpBFgCaERrnqYi2krDwX8jkubZjpkgE3gdO6wqQrMm9de6icnyk7fkwsRFTMfjwug=="],
|
||||
|
||||
"@emulatorjs/core-vice_xplus4": ["@emulatorjs/core-vice_xplus4@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-9ZeW5adQpqHbp28Z8dPkM7ouPiSX+qGe3mfyXBj6FhBdNrUPrBvbl+A3tLJVYiPX03d9ZLYcxeVn3i60a0WsgQ=="],
|
||||
|
||||
"@emulatorjs/core-vice_xvic": ["@emulatorjs/core-vice_xvic@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-7YFTY1Wa4cjNTsZl+oHecq+OAHskVPEHd29MEKbYQ9jruMmcEXlchuLjjjiEbUs0YLsOr8Gbg3RPBf/wxVi+fA=="],
|
||||
|
||||
"@emulatorjs/core-virtualjaguar": ["@emulatorjs/core-virtualjaguar@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-aZ5oWVrLrXwyVByK11jgcrcdqKM4MfhDucucuBk4duO2blHB8TS8f1XYUDbz0/N44Wl5+sphc5U5IaiXgvdFQw=="],
|
||||
|
||||
"@emulatorjs/core-yabause": ["@emulatorjs/core-yabause@4.2.3", "", { "dependencies": { "@emulatorjs/emulatorjs": "latest" } }, "sha512-12JwbgwoS1l4+KbsQ0jcIU71jVhn5XgXN80/1Fc8aZE4i4sEQatYKU3dZSMBlIJZaWgRN76PEjQymASMJA9/4w=="],
|
||||
|
||||
"@emulatorjs/cores": ["@emulatorjs/cores@4.2.3", "", { "dependencies": { "@emulatorjs/core-81": "latest", "@emulatorjs/core-a5200": "latest", "@emulatorjs/core-beetle_vb": "latest", "@emulatorjs/core-cap32": "latest", "@emulatorjs/core-crocods": "latest", "@emulatorjs/core-desmume": "latest", "@emulatorjs/core-desmume2015": "latest", "@emulatorjs/core-dosbox_pure": "latest", "@emulatorjs/core-fbalpha2012_cps1": "latest", "@emulatorjs/core-fbalpha2012_cps2": "latest", "@emulatorjs/core-fbneo": "latest", "@emulatorjs/core-fceumm": "latest", "@emulatorjs/core-fuse": "latest", "@emulatorjs/core-gambatte": "latest", "@emulatorjs/core-gearcoleco": "latest", "@emulatorjs/core-genesis_plus_gx": "latest", "@emulatorjs/core-handy": "latest", "@emulatorjs/core-mame2003": "latest", "@emulatorjs/core-mame2003_plus": "latest", "@emulatorjs/core-mednafen_ngp": "latest", "@emulatorjs/core-mednafen_pce": "latest", "@emulatorjs/core-mednafen_pcfx": "latest", "@emulatorjs/core-mednafen_psx_hw": "latest", "@emulatorjs/core-mednafen_wswan": "latest", "@emulatorjs/core-melonds": "latest", "@emulatorjs/core-mgba": "latest", "@emulatorjs/core-mupen64plus_next": "latest", "@emulatorjs/core-nestopia": "latest", "@emulatorjs/core-opera": "latest", "@emulatorjs/core-parallel_n64": "latest", "@emulatorjs/core-pcsx_rearmed": "latest", "@emulatorjs/core-picodrive": "latest", "@emulatorjs/core-ppsspp": "latest", "@emulatorjs/core-prboom": "latest", "@emulatorjs/core-prosystem": "latest", "@emulatorjs/core-puae": "latest", "@emulatorjs/core-same_cdi": "latest", "@emulatorjs/core-smsplus": "latest", "@emulatorjs/core-snes9x": "latest", "@emulatorjs/core-stella2014": "latest", "@emulatorjs/core-vice_x128": "latest", "@emulatorjs/core-vice_x64": "latest", "@emulatorjs/core-vice_x64sc": "latest", "@emulatorjs/core-vice_xpet": "latest", "@emulatorjs/core-vice_xplus4": "latest", "@emulatorjs/core-vice_xvic": "latest", "@emulatorjs/core-virtualjaguar": "latest", "@emulatorjs/core-yabause": "latest", "@emulatorjs/emulatorjs": "latest" } }, "sha512-1JxUjVMvEAM7ijUErkzgRSvWjibpjavaUtLlbqWicac4m6NQjKgi+Oc3Po4dcVH+ZPkS1Jt4LHb1WGpGmSIjwA=="],
|
||||
|
||||
"@emulatorjs/emulatorjs": ["@emulatorjs/emulatorjs@4.2.3", "", { "dependencies": { "@node-minify/clean-css": "^9.0.1", "@node-minify/core": "^9.0.2", "@node-minify/terser": "^9.0.1", "http-server": "^14.1.1", "nipplejs": "^0.10.2", "node-7z": "^3.0.0", "node-fetch": "^3.3.2", "socket.io": "^4.8.1" }, "optionalDependencies": { "@emulatorjs/cores": "latest" } }, "sha512-7z3qaA4LwyurhuGvdMUDF9xJpEbxC3SNy9+E9tSaOsRo8FCS2QXam/0k/lc9kqHWRFIlLKWahNjPAStyL0rFnw=="],
|
||||
|
||||
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
||||
|
||||
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||
|
|
@ -214,6 +317,8 @@
|
|||
|
||||
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="],
|
||||
|
||||
"@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="],
|
||||
|
|
@ -270,6 +375,8 @@
|
|||
|
||||
"@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="],
|
||||
|
||||
"@jimp/wasm-webp": ["@jimp/wasm-webp@1.6.0", "", { "dependencies": { "@jsquash/webp": "^1.4.0", "zod": "^3.23.8" } }, "sha512-P0zUpK6n2XIAn8bt0F6rhSn1+FgteBTrL+TBb6Oqw8v5qEDJoNYkd6LlfZYN8YwtRBTBdZ8GFnWsg2Sar+qOkA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
|
@ -284,6 +391,16 @@
|
|||
|
||||
"@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="],
|
||||
|
||||
"@jsquash/webp": ["@jsquash/webp@1.5.0", "", { "dependencies": { "wasm-feature-detect": "^1.2.11" } }, "sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw=="],
|
||||
|
||||
"@node-minify/clean-css": ["@node-minify/clean-css@9.0.1", "", { "dependencies": { "@node-minify/utils": "9.0.1", "clean-css": "5.3.3" } }, "sha512-GHTMmjGloRvNzqdG7foI0iZeS2QmuYCQvdASJP9sCKjkpH45bygODpXPYKnlzUEpQgYvPK9Q3GxqYnVY9SdoqA=="],
|
||||
|
||||
"@node-minify/core": ["@node-minify/core@9.0.2", "", { "dependencies": { "@node-minify/utils": "9.0.1", "glob": "10.3.3", "mkdirp": "3.0.1" } }, "sha512-FNhv29Wom6wKrrFKaeAfmZqz7TX5A1E6P+bpd0VIc+DYWMLUIhAViS8riaZg3A1oD0s06s+5BG2Fg7RqMKiKHw=="],
|
||||
|
||||
"@node-minify/terser": ["@node-minify/terser@9.0.1", "", { "dependencies": { "@node-minify/utils": "9.0.1", "terser": "5.36.0" } }, "sha512-WF78Ex+/xNZZDYvzwB7+sLUYQbJzyyS36ZjMhVqhORejHOsoOcTjQ9TdOgKZoY7wlfxjnLxeMKkO8R/R1KL9aQ=="],
|
||||
|
||||
"@node-minify/utils": ["@node-minify/utils@9.0.1", "", { "dependencies": { "gzip-size": "6.0.0" } }, "sha512-aC1+mhKTP3IMa2VcuGl3ui92LO/7CPQWldNGzu3BVGKiMNJ70AKJW/R6huuYCSuQyHDGM9oFwiVClsZnFxn67g=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
|
@ -322,6 +439,8 @@
|
|||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw=="],
|
||||
|
|
@ -376,6 +495,8 @@
|
|||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
|
||||
|
||||
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||
|
|
@ -468,6 +589,8 @@
|
|||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/fs-extra": ["@types/fs-extra@11.0.4", "", { "dependencies": { "@types/jsonfile": "*", "@types/node": "*" } }, "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ=="],
|
||||
|
|
@ -494,6 +617,8 @@
|
|||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"add-stream": ["add-stream@1.0.0", "", {}, "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ=="],
|
||||
|
|
@ -526,10 +651,16 @@
|
|||
|
||||
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"atomically": ["atomically@2.1.0", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q=="],
|
||||
|
||||
"await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="],
|
||||
|
||||
"axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
|
||||
|
||||
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
|
||||
|
||||
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
|
||||
|
|
@ -538,8 +669,12 @@
|
|||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ=="],
|
||||
|
||||
"basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="],
|
||||
|
||||
"binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
|
@ -566,6 +701,10 @@
|
|||
|
||||
"c12": ["c12@3.3.3", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.8", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"camelcase-keys": ["camelcase-keys@6.2.2", "", { "dependencies": { "camelcase": "^5.3.1", "map-obj": "^4.0.0", "quick-lru": "^4.0.1" } }, "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg=="],
|
||||
|
|
@ -586,6 +725,8 @@
|
|||
|
||||
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
|
||||
"clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
|
@ -598,6 +739,8 @@
|
|||
|
||||
"colorjs.io": ["colorjs.io@0.5.2", "", {}, "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
||||
|
||||
"compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="],
|
||||
|
|
@ -656,6 +799,10 @@
|
|||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"corser": ["corser@2.0.1", "", {}, "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ=="],
|
||||
|
||||
"cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "^1.0.0", "cross-spawn": "^7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
|
@ -674,6 +821,8 @@
|
|||
|
||||
"dargs": ["dargs@7.0.0", "", {}, "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"dateformat": ["dateformat@3.0.3", "", {}, "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q=="],
|
||||
|
||||
"debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="],
|
||||
|
|
@ -692,6 +841,8 @@
|
|||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
||||
|
||||
"detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="],
|
||||
|
|
@ -722,6 +873,12 @@
|
|||
|
||||
"dts-bundle-generator": ["dts-bundle-generator@9.5.1", "", { "dependencies": { "typescript": ">=5.0.2", "yargs": "^17.6.0" }, "bin": { "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" } }, "sha512-DxpJOb2FNnEyOzMkG11sxO2dmxPjthoVWxfKqWYJ/bI/rT1rvTMktF5EKjAYrRZu6Z6t3NhOUZ0sZ5ZXevOfbA=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"eden-tanstack-query": ["eden-tanstack-query@0.0.9", "", { "peerDependencies": { "@elysiajs/eden": ">=1.0.0", "@tanstack/query-core": "^5.90.16" } }, "sha512-EYnFasVEFHFZ9aoI2TDFHU+q0gRdvnFYvX8QzzjXww4dLy0qXDNTGvywmLuiqfiICDC2y4oh2/ZpIPFkGTLNPQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.277", "", {}, "sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw=="],
|
||||
|
|
@ -732,6 +889,10 @@
|
|||
|
||||
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
|
||||
|
||||
"engine.io": ["engine.io@6.6.5", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3" } }, "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A=="],
|
||||
|
||||
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
|
@ -740,6 +901,14 @@
|
|||
|
||||
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||
|
|
@ -752,6 +921,8 @@
|
|||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||
|
||||
"exact-mirror": ["exact-mirror@0.2.6", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-7s059UIx9/tnOKSySzUk5cPGkoILhTE4p6ncf6uIPaQ+9aRBQzQjc9+q85l51+oZ+P6aBxh084pD0CzBQPcFUA=="],
|
||||
|
|
@ -772,6 +943,8 @@
|
|||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"figures": ["figures@3.2.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg=="],
|
||||
|
||||
"file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="],
|
||||
|
|
@ -780,6 +953,14 @@
|
|||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
|
@ -792,8 +973,12 @@
|
|||
|
||||
"get-folder-size": ["get-folder-size@5.0.0", "", { "bin": { "get-folder-size": "bin/get-folder-size.js" } }, "sha512-+fgtvbL83tSDypEK+T411GDBQVQtxv+qtQgbV+HVa/TYubqDhNd5ghH/D6cOHY9iC5/88GtOZB7WI8PXy2A3bg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-pkg-repo": ["get-pkg-repo@4.2.1", "", { "dependencies": { "@hutson/parse-repository-url": "^3.0.0", "hosted-git-info": "^4.0.0", "through2": "^2.0.0", "yargs": "^16.2.0" }, "bin": { "get-pkg-repo": "src/cli.js" } }, "sha512-2+QbHjFRfGB74v/pYWjd5OhU3TDIC2Gv/YKUTk/tCvAz0pkn/Mz6P3uByuBimLOcPvN2jYdScl3xGFSrx0jEcA=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
||||
|
||||
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
||||
|
|
@ -808,28 +993,44 @@
|
|||
|
||||
"gitconfiglocal": ["gitconfiglocal@1.0.0", "", { "dependencies": { "ini": "^1.3.2" } }, "sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ=="],
|
||||
|
||||
"glob": ["glob@10.3.3", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.0.3", "minimatch": "^9.0.1", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", "path-scurry": "^1.10.1" }, "bin": { "glob": "dist/cjs/src/bin.js" } }, "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw=="],
|
||||
|
||||
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
|
||||
|
||||
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="],
|
||||
|
||||
"handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="],
|
||||
|
||||
"hard-rejection": ["hard-rejection@2.1.0", "", {}, "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@3.0.0", "", { "dependencies": { "whatwg-encoding": "^2.0.0" } }, "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
|
||||
"http-proxy": ["http-proxy@1.18.1", "", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ=="],
|
||||
|
||||
"http-server": ["http-server@14.1.1", "", { "dependencies": { "basic-auth": "^2.0.1", "chalk": "^4.1.2", "corser": "^2.0.1", "he": "^1.2.0", "html-encoding-sniffer": "^3.0.0", "http-proxy": "^1.18.1", "mime": "^1.6.0", "minimist": "^1.2.6", "opener": "^1.5.1", "portfinder": "^1.0.28", "secure-compare": "3.0.1", "union": "~0.5.0", "url-join": "^4.0.1" }, "bin": { "http-server": "bin/http-server" } }, "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
|
@ -878,6 +1079,8 @@
|
|||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jackspeak": ["jackspeak@2.3.6", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ=="],
|
||||
|
||||
"jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
|
@ -944,8 +1147,18 @@
|
|||
|
||||
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
||||
|
||||
"lodash.defaultsdeep": ["lodash.defaultsdeep@4.6.1", "", {}, "sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA=="],
|
||||
|
||||
"lodash.defaultto": ["lodash.defaultto@4.14.0", "", {}, "sha512-G6tizqH6rg4P5j32Wy4Z3ZIip7OfG8YWWlPFzUFGcYStH1Ld0l1tWs6NevEQNEDnO1M3NZYjuHuraaFSN5WqeQ=="],
|
||||
|
||||
"lodash.flattendeep": ["lodash.flattendeep@4.4.0", "", {}, "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ=="],
|
||||
|
||||
"lodash.isempty": ["lodash.isempty@4.4.0", "", {}, "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg=="],
|
||||
|
||||
"lodash.ismatch": ["lodash.ismatch@4.4.0", "", {}, "sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g=="],
|
||||
|
||||
"lodash.negate": ["lodash.negate@3.0.2", "", {}, "sha512-JGJYYVslKYC0tRMm/7igfdHulCjoXjoganRNWM8AgS+RXfOvFnPkOveDhPI65F9aAypCX9QEEQoBqWf7Q6uAeA=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
|
@ -956,6 +1169,8 @@
|
|||
|
||||
"map-obj": ["map-obj@4.3.0", "", {}, "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="],
|
||||
|
||||
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
||||
|
|
@ -966,7 +1181,11 @@
|
|||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
|
|
@ -978,6 +1197,8 @@
|
|||
|
||||
"minimist-options": ["minimist-options@4.1.0", "", { "dependencies": { "arrify": "^1.0.1", "is-plain-obj": "^1.1.0", "kind-of": "^6.0.3" } }, "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"modify-values": ["modify-values@1.0.1", "", {}, "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw=="],
|
||||
|
|
@ -986,14 +1207,24 @@
|
|||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
|
||||
|
||||
"nipplejs": ["nipplejs@0.10.2", "", {}, "sha512-XGxFY8C2DOtobf1fK+MXINTzkkXJLjZDDpfQhOUZf4TSytbc9s4bmA0lB9eKKM8iDivdr9NQkO7DpIQfsST+9g=="],
|
||||
|
||||
"node-7z": ["node-7z@3.0.0", "", { "dependencies": { "debug": "^4.3.2", "lodash.defaultsdeep": "^4.6.1", "lodash.defaultto": "^4.14.0", "lodash.flattendeep": "^4.4.0", "lodash.isempty": "^4.4.0", "lodash.negate": "^3.0.2", "normalize-path": "^3.0.0" } }, "sha512-KIznWSxIkOYO/vOgKQfJEaXd7rgoFYKZbaurainCEdMhYc7V7mRHX+qdf2HgbpQFcdJL/Q6/XOPrDLoBeTfuZA=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"node-disk-info": ["node-disk-info@1.3.0", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-NEx858vJZ0AoBtmD/ChBIHLjFTF28xCsDIgmFl4jtGKsvlUx9DU/OrMDjvj3qp/E4hzLN0HvTg7eJx5XFQvbeg=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-downloader-helper": ["node-downloader-helper@2.1.10", "", { "bin": { "ndh": "bin/ndh" } }, "sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg=="],
|
||||
|
||||
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-html-parser": ["node-html-parser@7.0.2", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ=="],
|
||||
|
|
@ -1014,6 +1245,8 @@
|
|||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
|
||||
|
|
@ -1022,6 +1255,8 @@
|
|||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"opener": ["opener@1.5.2", "", { "bin": { "opener": "bin/opener-bin.js" } }, "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
|
@ -1050,6 +1285,8 @@
|
|||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"path-type": ["path-type@3.0.0", "", { "dependencies": { "pify": "^3.0.0" } }, "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
|
@ -1070,6 +1307,8 @@
|
|||
|
||||
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
|
||||
"portfinder": ["portfinder@1.0.38", "", { "dependencies": { "async": "^3.2.6", "debug": "^4.3.6" } }, "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||
|
|
@ -1090,10 +1329,14 @@
|
|||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"q": ["q@1.5.1", "", {}, "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw=="],
|
||||
|
||||
"qr.js": ["qr.js@0.0.0", "", {}, "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ=="],
|
||||
|
||||
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="],
|
||||
|
|
@ -1132,6 +1375,8 @@
|
|||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
|
@ -1146,7 +1391,7 @@
|
|||
|
||||
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
|
|
@ -1194,6 +1439,8 @@
|
|||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"secure-compare": ["secure-compare@3.0.1", "", {}, "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"seroval": ["seroval@1.4.2", "", {}, "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ=="],
|
||||
|
|
@ -1206,8 +1453,24 @@
|
|||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="],
|
||||
|
||||
"socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="],
|
||||
|
||||
"socket.io-adapter": ["socket.io-adapter@2.5.6", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.18.3" } }, "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ=="],
|
||||
|
||||
"socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="],
|
||||
|
||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
|
@ -1230,12 +1493,16 @@
|
|||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"stringify-package": ["stringify-package@1.0.1", "", {}, "sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
|
@ -1304,6 +1571,10 @@
|
|||
|
||||
"trim-newlines": ["trim-newlines@3.0.1", "", {}, "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw=="],
|
||||
|
||||
"ts-apicalypse": ["ts-apicalypse@0.4.2", "", { "dependencies": { "axios": "^1.4.0" } }, "sha512-A02KFDFZHYTft0fTkxr5AERLb//XK/qjvGxrX8uNCwZAvFpLI/4TrOVxbq6kV2caZXWAn8eZZfRzVn1xd/cSMw=="],
|
||||
|
||||
"ts-igdb-client": ["ts-igdb-client@0.4.2", "", { "dependencies": { "axios": "^1.4.0", "ts-apicalypse": "^0.4.2" } }, "sha512-VGQbIyy75GbHW0WGGHBXsdJzuj8wOW/jiWURmQWltpkFjCTMSqqx9gTPSUUcJ0W2kdlWcyMU4TDDHo4N3Fij7A=="],
|
||||
|
||||
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
|
@ -1324,6 +1595,8 @@
|
|||
|
||||
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
||||
|
||||
"union": ["union@0.5.0", "", { "dependencies": { "qs": "^6.4.0" } }, "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA=="],
|
||||
|
||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||
|
||||
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||
|
|
@ -1332,6 +1605,8 @@
|
|||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="],
|
||||
|
|
@ -1344,6 +1619,8 @@
|
|||
|
||||
"varint": ["varint@6.0.0", "", {}, "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"vite-plugin-svg-icons-ng": ["vite-plugin-svg-icons-ng@1.5.2", "", { "dependencies": { "fast-glob": "^3.3.3", "fs-extra": "^11.3.2", "node-html-parser": "^7.0.1", "svgo": "^3.3.2" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-A68obs8XDT+q8q8dKyjrT/v0qw8h5pEBKXJ27aUXjARYeJ6MNvhIhRLLiUwnSrbn/B4TBF4UVaWRXKftAqP7+A=="],
|
||||
|
|
@ -1352,6 +1629,10 @@
|
|||
|
||||
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
|
||||
|
||||
"wasm-feature-detect": ["wasm-feature-detect@1.8.0", "", {}, "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"webview-bun": ["webview-bun@2.4.0", "", {}, "sha512-0+ugnQlcUHmuW+iLeb+Lzb8rGUJh7WEdXvNsuvaVEXT3EagK380XdD7heVJu0Ek/mNxMY3G2JM142YRQ1hDUGQ=="],
|
||||
|
|
@ -1368,6 +1649,10 @@
|
|||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
|
||||
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
|
||||
|
|
@ -1396,8 +1681,16 @@
|
|||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"@jimp/core/file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
|
||||
|
||||
"@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
|
@ -1430,6 +1723,12 @@
|
|||
|
||||
"@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/wasm-webp/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@node-minify/core/mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
|
||||
|
||||
"@node-minify/terser/terser": ["terser@5.36.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
|
@ -1456,6 +1755,8 @@
|
|||
|
||||
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"compare-func/dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
|
||||
|
||||
"conventional-changelog-writer/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
|
@ -1470,16 +1771,22 @@
|
|||
|
||||
"elysia/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"get-pkg-repo/through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="],
|
||||
|
||||
"get-pkg-repo/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
|
||||
|
||||
"git-semver-tags/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
"handlebars/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"html-encoding-sniffer/whatwg-encoding": ["whatwg-encoding@2.0.0", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="],
|
||||
|
|
@ -1498,6 +1805,8 @@
|
|||
|
||||
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"path-type/pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="],
|
||||
|
||||
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
||||
|
|
@ -1520,6 +1829,8 @@
|
|||
|
||||
"standard-version/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
|
||||
|
||||
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"svgo/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
|
||||
|
||||
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
|
@ -1574,10 +1885,18 @@
|
|||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"@jimp/core/file-type/strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
|
||||
|
||||
"@jimp/core/file-type/token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="],
|
||||
|
||||
"@node-minify/terser/terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
|
||||
|
|
@ -1590,6 +1909,8 @@
|
|||
|
||||
"get-pkg-repo/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"meow/read-pkg-up/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
|
@ -1722,8 +2043,6 @@
|
|||
|
||||
"dotgitignore/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
|
||||
|
||||
"get-pkg-repo/through2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"get-pkg-repo/through2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"meow/read-pkg-up/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
|
|
|||
27
drizzle/0001_outstanding_silk_fever.sql
Normal file
27
drizzle/0001_outstanding_silk_fever.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_games` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`source_id` text,
|
||||
`source` text,
|
||||
`igdb_id` integer,
|
||||
`name` text,
|
||||
`ra_id` integer,
|
||||
`path_fs` text,
|
||||
`last_played` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`metadata` text DEFAULT '{}',
|
||||
`slug` text,
|
||||
`platform_id` integer NOT NULL,
|
||||
`cover` blob,
|
||||
`type` text,
|
||||
`summary` text,
|
||||
FOREIGN KEY (`platform_id`) REFERENCES `platforms`(`id`) ON UPDATE cascade ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_games`("id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary") SELECT "id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary" FROM `games`;--> statement-breakpoint
|
||||
DROP TABLE `games`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_games` RENAME TO `games`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `games_igdb_id_unique` ON `games` (`igdb_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `games_ra_id_unique` ON `games` (`ra_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `games_slug_unique` ON `games` (`slug`);
|
||||
451
drizzle/meta/0001_snapshot.json
Normal file
451
drizzle/meta/0001_snapshot.json
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "6dc00b41-64d7-4cb3-ad90-f9e8e35af643",
|
||||
"prevId": "673fe5dc-58a5-495b-8fb1-104e7945e90b",
|
||||
"tables": {
|
||||
"collections": {
|
||||
"name": "collections",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"collections_games": {
|
||||
"name": "collections_games",
|
||||
"columns": {
|
||||
"collection_id": {
|
||||
"name": "collection_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"game_id": {
|
||||
"name": "game_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"collections_games_collection_id_collections_id_fk": {
|
||||
"name": "collections_games_collection_id_collections_id_fk",
|
||||
"tableFrom": "collections_games",
|
||||
"tableTo": "collections",
|
||||
"columnsFrom": [
|
||||
"collection_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "cascade"
|
||||
},
|
||||
"collections_games_game_id_games_id_fk": {
|
||||
"name": "collections_games_game_id_games_id_fk",
|
||||
"tableFrom": "collections_games",
|
||||
"tableTo": "games",
|
||||
"columnsFrom": [
|
||||
"game_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "cascade"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"games": {
|
||||
"name": "games",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"source_id": {
|
||||
"name": "source_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"igdb_id": {
|
||||
"name": "igdb_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ra_id": {
|
||||
"name": "ra_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"path_fs": {
|
||||
"name": "path_fs",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_played": {
|
||||
"name": "last_played",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'{}'"
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"platform_id": {
|
||||
"name": "platform_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cover": {
|
||||
"name": "cover",
|
||||
"type": "blob",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"summary": {
|
||||
"name": "summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"games_igdb_id_unique": {
|
||||
"name": "games_igdb_id_unique",
|
||||
"columns": [
|
||||
"igdb_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"games_ra_id_unique": {
|
||||
"name": "games_ra_id_unique",
|
||||
"columns": [
|
||||
"ra_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"games_slug_unique": {
|
||||
"name": "games_slug_unique",
|
||||
"columns": [
|
||||
"slug"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"games_platform_id_platforms_id_fk": {
|
||||
"name": "games_platform_id_platforms_id_fk",
|
||||
"tableFrom": "games",
|
||||
"tableTo": "platforms",
|
||||
"columnsFrom": [
|
||||
"platform_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "cascade"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"platforms": {
|
||||
"name": "platforms",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"igdb_id": {
|
||||
"name": "igdb_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"igdb_slug": {
|
||||
"name": "igdb_slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"moby_id": {
|
||||
"name": "moby_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"es_slug": {
|
||||
"name": "es_slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ra_id": {
|
||||
"name": "ra_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cover": {
|
||||
"name": "cover",
|
||||
"type": "blob",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"family_name": {
|
||||
"name": "family_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"platforms_igdb_id_unique": {
|
||||
"name": "platforms_igdb_id_unique",
|
||||
"columns": [
|
||||
"igdb_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"platforms_igdb_slug_unique": {
|
||||
"name": "platforms_igdb_slug_unique",
|
||||
"columns": [
|
||||
"igdb_slug"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"platforms_moby_id_unique": {
|
||||
"name": "platforms_moby_id_unique",
|
||||
"columns": [
|
||||
"moby_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"platforms_es_slug_unique": {
|
||||
"name": "platforms_es_slug_unique",
|
||||
"columns": [
|
||||
"es_slug"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"platforms_ra_id_unique": {
|
||||
"name": "platforms_ra_id_unique",
|
||||
"columns": [
|
||||
"ra_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"platforms_slug_unique": {
|
||||
"name": "platforms_slug_unique",
|
||||
"columns": [
|
||||
"slug"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"screenshots": {
|
||||
"name": "screenshots",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"game_id": {
|
||||
"name": "game_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "blob",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"screenshots_game_id_games_id_fk": {
|
||||
"name": "screenshots_game_id_games_id_fk",
|
||||
"tableFrom": "screenshots",
|
||||
"tableTo": "games",
|
||||
"columnsFrom": [
|
||||
"game_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "cascade"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@
|
|||
"when": 1771508990238,
|
||||
"tag": "0000_pretty_harry_osborn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1772998956867,
|
||||
"tag": "0001_outstanding_silk_fever",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -13,8 +13,8 @@
|
|||
"packageManager": "bun@1.3.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development bun run build:vite && WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS='--remote-debugging-port=9222' bun run ./scripts/dev.ts",
|
||||
"dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run dev'",
|
||||
"dev": "NODE_ENV=development bun run build:vite && bun run ./scripts/dev.ts",
|
||||
"dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'",
|
||||
"build:vite": "vite build",
|
||||
"build:prod:vite": "NODE_ENV=production bun run build:vite",
|
||||
"build:dev:vite": "NODE_ENV=development bun run build:vite",
|
||||
|
|
@ -44,6 +44,7 @@
|
|||
"@elysiajs/cors": "^1.4.1",
|
||||
"@elysiajs/eden": "^1.4.6",
|
||||
"@elysiajs/static": "^1.4.7",
|
||||
"@jimp/wasm-webp": "^1.6.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"conf": "^15.0.2",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
|
|
@ -59,6 +60,7 @@
|
|||
"systeminformation": "^5.31.1",
|
||||
"tough-cookie": "^6.0.0",
|
||||
"tough-cookie-file-store": "^3.3.0",
|
||||
"ts-igdb-client": "^0.4.2",
|
||||
"unzip-stream": "^0.3.4",
|
||||
"webview-bun": "^2.4.0",
|
||||
"zod": "^4.3.6"
|
||||
|
|
@ -66,6 +68,7 @@
|
|||
"devDependencies": {
|
||||
"@ap0nia/eden": "^1.0.0-next.22",
|
||||
"@ap0nia/eden-tanstack-query": "^1.0.0-next.22",
|
||||
"@emulatorjs/emulatorjs": "^4.2.3",
|
||||
"@hey-api/openapi-ts": "^0.91.0",
|
||||
"@noriginmedia/norigin-spatial-navigation": "^2.3.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
|
|
|||
|
|
@ -1,19 +1,28 @@
|
|||
// watcher.ts - run this instead of --watch
|
||||
import EventEmitter from "events";
|
||||
import { watch } from "fs";
|
||||
import browser from '../src/bun/browser';
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
const events = new EventEmitter();
|
||||
const abortController = new AbortController();
|
||||
|
||||
process.env.WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--remote-debugging-port=9222";
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
let retries = 0;
|
||||
|
||||
function spawnServer ()
|
||||
{
|
||||
return Bun.spawn(["bun", "run", "--inspect=127.0.0.1:9229/fixed-session", '--watch', "./src/bun/index.ts"], {
|
||||
return Bun.spawn(["bun", "run", '--watch', "--inspect=127.0.0.1:9228/fixed-session", "./src/bun/index.ts"], {
|
||||
env: {
|
||||
...Bun.env,
|
||||
HEADLESS: "true",
|
||||
},
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "inherit",
|
||||
stdin: "pipe",
|
||||
signal: abortController.signal,
|
||||
killSignal: 'SIGUSR1',
|
||||
ipc (message, subprocess, handle)
|
||||
{
|
||||
if (message.type === 'exitapp')
|
||||
|
|
@ -22,9 +31,17 @@ function spawnServer ()
|
|||
}
|
||||
},
|
||||
onExit (subprocess, exitCode, signalCode)
|
||||
{
|
||||
if (exitCode === 1 && retries <= 3)
|
||||
{
|
||||
server = spawnServer();
|
||||
retries++;
|
||||
} else
|
||||
{
|
||||
process.exit();
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -32,12 +49,18 @@ function spawnBrowser ()
|
|||
{
|
||||
try
|
||||
{
|
||||
return browser(events, !!Bun.env.FORCE_BROWSER);
|
||||
|
||||
return browser(events, Bun.env.FORCE_BROWSER === "true", { configPath: path.join(tmpdir(), 'gameflow') });
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
};
|
||||
}
|
||||
|
||||
const server = spawnServer();
|
||||
spawnBrowser()?.then(e => server.send({ type: 'exitapp' }));
|
||||
let server = spawnServer();
|
||||
spawnBrowser()?.then(async e =>
|
||||
{
|
||||
console.log("Sending exit Signal to server");
|
||||
await server.stdin.write('shutdown\n');
|
||||
await server.stdin.flush();
|
||||
});
|
||||
|
|
@ -6,6 +6,8 @@ import { Database } from "bun:sqlite";
|
|||
import * as schema from '../src/bun/api/schema/emulators';
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import path from 'node:path';
|
||||
import { ensureDir } from 'fs-extra';
|
||||
|
||||
/** get all latest supported romm platforms */
|
||||
const rommPlatforms = await getSupportedPlatformsEndpointApiPlatformsSupportedGet({ baseUrl: "https://demo.romm.app" });
|
||||
|
|
@ -41,10 +43,10 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
|||
|
||||
await Promise.all(platforms.map(async ([platform, arch]) =>
|
||||
{
|
||||
const systems = await Bun.file(`./vendors/es-de/systems/${mapSystem(platform, arch)}/es_systems.xml`).arrayBuffer();
|
||||
const $s = cheerio.load(Buffer.from(systems));
|
||||
const rules = await Bun.file(`./vendors/es-de/systems/${mapSystem(platform, arch)}/es_find_rules.xml`).arrayBuffer();
|
||||
const $r = cheerio.load(Buffer.from(rules));
|
||||
const systemsXml = await Bun.file(`./vendors/es-de/systems/${mapSystem(platform, arch)}/es_systems.xml`).arrayBuffer();
|
||||
const $s = cheerio.load(Buffer.from(systemsXml));
|
||||
const rulesXml = await Bun.file(`./vendors/es-de/systems/${mapSystem(platform, arch)}/es_find_rules.xml`).arrayBuffer();
|
||||
const $r = cheerio.load(Buffer.from(rulesXml));
|
||||
|
||||
const sqlitePath = `./vendors/es-de/emulators.${platform}.${arch}.sqlite`;
|
||||
const sqlite = new Database(sqlitePath, { create: true, readwrite: true });
|
||||
|
|
@ -52,7 +54,7 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
|||
migrate(db, { migrationsFolder: "./scripts/drizzle/es-de" });
|
||||
|
||||
/** Save the ruleset for emulators */
|
||||
await db.insert(schema.emulators).values($r('ruleList emulator').toArray().map(s =>
|
||||
const emulators = $r('ruleList emulator').toArray().map(s =>
|
||||
{
|
||||
const $emulator = $r(s);
|
||||
const $systempath = $emulator.find('rule[type=systempath] entry');
|
||||
|
|
@ -71,13 +73,27 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
|||
winregistrypath: $winregistrypath.toArray().map(p => $r(p).text()),
|
||||
};
|
||||
return emulator;
|
||||
}));
|
||||
});
|
||||
|
||||
await db.insert(schema.emulators).values(emulators);
|
||||
|
||||
/** Save the systems like ps2 or psp */
|
||||
await Promise.all($s(`systemList system`).toArray().map(async s =>
|
||||
const systems = await Promise.all($s(`systemList system`).toArray().map(async s =>
|
||||
{
|
||||
const name = $s(s).find("name").text();
|
||||
const fullname = $s(s).find("fullname").text();
|
||||
|
||||
const commands = $s(s).find("command").toArray().map(c =>
|
||||
{
|
||||
const command: typeof schema.commands.$inferInsert = {
|
||||
label: $s(c).attr('label'),
|
||||
command: $s(c).text(),
|
||||
system: name
|
||||
};
|
||||
|
||||
return command;
|
||||
});
|
||||
|
||||
const rommMapping = rommPlatforms.data?.find(p =>
|
||||
p.slug === (customMappings as any)[name] ||
|
||||
p.slug === name ||
|
||||
|
|
@ -87,17 +103,13 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
|||
p.display_name === fullname
|
||||
);
|
||||
|
||||
const system: typeof schema.systems.$inferInsert = {
|
||||
name,
|
||||
fullname,
|
||||
path: $s(s).find("path").text(),
|
||||
extension: $s(s).find("extension").text().replaceAll('.', '').split(' ')
|
||||
};
|
||||
const mappings: {
|
||||
source: string;
|
||||
sourceId: number | null;
|
||||
sourceSlug: string | null;
|
||||
system: string;
|
||||
}[] = [];
|
||||
|
||||
/** Store mappings to all other sources for easy reference */
|
||||
db.transaction(async (tx) =>
|
||||
{
|
||||
await tx.insert(schema.systems).values(system);
|
||||
if (rommMapping)
|
||||
{
|
||||
const sources: [string, keyof typeof rommMapping | null, keyof typeof rommMapping | null][] = [
|
||||
|
|
@ -114,27 +126,40 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
|||
['igdb', 'igdb_id', 'igdb_slug']
|
||||
];
|
||||
|
||||
await tx.insert(schema.systemMappings)
|
||||
.values(sources.map(([source, sourceId, sourceSlug]) => ({
|
||||
mappings.push(...sources.map(([source, sourceId, sourceSlug]) => ({
|
||||
source,
|
||||
sourceId: sourceId ? rommMapping[sourceId] as number : null,
|
||||
sourceSlug: sourceSlug ? rommMapping[sourceSlug] as string : null,
|
||||
system: system.name
|
||||
} satisfies typeof schema.systemMappings.$inferInsert))
|
||||
system: name
|
||||
}))
|
||||
.filter(m => m.sourceId !== null || m.sourceSlug !== null));
|
||||
}
|
||||
|
||||
const system = {
|
||||
name,
|
||||
fullname,
|
||||
path: $s(s).find("path").text(),
|
||||
extension: $s(s).find("extension").text().replaceAll('.', '').split(' '),
|
||||
commands,
|
||||
mappings
|
||||
};
|
||||
return system;
|
||||
}));
|
||||
|
||||
await Promise.all(systems.map(async system =>
|
||||
{
|
||||
/** Store mappings to all other sources for easy reference */
|
||||
await db.transaction(async (tx) =>
|
||||
{
|
||||
await tx.insert(schema.systems).values(system);
|
||||
if (system.mappings.length > 0)
|
||||
{
|
||||
await tx.insert(schema.systemMappings)
|
||||
.values(system.mappings);
|
||||
}
|
||||
});
|
||||
|
||||
await db.insert(schema.commands).values($s(s).find("command").toArray().map(c =>
|
||||
{
|
||||
const command: typeof schema.commands.$inferInsert = {
|
||||
label: $s(c).attr('label'),
|
||||
command: $s(c).text(),
|
||||
system: system.name
|
||||
};
|
||||
|
||||
return command;
|
||||
}));
|
||||
await db.insert(schema.commands).values(system.commands);
|
||||
}));
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import Conf from "conf";
|
|||
import projectPackage from '~/package.json';
|
||||
import { Notification, SettingsSchema, SettingsType } from "@shared/constants";
|
||||
import { client } from "@clients/romm/client.gen";
|
||||
import * as schema from "./schema/app";
|
||||
import * as emulatorSchema from "./schema/emulators";
|
||||
import * as schema from "@schema/app";
|
||||
import cacheSchema from "@schema/cache";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import { login, logout } from "./auth";
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { ActiveGame } from "../types/types";
|
||||
import EventEmitter from "node:events";
|
||||
|
|
@ -21,6 +21,7 @@ import { ErrorLike } from "bun";
|
|||
import { appPath, getErrorMessage } from "../utils";
|
||||
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||
import { ensureDir } from "fs-extra";
|
||||
import UpdateStoreJob from "./jobs/update-store";
|
||||
|
||||
export const config = new Conf<SettingsType>({
|
||||
projectName: projectPackage.name,
|
||||
|
|
@ -50,7 +51,10 @@ const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path),
|
|||
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
||||
export const jar = new CookieJar(fileCookieStore);
|
||||
let sqlite: Database;
|
||||
export const cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
|
||||
let cacheSqlite: Database;
|
||||
export let db: DrizzleSqliteDODatabase<typeof schema>;
|
||||
export let cache: DrizzleSqliteDODatabase<typeof cacheSchema>;
|
||||
await reloadDatabase();
|
||||
const emulatorsSqlite = new Database(appPath(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`), { readonly: true });
|
||||
export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
||||
|
|
@ -73,6 +77,7 @@ events.addListener('activegameexit', ({ error }) =>
|
|||
}
|
||||
});
|
||||
config.onDidChange('downloadPath', () => reloadDatabase());
|
||||
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
|
||||
|
||||
export async function cleanup ()
|
||||
{
|
||||
|
|
@ -86,13 +91,25 @@ export async function reloadDatabase ()
|
|||
{
|
||||
await ensureDir(config.get('downloadPath'));
|
||||
sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true });
|
||||
await ensureDir(path.join(os.tmpdir(), 'gameflow'));
|
||||
console.log("Loaded Cache from: ", cachePath);
|
||||
cacheSqlite = new Database(cachePath, { create: true, readwrite: true });
|
||||
db = drizzle(sqlite, { schema });
|
||||
cache = drizzle(cacheSqlite, { schema: cacheSchema });
|
||||
migrate(db!, { migrationsFolder: appPath("./drizzle") });
|
||||
cache.run(`
|
||||
CREATE TABLE IF NOT EXISTS item_cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
expire_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
interface AppEventMap
|
||||
{
|
||||
activegameexit: [{ source: string, id: number, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
||||
activegameexit: [{ source: string, id: string, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
||||
exitapp: [];
|
||||
notification: [Notification];
|
||||
}
|
||||
|
|
@ -1,45 +1,117 @@
|
|||
import Elysia, { sse, status } from "elysia";
|
||||
import { config, jar, taskQueue } from "./app";
|
||||
import { config, events, jar, taskQueue } from "./app";
|
||||
import z from "zod";
|
||||
import { client } from "@clients/romm/client.gen";
|
||||
import { loginApiLoginPost, logoutApiLogoutPost } from "@clients/romm";
|
||||
import secrets from '../api/secrets';
|
||||
import { LoginJob } from "./jobs/login-job";
|
||||
import TwitchLoginJob from "./jobs/twitch-login-job";
|
||||
|
||||
export default new Elysia()
|
||||
.post('/login/remote/start', async () =>
|
||||
.post('/login/twitch', async ({ body: { openInBrowser } }) =>
|
||||
{
|
||||
if (taskQueue.hasActiveOfType(TwitchLoginJob))
|
||||
{
|
||||
return status("Conflict", `Twitch Authentication already in progress`);
|
||||
}
|
||||
|
||||
if (!process.env.TWITCH_CLIENT_ID)
|
||||
{
|
||||
return status("Not Found", "Twitch Client ID not set");
|
||||
}
|
||||
|
||||
return taskQueue.enqueue(TwitchLoginJob.id, new TwitchLoginJob(process.env.TWITCH_CLIENT_ID, openInBrowser ?? false));
|
||||
},
|
||||
{ body: z.object({ openInBrowser: z.boolean().optional() }) })
|
||||
.post('/logout/twitch', async () =>
|
||||
{
|
||||
if (!process.env.TWITCH_CLIENT_ID)
|
||||
{
|
||||
return status("Not Found", "Twitch Client ID not set");
|
||||
}
|
||||
|
||||
const res = await fetch('https://id.twitch.tv/oauth2/revoke', {
|
||||
method: "POST", headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.TWITCH_CLIENT_ID
|
||||
})
|
||||
});
|
||||
|
||||
await secrets.delete({ service: 'gamflow_twitch', name: 'access_token' });
|
||||
await secrets.delete({ service: 'gamflow_twitch', name: 'refresh_token' });
|
||||
await secrets.delete({ service: 'gamflow_twitch', name: 'expires_in' });
|
||||
|
||||
return status(res.status, res.statusText);
|
||||
})
|
||||
.get('/login/twitch', async () =>
|
||||
{
|
||||
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
|
||||
if (!access_token)
|
||||
{
|
||||
return status('Not Found', "Not Logged In");
|
||||
}
|
||||
|
||||
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } });
|
||||
if (res.ok)
|
||||
{
|
||||
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
|
||||
}
|
||||
|
||||
if (!process.env.TWITCH_CLIENT_ID)
|
||||
{
|
||||
return status("Not Found", "Twitch Client ID not set");
|
||||
}
|
||||
|
||||
const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" });
|
||||
if (!refresh_token)
|
||||
{
|
||||
return status("Not Found", "Refresh Token Not Found");
|
||||
}
|
||||
|
||||
// refresh token
|
||||
const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', {
|
||||
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({
|
||||
client_id: process.env.TWITCH_CLIENT_ID,
|
||||
access_token,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token
|
||||
})
|
||||
});
|
||||
|
||||
if (refreshResponse.ok)
|
||||
{
|
||||
const data: {
|
||||
access_token: string,
|
||||
refresh_token: string,
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
} = await refreshResponse.json();
|
||||
|
||||
await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token });
|
||||
await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token });
|
||||
await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() });
|
||||
|
||||
events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' });
|
||||
|
||||
const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } });
|
||||
if (res.ok)
|
||||
{
|
||||
return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; };
|
||||
}
|
||||
}
|
||||
|
||||
return status(400, res.statusText);
|
||||
})
|
||||
.post('/login/romm', async () =>
|
||||
{
|
||||
if (taskQueue.hasActiveOfType(LoginJob))
|
||||
{
|
||||
return status("Conflict", "Login Already Active");
|
||||
}
|
||||
|
||||
const job = new LoginJob();
|
||||
taskQueue.enqueue("login", job);
|
||||
return status("OK");
|
||||
})
|
||||
.get('/login/remote/status', async function* ()
|
||||
{
|
||||
const job = taskQueue.findJob("login");
|
||||
if (job)
|
||||
{
|
||||
const loginJob = job.job as LoginJob;
|
||||
yield sse({ data: { endsAt: loginJob.endsAt, url: loginJob.url } });
|
||||
await taskQueue.waitForJob('login');
|
||||
yield sse({ data: {} });
|
||||
}
|
||||
|
||||
yield sse({ data: {} });
|
||||
})
|
||||
.post('/login/remote/cancel', async () =>
|
||||
{
|
||||
const job = taskQueue.findJob("login");
|
||||
if (job)
|
||||
{
|
||||
job.abort("cancel");
|
||||
await taskQueue.waitForJob('login');
|
||||
}
|
||||
return {};
|
||||
return taskQueue.enqueue(LoginJob.id, new LoginJob());
|
||||
})
|
||||
.post('/login', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
|
||||
.get('/login', async () =>
|
||||
|
|
|
|||
34
src/bun/api/cache.ts
Normal file
34
src/bun/api/cache.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { cache } from "./app";
|
||||
import cacheSchema from "@schema/cache";
|
||||
|
||||
export const CACHE_KEYS = {
|
||||
ROM_PLATFORMS: 'rom-platforms',
|
||||
STORE_GAME: (path: string) => `store-game-${path}`,
|
||||
STORE_GAME_MANIFEST: 'store-game-manifest'
|
||||
} as const;
|
||||
|
||||
export async function getOrCached<T> (key: string, getter: () => Promise<T>, options?: { expireMs?: number; }): Promise<T>
|
||||
{
|
||||
const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) });
|
||||
const updated_at = new Date();
|
||||
|
||||
if (cached && cached.expire_at > updated_at)
|
||||
{
|
||||
return cached.data as T;
|
||||
}
|
||||
|
||||
const data = await getter();
|
||||
|
||||
const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
await cache.insert(cacheSchema.item_cache)
|
||||
.values({ key, data, updated_at, expire_at })
|
||||
.onConflictDoUpdate({
|
||||
target: cacheSchema.item_cache.key,
|
||||
set: { data, updated_at, expire_at }
|
||||
})
|
||||
.run();
|
||||
|
||||
return data;
|
||||
}
|
||||
46
src/bun/api/emulatorjs/emulatorjs.ts
Normal file
46
src/bun/api/emulatorjs/emulatorjs.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// ES-DE to emulator JS mapping
|
||||
// TODO: use the retroarch cores based on ES-DE
|
||||
export const cores: Record<string, string> = {
|
||||
"atari5200": "atari5200",
|
||||
"virtualboy": "vb",
|
||||
"nds": "nds",
|
||||
"arcade": "arcade",
|
||||
"nes": "nes",
|
||||
"gb": "gb",
|
||||
"gbc": "gb",
|
||||
"colecovision": "coleco",
|
||||
"mastersystem": "segaMS",
|
||||
"megadrive": "segaMD",
|
||||
"gamegear": "segaGG",
|
||||
"segacd": "segaCD",
|
||||
"sega32x": "sega32x",
|
||||
"genesis": "sega",
|
||||
"mark3": "sega",
|
||||
"megacd": "sega",
|
||||
"megacdjp": "sega",
|
||||
"megadrivejp": "sega",
|
||||
"sg-1000": "sega",
|
||||
"atarilynx": "lynx",
|
||||
"mame": "mame",
|
||||
"ngp": "ngp",
|
||||
"supergrafx": "pce",
|
||||
"pcfx": "pcfx",
|
||||
"psx": "psx",
|
||||
"wonderswan": "ws",
|
||||
"gba": "gba",
|
||||
"n64": "n64",
|
||||
"3do": "3do",
|
||||
"psp": "psp",
|
||||
"atari7800": "atari7800",
|
||||
"snes": "snes",
|
||||
"atari2600": "atari2600",
|
||||
"atarijaguar": "jaguar",
|
||||
"saturn": "segaSaturn",
|
||||
"amiga": "amiga",
|
||||
"c64": "c64",
|
||||
"c128": "c128",
|
||||
"pet": "pet",
|
||||
"plus4": "plus4",
|
||||
"vic20": "vic20",
|
||||
"dos": "dos"
|
||||
};
|
||||
|
|
@ -1,23 +1,32 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { config, db, taskQueue } from "../app";
|
||||
import { activeGame, config, db, events, taskQueue } from "../app";
|
||||
import { and, eq, getTableColumns, sql } from "drizzle-orm";
|
||||
import z from "zod";
|
||||
import * as schema from "../schema/app";
|
||||
import * as schema from "@schema/app";
|
||||
import fs from "node:fs/promises";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants";
|
||||
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
|
||||
import { InstallJob } from "../jobs/install-job";
|
||||
import path from "node:path";
|
||||
import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils";
|
||||
import { calculateSize, checkInstalled, convertLocalToFrontend, convertRomToFrontend, convertRomToFrontendDetailed, convertStoreToFrontend, convertStoreToFrontendDetailed, getLocalGameMatch } from "./services/utils";
|
||||
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
|
||||
import { errorToResponse } from "elysia/adapter/bun/handler";
|
||||
import { launchCommand } from "./services/launchGameService";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { Jimp } from 'jimp';
|
||||
import { defaultFormats, defaultPlugins } from 'jimp';
|
||||
import { createJimp } from "@jimp/core";
|
||||
import webp from "@jimp/wasm-webp";
|
||||
import { extractStoreGameSourceId, getStoreGame, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
|
||||
|
||||
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height }: { blur?: number, width?: number, height?: number; })
|
||||
// A custom jimp that supports webp
|
||||
const Jimp = createJimp({
|
||||
formats: [...defaultFormats, webp],
|
||||
plugins: defaultPlugins,
|
||||
});
|
||||
|
||||
async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, height, noBlur }: { blur?: number, width?: number, height?: number; noBlur?: boolean; })
|
||||
{
|
||||
if (blur)
|
||||
if (blur && !noBlur)
|
||||
{
|
||||
const jimp = await Jimp.read(img);
|
||||
if (width)
|
||||
|
|
@ -48,6 +57,8 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width,
|
|||
export default new Elysia()
|
||||
.get('/game/local/:id/cover', async ({ params: { id }, query, set }) =>
|
||||
{
|
||||
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||
|
||||
const coverBlob = await db.query.games.findFirst({ columns: { cover: true, cover_type: true }, where: eq(schema.games.id, id) });
|
||||
if (!coverBlob || !coverBlob.cover)
|
||||
{
|
||||
|
|
@ -71,7 +82,7 @@ export default new Elysia()
|
|||
return processImage(`${rommAdress}/${path}`, query);
|
||||
}
|
||||
return status('Not Found');
|
||||
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) })
|
||||
}, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional(), noBlur: z.coerce.boolean().optional() }) })
|
||||
.get('/image', async ({ query }) =>
|
||||
{
|
||||
return processImage(query.url, query);
|
||||
|
|
@ -106,18 +117,24 @@ export default new Elysia()
|
|||
}, {
|
||||
params: z.object({ id: z.number() }),
|
||||
response: z.object({ installed: z.boolean() })
|
||||
}).get('/games', async ({ query: { platform_source, platform_slug, platform_id, collection_id } }) =>
|
||||
})
|
||||
.get('/games', async ({ query, set }) =>
|
||||
{
|
||||
const where: any[] = [];
|
||||
if (platform_slug)
|
||||
if (query.platform_slug)
|
||||
{
|
||||
where.push(eq(schema.platforms.slug, platform_slug));
|
||||
where.push(eq(schema.platforms.slug, query.platform_slug));
|
||||
}
|
||||
|
||||
if (query.source)
|
||||
{
|
||||
where.push(eq(schema.games.source, query.source));
|
||||
}
|
||||
|
||||
const games: FrontEndGameType[] = [];
|
||||
let localGamesSet: Set<number> | undefined;
|
||||
let localGamesSet: Set<string> | undefined;
|
||||
|
||||
if (!collection_id)
|
||||
if (!query.collection_id)
|
||||
{
|
||||
const localGames = await db.select({
|
||||
...getTableColumns(schema.games),
|
||||
|
|
@ -128,45 +145,87 @@ export default new Elysia()
|
|||
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
|
||||
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
|
||||
.groupBy(schema.games.id)
|
||||
|
||||
.offset(query.offset ?? 0)
|
||||
.limit(query.limit ?? 50)
|
||||
.where(and(...where));
|
||||
|
||||
localGamesSet = new Set(localGames.filter(g => !!g.source_id).map(g => g.source_id!));
|
||||
localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`));
|
||||
games.push(...localGames.map(g =>
|
||||
{
|
||||
const game: FrontEndGameType = {
|
||||
platform_display_name: g.platform?.name ?? "Local",
|
||||
id: { id: g.id, source: 'local' },
|
||||
updated_at: g.created_at,
|
||||
path_cover: `/api/romm/game/local/${g.id}/cover`,
|
||||
source_id: g.source_id,
|
||||
source: g.source,
|
||||
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
||||
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
|
||||
path_fs: g.path_fs,
|
||||
last_played: g.last_played,
|
||||
slug: g.slug,
|
||||
name: g.name,
|
||||
platform_id: g.platform_id
|
||||
};
|
||||
return game;
|
||||
return convertLocalToFrontend(g);
|
||||
}));
|
||||
}
|
||||
|
||||
if ((!platform_source || platform_source === 'romm') || !!collection_id)
|
||||
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
|
||||
{
|
||||
const rommGames = await getRomsApiRomsGet({ query: { platform_ids: platform_id ? [platform_id] : undefined, collection_id }, throwOnError: true });
|
||||
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(g.id)).map(g =>
|
||||
const rommGames = await getRomsApiRomsGet({
|
||||
query: {
|
||||
platform_ids: query.platform_id ? [query.platform_id] : undefined,
|
||||
collection_id: query.collection_id,
|
||||
limit: query.limit,
|
||||
offset: query.offset
|
||||
}, throwOnError: true
|
||||
});
|
||||
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(`romm@${g.id}`)).map(g =>
|
||||
{
|
||||
return convertRomToFrontend(g);
|
||||
}));
|
||||
}
|
||||
|
||||
if (query.source === 'store')
|
||||
{
|
||||
const gamesManifest = await getStoreGameManifest();
|
||||
set.headers['x-max-items'] = gamesManifest.filter(g => g.type === 'blob').length;
|
||||
|
||||
const storeGames = await Promise.all(gamesManifest
|
||||
.slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), gamesManifest.length))
|
||||
.map(async (e) =>
|
||||
{
|
||||
const system = path.dirname(e.path);
|
||||
const id = path.basename(e.path, path.extname(e.path));
|
||||
|
||||
const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) });
|
||||
|
||||
if (localGame)
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const storeGame = await getStoreGameFromPath(e.path);
|
||||
|
||||
return convertStoreToFrontend(system, id, storeGame);
|
||||
}));
|
||||
games.push(...storeGames.filter(g => g !== undefined));
|
||||
}
|
||||
|
||||
return { games };
|
||||
}, {
|
||||
query: GameListFilterSchema,
|
||||
})
|
||||
.get('/rom/:source/:id', async ({ params: { id, source } }) =>
|
||||
{
|
||||
const localGame = await db.query.games.findFirst({
|
||||
where: getLocalGameMatch(id, source),
|
||||
columns: { path_fs: true }
|
||||
});
|
||||
|
||||
if (!localGame?.path_fs)
|
||||
{
|
||||
return status("Not Found");
|
||||
}
|
||||
|
||||
const downloadPath = config.get('downloadPath');
|
||||
const path_fs = path.join(downloadPath, localGame.path_fs);
|
||||
const stats = await fs.stat(path_fs);
|
||||
if (stats.isDirectory())
|
||||
{
|
||||
return status("Not Found", "Rom is a folder");
|
||||
}
|
||||
|
||||
return Bun.file(path_fs);
|
||||
}, {
|
||||
params: z.object({ source: z.string(), id: z.string() })
|
||||
})
|
||||
.get('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
async function getLocalGameDetailed (match: any)
|
||||
|
|
@ -175,7 +234,7 @@ export default new Elysia()
|
|||
where: match,
|
||||
with: {
|
||||
screenshots: { columns: { id: true } },
|
||||
platform: { columns: { name: true } }
|
||||
platform: { columns: { name: true, slug: true } }
|
||||
}
|
||||
});
|
||||
if (localGame)
|
||||
|
|
@ -185,7 +244,7 @@ export default new Elysia()
|
|||
const game: FrontEndGameTypeDetailed = {
|
||||
path_cover: `/api/romm/game/local/${localGame.id}/cover`,
|
||||
updated_at: localGame.created_at,
|
||||
id: { id: localGame.id, source: 'local' },
|
||||
id: { id: String(localGame.id), source: 'local' },
|
||||
path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`,
|
||||
fs_size_bytes: fileSize ?? null,
|
||||
paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`),
|
||||
|
|
@ -199,7 +258,8 @@ export default new Elysia()
|
|||
last_played: localGame.last_played,
|
||||
slug: localGame.slug,
|
||||
name: localGame.name,
|
||||
platform_id: localGame.platform_id
|
||||
platform_id: localGame.platform_id,
|
||||
platform_slug: localGame.platform.slug
|
||||
};
|
||||
return game;
|
||||
}
|
||||
|
|
@ -209,7 +269,7 @@ export default new Elysia()
|
|||
|
||||
if (source === 'local')
|
||||
{
|
||||
const localGame = await getLocalGameDetailed(eq(schema.games.id, id));
|
||||
const localGame = await getLocalGameDetailed(eq(schema.games.id, Number(id)));
|
||||
if (localGame) return localGame;
|
||||
return status('Not Found');
|
||||
}
|
||||
|
|
@ -218,7 +278,9 @@ export default new Elysia()
|
|||
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
|
||||
if (localGame) return localGame;
|
||||
|
||||
const rom = await getRomApiRomsIdGet({ path: { id } });
|
||||
if (source === 'romm')
|
||||
{
|
||||
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
|
||||
if (rom.data)
|
||||
{
|
||||
const romGame = convertRomToFrontendDetailed(rom.data);
|
||||
|
|
@ -227,9 +289,19 @@ export default new Elysia()
|
|||
|
||||
return status("Not Found", rom.response);
|
||||
}
|
||||
else if (source === 'store')
|
||||
{
|
||||
const gameId = extractStoreGameSourceId(id);
|
||||
const storeGame = await getStoreGame(gameId.system, gameId.id);
|
||||
if (!storeGame) return status("Not Found");
|
||||
return convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame);
|
||||
}
|
||||
|
||||
return status("Not Found");
|
||||
}
|
||||
|
||||
}, {
|
||||
params: z.object({ source: z.string(), id: z.coerce.number() })
|
||||
params: z.object({ source: z.string(), id: z.string() })
|
||||
})
|
||||
.get('/status/:source/:id', async ({ params: { source, id }, set }) =>
|
||||
{
|
||||
|
|
@ -239,7 +311,7 @@ export default new Elysia()
|
|||
return buildStatusResponse(source, id);
|
||||
}, {
|
||||
response: z.any(),
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
query: z.object({ isLocal: z.boolean().optional() })
|
||||
})
|
||||
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||
|
|
@ -253,36 +325,51 @@ export default new Elysia()
|
|||
|
||||
return status(deleted.length > 0 ? 'OK' : 'Not Modified');
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
})
|
||||
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||
{
|
||||
if (!taskQueue.hasActive())
|
||||
{
|
||||
if (source === 'romm' || source === 'store')
|
||||
{
|
||||
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
|
||||
return status(200);
|
||||
}
|
||||
|
||||
return status('Not Implemented');
|
||||
} else
|
||||
{
|
||||
return status('Not Implemented');
|
||||
}
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
response: z.any()
|
||||
})
|
||||
.post('/game/:source/:id/play', async ({ params: { id, source }, set }) =>
|
||||
.post('/game/:source/:id/play', async ({ params: { id, source }, query, set }) =>
|
||||
{
|
||||
const validCommand = await getValidLaunchCommandsForGame(source, id);
|
||||
if (validCommand)
|
||||
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
||||
if (validCommands)
|
||||
{
|
||||
if (validCommand instanceof Error)
|
||||
if (validCommands instanceof Error)
|
||||
{
|
||||
return errorToResponse(validCommand, set);
|
||||
return errorToResponse(validCommands, set);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
await launchCommand(validCommand.command.command, source, id, validCommand.gameId);
|
||||
const validCommand = query.command_id ? validCommands.commands.find(c => c.id === query.command_id) : validCommands.commands[0];
|
||||
if (validCommand)
|
||||
{
|
||||
// launch command waits for the game to exit, we don't want that.
|
||||
launchCommand(validCommand.command, source, id, validCommands.gameId);
|
||||
return { type: 'application', command: null };
|
||||
} else
|
||||
{
|
||||
return status("Not Found");
|
||||
}
|
||||
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
|
|
@ -291,5 +378,27 @@ export default new Elysia()
|
|||
}
|
||||
}
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
query: z.object({ command_id: z.number().or(z.string()).optional() }),
|
||||
response: z.object({ type: z.enum(['emulatorjs', 'application']), command: z.string().nullable() })
|
||||
})
|
||||
.post("/stop", async ({ }) =>
|
||||
{
|
||||
if (activeGame)
|
||||
{
|
||||
events.emit('activegameexit', {
|
||||
source: 'local', id: String(activeGame.gameId),
|
||||
exitCode: null,
|
||||
signalCode: null
|
||||
});
|
||||
}
|
||||
})
|
||||
.get('/emulatorjs/data/cores/*', async ({ params }) =>
|
||||
{
|
||||
const res = await fetch(`https://cdn.emulatorjs.org/latest/data/cores/${params['*']}`);
|
||||
return res;
|
||||
})
|
||||
.get('/emulatorjs/data/*', async ({ params }) =>
|
||||
{
|
||||
return status("Not Found");
|
||||
});
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet, getRomsApiRomsGet } from "@clients/romm";
|
||||
import z from "zod";
|
||||
import { count, eq, getTableColumns, notInArray } from "drizzle-orm";
|
||||
import { count, eq, getTableColumns } from "drizzle-orm";
|
||||
import { db } from "../app";
|
||||
import { FrontEndPlatformType } from "@shared/constants";
|
||||
import * as schema from "../schema/app";
|
||||
import * as schema from "@schema/app";
|
||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||
|
||||
export default new Elysia()
|
||||
.get('/platforms', async () =>
|
||||
{
|
||||
const platforms: FrontEndPlatformType[] = [];
|
||||
let rommPlatformsSet: Set<string> | undefined;
|
||||
const { data: rommPlatforms } = await getPlatformsApiPlatformsGet();
|
||||
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e => console.error(e));
|
||||
|
||||
const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) })
|
||||
.from(schema.platforms)
|
||||
|
|
@ -32,7 +33,7 @@ export default new Elysia()
|
|||
path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`,
|
||||
game_count: p.rom_count,
|
||||
updated_at: new Date(p.updated_at),
|
||||
id: { source: 'romm', id: p.id },
|
||||
id: { source: 'romm', id: String(p.id) },
|
||||
hasLocal: localPlatformSet.has(p.slug),
|
||||
paths_screenshots: game.data?.items[0]?.merged_screenshots.map(s => `/api/romm/image/romm/${s}`) ?? []
|
||||
};
|
||||
|
|
@ -46,7 +47,13 @@ export default new Elysia()
|
|||
|
||||
platforms.push(...await Promise.all(localPlatforms.filter(p => !rommPlatformsSet?.has(p.slug)).map(async p =>
|
||||
{
|
||||
const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id), with: { screenshots: true }, columns: {} });
|
||||
const game = await db.query.games.findFirst({ where: eq(schema.games.platform_id, p.id) });
|
||||
let screenshots: { id: number; }[] = [];
|
||||
if (game)
|
||||
{
|
||||
screenshots = await db.query.screenshots.findMany({ where: eq(schema.screenshots.game_id, game.id), columns: { id: true } });
|
||||
}
|
||||
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
|
|
@ -54,9 +61,9 @@ export default new Elysia()
|
|||
path_cover: `/api/romm/platform/local/${p.id}/cover`,
|
||||
game_count: p.game_count,
|
||||
updated_at: p.created_at,
|
||||
id: { source: 'local', id: p.id },
|
||||
id: { source: 'local', id: String(p.id) },
|
||||
hasLocal: true,
|
||||
paths_screenshots: game?.screenshots?.map(s => `/api/romm/screenshot/${s.id}`) ?? []
|
||||
paths_screenshots: screenshots?.map(s => `/api/romm/screenshot/${s.id}`) ?? []
|
||||
|
||||
};
|
||||
|
||||
|
|
@ -66,13 +73,52 @@ export default new Elysia()
|
|||
return { platforms };
|
||||
}).get('/platforms/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
const rommPlatform = await getPlatformApiPlatformsIdGet({ path: { id } });
|
||||
if (rommPlatform.data)
|
||||
if (source === 'romm')
|
||||
{
|
||||
return rommPlatform.data;
|
||||
const { data: rommPlatform, response } = await getPlatformApiPlatformsIdGet({ path: { id } });
|
||||
if (rommPlatform)
|
||||
{
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: rommPlatform.slug,
|
||||
name: rommPlatform.display_name,
|
||||
family_name: rommPlatform.family_name,
|
||||
path_cover: `/api/romm/image/romm/assets/platforms/${rommPlatform.slug}.svg`,
|
||||
game_count: rommPlatform.rom_count,
|
||||
updated_at: new Date(rommPlatform.updated_at),
|
||||
id: { source: 'romm', id: String(rommPlatform.id) },
|
||||
paths_screenshots: [],
|
||||
hasLocal: false
|
||||
};
|
||||
|
||||
return platform;
|
||||
}
|
||||
|
||||
return status("Not Found", rommPlatform.response);
|
||||
return status("Not Found", response);
|
||||
}
|
||||
else if (source === 'local')
|
||||
{
|
||||
const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, id) });
|
||||
if (localPlatform)
|
||||
{
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: localPlatform.slug,
|
||||
name: localPlatform.name,
|
||||
family_name: localPlatform.family_name,
|
||||
path_cover: `/api/romm/platform/local/${localPlatform.id}/cover`,
|
||||
game_count: 0,
|
||||
updated_at: localPlatform.created_at,
|
||||
id: { source: 'local', id: String(localPlatform.id) },
|
||||
hasLocal: true,
|
||||
paths_screenshots: []
|
||||
};
|
||||
|
||||
return platform;
|
||||
}
|
||||
|
||||
return status("Not Found");
|
||||
}
|
||||
|
||||
return status("Not Implemented");
|
||||
}, { params: z.object({ source: z.string(), id: z.coerce.number() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
|
||||
{
|
||||
const coverBlob = await db.query.platforms.findFirst({
|
||||
|
|
|
|||
|
|
@ -2,26 +2,19 @@ import path from 'node:path';
|
|||
import { which } from 'bun';
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import * as schema from '../../schema/emulators';
|
||||
import * as appSchema from "../../schema/app";
|
||||
import * as schema from '@schema/emulators';
|
||||
import * as appSchema from "@schema/app";
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { activeGame, config, db, emulatorsDb, events, setActiveGame } from '../../app';
|
||||
import os from 'node:os';
|
||||
import { $ } from 'bun';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
|
||||
import { CommandEntry } from '@/shared/constants';
|
||||
|
||||
export const varRegex = /%([^%]+)%/g;
|
||||
|
||||
interface CommandEntry
|
||||
{
|
||||
label?: string;
|
||||
command: string;
|
||||
valid: boolean;
|
||||
emulator?: string;
|
||||
}
|
||||
|
||||
export async function launchCommand (validCommand: string, source: string, sourceId: number, id: number)
|
||||
export async function launchCommand (validCommand: string, source: string, sourceId: string, id: number)
|
||||
{
|
||||
if (activeGame && activeGame.process?.killed === false)
|
||||
{
|
||||
|
|
@ -69,13 +62,12 @@ export async function launchCommand (validCommand: string, source: string, sourc
|
|||
|
||||
if (source === 'romm')
|
||||
{
|
||||
updateRommProps(sourceId);
|
||||
updateRommProps(Number(sourceId));
|
||||
}
|
||||
else if (localGame?.source === 'romm' && localGame.source_id)
|
||||
{
|
||||
updateRommProps(localGame.source_id);
|
||||
updateRommProps(Number(localGame.source_id));
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/* Old spawn lanching, cases issues, needs to be ran as shell
|
||||
|
|
@ -117,7 +109,10 @@ export async function getValidLaunchCommands (data: {
|
|||
}): Promise<CommandEntry[]>
|
||||
{
|
||||
|
||||
const system = await emulatorsDb.query.systems.findFirst({ with: { commands: true }, where: eq(schema.systems.name, data.systemSlug) });
|
||||
const system = await emulatorsDb.query.systems.findFirst({
|
||||
with: { commands: true },
|
||||
where: eq(schema.systems.name, data.systemSlug)
|
||||
});
|
||||
|
||||
if (!system)
|
||||
{
|
||||
|
|
@ -165,7 +160,7 @@ export async function getValidLaunchCommands (data: {
|
|||
}
|
||||
}
|
||||
|
||||
const formattedCommands = await Promise.all(system.commands.map(async command =>
|
||||
const formattedCommands = await Promise.all(system.commands.map(async (command, index) =>
|
||||
{
|
||||
const label = command.label;
|
||||
let cmd = command.command;
|
||||
|
|
@ -213,14 +208,14 @@ export async function getValidLaunchCommands (data: {
|
|||
if (value.startsWith("%EMULATOR_"))
|
||||
{
|
||||
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
|
||||
let exec = await findExec(emulatorName);
|
||||
let exec = await findExecByName(emulatorName);
|
||||
if (data.customEmulatorConfig.has(emulatorName))
|
||||
{
|
||||
exec = data.customEmulatorConfig.get(emulatorName);
|
||||
exec = { path: data.customEmulatorConfig.get(emulatorName)!, type: 'custom' };
|
||||
}
|
||||
|
||||
emulator = emulatorName;
|
||||
return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec)) : undefined]];
|
||||
return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec.path)) : undefined]];
|
||||
}
|
||||
|
||||
const key = value[0].substring(1, value.length - 1);
|
||||
|
|
@ -237,6 +232,7 @@ export async function getValidLaunchCommands (data: {
|
|||
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
|
||||
|
||||
return {
|
||||
id: index,
|
||||
label: label ?? undefined,
|
||||
command: formattedCommand,
|
||||
valid: !invalid, emulator
|
||||
|
|
@ -246,13 +242,18 @@ export async function getValidLaunchCommands (data: {
|
|||
return formattedCommands.filter(c => !!c);
|
||||
}
|
||||
|
||||
export async function findExec (emulatorName: string)
|
||||
export async function findExecByName (emulatorName: string)
|
||||
{
|
||||
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) });
|
||||
if (!emulator)
|
||||
{
|
||||
throw new Error(`Could not find emulator ${emulatorName}`);
|
||||
}
|
||||
return findExec(emulator);
|
||||
}
|
||||
|
||||
export async function findExec (emulator: { winregistrypath: string[], systempath: string[], staticpath: string[]; })
|
||||
{
|
||||
if (os.platform() === 'win32')
|
||||
{
|
||||
const regValues = emulator.winregistrypath;
|
||||
|
|
@ -263,7 +264,7 @@ export async function findExec (emulatorName: string)
|
|||
const registryValue = await readRegistryValue(node);
|
||||
if (registryValue)
|
||||
{
|
||||
return registryValue;
|
||||
return { path: registryValue, type: 'registry' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,7 +277,7 @@ export async function findExec (emulatorName: string)
|
|||
const systemPath = await resolveSystemPath(systempaths);
|
||||
if (systemPath)
|
||||
{
|
||||
return systemPath;
|
||||
return { path: systemPath, type: 'system' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,7 +287,7 @@ export async function findExec (emulatorName: string)
|
|||
const staticPath = await resolveStaticPath(staticPaths);
|
||||
if (staticPath)
|
||||
{
|
||||
return staticPath;
|
||||
return { path: staticPath, type: 'static' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import { GameInstallProgress, GameStatusType, } from "@shared/constants";
|
||||
import { GameInstallProgress, GameStatusType, RPC_URL, } from "@shared/constants";
|
||||
import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app";
|
||||
import { getValidLaunchCommands } from "./launchGameService";
|
||||
import * as schema from '../../schema/app';
|
||||
import * as schema from '@schema/app';
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { getLocalGameMatch } from "./utils";
|
||||
import { getRomApiRomsIdGet } from "@/clients/romm";
|
||||
import fs from 'node:fs/promises';
|
||||
import { ErrorLike } from "elysia/universal";
|
||||
import { getStoreGameFromId } from "../../store/services/gamesService";
|
||||
import { cores } from "../../emulatorjs/emulatorjs";
|
||||
import { host } from "@/bun/utils/host";
|
||||
|
||||
class CommandSearchError extends Error
|
||||
{
|
||||
|
|
@ -18,7 +21,7 @@ class CommandSearchError extends Error
|
|||
}
|
||||
}
|
||||
|
||||
export async function getLocalGame (source: string, id: number)
|
||||
export async function getLocalGame (source: string, id: string)
|
||||
{
|
||||
const localGames = await db.select({ id: schema.games.id, path_fs: schema.games.path_fs, platform_slug: schema.platforms.es_slug })
|
||||
.from(schema.games)
|
||||
|
|
@ -33,7 +36,7 @@ export async function getLocalGame (source: string, id: number)
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export async function getValidLaunchCommandsForGame (source: string, id: number)
|
||||
export async function getValidLaunchCommandsForGame (source: string, id: string)
|
||||
{
|
||||
const localGame = await getLocalGame(source, id);
|
||||
if (localGame)
|
||||
|
|
@ -42,18 +45,28 @@ export async function getValidLaunchCommandsForGame (source: string, id: number)
|
|||
{
|
||||
if (localGame.path_fs)
|
||||
{
|
||||
|
||||
try
|
||||
{
|
||||
const commands = await getValidLaunchCommands({ systemSlug: localGame.platform_slug, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs });
|
||||
|
||||
if (cores[localGame.platform_slug])
|
||||
{
|
||||
const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`;
|
||||
commands.push({
|
||||
id: 'emulatorjs',
|
||||
label: "Emulator JS", command: `core=${cores[localGame.platform_slug]}&gameUrl=${encodeURIComponent(gameUrl)}`, valid: true, emulator: 'emulatorjs'
|
||||
});
|
||||
}
|
||||
|
||||
const validCommand = commands.find(c => c.valid);
|
||||
if (validCommand)
|
||||
{
|
||||
return { command: validCommand, gameId: localGame.id, source: source, sourceId: id };
|
||||
|
||||
return { commands: commands.filter(c => c.valid), gameId: localGame.id, source: source, sourceId: id };
|
||||
}
|
||||
else
|
||||
{
|
||||
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator).join(',')}`);
|
||||
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`);
|
||||
}
|
||||
} catch (error)
|
||||
{
|
||||
|
|
@ -76,7 +89,7 @@ export async function getValidLaunchCommandsForGame (source: string, id: number)
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export default async function buildStatusResponse (source: string, id: number)
|
||||
export default async function buildStatusResponse (source: string, id: string)
|
||||
{
|
||||
let cleanup: (() => void) | undefined;
|
||||
let closed = false;
|
||||
|
|
@ -87,6 +100,7 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
|
||||
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping')
|
||||
{
|
||||
if (closed) return;
|
||||
const evntString = event ? `event: ${event}\n` : '';
|
||||
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
|
||||
}
|
||||
|
|
@ -136,13 +150,14 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
}
|
||||
else
|
||||
{
|
||||
enqueue({ status: 'installed', details: validCommand.command.label });
|
||||
enqueue({ status: 'installed', details: validCommand.commands[0].label, commands: validCommand.commands });
|
||||
}
|
||||
|
||||
} else if (source === 'romm')
|
||||
}
|
||||
else if (source === 'romm')
|
||||
{
|
||||
// TODO: Add Caching
|
||||
const remoteGame = await getRomApiRomsIdGet({ path: { id } });
|
||||
const remoteGame = await getRomApiRomsIdGet({ path: { id: Number(id) } });
|
||||
const stats = await fs.statfs(config.get('downloadPath'));
|
||||
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
|
||||
{
|
||||
|
|
@ -152,6 +167,20 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
enqueue({ status: 'install', details: 'Install' });
|
||||
}
|
||||
|
||||
} else if (source === 'store')
|
||||
{
|
||||
const storeGame = await getStoreGameFromId(id);
|
||||
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||
const size = Number(fileResponse.headers.get('content-length'));
|
||||
const stats = await fs.statfs(config.get('downloadPath'));
|
||||
|
||||
if (size > stats.bsize * stats.bavail)
|
||||
{
|
||||
enqueue({ status: 'error', error: "Not Enough Free Space" });
|
||||
} else
|
||||
{
|
||||
enqueue({ status: 'install', details: 'Install' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -190,7 +219,7 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
{
|
||||
enqueue({
|
||||
status: 'error',
|
||||
error: error
|
||||
error: getErrorMessage(error)
|
||||
}, 'error');
|
||||
}
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import getFolderSize from "get-folder-size";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { config } from "../../app";
|
||||
import { config, db, emulatorsDb } from "../../app";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as schema from "../../schema/app";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
|
||||
import * as schema from "@schema/app";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed, StoreGameType } from "@shared/constants";
|
||||
import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
|
||||
export async function calculateSize (installPath: string | null)
|
||||
{
|
||||
|
|
@ -19,15 +20,15 @@ export async function checkInstalled (installPath: string | null)
|
|||
return fs.exists(path.join(config.get('downloadPath'), installPath));
|
||||
}
|
||||
|
||||
export function getLocalGameMatch (id: number, source: string)
|
||||
export function getLocalGameMatch (id: string, source: string)
|
||||
{
|
||||
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, id);
|
||||
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id));
|
||||
}
|
||||
|
||||
export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
|
||||
{
|
||||
const game: FrontEndGameType = {
|
||||
id: { id: rom.id, source: 'romm' },
|
||||
id: { id: String(rom.id), source: 'romm' },
|
||||
path_cover: `/api/romm/image/romm${rom.path_cover_large}`,
|
||||
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
|
||||
updated_at: new Date(rom.updated_at),
|
||||
|
|
@ -40,11 +41,131 @@ export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
|
|||
source: null,
|
||||
source_id: null,
|
||||
paths_screenshots: rom.merged_screenshots.map(s => `/api/romm/image/romm/${s}`),
|
||||
platform_slug: rom.platform_slug
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & {
|
||||
platform?: typeof schema.platforms.$inferSelect | null;
|
||||
screenshotIds?: number[];
|
||||
})
|
||||
{
|
||||
const game: FrontEndGameType = {
|
||||
platform_display_name: g.platform?.name ?? "Local",
|
||||
id: { id: String(g.id), source: 'local' },
|
||||
updated_at: g.created_at,
|
||||
path_cover: `/api/romm/game/local/${g.id}/cover`,
|
||||
source_id: g.source_id,
|
||||
source: g.source,
|
||||
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
||||
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
|
||||
path_fs: g.path_fs,
|
||||
last_played: g.last_played,
|
||||
slug: g.slug,
|
||||
name: g.name,
|
||||
platform_id: g.platform_id,
|
||||
platform_slug: g.platform?.slug ?? null
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export function convertLocalToFrontendDetailed (g: typeof schema.games.$inferSelect & {
|
||||
platform?: typeof schema.platforms.$inferSelect | null;
|
||||
screenshotIds?: number[];
|
||||
})
|
||||
{
|
||||
const game: FrontEndGameTypeDetailed = {
|
||||
platform_display_name: g.platform?.name ?? "Local",
|
||||
id: { id: String(g.id), source: 'local' },
|
||||
updated_at: g.created_at,
|
||||
path_cover: `/api/romm/game/local/${g.id}/cover`,
|
||||
source_id: g.source_id,
|
||||
source: g.source,
|
||||
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
||||
paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [],
|
||||
path_fs: g.path_fs,
|
||||
last_played: g.last_played,
|
||||
slug: g.slug,
|
||||
name: g.name,
|
||||
platform_id: g.platform_id,
|
||||
platform_slug: g.platform?.slug ?? null,
|
||||
summary: g.summary,
|
||||
fs_size_bytes: 0,
|
||||
missing: false,
|
||||
local: true
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameType>
|
||||
{
|
||||
let size: number | null = null;
|
||||
try
|
||||
{
|
||||
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||
size = Number(fileResponse.headers.get('content-length'));
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
}
|
||||
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
|
||||
where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm'))
|
||||
});
|
||||
|
||||
const platformDef = await emulatorsDb.query.systems.findFirst({
|
||||
where: eq(emulatorSchema.systems.name, system),
|
||||
columns: { fullname: true }
|
||||
});
|
||||
|
||||
const gameId = `${system}@${id}`;
|
||||
|
||||
const game: FrontEndGameType = {
|
||||
platform_display_name: platformDef?.fullname ?? system,
|
||||
path_platform_cover: `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`,
|
||||
id: { source: 'store', id: gameId },
|
||||
source: null,
|
||||
source_id: null,
|
||||
path_fs: null,
|
||||
path_cover: `/api/romm/image?url=${encodeURIComponent(storeGame.pictures.titlescreens?.[0])}`,
|
||||
last_played: null,
|
||||
updated_at: new Date(),
|
||||
slug: null,
|
||||
name: storeGame.title,
|
||||
platform_id: null,
|
||||
platform_slug: system,
|
||||
paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? []
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export async function convertStoreToFrontendDetailed (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameTypeDetailed>
|
||||
{
|
||||
let size: number | null = null;
|
||||
try
|
||||
{
|
||||
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||
size = Number(fileResponse.headers.get('content-length'));
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const detailed: FrontEndGameTypeDetailed = {
|
||||
...await convertStoreToFrontend(system, id, storeGame),
|
||||
summary: storeGame.description,
|
||||
fs_size_bytes: size,
|
||||
missing: false,
|
||||
local: false,
|
||||
};
|
||||
|
||||
return detailed;
|
||||
}
|
||||
|
||||
export function convertRomToFrontendDetailed (rom: DetailedRomSchema)
|
||||
{
|
||||
const detailed: FrontEndGameTypeDetailed = {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@ import { IJob, JobContext } from "../task-queue";
|
|||
import { mkdir } from 'node:fs/promises';
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
import fs from 'node:fs/promises';
|
||||
import * as schema from "../schema/app";
|
||||
import * as emulatorSchema from "../schema/emulators";
|
||||
import * as schema from "@schema/app";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import path from 'node:path';
|
||||
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm";
|
||||
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm";
|
||||
import { config, db, emulatorsDb, jar } from "../app";
|
||||
import unzip from 'unzip-stream';
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
||||
import * as igdb from 'ts-igdb-client';
|
||||
import secrets from "../secrets";
|
||||
|
||||
interface JobConfig
|
||||
{
|
||||
|
|
@ -18,15 +21,15 @@ interface JobConfig
|
|||
|
||||
export class InstallJob implements IJob
|
||||
{
|
||||
public id: number;
|
||||
public gameId: string;
|
||||
public source: string;
|
||||
public sourceId: number;
|
||||
|
||||
public sourceId: string;
|
||||
public config?: JobConfig;
|
||||
static id = "install-job" as const;
|
||||
|
||||
constructor(id: number, source: string, sourceId: number, config?: JobConfig)
|
||||
constructor(id: string, source: string, sourceId: string, config?: JobConfig)
|
||||
{
|
||||
this.id = id;
|
||||
this.gameId = id;
|
||||
this.config = config;
|
||||
this.sourceId = sourceId;
|
||||
this.source = source;
|
||||
|
|
@ -41,6 +44,65 @@ export class InstallJob implements IJob
|
|||
{
|
||||
const downloadPath = config.get('downloadPath');
|
||||
|
||||
let downloadUrl: URL;
|
||||
let cookie: string = '';
|
||||
let screenshotUrls: string[];
|
||||
let coverUrl: string;
|
||||
let rommPlatform: PlatformSchema | undefined;
|
||||
let slug: string | null;
|
||||
let path_fs: string | undefined;
|
||||
let summary: string | null;
|
||||
let name: string | null;
|
||||
let last_played: Date | null;
|
||||
let igdb_id: number | null;
|
||||
let ra_id: number | null;
|
||||
let source_id: string;
|
||||
let system_slug: string;
|
||||
let extract_path: string;
|
||||
|
||||
switch (this.source)
|
||||
{
|
||||
case 'romm':
|
||||
|
||||
const rom = (await getRomApiRomsIdGet({ path: { id: Number(this.gameId) }, throwOnError: true })).data;
|
||||
rommPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data;
|
||||
|
||||
const rommAddress = config.get('rommAddress');
|
||||
coverUrl = `${rommAddress}${rom.path_cover_large}`;
|
||||
screenshotUrls = rom.merged_screenshots.map(s => `${config.get('rommAddress')}${s}`);
|
||||
last_played = rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null;
|
||||
igdb_id = rom.igdb_id;
|
||||
ra_id = rom.ra_id;
|
||||
summary = rom.summary;
|
||||
name = rom.name;
|
||||
path_fs = path.join(rom.fs_path, rom.fs_name);
|
||||
source_id = String(rom.id);
|
||||
slug = rom.slug;
|
||||
system_slug = rommPlatform.slug;
|
||||
extract_path = '';
|
||||
|
||||
downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
|
||||
downloadUrl.searchParams.set('rom_ids', String(this.gameId));
|
||||
cookie = await jar.getCookieString(config.get('rommAddress') ?? '');
|
||||
break;
|
||||
case 'store':
|
||||
const game = await getStoreGameFromId(this.gameId);
|
||||
const gameId = extractStoreGameSourceId(this.gameId);
|
||||
coverUrl = game.pictures.titlescreens[0];
|
||||
screenshotUrls = game.pictures.screenshots;
|
||||
downloadUrl = new URL(game.file);
|
||||
slug = this.gameId;
|
||||
source_id = this.gameId;
|
||||
name = game.title;
|
||||
summary = game.description;
|
||||
system_slug = gameId.system;
|
||||
extract_path = 'roms', gameId.system;
|
||||
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unsupported source");
|
||||
}
|
||||
|
||||
if (this.config?.dryDownload !== true)
|
||||
{
|
||||
/*
|
||||
|
|
@ -92,11 +154,10 @@ export class InstallJob implements IJob
|
|||
await fs.rm(zipFilePath);*/
|
||||
|
||||
cx.setProgress(0, 'download');
|
||||
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
|
||||
downloadUrl.searchParams.set('rom_ids', String(this.id));
|
||||
|
||||
const res = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
|
||||
cookie: cookie
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -119,62 +180,99 @@ export class InstallJob implements IJob
|
|||
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
Readable.fromWeb(res.body as any).pipe(progressStream).pipe(unzip.Extract({ path: downloadPath })).on('close', resolve).on('error', reject);
|
||||
const extract = unzip.Extract({ path: path.join(downloadPath, extract_path), });
|
||||
(extract as any).unzipStream.on('entry', (entry: any) =>
|
||||
{
|
||||
if (!path_fs)
|
||||
path_fs = path.join(extract_path, entry.path);
|
||||
});
|
||||
Readable.fromWeb(res.body as any).pipe(progressStream)
|
||||
.pipe(extract)
|
||||
.on('close', resolve)
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const rom = (await getRomApiRomsIdGet({ path: { id: this.id }, throwOnError: true })).data;
|
||||
const romPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data;
|
||||
|
||||
if (this.config?.dryDownload === true)
|
||||
{
|
||||
rom.files.length;
|
||||
await mkdir(path.join(downloadPath, rom.fs_path, rom.fs_name), { recursive: true });
|
||||
await mkdir(path.join(downloadPath, extract_path), { recursive: true });
|
||||
}
|
||||
|
||||
// pre-fetch screenshots
|
||||
const screenshots = await Promise.all(rom.merged_screenshots.map(s => fetch(`${config.get('rommAddress')}${s}`)));
|
||||
|
||||
const rommAddress = config.get('rommAddress');
|
||||
const coverResponse = await fetch(`${rommAddress}${rom.path_cover_large}`);
|
||||
|
||||
const coverResponse = await fetch(coverUrl);
|
||||
const cover = Buffer.from(await coverResponse.arrayBuffer());
|
||||
|
||||
if (cx.abortSignal.aborted) return;
|
||||
|
||||
await db.transaction(async (tx) =>
|
||||
{
|
||||
// Search for existing platform
|
||||
const platformSearch = [];
|
||||
if (romPlatform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, romPlatform.igdb_id));
|
||||
if (romPlatform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, romPlatform.igdb_slug));
|
||||
if (romPlatform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, romPlatform.ra_id));
|
||||
if (romPlatform.slug) platformSearch.push(eq(schema.platforms.slug, romPlatform.slug));
|
||||
if (romPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, romPlatform.moby_id));
|
||||
const platformSearch = [eq(schema.platforms.slug, system_slug)];
|
||||
const esPlatformSearch = [eq(emulatorSchema.systemMappings.system, system_slug)];
|
||||
|
||||
const esPlatform = await emulatorsDb
|
||||
.select({ slug: emulatorSchema.systemMappings.system, romm_slug: emulatorSchema.systemMappings.sourceSlug })
|
||||
.from(emulatorSchema.systemMappings)
|
||||
.where(and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, romPlatform.slug)));
|
||||
if (rommPlatform)
|
||||
{
|
||||
if (rommPlatform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, rommPlatform.igdb_id));
|
||||
if (rommPlatform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, rommPlatform.igdb_slug));
|
||||
if (rommPlatform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, rommPlatform.ra_id));
|
||||
if (rommPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, rommPlatform.moby_id));
|
||||
|
||||
const existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
|
||||
esPlatformSearch.push(eq(emulatorSchema.systemMappings.source, 'romm'));
|
||||
esPlatformSearch.push(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform.slug));
|
||||
}
|
||||
|
||||
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({
|
||||
with: { system: true },
|
||||
where: and(...esPlatformSearch)
|
||||
});
|
||||
|
||||
if (esPlatform)
|
||||
platformSearch.push(eq(schema.platforms.es_slug, esPlatform.system.name));
|
||||
|
||||
let existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
|
||||
let platformId: number;
|
||||
if (!existingPlatform)
|
||||
{
|
||||
// TODO: use something else than the romm demo as CDN
|
||||
const platformCover = await fetch(`https://demo.romm.app/assets/platforms/${system_slug}.svg`);
|
||||
|
||||
if (!esPlatform && !rommPlatform)
|
||||
{
|
||||
// go to unknown platform
|
||||
existingPlatform = await tx.query.platforms.findFirst({ where: eq(schema.platforms.slug, "unknown") });
|
||||
|
||||
if (existingPlatform)
|
||||
{
|
||||
platformId = existingPlatform.id;
|
||||
} else
|
||||
{
|
||||
const [{ id }] = await tx.insert(schema.platforms).values({
|
||||
slug: 'unknown',
|
||||
name: "Unknown"
|
||||
}).returning({ id: schema.platforms.id });
|
||||
platformId = id;
|
||||
}
|
||||
} else
|
||||
{
|
||||
// Create new local platform
|
||||
const platformCover = await fetch(`${rommAddress}/assets/platforms/${romPlatform.slug.toLocaleLowerCase()}.svg`);
|
||||
const platform: typeof schema.platforms.$inferInsert = {
|
||||
slug: romPlatform.slug,
|
||||
igdb_id: romPlatform.igdb_id,
|
||||
igdb_slug: romPlatform.igdb_slug,
|
||||
ra_id: romPlatform.ra_id,
|
||||
slug: rommPlatform?.slug ?? esPlatform?.system.name ?? '',
|
||||
igdb_id: rommPlatform?.igdb_id,
|
||||
igdb_slug: rommPlatform?.igdb_slug,
|
||||
ra_id: rommPlatform?.ra_id,
|
||||
cover: Buffer.from(await platformCover.arrayBuffer()),
|
||||
cover_type: platformCover.headers.get('content-type'),
|
||||
name: romPlatform.name,
|
||||
family_name: romPlatform.family_name,
|
||||
es_slug: esPlatform.length > 0 ? esPlatform[0].slug : undefined
|
||||
name: rommPlatform?.name ?? esPlatform?.system.fullname ?? '',
|
||||
family_name: rommPlatform?.family_name,
|
||||
es_slug: esPlatform?.system.name ?? undefined
|
||||
};
|
||||
|
||||
// TODO: add ES slug once I have better way to query ES
|
||||
const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id });
|
||||
platformId = id;
|
||||
}
|
||||
|
||||
} else
|
||||
{
|
||||
platformId = existingPlatform.id;
|
||||
|
|
@ -182,22 +280,40 @@ export class InstallJob implements IJob
|
|||
|
||||
// create the rom
|
||||
const game: typeof schema.games.$inferInsert = {
|
||||
source_id: rom.id,
|
||||
source: 'romm',
|
||||
slug: rom.slug,
|
||||
path_fs: path.join(rom.fs_path, rom.fs_name),
|
||||
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
|
||||
source_id,
|
||||
source: this.source,
|
||||
slug,
|
||||
path_fs,
|
||||
last_played: last_played,
|
||||
platform_id: platformId,
|
||||
igdb_id: rom.igdb_id,
|
||||
ra_id: rom.ra_id,
|
||||
summary: rom.summary,
|
||||
name: rom.name,
|
||||
cover: Buffer.from(await coverResponse.arrayBuffer()),
|
||||
igdb_id: igdb_id,
|
||||
ra_id: ra_id,
|
||||
summary: summary,
|
||||
name,
|
||||
cover,
|
||||
cover_type: coverResponse.headers.get('content-type')
|
||||
};
|
||||
|
||||
// Save screenshots and update database
|
||||
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
|
||||
|
||||
if (screenshotUrls.length <= 0 && process.env.TWITCH_CLIENT_ID)
|
||||
{
|
||||
const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' });
|
||||
if (access_token)
|
||||
{
|
||||
const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token);
|
||||
|
||||
const { data } = await client.request('artworks').pipe(igdb.fields(['game', 'url']), igdb.where('game', '=', igdb_id)).execute();
|
||||
|
||||
screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!));
|
||||
}
|
||||
}
|
||||
|
||||
// pre-fetch screenshots
|
||||
const screenshots = await Promise.all(screenshotUrls.map(s => fetch(s)));
|
||||
|
||||
if (screenshots.length > 0)
|
||||
{
|
||||
await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
|
||||
{
|
||||
const screenshot: typeof schema.screenshots.$inferInsert = {
|
||||
|
|
@ -208,6 +324,8 @@ export class InstallJob implements IJob
|
|||
|
||||
return screenshot;
|
||||
})));
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
80
src/bun/api/jobs/jobs.ts
Normal file
80
src/bun/api/jobs/jobs.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import Elysia from "elysia";
|
||||
import z, { } from "zod";
|
||||
import { taskQueue } from "../app";
|
||||
import { LoginJob } from "./login-job";
|
||||
import TwitchLoginJob from "./twitch-login-job";
|
||||
import UpdateStoreJob from "./update-store";
|
||||
|
||||
function registerJob<const Path extends string, TS, T extends { id: Path, dataSchema?: TS; }> (job: T, path: Path, dataSchema: TS)
|
||||
{
|
||||
return new Elysia().ws(path, {
|
||||
body: z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('cancel') })
|
||||
]),
|
||||
response: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(['data', 'started', 'progress']),
|
||||
status: z.string(),
|
||||
progress: z.number(),
|
||||
data: dataSchema
|
||||
}),
|
||||
z.object({ type: z.literal(['completed', 'ended']) }),
|
||||
z.object({ type: z.literal('error'), error: z.unknown() })
|
||||
]),
|
||||
open (ws)
|
||||
{
|
||||
const job = taskQueue.findJob(path);
|
||||
if (job)
|
||||
{
|
||||
ws.send({ type: 'data', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
|
||||
(ws.data as any).cleanup = [
|
||||
taskQueue.on('started', ({ id, job }) =>
|
||||
{
|
||||
if (id === path)
|
||||
{
|
||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('progress', ({ id, job }) =>
|
||||
{
|
||||
if (id === path)
|
||||
{
|
||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('completed', ({ id }) =>
|
||||
{
|
||||
if (id === path)
|
||||
{
|
||||
ws.send({ type: 'completed' });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('error', ({ id, error }) =>
|
||||
{
|
||||
if (id === path)
|
||||
{
|
||||
ws.send({ type: 'error', error: error });
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
close (ws)
|
||||
{
|
||||
(ws.data as any).cleanup.forEach((d: Function) => d());
|
||||
},
|
||||
message (ws, message)
|
||||
{
|
||||
if (message.type === 'cancel')
|
||||
{
|
||||
taskQueue.findJob(path)?.abort('cancel');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const jobs = new Elysia({ prefix: '/api/jobs' })
|
||||
.use(registerJob(LoginJob, LoginJob.id, LoginJob.dataSchema))
|
||||
.use(registerJob(TwitchLoginJob, TwitchLoginJob.id, TwitchLoginJob.dataSchema))
|
||||
.use(registerJob(UpdateStoreJob, UpdateStoreJob.id, undefined));
|
||||
|
|
@ -4,20 +4,27 @@ import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
|||
import { host, localIp } from "@/bun/utils/host";
|
||||
import cors from "@elysiajs/cors";
|
||||
import { tryLoginAndSave } from "../auth";
|
||||
import z from "zod";
|
||||
import { config } from "../app";
|
||||
import z from "zod";
|
||||
import { delay } from "@/shared/utils";
|
||||
|
||||
export class LoginJob implements IJob
|
||||
{
|
||||
endsAt: Date;
|
||||
startedAt: Date;
|
||||
url: string;
|
||||
static id = "login-job" as const;
|
||||
static dataSchema = z.object({ endsAt: z.date(), startedAt: z.date(), url: z.url() });
|
||||
|
||||
constructor()
|
||||
{
|
||||
this.endsAt = new Date();
|
||||
this.endsAt = new Date(new Date().getTime() + 300000);
|
||||
this.startedAt = new Date();
|
||||
this.url = `http://${localIp}:${LOGIN_PORT}/`;
|
||||
}
|
||||
|
||||
exposeData = (): z.infer<typeof LoginJob.dataSchema> => ({ endsAt: this.endsAt, startedAt: this.startedAt, url: this.url });
|
||||
|
||||
async start (context: JobContext): Promise<any>
|
||||
{
|
||||
const loginServer = new Elysia({ serve: { hostname: localIp, port: LOGIN_PORT } })
|
||||
|
|
@ -44,12 +51,7 @@ export class LoginJob implements IJob
|
|||
try
|
||||
{
|
||||
loginServer.listen({});
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
this.endsAt = new Date(new Date().getTime() + 300000);
|
||||
context.abortSignal.addEventListener('abort', () => reject());
|
||||
setTimeout(() => { reject('timeout'); }, 300000); // auto close after 5 minutes
|
||||
});
|
||||
await delay(this.endsAt, context.abortSignal);
|
||||
} catch
|
||||
{
|
||||
} finally
|
||||
|
|
|
|||
110
src/bun/api/jobs/twitch-login-job.ts
Normal file
110
src/bun/api/jobs/twitch-login-job.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { IJob, JobContext } from "../task-queue";
|
||||
import secrets from "../secrets";
|
||||
import open from "open";
|
||||
import z from "zod";
|
||||
import { delay } from "@/shared/utils";
|
||||
|
||||
|
||||
interface TwitchDevice
|
||||
{
|
||||
device_code: string,
|
||||
expires_in: number,
|
||||
expires_at: Date,
|
||||
started_at: Date,
|
||||
interval: number,
|
||||
user_code: string,
|
||||
verification_uri: string;
|
||||
}
|
||||
|
||||
export default class TwitchLoginJob implements IJob
|
||||
{
|
||||
twitchScopes = "analytics:read:extensions analytics:read:games user:read:email";
|
||||
device?: TwitchDevice;
|
||||
clientId: string;
|
||||
openInBrowser: boolean;
|
||||
static id = 'twitch-login-job' as const;
|
||||
static dataSchema = z.object({ expires_at: z.date(), started_at: z.date(), url: z.url(), user_code: z.string() }).or(z.undefined());
|
||||
|
||||
constructor(clientId: string, openInBrowser: boolean)
|
||||
{
|
||||
this.clientId = clientId;
|
||||
this.openInBrowser = openInBrowser;
|
||||
}
|
||||
|
||||
exposeData = (): z.infer<typeof TwitchLoginJob.dataSchema> => this.device ? ({
|
||||
expires_at: this.device.expires_at,
|
||||
started_at: this.device.started_at,
|
||||
url: this.device.verification_uri,
|
||||
user_code: this.device.user_code
|
||||
}) : undefined;
|
||||
|
||||
async start (context: JobContext): Promise<any>
|
||||
{
|
||||
context.setProgress(0, "Retrieving Device");
|
||||
let res = await fetch("https://id.twitch.tv/oauth2/device", {
|
||||
method: "POST",
|
||||
body: new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
scopes: this.twitchScopes
|
||||
}),
|
||||
signal: context.abortSignal
|
||||
});
|
||||
|
||||
const device: TwitchDevice = await res.json();
|
||||
const expiredTimeout = setTimeout(() => context.abort('expired'), device.expires_in * 1000);
|
||||
device.expires_at = new Date(new Date().getTime() + device.expires_in * 1000);
|
||||
device.started_at = new Date();
|
||||
this.device = device;
|
||||
|
||||
try
|
||||
{
|
||||
if (this.openInBrowser)
|
||||
open(device.verification_uri);
|
||||
this.device = device;
|
||||
context.setProgress(50, "Waiting For Authentication");
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (context.abortSignal.aborted) break;
|
||||
await delay(device.interval * 1000, context.abortSignal);
|
||||
|
||||
res = await fetch("https://id.twitch.tv/oauth2/token", {
|
||||
method: "POST",
|
||||
body: new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
scopes: this.twitchScopes,
|
||||
device_code: this.device.device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
||||
}),
|
||||
signal: context.abortSignal
|
||||
});
|
||||
|
||||
if (res.status === 200)
|
||||
{
|
||||
const data: {
|
||||
access_token: string,
|
||||
expires_in: number,
|
||||
refresh_token: string,
|
||||
scope: string[],
|
||||
token_type: string;
|
||||
} = await res.json();
|
||||
|
||||
secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token });
|
||||
secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token });
|
||||
secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() });
|
||||
break;
|
||||
}
|
||||
else if (res.status !== 400)
|
||||
{
|
||||
console.error(res.statusText);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} finally
|
||||
{
|
||||
clearTimeout(expiredTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
76
src/bun/api/jobs/update-store.ts
Normal file
76
src/bun/api/jobs/update-store.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { ensureDir } from "fs-extra";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { getStoreFolder } from "../store/store";
|
||||
|
||||
export default class UpdateStoreJob implements IJob
|
||||
{
|
||||
static id = "update-store" as const;
|
||||
static origin = "https://github.com/simeonradivoev/gameflow-store.git";
|
||||
static branch = "master";
|
||||
|
||||
async gitCommand (commands: string[], dir: string)
|
||||
{
|
||||
const proc = Bun.spawn(['git', ...commands], {
|
||||
cwd: dir,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [output] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
return output.trim();
|
||||
}
|
||||
|
||||
async isGitRepo (dir: string)
|
||||
{
|
||||
return (await this.gitCommand(["rev-parse", "--is-inside-work-tree"], dir)) === 'true';
|
||||
}
|
||||
|
||||
async getOrigin (dir: string)
|
||||
{
|
||||
const origin = await this.gitCommand(["remote", "get-url", "origin"], dir);
|
||||
return origin;
|
||||
}
|
||||
|
||||
async hasChanges (dir: string)
|
||||
{
|
||||
return (await this.gitCommand(["status", "--porcelain"], dir)).length > 0;
|
||||
}
|
||||
|
||||
async start (context: JobContext)
|
||||
{
|
||||
const storeFolder = getStoreFolder();
|
||||
await ensureDir(storeFolder);
|
||||
context.setProgress(10);
|
||||
if (await this.isGitRepo(storeFolder))
|
||||
{
|
||||
const existingOrigin = await this.getOrigin(storeFolder);
|
||||
if (existingOrigin !== UpdateStoreJob.origin)
|
||||
{
|
||||
throw new Error(`Git Repo in downloads is not valid. It has origin of ${existingOrigin}. Repo must be of ${UpdateStoreJob.origin}`);
|
||||
}
|
||||
|
||||
// check for uncommitted changes
|
||||
const status = await this.gitCommand([" status", "--porcelain"], storeFolder);
|
||||
if (status.length > 0)
|
||||
{
|
||||
console.log("Cleaning local changes...");
|
||||
await this.gitCommand(["reset", "--hard"], storeFolder);
|
||||
await this.gitCommand(["clean", "-fd"], storeFolder);
|
||||
}
|
||||
|
||||
// fetch & reset to remote
|
||||
await this.gitCommand(["fetch", "origin"], storeFolder);
|
||||
await this.gitCommand(["reset", "--hard", `origin/${UpdateStoreJob.branch}`], storeFolder);
|
||||
console.log("Shop Repo updated");
|
||||
} else
|
||||
{
|
||||
context.setProgress(50);
|
||||
await this.gitCommand(["clone", "--depth", "1", "--branch", UpdateStoreJob.branch, UpdateStoreJob.origin, '.'], storeFolder);
|
||||
context.setProgress(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,21 @@
|
|||
import { cors } from "@elysiajs/cors";
|
||||
import Elysia from "elysia";
|
||||
import { RPC_PORT } from "../../shared/constants";
|
||||
import { RPC_PORT } from "@shared/constants";
|
||||
import clients from "./clients";
|
||||
import { settings } from "./settings";
|
||||
import { settings } from "./settings/settings";
|
||||
import { system } from "./system";
|
||||
import { store } from "./store/store";
|
||||
import { host } from "../utils/host";
|
||||
import { jobs } from "./jobs/jobs";
|
||||
|
||||
const api = new Elysia({ serve: {} })
|
||||
.use([cors(), clients, settings, system]);
|
||||
.use([cors(), clients, settings, system, store, jobs]);
|
||||
|
||||
export type RommAPIType = typeof clients;
|
||||
export type SettingsAPIType = typeof settings;
|
||||
export type SystemAPIType = typeof system;
|
||||
export type StoreAPIType = typeof store;
|
||||
export type JobsAPIType = typeof jobs;
|
||||
|
||||
export function RunAPIServer ()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { integer, text, sqliteTable, blob } from "drizzle-orm/sqlite-core";
|
|||
|
||||
export const games = sqliteTable('games', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
source_id: integer('source_id').unique(),
|
||||
source_id: text('source_id'),
|
||||
source: text("source"),
|
||||
igdb_id: integer("igdb_id").unique(),
|
||||
name: text("name"),
|
||||
|
|
|
|||
10
src/bun/api/schema/cache.ts
Normal file
10
src/bun/api/schema/cache.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export default {
|
||||
item_cache: sqliteTable('item_cache', {
|
||||
key: text('key').primaryKey(),
|
||||
data: text('data', { mode: 'json' }).notNull(),
|
||||
expire_at: integer("expire_at", { mode: 'timestamp' }).notNull(),
|
||||
updated_at: integer("updated_at", { mode: 'timestamp' }).notNull(),
|
||||
})
|
||||
};
|
||||
|
|
@ -29,6 +29,10 @@ export const systemMappings = sqliteTable('systemMappings', {
|
|||
system: text().notNull().references(() => systems.name)
|
||||
});
|
||||
|
||||
export const systemMappingsRelations = relations(systemMappings, ({ one }) => ({
|
||||
system: one(systems, { fields: [systemMappings.system], references: [systems.name] })
|
||||
}));
|
||||
|
||||
export const commands = sqliteTable('commands', {
|
||||
system: text().references(() => systems.name, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
label: text(),
|
||||
|
|
@ -36,7 +40,7 @@ export const commands = sqliteTable('commands', {
|
|||
});
|
||||
|
||||
export const commandsRelations = relations(commands, ({ one }) => ({
|
||||
author: one(systems, {
|
||||
system: one(systems, {
|
||||
fields: [commands.system],
|
||||
references: [systems.name],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
import z from "zod";
|
||||
import { LOGIN_PORT, SettingsSchema } from "@shared/constants";
|
||||
import Elysia, { status } from "elysia";
|
||||
import { config, customEmulators, db, emulatorsDb, taskQueue } from "./app";
|
||||
import * as appSchema from './schema/app';
|
||||
import { findExec } from "./games/services/launchGameService";
|
||||
import * as emulatorSchema from "./schema/emulators";
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync } from "node:fs";
|
||||
import { InstallJob } from "./jobs/install-job";
|
||||
import { move } from "fs-extra";
|
||||
|
||||
export const settings = new Elysia({ prefix: '/api/settings' })
|
||||
.get('/emulators/automatic', async () =>
|
||||
{
|
||||
const localGames = await db.select({ es_slug: appSchema.platforms.es_slug, platform_id: appSchema.platforms.id })
|
||||
.from(appSchema.games)
|
||||
.leftJoin(appSchema.platforms, eq(appSchema.games.platform_id, appSchema.platforms.id))
|
||||
.groupBy(appSchema.platforms.es_slug);
|
||||
|
||||
const platformLookup = new Map(localGames.map(g => [g.es_slug, g.platform_id]));
|
||||
|
||||
const commands = await emulatorsDb
|
||||
.select({ command: emulatorSchema.commands.command, system_slug: emulatorSchema.systems.name })
|
||||
.from(emulatorSchema.commands).where(inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.es_slug).map(s => s.es_slug!)))))
|
||||
.leftJoin(emulatorSchema.systems, eq(emulatorSchema.systems.name, emulatorSchema.commands.system));
|
||||
|
||||
|
||||
const emulatorCounts: Record<string, number> = {};
|
||||
const emulators = commands
|
||||
.flatMap(command =>
|
||||
{
|
||||
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
|
||||
if (!matches)
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
|
||||
matches.forEach(m =>
|
||||
{
|
||||
emulatorCounts[m] = (emulatorCounts[m] ?? 0) + 1;
|
||||
});
|
||||
|
||||
return matches?.map(m => [m, command.system_slug] as [string, string]);
|
||||
}
|
||||
).filter(c => !!c);
|
||||
const uniqueEmulators = new Map(emulators);
|
||||
|
||||
return await Promise.all(Array.from(uniqueEmulators.entries()).map(async ([emulator, system_slug]) =>
|
||||
{
|
||||
let execPath: string | undefined;
|
||||
if (customEmulators.has(emulator))
|
||||
{
|
||||
execPath = customEmulators.get(emulator);
|
||||
} else
|
||||
{
|
||||
execPath = await findExec(emulator);
|
||||
}
|
||||
|
||||
let platform: number | null | undefined = null;
|
||||
if (emulatorCounts[emulator] <= 1)
|
||||
{
|
||||
platform = platformLookup.get(system_slug);
|
||||
}
|
||||
|
||||
return { emulator: emulator, path: execPath, exists: !!execPath && await fs.exists(execPath), path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null };
|
||||
}));
|
||||
}, {
|
||||
response: z.array(z.object({ emulator: z.string(), path: z.string().optional(), exists: z.boolean(), path_cover: z.string().nullable() }))
|
||||
})
|
||||
.put('/emulators/custom/:id', async ({ params: { id }, body: { value } }) =>
|
||||
{
|
||||
return customEmulators.set(id, value);
|
||||
},
|
||||
{
|
||||
body: z.object({ value: z.string() })
|
||||
})
|
||||
.delete('/emulators/custom/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
return customEmulators.delete(id);
|
||||
})
|
||||
.get('/emulators/custom/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
return customEmulators.get(id);
|
||||
},
|
||||
{
|
||||
response: z.string()
|
||||
})
|
||||
.get('/emulators/custom', async () =>
|
||||
{
|
||||
return Object.keys(customEmulators.store);
|
||||
}, {
|
||||
response: z.array(z.string())
|
||||
})
|
||||
.put('/path/download', async ({ body: { manualPath, drive } }) =>
|
||||
{
|
||||
if (taskQueue.hasActiveOfType(InstallJob))
|
||||
{
|
||||
return status("Forbidden", "Installation in progress");
|
||||
}
|
||||
|
||||
const oldDownloadPath = config.get('downloadPath');
|
||||
if (!existsSync(oldDownloadPath))
|
||||
{
|
||||
return status("Not Found", "Old download path doesn't exist");
|
||||
}
|
||||
|
||||
async function isDirEmpty (dirname: string)
|
||||
{
|
||||
const files = await fs.readdir(dirname);
|
||||
return files.length === 0;
|
||||
}
|
||||
|
||||
const path = manualPath ?? drive;
|
||||
|
||||
if (!path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(path) && !isDirEmpty(path))
|
||||
{
|
||||
return status("Conflict", "New location already exists and is not empty");
|
||||
}
|
||||
|
||||
await move(oldDownloadPath, path);
|
||||
config.set('downloadPath', manualPath);
|
||||
return manualPath;
|
||||
}, {
|
||||
body: z.object({
|
||||
manualPath: z.string().optional(),
|
||||
drive: z.string().optional()
|
||||
})
|
||||
})
|
||||
.get("/:id", async ({ params: { id } }) =>
|
||||
{
|
||||
const value = config.get(id);
|
||||
return { value: value };
|
||||
}, {
|
||||
params: z.object({ id: z.keyof(SettingsSchema) }),
|
||||
}).post('/:id',
|
||||
async ({ params: { id }, body: { value }, }) =>
|
||||
{
|
||||
config.set(id, value);
|
||||
}, {
|
||||
params: z.object({ id: z.keyof(SettingsSchema) }),
|
||||
body: z.object({ value: z.any() }),
|
||||
});
|
||||
|
||||
193
src/bun/api/settings/services.ts
Normal file
193
src/bun/api/settings/services.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
|
||||
import * as appSchema from '@schema/app';
|
||||
import { findExec, findExecByName } from "../games/services/launchGameService";
|
||||
import * as emulatorSchema from "@schema/emulators";
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { customEmulators, db, emulatorsDb } from '../app';
|
||||
import fs from 'node:fs/promises';
|
||||
import { cores } from '../emulatorjs/emulatorjs';
|
||||
|
||||
/**
|
||||
* Get emulators based on local games. Only the ones we probably need.
|
||||
* */
|
||||
export async function getRelevantEmulators ()
|
||||
{
|
||||
const localGames = await db.select({ es_slug: appSchema.platforms.es_slug, platform_id: appSchema.platforms.id, platform_name: appSchema.platforms.name })
|
||||
.from(appSchema.games)
|
||||
.leftJoin(appSchema.platforms, eq(appSchema.games.platform_id, appSchema.platforms.id))
|
||||
.groupBy(appSchema.platforms.es_slug);
|
||||
|
||||
const platformLookup = new Map(localGames.filter(g => g.es_slug).map(g => [g.es_slug!, g]));
|
||||
const platformViability = new Map(localGames.filter(g => g.es_slug).map(g => [g.es_slug!, false]));
|
||||
|
||||
// check emulator js
|
||||
for (const platform of platformLookup)
|
||||
{
|
||||
if (cores[platform[0]])
|
||||
platformViability.set(platform[0], true);
|
||||
}
|
||||
|
||||
// all commands based on the local games
|
||||
const commands = await emulatorsDb
|
||||
.select({ command: emulatorSchema.commands.command, system_slug: emulatorSchema.systems.name })
|
||||
.from(emulatorSchema.commands).where(inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.es_slug).map(s => s.es_slug!)))))
|
||||
.leftJoin(emulatorSchema.systems, eq(emulatorSchema.systems.name, emulatorSchema.commands.system));
|
||||
|
||||
|
||||
// get all emulators in said commands
|
||||
const emulators = commands
|
||||
.flatMap(command =>
|
||||
{
|
||||
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
|
||||
if (!matches)
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return matches?.map(m => ({ emulator: m, system: command.system_slug }));
|
||||
}
|
||||
).filter(c => !!c);
|
||||
|
||||
// Group them by name
|
||||
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
|
||||
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
|
||||
{
|
||||
let execPath: { path: string; type: string, } | undefined;
|
||||
if (customEmulators.has(emulator))
|
||||
{
|
||||
execPath = { path: customEmulators.get(emulator), type: 'custom' };
|
||||
} else
|
||||
{
|
||||
execPath = await findExecByName(emulator);
|
||||
}
|
||||
|
||||
let platform: number | null | undefined = null;
|
||||
const validSystemSlug = system_slug.find(s => s.system);
|
||||
if (validSystemSlug?.system)
|
||||
{
|
||||
platform = platformLookup.get(validSystemSlug.system)?.platform_id;
|
||||
}
|
||||
|
||||
// check if automatic or custom path found existing binary.
|
||||
// This might not be the actual emulator but I don't care.
|
||||
const exists = !!execPath && await fs.exists(execPath.path);
|
||||
const systems = Array.from(new Set(system_slug.filter(s => s.system).map(s => s.system!)));
|
||||
if (exists)
|
||||
{
|
||||
systems.forEach(s => platformViability.set(s, true));
|
||||
}
|
||||
|
||||
return {
|
||||
emulator: emulator,
|
||||
path: execPath,
|
||||
exists: exists,
|
||||
isCritical: false,
|
||||
path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null,
|
||||
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s)
|
||||
};
|
||||
}));
|
||||
|
||||
finalEmulators.push({
|
||||
emulator: 'emulatorjs',
|
||||
exists: true,
|
||||
path: { path: 'localhost', type: 'js' },
|
||||
path_cover: `/api/romm/image?url=${encodeURIComponent('https://emulatorjs.org/logo/EmulatorJS.png')}`,
|
||||
isCritical: false,
|
||||
systems: []
|
||||
});
|
||||
|
||||
return finalEmulators.map(e =>
|
||||
{
|
||||
e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!));
|
||||
return e;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Only emulators we strictly need based on local games. Emulator JS is included as bundled.
|
||||
* If there is even single emulator for a system don't include emulators for that system.
|
||||
*/
|
||||
/*export async function getMissingEmulators ()
|
||||
{
|
||||
const localGames = await db.query.games.findMany({
|
||||
columns: {
|
||||
platform_id: true,
|
||||
slug: true
|
||||
},
|
||||
with: {
|
||||
platform: {
|
||||
columns: {
|
||||
name: true,
|
||||
es_slug: true
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
const platformLookup = new Map(localGames.map(g => [g.platform.es_slug, g]));
|
||||
const platformViability = new Map(localGames.map(g => [g.platform.es_slug, false]));
|
||||
|
||||
// all commands based on the local games
|
||||
const commands = await emulatorsDb.query.commands.findMany({
|
||||
columns: { command: true },
|
||||
where: inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.platform.es_slug).map(s => s.platform.es_slug!)))),
|
||||
with: { system: { columns: { name: true } } }
|
||||
});
|
||||
|
||||
// get all emulators in said commands
|
||||
const emulators = commands
|
||||
.flatMap(command =>
|
||||
{
|
||||
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
|
||||
if (!matches)
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return matches?.map(m => ({ emulator: m, system: command.system?.name }));
|
||||
}
|
||||
).filter(c => !!c);
|
||||
|
||||
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
|
||||
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
|
||||
{
|
||||
let execPath: { path: string; type: string, } | undefined;
|
||||
if (customEmulators.has(emulator))
|
||||
{
|
||||
execPath = { path: customEmulators.get(emulator), type: 'custom' };
|
||||
} else
|
||||
{
|
||||
execPath = await findExecByName(emulator);
|
||||
}
|
||||
|
||||
let platform: number | null | undefined = null;
|
||||
if (system_slug.length <= 1)
|
||||
{
|
||||
platform = platformLookup.get(system_slug[0].system)?.platform_id;
|
||||
}
|
||||
|
||||
// check if automatic or custom path found existing binary.
|
||||
// This might not be the actual emulator but I don't care.
|
||||
const exists = !!execPath && await fs.exists(execPath.path);
|
||||
const systems = Array.from(new Set(system_slug.map(s => s.system)));
|
||||
if (exists)
|
||||
{
|
||||
systems.forEach(s => platformViability.set(s, true));
|
||||
}
|
||||
|
||||
return {
|
||||
emulator: emulator,
|
||||
path: execPath,
|
||||
exists: exists,
|
||||
isCritical: false,
|
||||
path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null,
|
||||
systems: systems.map(s => platformLookup.get(s)).filter(s => !!s)
|
||||
};
|
||||
}));
|
||||
|
||||
return finalEmulators.map(e =>
|
||||
{
|
||||
e.isCritical = !e.systems.filter(s => s?.es_slug).some(s => !!platformViability.get(s?.es_slug!));
|
||||
return e;
|
||||
});
|
||||
}*/
|
||||
94
src/bun/api/settings/settings.ts
Normal file
94
src/bun/api/settings/settings.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import z from "zod";
|
||||
import { SettingsSchema } from "@shared/constants";
|
||||
import Elysia, { status } from "elysia";
|
||||
import { config, customEmulators, taskQueue } from "../app";
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync } from "node:fs";
|
||||
import { InstallJob } from "../jobs/install-job";
|
||||
import { move } from "fs-extra";
|
||||
import { getRelevantEmulators } from "./services";
|
||||
|
||||
export const settings = new Elysia({ prefix: '/api/settings' })
|
||||
.get('/emulators/automatic', async () =>
|
||||
{
|
||||
return getRelevantEmulators();
|
||||
})
|
||||
.put('/emulators/custom/:id', async ({ params: { id }, body: { value } }) =>
|
||||
{
|
||||
return customEmulators.set(id, value);
|
||||
},
|
||||
{
|
||||
body: z.object({ value: z.string() })
|
||||
})
|
||||
.delete('/emulators/custom/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
return customEmulators.delete(id);
|
||||
})
|
||||
.get('/emulators/custom/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
return customEmulators.get(id);
|
||||
},
|
||||
{
|
||||
response: z.string()
|
||||
})
|
||||
.get('/emulators/custom', async () =>
|
||||
{
|
||||
return Object.keys(customEmulators.store);
|
||||
}, {
|
||||
response: z.array(z.string())
|
||||
})
|
||||
.put('/path/download', async ({ body: { manualPath, drive } }) =>
|
||||
{
|
||||
if (taskQueue.hasActiveOfType(InstallJob))
|
||||
{
|
||||
return status("Forbidden", "Installation in progress");
|
||||
}
|
||||
|
||||
const oldDownloadPath = config.get('downloadPath');
|
||||
if (!existsSync(oldDownloadPath))
|
||||
{
|
||||
return status("Not Found", "Old download path doesn't exist");
|
||||
}
|
||||
|
||||
async function isDirEmpty (dirname: string)
|
||||
{
|
||||
const files = await fs.readdir(dirname);
|
||||
return files.length === 0;
|
||||
}
|
||||
|
||||
const path = manualPath ?? drive;
|
||||
|
||||
if (!path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(path) && !isDirEmpty(path))
|
||||
{
|
||||
return status("Conflict", "New location already exists and is not empty");
|
||||
}
|
||||
|
||||
await move(oldDownloadPath, path);
|
||||
config.set('downloadPath', manualPath);
|
||||
return manualPath;
|
||||
}, {
|
||||
body: z.object({
|
||||
manualPath: z.string().optional(),
|
||||
drive: z.string().optional()
|
||||
})
|
||||
})
|
||||
.get("/:id", async ({ params: { id } }) =>
|
||||
{
|
||||
const value = config.get(id);
|
||||
return { value: value };
|
||||
}, {
|
||||
params: z.object({ id: z.keyof(SettingsSchema) }),
|
||||
}).post('/:id',
|
||||
async ({ params: { id }, body: { value }, }) =>
|
||||
{
|
||||
config.set(id, value);
|
||||
}, {
|
||||
params: z.object({ id: z.keyof(SettingsSchema) }),
|
||||
body: z.object({ value: z.any() }),
|
||||
});
|
||||
|
||||
59
src/bun/api/store/services/gamesService.ts
Normal file
59
src/bun/api/store/services/gamesService.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { GithubManifestSchema, StoreGameSchema } from "@/shared/constants";
|
||||
import { CACHE_KEYS, getOrCached } from "../../cache";
|
||||
|
||||
export async function getStoreGameManifest ()
|
||||
{
|
||||
return getOrCached(CACHE_KEYS.STORE_GAME_MANIFEST, async () =>
|
||||
{
|
||||
const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json()).then(data => GithubManifestSchema.parseAsync(data));
|
||||
|
||||
return store.tree.filter((e: any) =>
|
||||
{
|
||||
if (e.type === 'blob' && e.path !== "featured.json")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; })
|
||||
{
|
||||
const offset = filter?.offset ?? 0;
|
||||
const limit = Math.min(50, filter?.limit ?? 10);
|
||||
|
||||
const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) =>
|
||||
{
|
||||
return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, "")))));
|
||||
}));
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
export function extractStoreGameSourceId (id: string)
|
||||
{
|
||||
const gameId = id.split('@');
|
||||
if (gameId.length !== 2)
|
||||
throw new Error("Store ID should include platform and name with @ separator");
|
||||
return { system: gameId[0], id: gameId[1] };
|
||||
}
|
||||
|
||||
export function getStoreGameFromId (id: string)
|
||||
{
|
||||
const data = extractStoreGameSourceId(id);
|
||||
return getStoreGame(data.system, data.id);
|
||||
}
|
||||
|
||||
export async function getStoreGame (system: string, id: string)
|
||||
{
|
||||
return getStoreGameFromPath(`${system}/${encodeURIComponent(id)}.json`);
|
||||
}
|
||||
|
||||
export async function getStoreGameFromPath (path: string)
|
||||
{
|
||||
const game = await getOrCached(CACHE_KEYS.STORE_GAME(path), () => fetch(`https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/${path}`)
|
||||
.then(e => e.json())
|
||||
.then(g => StoreGameSchema.parseAsync(g)));
|
||||
return game;
|
||||
}
|
||||
201
src/bun/api/store/store.ts
Normal file
201
src/bun/api/store/store.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
|
||||
import Elysia from "elysia";
|
||||
import { config, customEmulators, db } from "../app";
|
||||
import path from "node:path";
|
||||
import fs from 'node:fs/promises';
|
||||
import { EmulatorPackageSchema, EmulatorPackageType, FrontEndEmulator, FrontEndEmulatorDetailed, StoreGameSchema } from "@/shared/constants";
|
||||
import { findExec } from "../games/services/launchGameService";
|
||||
import { emulatorsDb } from '../app';
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as emulatorSchema from '@schema/emulators';
|
||||
import * as appSchema from '@schema/app';
|
||||
import z from "zod";
|
||||
import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils";
|
||||
import { getPlatformsApiPlatformsGet } from "@/clients/romm";
|
||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||
|
||||
export function getStoreFolder ()
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
return path.join(downlodDir, "store");
|
||||
}
|
||||
|
||||
async function getAllStoreEmulatorPackages ()
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
const emulatorsBucket = path.join(downlodDir, "store", "buckets", "emulators");
|
||||
const emulators = await fs.readdir(emulatorsBucket);
|
||||
const emulatorsRawData = await Promise.all(emulators.map(e => fs.readFile(path.join(emulatorsBucket, e), 'utf-8')));
|
||||
|
||||
const emulatesParsed = emulatorsRawData.map(d => EmulatorPackageSchema.safeParse(JSON.parse(d))).filter(e =>
|
||||
{
|
||||
if (e.error)
|
||||
{
|
||||
console.error(e.error);
|
||||
}
|
||||
return e.data;
|
||||
}).map(e => e.data!);
|
||||
|
||||
return emulatesParsed;
|
||||
}
|
||||
|
||||
async function buildSystems (emulator: EmulatorPackageType)
|
||||
{
|
||||
const systems = await Promise.all(emulator.systems.map(async system =>
|
||||
{
|
||||
const rommSystem = await emulatorsDb.query.systemMappings.findFirst({
|
||||
where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system))
|
||||
});
|
||||
|
||||
const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } });
|
||||
|
||||
let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`;
|
||||
|
||||
return { id: system, name: esSystem?.fullname ?? system, icon: icon };
|
||||
}));
|
||||
|
||||
return systems;
|
||||
}
|
||||
|
||||
export const store = new Elysia({ prefix: '/api/store' })
|
||||
.get('/emulators', async ({ query }) =>
|
||||
{
|
||||
const rommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data).catch(e =>
|
||||
{
|
||||
console.error(e);
|
||||
return undefined;
|
||||
});
|
||||
const emulatesParsed = await getAllStoreEmulatorPackages();
|
||||
let frontEndEmulators = await Promise.all(emulatesParsed
|
||||
.filter(e => e.os.includes(process.platform as any))
|
||||
.map(async (emulator) =>
|
||||
{
|
||||
let execPath: { path: string; type: string; } | undefined;
|
||||
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) });
|
||||
|
||||
if (esEmulator)
|
||||
{
|
||||
if (customEmulators.has(emulator?.name))
|
||||
{
|
||||
execPath = { path: customEmulators.get(emulator.name), type: 'custom' };
|
||||
} else
|
||||
{
|
||||
execPath = await findExec(esEmulator);
|
||||
}
|
||||
}
|
||||
|
||||
const exists = !!execPath && await fs.exists(execPath.path);
|
||||
const systems = await buildSystems(emulator);
|
||||
|
||||
const gameCounts = await Promise.all(systems.map(async (s) =>
|
||||
{
|
||||
const rommMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, s.id)) });
|
||||
const romPlatform = rommPlatforms?.find(p => p.slug === (rommMapping?.sourceSlug ?? s.id));
|
||||
if (romPlatform)
|
||||
{
|
||||
return romPlatform.rom_count;
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
}));
|
||||
|
||||
const gameCount = gameCounts.reduce((a, c) => a + c);
|
||||
|
||||
return { ...emulator, exists, systems, gameCount } satisfies FrontEndEmulator;
|
||||
}));
|
||||
|
||||
if (query.missing)
|
||||
{
|
||||
frontEndEmulators = frontEndEmulators.filter(e => !e.exists);
|
||||
}
|
||||
|
||||
if (query.orderBy === 'importance')
|
||||
{
|
||||
frontEndEmulators.sort((a, b) =>
|
||||
{
|
||||
const gameCountDiff = b.gameCount - a.gameCount;
|
||||
if (gameCountDiff !== 0) return gameCountDiff;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
if (query.limit)
|
||||
{
|
||||
frontEndEmulators = frontEndEmulators.splice(0, query.limit);
|
||||
}
|
||||
|
||||
return frontEndEmulators;
|
||||
},
|
||||
{
|
||||
query: z.object({
|
||||
limit: z.coerce.number().optional(),
|
||||
missing: z.stringbool().optional().describe("Show Only Non Installed emulators"),
|
||||
orderBy: z.enum(['name', 'recently_updated', 'importance']).optional()
|
||||
})
|
||||
})
|
||||
.get('/games/featured', async () =>
|
||||
{
|
||||
const response = await fetch('https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/featured.json');
|
||||
const games = await z.object({ featured: z.array(StoreGameSchema) }).parseAsync(await response.json());
|
||||
return Promise.all(games.featured.map(async g =>
|
||||
{
|
||||
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(`${g.system}@${g.title}`, 'store') });
|
||||
if (localGame) return convertLocalToFrontendDetailed(localGame);
|
||||
return convertStoreToFrontendDetailed(g.system, g.title, g);
|
||||
}));
|
||||
})
|
||||
.get('/stats', async () =>
|
||||
{
|
||||
const emulatesParsed = await getAllStoreEmulatorPackages();
|
||||
const storeEmulatorCount = emulatesParsed.filter(e => e.os.includes(process.platform as any)).length;
|
||||
const gameCount = await db.$count(appSchema.games);
|
||||
return {
|
||||
storeEmulatorCount,
|
||||
gameCount
|
||||
};
|
||||
})
|
||||
.get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) =>
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
return Bun.file(path.join(downlodDir, "store", "media", "screenshots", id, name));
|
||||
},
|
||||
{ params: z.object({ id: z.string(), name: z.string() }) })
|
||||
.get('/details/emulator/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
const downlodDir = config.get('downloadPath');
|
||||
const emulatorPath = path.join(downlodDir, "store", "buckets", "emulators", `${id}.json`);
|
||||
const emulatorScreenshotsPath = path.join(downlodDir, "store", "media", "screenshots", id);
|
||||
const emulatorPackage = await EmulatorPackageSchema.parseAsync(JSON.parse(await fs.readFile(emulatorPath, 'utf-8')));
|
||||
|
||||
const systems = await buildSystems(emulatorPackage);
|
||||
let execPath: { path: string; type: string; } | undefined;
|
||||
const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulatorPackage.name) });
|
||||
|
||||
if (esEmulator)
|
||||
{
|
||||
if (customEmulators.has(emulatorPackage?.name))
|
||||
{
|
||||
execPath = { path: customEmulators.get(emulatorPackage.name), type: 'custom' };
|
||||
} else
|
||||
{
|
||||
execPath = await findExec(esEmulator);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : [];
|
||||
const exists = !!execPath && await fs.exists(execPath.path);
|
||||
const emulator: FrontEndEmulatorDetailed = {
|
||||
...emulatorPackage,
|
||||
systems,
|
||||
exists,
|
||||
status: {
|
||||
source: execPath?.type,
|
||||
location: execPath?.path
|
||||
},
|
||||
screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`)
|
||||
};
|
||||
|
||||
return emulator;
|
||||
}, { params: z.object({ id: z.string() }) });
|
||||
|
|
@ -2,7 +2,7 @@ import Elysia from "elysia";
|
|||
import open from 'open';
|
||||
import z from "zod";
|
||||
import os from 'node:os';
|
||||
import { config, events } from "./app";
|
||||
import { cachePath, config, events } from "./app";
|
||||
import { isSteamDeck, openExternal } from "../utils";
|
||||
import fs from 'node:fs/promises';
|
||||
import buildNotificationsStream from "./notifications";
|
||||
|
|
@ -11,6 +11,7 @@ import { DirSchema, DownloadsDrive } from "@/shared/constants";
|
|||
import { getDevices, getDevicesCurated } from "./drives";
|
||||
import getFolderSize from "get-folder-size";
|
||||
import si from 'systeminformation';
|
||||
import { getStoreFolder } from "./store/store";
|
||||
|
||||
export const system = new Elysia({ prefix: '/api/system' })
|
||||
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>
|
||||
|
|
@ -48,7 +49,9 @@ export const system = new Elysia({ prefix: '/api/system' })
|
|||
hostname: os.hostname(),
|
||||
steamDeck: process.env.SteamDeck,
|
||||
machine: os.machine(),
|
||||
source
|
||||
source,
|
||||
cacheSize: (await fs.stat(cachePath)).size,
|
||||
storeSize: (await getFolderSize(getStoreFolder())).size
|
||||
};
|
||||
})
|
||||
.get('/notifications', ({ set }) =>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export class TaskQueue
|
|||
setTimeout(this.processQueue);
|
||||
});
|
||||
return promise;
|
||||
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
|
@ -91,8 +92,10 @@ export class TaskQueue
|
|||
|
||||
export interface EventsList
|
||||
{
|
||||
started: [e: BaseEvent];
|
||||
progress: [e: ProgressEvent];
|
||||
abort: [e: AbortEvent];
|
||||
/** Called when the job successfully completes */
|
||||
completed: [e: CompletedEvent];
|
||||
error: [e: ErrorEvent];
|
||||
ended: [e: BaseEvent];
|
||||
|
|
@ -101,7 +104,7 @@ export interface EventsList
|
|||
interface BaseEvent
|
||||
{
|
||||
id: string;
|
||||
job: IJob;
|
||||
job: IPublicJob;
|
||||
}
|
||||
|
||||
interface ErrorEvent extends BaseEvent
|
||||
|
|
@ -128,6 +131,7 @@ interface CompletedEvent extends BaseEvent
|
|||
export interface IJob
|
||||
{
|
||||
start (context: JobContext): Promise<any>;
|
||||
exposeData?(): any;
|
||||
}
|
||||
|
||||
export type JobStatus = 'completed' | 'error' | 'running' | 'waiting' | 'aborted';
|
||||
|
|
@ -137,7 +141,7 @@ export interface IPublicJob
|
|||
progress: number;
|
||||
state?: string;
|
||||
status: JobStatus;
|
||||
job: any;
|
||||
job: IJob;
|
||||
abort: (reason?: any) => void;
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +156,7 @@ export class JobContext implements IPublicJob
|
|||
private error?: any;
|
||||
private events: EventEmitter<EventsList>;
|
||||
private abortController: AbortController;
|
||||
private m_job: IJob;
|
||||
private readonly m_job: IJob;
|
||||
|
||||
constructor(id: string, events: EventEmitter<EventsList>, job: IJob)
|
||||
{
|
||||
|
|
@ -162,7 +166,7 @@ export class JobContext implements IPublicJob
|
|||
this.abortController.signal.addEventListener('abort', () =>
|
||||
{
|
||||
this.aborted = true;
|
||||
this.events.emit('abort', { id: this.m_id, reason: this.abortController.signal.reason, job: this.m_job } satisfies AbortEvent);
|
||||
this.events.emit('abort', { id: this.m_id, reason: this.abortController.signal.reason, job: this } satisfies AbortEvent);
|
||||
});
|
||||
this.events = events;
|
||||
}
|
||||
|
|
@ -171,19 +175,24 @@ export class JobContext implements IPublicJob
|
|||
{
|
||||
try
|
||||
{
|
||||
this.events.emit('started', { id: this.m_id, job: this });
|
||||
await this.m_job.start(this);
|
||||
this.completed = true;
|
||||
this.events.emit('completed', { id: this.m_id, job: this.m_job });
|
||||
this.events.emit('completed', { id: this.m_id, job: this });
|
||||
|
||||
} catch (error)
|
||||
{
|
||||
if (error !== 'cancel')
|
||||
{
|
||||
console.error(error);
|
||||
this.events.emit('error', { id: this.m_id, job: this.m_job, error });
|
||||
}
|
||||
|
||||
this.events.emit('error', { id: this.m_id, job: this, error });
|
||||
this.error = error;
|
||||
} finally
|
||||
{
|
||||
this.running = false;
|
||||
this.events.emit('ended', { id: this.m_id, job: this.m_job });
|
||||
this.events.emit('ended', { id: this.m_id, job: this });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -211,7 +220,7 @@ export class JobContext implements IPublicJob
|
|||
this.m_progress = progress;
|
||||
if (state)
|
||||
this.m_state = state;
|
||||
this.events.emit('progress', { id: this.m_id, progress, state: state ?? this.m_state, job: this.m_job });
|
||||
this.events.emit('progress', { id: this.m_id, progress, state: state ?? this.m_state, job: this });
|
||||
}
|
||||
|
||||
public abort (reason?: any)
|
||||
|
|
|
|||
|
|
@ -1,32 +1,42 @@
|
|||
import { killBrowser, spawnBrowser } from './utils/browser-spawner';
|
||||
import { BuildParams } from './utils/browser-params';
|
||||
import { BrowserParams, BuildParams } from './utils/browser-params';
|
||||
import os from 'node:os';
|
||||
import { EventEmitter } from 'node:stream';
|
||||
import { config } from './api/app';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
export default async function init (events: EventEmitter, forceBrowser: boolean)
|
||||
export default async function init (events: EventEmitter, forceBrowser: boolean, params: BrowserParams)
|
||||
{
|
||||
if (forceBrowser)
|
||||
{
|
||||
await runBrowser(events);
|
||||
await runBrowser(events, params);
|
||||
} else
|
||||
{
|
||||
try
|
||||
{
|
||||
await runWebview(events);
|
||||
await runWebview(events, params);
|
||||
} catch (error)
|
||||
{
|
||||
await runBrowser(events);
|
||||
await runBrowser(events, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runWebview (events: EventEmitter)
|
||||
async function runWebview (events: EventEmitter, params: BrowserParams)
|
||||
{
|
||||
const webviewWorker = new Worker(new URL(`./webview/${os.platform()}`, import.meta.url).href, {
|
||||
const webviewPath = process.env.IS_BINARY ? `./webview/${os.platform()}` : new URL(`./webview/${os.platform()}`, import.meta.url).href;
|
||||
console.log("Launching Webview Worker at: ", webviewPath);
|
||||
const config: Record<string, string> = {};
|
||||
if (params.windowSize)
|
||||
{
|
||||
config.WINDOW_WIDTH = String(params.windowSize?.width);
|
||||
config.WINDOW_HEIGHT = String(params.windowSize?.height);
|
||||
}
|
||||
const webviewWorker = new Worker(webviewPath, {
|
||||
smol: true,
|
||||
ref: false
|
||||
ref: false,
|
||||
env: {
|
||||
...config,
|
||||
...process.env as any
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) =>
|
||||
|
|
@ -39,8 +49,9 @@ async function runWebview (events: EventEmitter)
|
|||
|
||||
webviewWorker.addEventListener('message', (e) =>
|
||||
{
|
||||
if (e.data === 'destroyed')
|
||||
if (e.data.data === 'destroyed')
|
||||
{
|
||||
console.log("Webview Destroyed");
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
|
@ -48,14 +59,15 @@ async function runWebview (events: EventEmitter)
|
|||
events.on('exitapp', () =>
|
||||
{
|
||||
resolve(true);
|
||||
console.log("Terminating Webview Worker");
|
||||
webviewWorker.terminate();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runBrowser (events: EventEmitter)
|
||||
async function runBrowser (events: EventEmitter, params: BrowserParams)
|
||||
{
|
||||
const browserParams = await BuildParams({ configPath: dirname(config.path) });
|
||||
const browserParams = await BuildParams(params);
|
||||
if (!browserParams)
|
||||
{
|
||||
console.error("Could not find valid browser");
|
||||
|
|
@ -72,7 +84,7 @@ async function runBrowser (events: EventEmitter)
|
|||
detached: false,
|
||||
execPath: browserParams.browser.path,
|
||||
source: browserParams.browser.source,
|
||||
configPath: dirname(config.path),
|
||||
configPath: params.configPath,
|
||||
ipc (message)
|
||||
{
|
||||
console.log(message);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { RunBunServer } from './server';
|
||||
import { RunAPIServer } from './api/rpc';
|
||||
import { cleanup as appCleanup, events } from './api/app';
|
||||
import { cleanup as appCleanup, config, events } from './api/app';
|
||||
import init from './browser';
|
||||
import { dirname } from 'pathe';
|
||||
import { createInterface } from 'readline';
|
||||
|
||||
const api = RunAPIServer();
|
||||
let bunServer: { stop: () => void; url: URL; } | undefined;
|
||||
let bunServer: { stop: () => void; } | undefined;
|
||||
|
||||
if (!Bun.env.PUBLIC_ACCESS)
|
||||
{
|
||||
|
|
@ -16,21 +18,25 @@ async function cleanup ()
|
|||
console.log("Cleaning Up");
|
||||
await appCleanup();
|
||||
bunServer?.stop();
|
||||
await api.apiServer.stop();
|
||||
await api.apiServer.stop(true);
|
||||
await api.cleanup();
|
||||
console.log("Finished Cleaning Up");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (Bun.env.HEADLESS)
|
||||
{
|
||||
// Called by outside force
|
||||
process.on('message', ({ type }) =>
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
|
||||
rl.on("line", async (line) =>
|
||||
{
|
||||
if (type === 'exitapp')
|
||||
if (line.trim() === "shutdown")
|
||||
{
|
||||
cleanup();
|
||||
console.log("Graceful Shutdown");
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Called by user
|
||||
events.on('exitapp', () =>
|
||||
{
|
||||
|
|
@ -39,7 +45,11 @@ if (Bun.env.HEADLESS)
|
|||
});
|
||||
} else
|
||||
{
|
||||
await init(events, !!Bun.env.FORCE_BROWSER);
|
||||
await init(events, Bun.env.FORCE_BROWSER === "true", {
|
||||
configPath: dirname(config.path),
|
||||
windowPosition: config.get('windowPosition'),
|
||||
windowSize: config.get('windowSize')
|
||||
});
|
||||
await cleanup();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,44 @@
|
|||
import { SERVER_PORT } from "../shared/constants";
|
||||
import { SERVER_PORT } from "@shared/constants";
|
||||
import path from 'node:path';
|
||||
import appInfo from '../../package.json';
|
||||
import appInfo from '~/package.json';
|
||||
import { host } from "./utils/host";
|
||||
import { appPath } from "./utils";
|
||||
import Elysia, { file } from "elysia";
|
||||
import cors from "@elysiajs/cors";
|
||||
import staticPlugin from "@elysiajs/static";
|
||||
|
||||
export function RunBunServer ()
|
||||
{
|
||||
console.log("Launching Server on port ", SERVER_PORT);
|
||||
return Bun.serve({
|
||||
return new Elysia()
|
||||
.use(cors())
|
||||
.get("/", ({ set }) =>
|
||||
{
|
||||
set.headers['cross-origin-opener-policy'] = 'same-origin';
|
||||
set.headers['cross-origin-embedder-policy'] = 'require-corp';
|
||||
return file("./dist/index.html");
|
||||
})
|
||||
.get('/emulatorjs', ({ set }) =>
|
||||
{
|
||||
set.headers['cross-origin-opener-policy'] = 'same-origin';
|
||||
set.headers['cross-origin-embedder-policy'] = 'require-corp';
|
||||
set.headers['cross-origin-resource-policy'] = 'cross-origin';
|
||||
return file('./dist/emulatorjs/index.html');
|
||||
})
|
||||
.use(staticPlugin({
|
||||
indexHTML: false,
|
||||
assets: "dist",
|
||||
prefix: "/",
|
||||
alwaysStatic: true
|
||||
})).listen({ port: SERVER_PORT, hostname: host }, console.log);
|
||||
/*return Bun.serve({
|
||||
port: SERVER_PORT,
|
||||
hostname: host,
|
||||
routes: {
|
||||
"/": Bun.file(appPath("./dist/index.html")),
|
||||
// Serve a file by lazily loading it into memory
|
||||
"/favicon.ico": Bun.file(appPath("./dist/favicon.ico")),
|
||||
"/emulatorjs/": Bun.file(appPath("./dist/emulatorjs/index.html")),
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": new Response(
|
||||
JSON.stringify({
|
||||
name: appInfo.name,
|
||||
|
|
@ -33,5 +58,5 @@ export function RunBunServer ()
|
|||
const url = new URL(req.url);
|
||||
return new Response(Bun.file(appPath(`./${path.join('dist', url.pathname)}`)));
|
||||
},
|
||||
});
|
||||
});*/
|
||||
}
|
||||
26
src/bun/types/types.d.ts
vendored
26
src/bun/types/types.d.ts
vendored
|
|
@ -8,3 +8,29 @@ export type ActiveGame = {
|
|||
name: string;
|
||||
command: string;
|
||||
};
|
||||
|
||||
interface ObjectConstructor
|
||||
{
|
||||
/**
|
||||
* Groups members of an iterable according to the return value of the passed callback.
|
||||
* @param items An iterable.
|
||||
* @param keySelector A callback which will be invoked for each item in items.
|
||||
*/
|
||||
groupBy<K extends PropertyKey, T> (
|
||||
items: Iterable<T>,
|
||||
keySelector: (item: T, index: number) => K,
|
||||
): Partial<Record<K, T[]>>;
|
||||
}
|
||||
|
||||
interface MapConstructor
|
||||
{
|
||||
/**
|
||||
* Groups members of an iterable according to the return value of the passed callback.
|
||||
* @param items An iterable.
|
||||
* @param keySelector A callback which will be invoked for each item in items.
|
||||
*/
|
||||
groupBy<K, T> (
|
||||
items: Iterable<T>,
|
||||
keySelector: (item: T, index: number) => K,
|
||||
): Map<K, T[]>;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import { $ } from 'bun';
|
||||
import path from 'node:path';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { SERVER_URL } from "../../shared/constants";
|
||||
import { SERVER_URL } from "@shared/constants";
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getBrowserPath } from "./get-browser";
|
||||
import { isSteamDeckGameMode } from "../utils";
|
||||
import { config } from "../api/app";
|
||||
import { ensureDir } from 'fs-extra';
|
||||
import { host } from "./host";
|
||||
|
||||
export async function BuildParams (data: { configPath: string; })
|
||||
export interface BrowserParams
|
||||
{
|
||||
configPath: string;
|
||||
windowPosition?: { x: number, y: number; };
|
||||
windowSize?: { width?: number, height?: number; };
|
||||
}
|
||||
|
||||
export async function BuildParams (data: BrowserParams)
|
||||
{
|
||||
const validBrowser = await getBrowserPath({
|
||||
browserOrder: Bun.env.BROWSER_PRIORITY ? Bun.env.BROWSER_PRIORITY.split(',') as any : ['chrome', 'chromium']
|
||||
|
|
@ -52,9 +58,9 @@ export async function BuildParams (data: { configPath: string; })
|
|||
if (isSteamDeckGameMode())
|
||||
{
|
||||
args.push('--kiosk');
|
||||
} else
|
||||
} else if (data.windowSize)
|
||||
{
|
||||
args.push(`--window-size=${config.get('windowSize.width')},${config.get('windowSize.height')}`);
|
||||
args.push(`--window-size=${data.windowSize.width},${data.windowSize.height}`);
|
||||
}
|
||||
|
||||
args.push('--password-store=basic');
|
||||
|
|
@ -71,9 +77,9 @@ export async function BuildParams (data: { configPath: string; })
|
|||
args.push('--remote-debugging-port=9222');
|
||||
}
|
||||
|
||||
if (config.has('windowPosition'))
|
||||
if (data.windowPosition)
|
||||
{
|
||||
args.push(`--window-position=${config.get('windowPosition.x')},${config.get('windowPosition.y')}`);
|
||||
args.push(`--window-position=${data.windowPosition.x},${data.windowPosition.y}`);
|
||||
}
|
||||
|
||||
if (isEdge)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { SERVER_URL } from "@/shared/constants";
|
||||
import { SERVER_URL } from "@shared/constants";
|
||||
import { host } from "../utils/host";
|
||||
|
||||
export default function (webview: { navigate: (url: string) => void; run: () => void; destroy: () => void; })
|
||||
|
|
@ -14,4 +14,5 @@ export default function (webview: { navigate: (url: string) => void; run: () =>
|
|||
};
|
||||
webview.navigate(SERVER_URL(host));
|
||||
webview.run();
|
||||
postMessage({ data: 'destroyed' });
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Webview } from 'webview-bun';
|
||||
import { Size, SizeHint, Webview } from 'webview-bun';
|
||||
import webviewWorkerBase from "./base";
|
||||
|
||||
if (process.env.FLATPAK_BUILD === "true")
|
||||
|
|
@ -28,6 +28,9 @@ if (process.env.FLATPAK_BUILD === "true")
|
|||
} else
|
||||
{
|
||||
console.log("Launching Webview");
|
||||
const webview = new Webview(import.meta.env.NODE_ENV === 'development');
|
||||
let size: Size | undefined = undefined;
|
||||
if (process.env.WINDOW_WIDTH && process.env.WINDOW_HEIGHT)
|
||||
size = { width: Number(process.env.WINDOW_WIDTH), height: Number(process.env.WINDOW_HEIGHT), hint: SizeHint.NONE };
|
||||
const webview = new Webview(process.env.NODE_ENV === 'development', size);
|
||||
webviewWorkerBase(webview);
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
|
||||
import { Webview } from 'webview-bun';
|
||||
import { Size, SizeHint, Webview } from 'webview-bun';
|
||||
import webviewWorkerBase from "./base";
|
||||
|
||||
const webview = new Webview(import.meta.env.NODE_ENV === 'development');
|
||||
let size: Size | undefined = undefined;
|
||||
if (process.env.WINDOW_WIDTH && process.env.WINDOW_HEIGHT)
|
||||
size = { width: Number(process.env.WINDOW_WIDTH), height: Number(process.env.WINDOW_HEIGHT), hint: SizeHint.NONE };
|
||||
const webview = new Webview(process.env.NODE_ENV === 'development', size);
|
||||
webviewWorkerBase(webview);
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
|
||||
import classNames from 'classnames';
|
||||
import { createContext, JSX, Ref, useContext, useEffect, useState } from 'react';
|
||||
import { CSSProperties, JSX, Ref, useEffect, useRef, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
import { useLocalSetting } from '../scripts/utils';
|
||||
|
||||
export const AnimatedBackgroundContext = createContext({} as { setBackground: (url: string) => void; });
|
||||
import { mobileCheck, useLocalSetting } from '../scripts/utils';
|
||||
import { AnimatedBackgroundContext } from '../scripts/contexts';
|
||||
|
||||
export function AnimatedBackground (data: {
|
||||
children?: any;
|
||||
|
|
@ -15,26 +14,43 @@ export function AnimatedBackground (data: {
|
|||
className?: string;
|
||||
animated?: boolean,
|
||||
scrolling?: boolean;
|
||||
style?: CSSProperties;
|
||||
})
|
||||
{
|
||||
const animateBackground = true;
|
||||
|
||||
const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ? useSessionStorage<string | undefined>(
|
||||
data.backgroundKey!,
|
||||
const animateBackground = useLocalSetting('backgroundAnimation');
|
||||
const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ?
|
||||
useSessionStorage<string | undefined>(
|
||||
data.backgroundKey,
|
||||
data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined,
|
||||
) : useState<string | undefined>();
|
||||
)
|
||||
: useState<string | undefined>();
|
||||
|
||||
const [lastBackgroundUrl, setLastBackgroundUrl] = useState<string | undefined>(undefined);
|
||||
const backgroundElementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const lastBg = backgroundUrl;
|
||||
|
||||
if (data.backgroundUrl != backgroundUrl)
|
||||
{
|
||||
setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined);
|
||||
setLastBackgroundUrl(lastBg);
|
||||
}
|
||||
}, [data.backgroundUrl]);
|
||||
|
||||
let finalBackgroundUrl;
|
||||
let finalBackgroundUrl: URL | undefined;
|
||||
try
|
||||
{
|
||||
finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined;
|
||||
} catch { }
|
||||
|
||||
let finalLastBackgroundUrl: URL | undefined;
|
||||
try
|
||||
{
|
||||
finalLastBackgroundUrl = lastBackgroundUrl ? new URL(lastBackgroundUrl) : undefined;
|
||||
} catch { }
|
||||
|
||||
const blur = useLocalSetting('backgroundBlur');
|
||||
if (blur)
|
||||
{
|
||||
|
|
@ -43,11 +59,41 @@ export function AnimatedBackground (data: {
|
|||
finalBackgroundUrl?.searchParams.set('blur', String(24));
|
||||
}
|
||||
|
||||
finalBackgroundUrl?.searchParams.set('height', String(320));
|
||||
if (!finalLastBackgroundUrl?.searchParams.has('blur'))
|
||||
{
|
||||
finalLastBackgroundUrl?.searchParams.set('blur', String(24));
|
||||
}
|
||||
|
||||
finalBackgroundUrl?.searchParams.set('height', String(320));
|
||||
finalLastBackgroundUrl?.searchParams.set('height', String(320));
|
||||
}
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (finalBackgroundUrl && backgroundElementRef.current)
|
||||
{
|
||||
const finalBackgroundImg = new Image();
|
||||
finalBackgroundImg.addEventListener('load', e =>
|
||||
{
|
||||
if (backgroundElementRef.current)
|
||||
{
|
||||
backgroundElementRef.current.style.backgroundImage = `url('${finalBackgroundUrl.href}')`;
|
||||
backgroundElementRef.current.style.opacity = "1";
|
||||
backgroundElementRef.current.style.backgroundSize = "100%";
|
||||
}
|
||||
});
|
||||
finalBackgroundImg.src = finalBackgroundUrl.href;
|
||||
}
|
||||
|
||||
|
||||
}, [finalBackgroundUrl]);
|
||||
|
||||
const isMobile = mobileCheck();
|
||||
|
||||
function handleSetBackground (url: string)
|
||||
{
|
||||
|
||||
setLastBackgroundUrl(backgroundUrl);
|
||||
setBackgroundUrl(url);
|
||||
}
|
||||
|
||||
|
|
@ -70,30 +116,40 @@ export function AnimatedBackground (data: {
|
|||
return (
|
||||
<AnimatedBackgroundContext value={{ setBackground: handleSetBackground }}>
|
||||
<div ref={data.ref}
|
||||
className={twMerge("w-full h-full flex flex-col", data.scrolling ? "overflow-y-scroll animate-bg-zoom-scroll" : "overflow-hidden", data.className)}
|
||||
style={data.scrolling ? {
|
||||
backgroundImage: `url('${finalBackgroundUrl?.href}')`,
|
||||
backgroundAttachment: 'local',
|
||||
backgroundSize: '100%',
|
||||
backgroundPositionY: 'bottom',
|
||||
backgroundPositionX: 'center',
|
||||
backgroundBlendMode: blur ? 'normal' : 'soft-light',
|
||||
backgroundColor: "var(--color-base-100)",
|
||||
} : {}}
|
||||
style={data.style}
|
||||
className={twMerge("relative w-full h-full flex flex-col", data.scrolling ? "overflow-y-scroll animate-bg-zoom-scroll" : "overflow-hidden", data.className)}
|
||||
|
||||
>
|
||||
{!data.scrolling && <div className='absolute top-0 left-0 overflow-hidden w-full h-full'>
|
||||
{<img
|
||||
{!data.scrolling && <div className='absolute top-0 left-0 right-0 bottom-0 overflow-hidden'>
|
||||
<div className='fixed bg-base-100 top-0 left-0 right-0 bottom-0 -z-5'></div>
|
||||
{blur && finalLastBackgroundUrl && <img className='absolute w-full h-full object-cover object-center -z-4' src={finalLastBackgroundUrl.href}></img>}
|
||||
{finalBackgroundUrl ? <img
|
||||
key={finalBackgroundUrl?.href}
|
||||
className={classNames('absolute w-full h-full object-cover object-center opacity-0 -z-3')}
|
||||
className={'absolute w-full h-full object-cover object-center opacity-0 -z-3'}
|
||||
src={finalBackgroundUrl?.href}
|
||||
onLoad={e => e.currentTarget.classList.add(blur ? "animate-bg-zoom-big" : "animate-bg-zoom")}
|
||||
></img>}
|
||||
<div className='absolute w-full h-full bg-linear-to-b from-base-100/60 to-base-300/80 -z-2' />
|
||||
></img> : <><div className='mobile:hidden bg-gradient'></div></>}
|
||||
<div className='absolute top-0 left-0 right-0 bottom-0 bg-linear-to-b from-base-100/60 to-base-300/80 -z-2' />
|
||||
<div className='mobile:hidden bg-noise'></div>
|
||||
</div>}
|
||||
{data.animated && animateBackground && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}>
|
||||
{data.animated && animateBackground && <div className="fixed overflow-hidden top-0 left-0 right-0 bottom-0" style={{ zIndex: -1 }}>
|
||||
{backgroundElements}
|
||||
</div>}
|
||||
{data.children}
|
||||
{!!data.scrolling && <>
|
||||
<div key={finalBackgroundUrl?.href} ref={backgroundElementRef} className='absolute top-0 bottom-0 left-0 right-0' style={data.scrolling ? {
|
||||
backgroundAttachment: 'local',
|
||||
backgroundSize: '105%',
|
||||
opacity: 0,
|
||||
transition: 'all ease-out',
|
||||
backgroundPositionY: 'bottom',
|
||||
backgroundPositionX: 'center',
|
||||
transitionDuration: "400ms",
|
||||
backgroundBlendMode: blur ? 'normal' : 'soft-light',
|
||||
backgroundColor: "var(--color-base-300)",
|
||||
} : {}}></div>
|
||||
<div className='mobile:hidden bg-noise opacity-30 z-1'></div>
|
||||
</>}
|
||||
</div>
|
||||
</AnimatedBackgroundContext >
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import { doesFocusableExist, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function AutoFocus (data: { focus: () => void; force?: boolean; delay?: number; })
|
||||
export function AutoFocus (data: {
|
||||
parentKey?: string;
|
||||
focus: () => void;
|
||||
force?: boolean;
|
||||
delay?: number;
|
||||
})
|
||||
{
|
||||
useEffect(() =>
|
||||
{
|
||||
let delayTimeout: number | undefined;
|
||||
|
||||
if (data.force || !getCurrentFocusKey() || !doesFocusableExist(getCurrentFocusKey()))
|
||||
if (data.force || !getCurrentFocusKey() || getCurrentFocusKey() === data.parentKey || !doesFocusableExist(getCurrentFocusKey()))
|
||||
{
|
||||
if (data.delay)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -32,11 +32,10 @@ export interface GameCardParams
|
|||
className?: string;
|
||||
onFocus?: GameCardFocusHandler;
|
||||
onBlur?: (id: string) => void;
|
||||
onAction?: () => void;
|
||||
clickFocuses?: boolean;
|
||||
}
|
||||
|
||||
export default function GameCard (data: GameCardParams)
|
||||
export default function CardElement (data: GameCardParams & InteractParams)
|
||||
{
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: data.focusKey,
|
||||
|
|
@ -57,40 +56,35 @@ export default function GameCard (data: GameCardParams)
|
|||
scrollSnapAlign: "center"
|
||||
}}
|
||||
onFocus={focusSelf}
|
||||
onDoubleClick={data.onAction}
|
||||
onDoubleClick={e => data.onAction?.(e.nativeEvent)}
|
||||
onClick={() =>
|
||||
{
|
||||
focusSelf();
|
||||
data.onAction?.();
|
||||
}}
|
||||
className={twMerge(
|
||||
`game-card bg-base-300 game-card-height flex flex-col justify-end z-5 ring-primary`,
|
||||
'max-h-(--game-card-height) min-w-(--game-card-width) w-(--game-card-width)',
|
||||
"overflow-hidden transition-all duration-200 drop-shadow-lg cursor-pointer",
|
||||
classNames({
|
||||
"focused animate-wiggle ring-7 bg-base-content text-base-300 drop-shadow-xl drop-shadow-black/30 scale-102 z-10": focused && !isPointer,
|
||||
"group hover:focused hover:animate-wiggle sm:hover:ring-4 md:hover:ring-7 hover:bg-base-content hover:text-base-300 hover:drop-shadow-xl hover:drop-shadow-black/30 hover:scale-102 hover:z-10": isMouse,
|
||||
"h-(--game-card-height)": typeof data.preview === "string"
|
||||
}),
|
||||
"relative game-card bg-base-300 flex flex-col z-5 overflow-hidden transition-all duration-200 not-mobile:drop-shadow-lg cursor-pointer focusable focusable-primary focusable-hover select-none focused focused:not-control-mouse:animate-wiggle focused:not-control-mouse:bg-base-content focused:not-control-mouse:text-base-300 focused:not-control-mouse:drop-shadow-xl focused:not-control-mouse:drop-shadow-black/30 focused:not-control-mouse:scale-102 focused:not-control-mouse:z-10 group control-mouse:hover:bg-base-200 h-full [--tw-border-style:inset] border-2 border-base-content/5 backdrop-opacity-0 active:bg-base-content! active:text-base-100 active:transition-none",
|
||||
data.className
|
||||
)}
|
||||
>
|
||||
<div className={twMerge(
|
||||
"overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all",
|
||||
<div id="preview" className={twMerge(
|
||||
"overflow-hidden bg-base-400 rounded-t-xl rounded-b-md transition-all",
|
||||
focused ? "sm:mt-1 sm:mx-1" : "sm:mt-1 sm:mx-1",
|
||||
focused ? "md:mt-2 md:mx-2" : "md:mt-2 md:mx-2",
|
||||
classNames({ "h-full": typeof data.preview === "string" })
|
||||
)}>
|
||||
{typeof data.preview === "string" ? (
|
||||
<img className={classNames("object-cover w-full h-full", { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
|
||||
<img draggable={false} className={classNames("object-cover w-full h-full", { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
|
||||
) : (
|
||||
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
|
||||
)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-0 flex pr-2 justify-end items-center sm:gap-1 md:gap-2">
|
||||
<div className="h-0 flex pr-2 justify-end items-center sm:gap-1 md:gap-2 z-2">
|
||||
{data.badges?.map((b, i) =>
|
||||
<div key={i}
|
||||
className={
|
||||
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 sm:last:mr-1 md:last:mr-4 transition-colors",
|
||||
twMerge("bg-base-100 text-base-content not-mobile:not-in-focused:drop-shadow-lg sm:border-3 md:border-6 border-base-300 in-focused:border-base-content overflow-hidden rounded-full sm:last:mr-1 md:last:mr-4 transition-colors",
|
||||
classNames({
|
||||
"bg-primary text-primary-content": focused && !isPointer,
|
||||
"group-hover:bg-primary group-hover:text-primary-content": isPointer
|
||||
|
|
@ -100,7 +94,7 @@ export default function GameCard (data: GameCardParams)
|
|||
</div>)
|
||||
}
|
||||
</div>
|
||||
<div className="flex flex-col sm:p-2 md:p-4">
|
||||
<div className="flex flex-col sm:p-2 grow md:p-4 justify-center">
|
||||
<div className="md:text-xl sm:text-sm font-bold text-nowrap text-ellipsis overflow-hidden">
|
||||
{data.title}
|
||||
</div>
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import
|
||||
{
|
||||
FocusContext,
|
||||
FocusDetails,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { GameMeta } from "../../shared/constants";
|
||||
import GameCard, { GameCardFocusHandler, GameCardParams } from "./GameCard";
|
||||
import CardElement, { GameCardFocusHandler, GameCardParams } from "./CardElement";
|
||||
import { JSX } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||
|
|
@ -47,7 +46,7 @@ export function CardList (data: {
|
|||
useShortcuts(g.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]);
|
||||
|
||||
return (
|
||||
<GameCard
|
||||
<CardElement
|
||||
key={g.id}
|
||||
type={data.type}
|
||||
index={i}
|
||||
|
|
@ -74,9 +73,9 @@ export function CardList (data: {
|
|||
id={`card-list-${data.id}`}
|
||||
ref={ref}
|
||||
save-child-focus="session"
|
||||
className={twMerge("items-center justify-center-safe landscape:h-(--game-card-height) ",
|
||||
data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-(--game-card-height) grid-cols-[repeat(auto-fill,var(--game-card-width))]" :
|
||||
'landscape:flex sm:gap-2 md:gap-6 portrait:grid portrait:auto-rows-(--game-card-height) portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))]',
|
||||
className={twMerge("items-center justify-center-safe h-full",
|
||||
data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-min grid-cols-[repeat(auto-fill,var(--game-card-width))]" :
|
||||
'landscape:grid landscape:grid-flow-col landscape:auto-cols-min auto-rows-[1fr] sm:gap-2 md:gap-4 portrait:grid portrait:auto-rows-min portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))] *:portrait:aspect-8/10 *:landscape:aspect-8/12 sm:landscape:max-h-84 md:max-h-128!',
|
||||
data.className
|
||||
)}
|
||||
onKeyDown={(e) =>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function Clock() {
|
||||
export default function Clock ()
|
||||
{
|
||||
const locale = "en";
|
||||
const [today, setDate] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
useEffect(() =>
|
||||
{
|
||||
const timer = setInterval(() =>
|
||||
{
|
||||
setDate(new Date());
|
||||
}, 60 * 1000);
|
||||
|
||||
return () => {
|
||||
return () =>
|
||||
{
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@ import { useSuspenseQuery } from "@tanstack/react-query";
|
|||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { CardList, GameMetaExtra } from "./CardList";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { GameCardFocusHandler } from "./GameCard";
|
||||
import { GameCardFocusHandler } from "./CardElement";
|
||||
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
|
||||
export default function CollectionList (data: {
|
||||
id: string,
|
||||
setBackground: (url: string) => void;
|
||||
className?: string;
|
||||
onFocus?: GameCardFocusHandler;
|
||||
onSelect?: (id: string) => void;
|
||||
})
|
||||
{
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -20,6 +22,12 @@ export default function CollectionList (data: {
|
|||
staleTime: DefaultRommStaleTime
|
||||
});
|
||||
|
||||
const handleDefaultSelect = (id: string) =>
|
||||
{
|
||||
SaveSource('game-list', { search: { focus: getCurrentFocusKey() } });
|
||||
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
return (
|
||||
<CardList
|
||||
type="collection"
|
||||
|
|
@ -38,11 +46,7 @@ export default function CollectionList (data: {
|
|||
</span>
|
||||
],
|
||||
} satisfies GameMetaExtra))}
|
||||
onSelectGame={(id) =>
|
||||
{
|
||||
SaveSource('game-list');
|
||||
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
}}
|
||||
onSelectGame={data.onSelect ? data.onSelect : handleDefaultSelect}
|
||||
onGameFocus={(id, node, details) =>
|
||||
{
|
||||
data.setBackground(
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { AnimatedBackground } from './AnimatedBackground';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { HeaderUI } from './Header';
|
||||
import { GameList, GameListFilter } from './GameList';
|
||||
import { GameList } from './GameList';
|
||||
import { Search, Settings2 } from 'lucide-react';
|
||||
import { JSX, Suspense } from 'react';
|
||||
import Shortcuts from './Shortcuts';
|
||||
import { AutoFocus } from './AutoFocus';
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||
import { Router } from '..';
|
||||
import { PopSource } from '../scripts/spatialNavigation';
|
||||
import { PopNavigateSource, PopSource } from '../scripts/spatialNavigation';
|
||||
import { GameListFilterType } from '@/shared/constants';
|
||||
import { GameCardFocusHandler } from './GameCard';
|
||||
import { GameCardFocusHandler } from './CardElement';
|
||||
|
||||
export interface CollectionsDetailParams
|
||||
{
|
||||
|
|
@ -22,16 +22,6 @@ export interface CollectionsDetailParams
|
|||
footer?: JSX.Element;
|
||||
}
|
||||
|
||||
function HandleGoBack ()
|
||||
{
|
||||
const source = PopSource('game-list');
|
||||
if (source)
|
||||
{
|
||||
console.log("Found source ", source, " to go back to");
|
||||
}
|
||||
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
|
||||
}
|
||||
|
||||
export function CollectionsDetail (data: CollectionsDetailParams)
|
||||
{
|
||||
const focusKey = `game-list-${data.id}-${data.filters ? Object.values(data.filters).map(f => String(f)).join(",") : ''}`;
|
||||
|
|
@ -40,7 +30,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
preferredChildFocusKey: `${focusKey}-list`,
|
||||
});
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => PopNavigateSource('game-list', '/') }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
|
||||
const handleScroll: GameCardFocusHandler = (id, node, details) =>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { FocusContext, FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { createContext, JSX, useContext, useEffect } from "react";
|
||||
import { JSX, useContext, useEffect } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { X } from "lucide-react";
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
||||
|
||||
const ContextDialogContext = createContext({} as {
|
||||
close: () => void,
|
||||
id: string;
|
||||
});
|
||||
import { ContextDialogContext } from "../scripts/contexts";
|
||||
|
||||
export function ContextList (data: { options?: DialogEntry[]; className?: string; showCloseButton?: boolean; })
|
||||
{
|
||||
|
|
@ -35,12 +31,12 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
|||
trackChildren: typeof data.content !== 'string'
|
||||
});
|
||||
const colors = {
|
||||
primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused || hasFocusedChild }),
|
||||
secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused || hasFocusedChild }),
|
||||
accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused || hasFocusedChild }),
|
||||
info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused || hasFocusedChild }),
|
||||
warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused || hasFocusedChild }),
|
||||
error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused || hasFocusedChild })
|
||||
primary: "active:bg-primary control-pointer:hover:bg-primary focused:bg-primary focused:text-primary-content in-focused:bg-primary in-focused:text-primary-content",
|
||||
secondary: "active:bg-secondary control-pointer:hover:bg-secondary focused:bg-secondary focused:text-secondary-content in-focused:bg-secondary in-focused:text-secondary-content",
|
||||
accent: "active:bg-accent control-pointer:hover:bg-accent focused:bg-accent focused:text-accent-content in-focused:bg-accent in-focused:text-accent-content",
|
||||
info: "active:bg-info control-pointer:hover:bg-info focused:bg-info focused:text-info-content in-focused:bg-info in-focused:text-info-content",
|
||||
warning: "active:bg-warning control-pointer:hover:bg-warning focused:bg-warning focused:text-warning-content in-focused:bg-warning in-focused:text-warning-content",
|
||||
error: "active:bg-error control-pointer:hover:bg-error focused:bg-error focused:text-error-content in-focused:bg-error in-focused:text-error-content"
|
||||
};
|
||||
if (data.shortcuts)
|
||||
{
|
||||
|
|
@ -51,8 +47,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
|||
className={
|
||||
twMerge("flex cursor-pointer sm:text-sm md:text-base")}>
|
||||
<FocusContext value={focusKey}>
|
||||
<div className={twMerge("flex w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl transition-all gap-2",
|
||||
classNames({ "font-semibold": focused || hasFocusedChild }),
|
||||
<div className={twMerge("flex w-full sm:h-12 md:h-14 items-center px-4 rounded-2xl transition-all gap-2 active:animate-scale in-focused:font-semibold",
|
||||
data.className,
|
||||
colors[data.type])}>
|
||||
{data.icon}
|
||||
|
|
@ -105,7 +100,7 @@ export function ContextDialog (data: {
|
|||
}] : [], [data.open]);
|
||||
|
||||
return <dialog ref={ref} open={data.open} closedby="any" className={
|
||||
twMerge("absolute modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
||||
twMerge("fixed modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",
|
||||
classNames({ "opacity-0": !data.open }))
|
||||
}
|
||||
onClick={() =>
|
||||
|
|
|
|||
33
src/mainview/components/Error.tsx
Normal file
33
src/mainview/components/Error.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { Home, TriangleAlert } from "lucide-react";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
|
||||
import { Router } from "..";
|
||||
import Shortcuts from "./Shortcuts";
|
||||
import { Button } from "./options/Button";
|
||||
import { useEffect } from "react";
|
||||
import { ErrorComponentProps } from "@tanstack/react-router";
|
||||
import { mobileCheck } from "../scripts/utils";
|
||||
|
||||
export default function Error (data: ErrorComponentProps)
|
||||
{
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "not-found" });
|
||||
const handleReturn = () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
|
||||
useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
|
||||
useEffect(() => { focusSelf(); }, []);
|
||||
|
||||
return <div ref={ref} className="absolute flex flex-col justify-center items-center w-full h-full gap-4">
|
||||
<FocusContext value={focusKey}>
|
||||
<p className="flex gap-2 items-center text-4xl text-error text-shadow-lg">
|
||||
<TriangleAlert className="size-12" />
|
||||
{data.error.message}
|
||||
</p>
|
||||
<p className="flex gap-2 text-lg text-base-content/50 text-shadow-lg">{window.location.href} </p>
|
||||
<Button className="text-2xl! p-6! focusable focusable-primary" id="return" onAction={handleReturn}><Home />Return Home</Button>
|
||||
<div className="mobile:hidden bg-gradient"></div>
|
||||
<div className="mobile:hidden bg-noise"></div>
|
||||
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ContextList, DialogEntry, OptionElement } from "./ContextDialog";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ContextList, DialogEntry } from "./ContextDialog";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { createContext, useContext, useRef, useState } from "react";
|
||||
import { useContext, useRef, useState } from "react";
|
||||
import path from "pathe";
|
||||
import { Check, File, Folder, FolderClosed, FolderInput, FolderOutput, FolderPlus, HardDrive, Plus, Save, Undo, Usb, X } from "lucide-react";
|
||||
import { Check, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
|
||||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { DirType, Drive } from "@/shared/constants";
|
||||
import { DirType } from "@/shared/constants";
|
||||
import classNames from "classnames";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
|
||||
|
|
@ -13,17 +13,8 @@ import SvgIcon from "./SvgIcon";
|
|||
import { Button } from "./options/Button";
|
||||
import toast from "react-hot-toast";
|
||||
import { drivesQuery, filesQuery } from "../scripts/queries";
|
||||
|
||||
const FilePickerContext = createContext<{
|
||||
allowNewFolderCreation: boolean;
|
||||
isDirectoryPicker: boolean;
|
||||
setCurrentPath: (path: string) => void;
|
||||
currentPath: string | undefined,
|
||||
startingPath: string | undefined;
|
||||
refetchFiles: () => void;
|
||||
drives: Drive[],
|
||||
activeDrive: Drive | undefined;
|
||||
}>({} as any);
|
||||
import { FilePickerContext } from "../scripts/contexts";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
|
||||
function List (data: {
|
||||
id: string,
|
||||
|
|
@ -137,7 +128,7 @@ function NewFolderOption (data: { id: string, dirname: string; })
|
|||
});
|
||||
return <div className="flex gap-2 grow -ml-2">
|
||||
<NewFolderInput className="grow" id={`${data.id}-input`} setName={setName} name={name} />
|
||||
<Button id={`${data.id}-create`} onAction={createMutation.mutate} type="button" ><FolderPlus /></Button>
|
||||
<Button id={`${data.id}-create`} onAction={e => createMutation.mutate()} type="button" ><FolderPlus /></Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +140,7 @@ function OptionButtons (data: {
|
|||
})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `options-${data.id}`, onEnterPress: data.onSelect });
|
||||
return <div ref={ref} className="flex md:inline h-12 w-full justify-end gap-2">
|
||||
return <div ref={ref} className="flex h-12 w-full justify-end gap-2">
|
||||
<FocusContext value={focusKey}>
|
||||
{data.showConfirm && <Button className="p-6 ring-accent-content" onAction={data.onSelect} id={`${data.id}-select`} focusClassName="ring-7" type="button" ><Check />Select</Button>}
|
||||
<Button className="md:p-6 ring-warning-content" onAction={data.onCancel} id={`${data.id}-cancel`} type="button" focusClassName="ring-7 btn-warning" ><X />Cancel</Button>
|
||||
|
|
@ -252,6 +243,8 @@ export default function FilePicker (data: {
|
|||
[<><HardDrive />{activeDrive?.label}</>, ...fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep)] :
|
||||
fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep);
|
||||
|
||||
const { isPointer } = useActiveControl();
|
||||
|
||||
return <div className="flex flex-col h-full max-h-full gap-3">
|
||||
<FilePickerContext value={{
|
||||
setCurrentPath,
|
||||
|
|
@ -271,8 +264,9 @@ export default function FilePicker (data: {
|
|||
setCurrentPath(path.join(...fullPath.slice(-i)))
|
||||
}>{p}</a>
|
||||
</li>)}
|
||||
|
||||
{(filesLoading || drivesLoading) && <li className="mr-2 loading loading-spinner sm:loading-md md:loading-sm"></li>}
|
||||
</ul>
|
||||
{(filesLoading || drivesLoading) && <span className="loading loading-spinner sm:loading-md md:loading-lg"></span>}
|
||||
</div>}
|
||||
|
||||
<ListWithDrives
|
||||
|
|
@ -281,11 +275,11 @@ export default function FilePicker (data: {
|
|||
onSelect={data.onSelect}
|
||||
parentPath={files?.parentPath ?? ''}
|
||||
/>
|
||||
<OptionButtons
|
||||
{isPointer && <OptionButtons
|
||||
showConfirm={!!data.isDirectoryPicker}
|
||||
onCancel={data.cancel}
|
||||
onSelect={() => currentPath ? data.onSelect(currentPath) : undefined}
|
||||
id={data.id} />
|
||||
id={data.id} />}
|
||||
</FilePickerContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -4,10 +4,7 @@ import
|
|||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import SvgIcon from "./SvgIcon";
|
||||
import classNames from "classnames";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
function FilterCat (
|
||||
data: {
|
||||
|
|
@ -25,31 +22,12 @@ function FilterCat (
|
|||
onEnterPress: data.onAction
|
||||
});
|
||||
|
||||
const { filter } = useSearch({ from: '/' });
|
||||
useEffect(() =>
|
||||
{
|
||||
if (filter == data.id && data.hasFocusedPeer)
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
const { isMouse } = useActiveControl();
|
||||
|
||||
return (
|
||||
<li
|
||||
aria-selected={data.active}
|
||||
ref={ref}
|
||||
onClick={focusSelf}
|
||||
className={classNames(
|
||||
"sm:text-sm sm:px-2",
|
||||
"flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg",
|
||||
{
|
||||
"bg-base-content px-3 text-base-300 drop-shadow cursor-default":
|
||||
focused || data.active,
|
||||
"ring-primary ring-7": focused && !isMouse,
|
||||
"hover:bg-base-content/40 cursor-pointer": !focused,
|
||||
},
|
||||
)}
|
||||
className={"sm:text-sm sm:px-2 flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg focusable focusable-primary hover:not-focused:not-aria-selected:bg-base-content/40 not-focused:cursor-pointer aria-selected:bg-base-content aria-selected:text-base-300 aria-selected:drop-shadow aria-selected:cursor-default active:bg-accent! active:text-accent-content! active:ring-offset-7 active:ring-offset-base-content select-none"}
|
||||
>
|
||||
{data.children ?? data.label}
|
||||
</li>
|
||||
|
|
@ -59,35 +37,37 @@ function FilterCat (
|
|||
export function FilterUI (data: {
|
||||
id: string;
|
||||
options: Record<string, FilterOption>;
|
||||
selected: string;
|
||||
setSelected: (id: string) => void;
|
||||
containerClassName?: string;
|
||||
className?: string;
|
||||
})
|
||||
{
|
||||
const defaultFocus = Object.entries(data.options).filter(o => o[1].selected)[0]?.[0];
|
||||
const { ref, focusKey, hasFocusedChild } = useFocusable({
|
||||
focusKey: `filter-${data.id}`,
|
||||
focusKey: data.id,
|
||||
saveLastFocusedChild: false,
|
||||
autoRestoreFocus: false,
|
||||
preferredChildFocusKey: data.selected,
|
||||
preferredChildFocusKey: `${data.id}-${defaultFocus}`,
|
||||
trackChildren: true
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
save-child-focus="session"
|
||||
className={data.containerClassName}
|
||||
>
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<ul className="flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm sm:h-9 md:h-14">
|
||||
<ul className={twMerge("flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm sm:portrait:h-12 sm:landscape:h-9 md:h-14!", data.className)}>
|
||||
<li className=" flex px-4 items-center justify-center rounded-full">
|
||||
<SvgIcon className="sm:size-5 md:size-8" icon="steamdeck_button_l1_outline" />
|
||||
</li>
|
||||
{Object.entries(data.options)?.map(([id, option]) => (
|
||||
<FilterCat
|
||||
hasFocusedPeer={hasFocusedChild}
|
||||
id={id}
|
||||
id={`${data.id}-${id}`}
|
||||
key={id}
|
||||
onFocus={() => data.setSelected(id)}
|
||||
active={id === data.selected}
|
||||
active={option.selected}
|
||||
{...option}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
21
src/mainview/components/FocusDots.tsx
Normal file
21
src/mainview/components/FocusDots.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { setFocus } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useGlobalFocus } from "../scripts/spatialNavigation";
|
||||
|
||||
export default function FocusDots (data: {
|
||||
elements: string[];
|
||||
|
||||
})
|
||||
{
|
||||
const focusedKey = useGlobalFocus();
|
||||
|
||||
return <div className="divider opacity-20"><div className="flex gap-2 py-6 justify-center items-center h-3">{data.elements.map((em, i) =>
|
||||
{
|
||||
const focused = em === focusedKey;
|
||||
return <button key={i} onClick={(e) => setFocus(em, { nativeEvent: e.nativeEvent })}
|
||||
className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
|
||||
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
|
||||
}))}></button>;
|
||||
})}</div></div>;
|
||||
}
|
||||
46
src/mainview/components/FrontEndGameCard.tsx
Normal file
46
src/mainview/components/FrontEndGameCard.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { FrontEndGameType, FrontEndId, RPC_URL } from "@/shared/constants";
|
||||
import CardElement from "./CardElement";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { Router } from "..";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { JSX } from "react";
|
||||
import { FOCUS_KEYS } from "../scripts/types";
|
||||
|
||||
export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; } & FocusParams & InteractParams)
|
||||
{
|
||||
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null)
|
||||
{
|
||||
SaveSource('details', { search: { focus: FOCUS_KEYS.GAME_CARD(data.game.id.id) } });
|
||||
console.log({ id: String(sourceId ?? id.id), source: source ?? id.source });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`);
|
||||
platformUrl.searchParams.set('width', "64");
|
||||
const subtitle = <div className="flex gap-1 items-center">
|
||||
{!!data.game.path_platform_cover && <img className="sm:hidden md:inline size-4" src={platformUrl.href} />}
|
||||
<p className="opacity-80">{data.game.platform_display_name}</p>
|
||||
</div>;
|
||||
|
||||
const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_cover}`);
|
||||
previewUrl.searchParams.delete('ts');
|
||||
previewUrl.searchParams.set('width', "640");
|
||||
|
||||
const badges: JSX.Element[] = [];
|
||||
if (data.game.id.source === 'local')
|
||||
{
|
||||
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
|
||||
}
|
||||
|
||||
return <CardElement
|
||||
badges={badges}
|
||||
onFocus={data.onFocus}
|
||||
onAction={(e) => data.onAction ? data.onAction(e) : handleDefaultSelect(data.game.id, data.game.source, data.game.source_id)}
|
||||
preview={previewUrl.href}
|
||||
title={data.game.name ?? ""}
|
||||
subtitle={subtitle}
|
||||
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id.id)}
|
||||
index={data.index}
|
||||
id={`game-${data.game.id.source}-${data.game.id.id}`}
|
||||
/>;
|
||||
}
|
||||
|
|
@ -6,8 +6,7 @@ import { SaveSource } from "../scripts/spatialNavigation";
|
|||
import { rommApi } from "../scripts/clientApi";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { JSX } from "react";
|
||||
import { GameCardFocusHandler } from "./GameCard";
|
||||
import { gameQuery } from "../scripts/queries";
|
||||
import { GameCardFocusHandler } from "./CardElement";
|
||||
import { useLocalSetting } from "../scripts/utils";
|
||||
|
||||
export interface GameListParams
|
||||
|
|
@ -16,7 +15,7 @@ export interface GameListParams
|
|||
filters?: GameListFilterType,
|
||||
grid?: boolean,
|
||||
setBackground?: (url: string) => void;
|
||||
onGameSelect?: (id: FrontEndId) => void;
|
||||
onGameSelect?: (id: FrontEndId, source: string | null, sourceId: string | null) => void;
|
||||
onFocus?: GameCardFocusHandler;
|
||||
className?: string;
|
||||
}
|
||||
|
|
@ -33,7 +32,7 @@ export function GameList (data: GameListParams)
|
|||
const queryClient = useQueryClient();
|
||||
const blur = useLocalSetting('backgroundBlur');
|
||||
|
||||
const handleFocus = (id: FrontEndId, source: string | null, sourceId: number | null) =>
|
||||
const handleFocus = (id: FrontEndId, source: string | null, sourceId: string | null) =>
|
||||
{
|
||||
const game = games.data?.games.find((g) => g.id === id);
|
||||
if (game)
|
||||
|
|
@ -52,7 +51,7 @@ export function GameList (data: GameListParams)
|
|||
}
|
||||
};
|
||||
|
||||
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: number | null)
|
||||
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null)
|
||||
{
|
||||
SaveSource('details');
|
||||
navigator({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
|
|
@ -73,11 +72,11 @@ export function GameList (data: GameListParams)
|
|||
const badges: JSX.Element[] = [];
|
||||
if (g.id.source === 'local')
|
||||
{
|
||||
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
|
||||
badges.push(<HardDrive className="sm:size-4 md:size-8 md:p-1 m-1" />);
|
||||
}
|
||||
const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
|
||||
previewUrl.searchParams.delete('ts');
|
||||
previewUrl.searchParams.set('width', "640");
|
||||
previewUrl.searchParams.set('width', "16");
|
||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
|
||||
platformUrl.searchParams.set('width', "64");
|
||||
|
||||
|
|
@ -93,7 +92,7 @@ export function GameList (data: GameListParams)
|
|||
),
|
||||
previewUrl: previewUrl.href,
|
||||
badges: badges,
|
||||
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id) : handleDefaultSelect(g.id, g.source, g.source_id),
|
||||
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g.id, g.source, g.source_id),
|
||||
onFocus: () => handleFocus(g.id, g.source, g.source_id)
|
||||
} satisfies GameMetaExtra;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ import
|
|||
} from "lucide-react";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
|
||||
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@clients/romm/@tanstack/react-query.gen";
|
||||
import { RPC_URL } from "../../shared/constants";
|
||||
import { JSX, useEffect, useRef } from "react";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { SaveSource, useFocusableDynamic } from "../scripts/spatialNavigation";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { Router } from "..";
|
||||
|
||||
|
|
@ -54,14 +54,14 @@ function HeaderAvatar (data: {
|
|||
id={data.id}
|
||||
ref={ref}
|
||||
onClick={data.onSelect}
|
||||
style={{ viewTransitionName: `header-account-${data.id}` }}
|
||||
className={classNames(
|
||||
`avatar indicator ring-base-100 ring-offset-base-100 sm:size-8 md:size-14 rounded-full flex items-center justify-center`,
|
||||
`avatar indicator ring-offset-base-100 sm:size-8 md:size-14 rounded-full flex items-center justify-center`,
|
||||
bgColors[data.type ?? "none"],
|
||||
"text-base-content cursor-pointer transition-all drop-shadow-md",
|
||||
"hover:ring-primary hover:ring-7",
|
||||
"hover:ring-primary hover:ring-7 focusable focusable-primary focused:ring-offset-base-100",
|
||||
{
|
||||
"ring-5 hover:ring-offset-5": data.active,
|
||||
"sm:ring-4 md:ring-7 ring-primary ring-offset-base-100": focused,
|
||||
"ring-offset-5": focused && data.active,
|
||||
},
|
||||
data.className,
|
||||
|
|
@ -276,7 +276,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
|||
export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; })
|
||||
{
|
||||
return <div className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
|
||||
<div className="flex sm:gap-2 md:gap-5 items-center">
|
||||
<div className="flex sm:gap-2 md:gap-5 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
|
||||
<ClockStatus />
|
||||
<WiFiStatus />
|
||||
<BluetoothStatus />
|
||||
|
|
@ -289,22 +289,29 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
|
|||
key={b.id}
|
||||
className="header-icon sm:size-10 md:size-16"
|
||||
id={b.id}
|
||||
icon={b.icon}
|
||||
external={b.external}
|
||||
action={b.action}
|
||||
/>)}
|
||||
style={{ viewTransitionName: `header-button-${b.id}` }}
|
||||
onAction={b.action}
|
||||
>{b.icon}</RoundButton>)}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[]; buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; })
|
||||
export function HeaderUI (data: {
|
||||
buttons?: HeaderButton[];
|
||||
accounts?: HeaderAccount[];
|
||||
buttonElements?: JSX.Element[] | JSX.Element;
|
||||
title?: JSX.Element;
|
||||
preferredChildFocusKey?: string;
|
||||
})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "header-elements" });
|
||||
const { ref, focusKey } = useFocusable({ focusKey: "header-elements", preferredChildFocusKey: data.preferredChildFocusKey });
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<header
|
||||
ref={ref}
|
||||
className={`flex items-center justify-between text-base-content`}
|
||||
className="flex items-center justify-between text-base-content"
|
||||
style={{ viewTimelineName: 'header' }}
|
||||
>
|
||||
<HeaderAccounts accounts={data.accounts} />
|
||||
{data.title}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import classNames from 'classnames';
|
||||
import { GameCardSkeleton } from './GameCard';
|
||||
import { GameCardSkeleton } from './CardElement';
|
||||
|
||||
export default function LoadingCardList (data: { placeholderCount: number, grid?: boolean; })
|
||||
{
|
||||
|
|
|
|||
31
src/mainview/components/NotFound.tsx
Normal file
31
src/mainview/components/NotFound.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { Home, TriangleAlert } from "lucide-react";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
|
||||
import { Router } from "..";
|
||||
import Shortcuts from "./Shortcuts";
|
||||
import { Button } from "./options/Button";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function NotFound ()
|
||||
{
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "not-found" });
|
||||
const handleReturn = () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
|
||||
useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
|
||||
useEffect(() => { focusSelf(); }, []);
|
||||
|
||||
return <div ref={ref} className="absolute flex flex-col justify-center items-center w-full h-full gap-4">
|
||||
<FocusContext value={focusKey}>
|
||||
<p className="flex gap-2 items-center text-4xl text-error text-shadow-lg">
|
||||
<TriangleAlert className="size-12" />
|
||||
Not found
|
||||
</p>
|
||||
<p className="flex gap-2 text-lg text-base-content/50 text-shadow-lg">{window.location.href} </p>
|
||||
<Button className="text-2xl! p-6! focusable focusable-primary" id="return" onAction={handleReturn}><Home />Return Home</Button>
|
||||
<div className="mobile:hidden bg-gradient"></div>
|
||||
<div className="mobile:hidden bg-noise"></div>
|
||||
<div className="flex justify-end fixed bottom-4 left-4 right-4"><Shortcuts shortcuts={shortcuts} /></div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,16 +1,25 @@
|
|||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
|
||||
import { CardList, GameMetaExtra } from "./CardList";
|
||||
import classNames from "classnames";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import { JSX, useMemo } from "react";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { GameCardFocusHandler } from "./GameCard";
|
||||
import { GameCardFocusHandler } from "./CardElement";
|
||||
import { mobileCheck } from "../scripts/utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: GameCardFocusHandler; grid?: boolean; })
|
||||
export function PlatformsList (data: {
|
||||
id: string,
|
||||
setBackground: (url: string) => void;
|
||||
className?: string;
|
||||
onFocus?: GameCardFocusHandler;
|
||||
grid?: boolean;
|
||||
onSelect?: (source: string, id: string) => void;
|
||||
})
|
||||
{
|
||||
const isMobile = mobileCheck();
|
||||
const navigate = useNavigate();
|
||||
const { data: platforms } = useSuspenseQuery(
|
||||
{
|
||||
|
|
@ -25,6 +34,12 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
|
|||
staleTime: DefaultRommStaleTime,
|
||||
});
|
||||
|
||||
const handleDefaultSelect = (source: string, id: string) =>
|
||||
{
|
||||
SaveSource('game-list');
|
||||
navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
const platformsMapped = useMemo(() => platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
|
||||
.map((g, i) =>
|
||||
{
|
||||
|
|
@ -44,13 +59,9 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
|
|||
onFocus: () => data.setBackground(
|
||||
g.paths_screenshots.length > 0 ? `${RPC_URL(__HOST__)}${g.paths_screenshots[new Date().getMinutes() % g.paths_screenshots.length]}` : `${RPC_URL(__HOST__)}/api/romm/image?url=https://picsum.photos/id/${10 + i}/1280/720.webp`,
|
||||
),
|
||||
onSelect: () =>
|
||||
{
|
||||
SaveSource('game-list');
|
||||
navigate({ to: `/platform/${g.id.source}/${g.id.id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
},
|
||||
onSelect: () => data.onSelect ? data.onSelect(g.id.source, g.id.id) : handleDefaultSelect(g.id.source, g.id.id),
|
||||
preview:
|
||||
({ focused }) => <div
|
||||
() => <div
|
||||
className="flex p-6 bg-base-100 justify-center"
|
||||
style={{
|
||||
background: `linear-gradient(
|
||||
|
|
@ -58,11 +69,11 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
|
|||
color-mix(in srgb, var(--color-base-300) 60%, transparent)
|
||||
), url(https://picsum.photos/id/${10 + i}/100/100.webp?blur=10) center / cover`,
|
||||
|
||||
backgroundBlendMode: "screen",
|
||||
boxShadow: 'inset 0 0 32px rgba(0,0,0,0.6)'
|
||||
backgroundBlendMode: isMobile ? undefined : "screen",
|
||||
boxShadow: isMobile ? undefined : 'inset 0 0 32px rgba(0,0,0,0.6)'
|
||||
}}
|
||||
>
|
||||
<img className={classNames("drop-shadow-2xl", { "animate-rotate": focused })}
|
||||
<img draggable={false} className={"not-mobile:drop-shadow-2xl in-focus:animate-rotate"}
|
||||
src={coverUrl.href}
|
||||
></img>
|
||||
</div>
|
||||
|
|
@ -76,7 +87,7 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
|
|||
type="platform"
|
||||
id={data.id}
|
||||
grid={data.grid}
|
||||
className={data.className}
|
||||
className={twMerge('*:aspect-8/10! md:py-12', data.className)}
|
||||
onGameFocus={data.onFocus}
|
||||
games={platformsMapped}
|
||||
onSelectGame={(id) =>
|
||||
|
|
|
|||
|
|
@ -1,39 +1,20 @@
|
|||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { JSX } from "react";
|
||||
import { CSSProperties, JSX } from "react";
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { Button, ButtonStyle } from "./options/Button";
|
||||
|
||||
export function RoundButton (data: {
|
||||
id: string;
|
||||
icon: JSX.Element;
|
||||
children?: any;
|
||||
className?: string;
|
||||
external?: boolean;
|
||||
action?: () => void;
|
||||
})
|
||||
style?: ButtonStyle;
|
||||
} & InteractParams & FocusParams)
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: data.id,
|
||||
onEnterPress: data.action,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
id={data.id}
|
||||
ref={ref}
|
||||
onClick={data.action}
|
||||
className={classNames(twMerge(
|
||||
"rounded-full size-14 flex items-center justify-center bg-base-100 text-base-content cursor-pointer transition-all drop-shadow-sm",
|
||||
data.className, classNames(data.external === true
|
||||
? {
|
||||
"hover:ring-7 hover:ring-primary hover:bg-base-content hover:text-base-300": true,
|
||||
"ring-7 ring-primary bg-base-content text-base-100": focused,
|
||||
}
|
||||
: {
|
||||
"hover:bg-primary hover:text-primary-content": true,
|
||||
"bg-primary text-primary-content": focused,
|
||||
},)),
|
||||
)}
|
||||
>
|
||||
{data.icon}
|
||||
</div>
|
||||
<Button onFocus={data.onFocus} id={data.id} style={data.style} className={twMerge("rounded-full", data.external && "focusable focusable-primary focusable-hover", data.className)} onAction={data.onAction}>
|
||||
{data.children}
|
||||
</Button>
|
||||
|
||||
);
|
||||
}
|
||||
|
|
|
|||
49
src/mainview/components/Screenshots.tsx
Normal file
49
src/mainview/components/Screenshots.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { RPC_URL } from "@/shared/constants";
|
||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import FocusDots from "./FocusDots";
|
||||
import { scrollIntoNearestParent, useDragScroll } from "../scripts/utils";
|
||||
import { Fullscreen } from "lucide-react";
|
||||
|
||||
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; })
|
||||
{
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: `screenshot-${data.index}`,
|
||||
onEnterPress: () => (ref.current as HTMLElement).requestFullscreen(),
|
||||
onFocus: (e, p, details) =>
|
||||
{
|
||||
data.setFocused?.(data.index);
|
||||
scrollIntoNearestParent(ref.current, { behavior: details.instant ? 'instant' : 'smooth' });
|
||||
}
|
||||
}); 4096;
|
||||
return <div ref={ref} className="group relative flex min-w-fit aspect-video max-h-[60vh] rounded-3xl focusable focusable-accent not-focused:cursor-pointer overflow-hidden">
|
||||
<img ref={imageRef} draggable={false} className="object-cover w-full h-full" onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" />
|
||||
<div className="absolute flex justify-center items-center bottom-2 right-2 size-10 rounded-full bg-base-100 hover:bg-base-content hover:text-base-300 cursor-pointer opacity-60 not-control-mouse:hidden invisible group-has-hover:visible" onClick={() => imageRef.current?.requestFullscreen()}> <Fullscreen /> </div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function Screenshots (data: { screenshots: string[]; } & FocusParams)
|
||||
{
|
||||
const scrollRef = useRef(null);
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'screenshot-list',
|
||||
onFocus: (e, p, details) =>
|
||||
{
|
||||
data.onFocus?.(focusKey, ref.current, details);
|
||||
}
|
||||
});
|
||||
useDragScroll(scrollRef);
|
||||
|
||||
return <div ref={ref} className="flex flex-col w-full z-0 min-h-0">
|
||||
<FocusContext value={focusKey}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-6 px-16 py-2 sm:overflow-scroll md:overflow-hidden no-scrollbar justify-center-safe"
|
||||
>
|
||||
{data.screenshots.map((s, i) => <Screenshot key={s} index={i} path={s} />)}
|
||||
</div>
|
||||
<FocusDots elements={data.screenshots.map((_, i) => `screenshot-${i}`)} />
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ export default function ShortcutPrompt (data: {
|
|||
onClick={data.onClick}
|
||||
style={{ viewTransitionName: data.id }}
|
||||
className={twMerge("xs:text-xs sm:p-1 sm:text-sm",
|
||||
"flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm ring-[1px] ring-base-content/10 drop-shadow-black/30",
|
||||
"flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm ring-[1px] ring-base-content/10 drop-shadow-black/30 active:text-base-300 active:bg-base-content",
|
||||
data.className,
|
||||
classNames({
|
||||
"hover:bg-base-300 cursor-pointer": !!data.onClick,
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
|
|||
const { control } = useActiveControl();
|
||||
const showKeyboard = control === 'keyboard' || control === 'mouse';
|
||||
return (
|
||||
<div className="flex gap-2 z-1000 h-10">
|
||||
<div className="flex gap-2 z-1000" style={{ viewTimelineName: "shortcuts" }}>
|
||||
{data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt
|
||||
key={s.button}
|
||||
id={`shortcut-${s.button}`}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,26 @@ import
|
|||
import classNames from "classnames";
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
|
||||
export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
const styles = {
|
||||
base: 'bg-base-200 text-base-content active:bg-base-300! active:text-base-content! active:ring-offset-base-content',
|
||||
accent: "bg-accent text-accent-content active:bg-base-content! active:text-base-content active:ring-offset-accent",
|
||||
primary: "bg-primary text-primary-content active:bg-base-content! active:text-base-content! active:ring-offset-primary",
|
||||
secondary: "bg-secondary text-secondary-content active:bg-base-content! active:text-base-content! active:ring-offset-secondary",
|
||||
info: "bg-info text-info-content active:bg-base-content! active:text-base-content! active:ring-offset-info",
|
||||
success: "bg-success text-success-content active:bg-base-content! active:text-base-content! active:ring-offset-success",
|
||||
warning: "bg-warning text-warning-content active:bg-base-content! active:text-base-content! active:ring-offset-warning",
|
||||
error: "bg-error text-error-content active:bg-base-content! active:text-base-content! active:ring-offset-error",
|
||||
};
|
||||
|
||||
export function Button (data: {
|
||||
id: string,
|
||||
children?: any,
|
||||
className?: string,
|
||||
disabled?: boolean,
|
||||
type?: "reset" | "button" | "submit";
|
||||
style?: ButtonStyle,
|
||||
shortcutLabel?: string;
|
||||
focusClassName?: string;
|
||||
} & InteractParams & FocusParams)
|
||||
|
|
@ -20,7 +34,7 @@ export function Button (data: {
|
|||
const { ref, focused, focusKey } = useFocusable({
|
||||
focusKey: data.id,
|
||||
onEnterPress: data.onAction,
|
||||
onFocus: data.onFocus,
|
||||
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details),
|
||||
focusable: !data.disabled
|
||||
});
|
||||
|
||||
|
|
@ -31,9 +45,10 @@ export function Button (data: {
|
|||
|
||||
return <button
|
||||
ref={ref}
|
||||
onClick={data.onAction}
|
||||
onClick={e => data.onAction?.(e.nativeEvent)}
|
||||
disabled={data.disabled}
|
||||
className={twMerge("btn rounded-full focus:bg-base-content focus:text-base-300 md:text-lg",
|
||||
className={twMerge("flex items-center justify-center px-4 py-2 disabled:bg-base-200/40 disabled:text-base-content/40 cursor-pointer rounded-3xl md:text-lg not-control-mouse:focused:drop-shadow-lg border border-base-content/5 not-control-mouse:focused:bg-base-content not-control-mouse:focused:text-base-100 control-mouse:hover:bg-base-content control-mouse:hover:text-base-100 active:transition-none active:ring-offset-4",
|
||||
styles[data.style ?? 'base'],
|
||||
focused ? data.focusClassName : undefined,
|
||||
classNames({
|
||||
"btn-accent": focused,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { HTMLInputTypeAttribute, JSX } from "react";
|
||||
import { LocalSettingsSchema, LocalSettingsType } from "../../../shared/constants";
|
||||
import { LocalSettingsSchema, LocalSettingsType } from "@shared/constants";
|
||||
import { OptionSpace } from "./OptionSpace";
|
||||
import { OptionInput } from "./OptionInput";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
|
|
@ -18,7 +18,7 @@ export function LocalOption (data: {
|
|||
const [localValue, setLocalValue] = useLocalStorage<any>(data.id, LocalSettingsSchema.shape[data.id].parse(undefined), { deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v)) });
|
||||
|
||||
return (
|
||||
<OptionSpace label={data.label}>
|
||||
<OptionSpace id={`${data.id}-space`} label={data.label}>
|
||||
{data.type === 'dropdown' && data.values && <OptionDropdown values={data.values} icon={data.icon}
|
||||
name={data.id ?? ""}
|
||||
type={data.type}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import classNames from "classnames";
|
||||
import { ChangeEventHandler, FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef, useState } from "react";
|
||||
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useOptionContext } from "./OptionSpace";
|
||||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { systemApi } from "../../scripts/clientApi";
|
||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
|
|
@ -39,16 +37,13 @@ export function OptionDropdown (data: {
|
|||
|
||||
return (
|
||||
<>
|
||||
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
|
||||
classNames({ "[&_button]:not-focus:ring-7 [&_button]:not-focus:ring-accent": focused }))}>
|
||||
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
|
||||
"text-primary-content": option.focused
|
||||
}))}>{data.icon}</span>}
|
||||
<label ref={ref} className={twMerge("flex group-focusable items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent")}>
|
||||
{!!data.icon && <span className={"text-base-content/80 is-focused:text-primary-content"}>{data.icon}</span>}
|
||||
<button onClick={() =>
|
||||
{
|
||||
console.log("Open");
|
||||
setOpen(true);
|
||||
}} className={classNames('btn input rounded-full cursor-pointer grow', { "bg-base-200": !focused })}>{data.value}<ChevronDown /></button>
|
||||
}} className={'flex items-center justify-center border h-10 border-base-content/30 px-4 py-2 rounded-full cursor-pointer grow not-in-focused:bg-base-200 focusable focusable-accent hover:border-base-content hover:bg-base-content hover:text-base-300'}>{data.value}<ChevronDown /></button>
|
||||
</label>
|
||||
{open && <ContextDialog id={`${data.name}-context`} open={true} close={handleClose}>
|
||||
<ContextList options={data.values.map((v, i) => ({
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import classNames from "classnames";
|
||||
import { ChangeEventHandler, FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react";
|
||||
import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, JSX, useRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useOptionContext } from "./OptionSpace";
|
||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { systemApi } from "../../scripts/clientApi";
|
||||
import { Check, CheckIcon, X } from "lucide-react";
|
||||
import { CheckIcon, X } from "lucide-react";
|
||||
|
||||
export function OptionInput (data: {
|
||||
name: string;
|
||||
|
|
@ -52,11 +51,8 @@ export function OptionInput (data: {
|
|||
};
|
||||
|
||||
return (
|
||||
<label ref={ref} className={twMerge("flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent",
|
||||
classNames({ "[&_.focus-target]:not-focus:ring-7 [&_.focus-target]:not-focus:ring-accent": focused, "pl-1": data.type === 'checkbox' }))}>
|
||||
{!!data.icon && <span className={twMerge("text-base-content/80", classNames({
|
||||
"text-primary-content": option.focused
|
||||
}))}>{data.icon}</span>}
|
||||
<label ref={ref} className={`flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent group-focusable`}>
|
||||
{!!data.icon && <span className="text-base-content/80">{data.icon}</span>}
|
||||
{data.type !== 'checkbox' && <input
|
||||
ref={inputRef}
|
||||
id={data.name}
|
||||
|
|
@ -72,17 +68,11 @@ export function OptionInput (data: {
|
|||
onBlur={data.onBlur}
|
||||
defaultChecked={typeof data.defaultValue === 'boolean' ? data.defaultValue : undefined}
|
||||
className={twMerge(
|
||||
"focus-target text-base-content",
|
||||
"input grow rounded-full ring-primary-content focus:ring-7", classNames({
|
||||
"bg-base-200": !focused
|
||||
}),
|
||||
"flex text-base-content px-4 py-2 items-center justify-center border border-base-content/20 grow rounded-full focus:ring-base-content in-focused:bg-base-200 focusable focusable-accent focus:not-focused:ring-7 control-mouse:ring-0! hover:border-base-content",
|
||||
data.className
|
||||
)}
|
||||
/>}
|
||||
{data.type === 'checkbox' && <div className={classNames("toggle focus-target toggle-primary toggle-xl border-base-content/30 rounded-full before:rounded-full text-base-content", {
|
||||
"bg-base-200": !focused,
|
||||
"border-0": focused,
|
||||
})}>
|
||||
{data.type === 'checkbox' && <div className="toggle toggle-xl before:size-6 h-8 border-base-content/30 rounded-full before:rounded-full text-base-content not-in-focus:bg-base-200 focused-child:border-0 ml-1 ring-7 hover:border-base-content focusable focusable-accent">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={data.name}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
import { FocusContext, FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { OptionContext } from "@/mainview/scripts/contexts";
|
||||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { createContext, JSX, useContext, useEffect, useMemo } from "react";
|
||||
import { JSX, useContext, useEffect, useMemo } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const OptionContext = createContext(
|
||||
{} as {
|
||||
focused: boolean;
|
||||
focus: (focusDetails?: FocusDetails | undefined) => void;
|
||||
eventTarget: EventTarget;
|
||||
},
|
||||
);
|
||||
|
||||
export function useOptionContext (params?: { onOptionEnterPress?: () => void; })
|
||||
{
|
||||
const context = useContext(OptionContext);
|
||||
|
|
@ -81,11 +74,7 @@ export function OptionSpace (data: {
|
|||
<OptionContext value={{ focused, focus: focusSelf, eventTarget }}>
|
||||
<li
|
||||
ref={ref}
|
||||
className={twMerge("flex portrait:flex-col portrait:gap-2 portrait:p-4 md:flex-row sm:p-2 md:p-4 md:pl-8! rounded-3xl border-b border-base-content/5",
|
||||
classNames(
|
||||
{
|
||||
"bg-base-300": focused || hasFocusedChild,
|
||||
}),
|
||||
className={twMerge("flex portrait:flex-col portrait:gap-2 portrait:p-4 md:flex-row sm:p-2 md:p-4 md:pl-8! rounded-3xl border-b border-base-content/5 focused:bg-base-300 focused-child:bg-base-300",
|
||||
data.className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
|||
};
|
||||
|
||||
return (
|
||||
<OptionSpace id={data.id} className="gap-2" label={<>{data.label}{changed && <Pen />}</>}>
|
||||
<OptionSpace id={`${data.id}-space`} className="gap-2" label={<>{data.label}{changed && <Pen />}</>}>
|
||||
<OptionInput
|
||||
icon={data.icon}
|
||||
name={`${data.id}-input`}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const { useAppForm: useSettingsForm, useTypedAppFormContext: useSettingsF
|
|||
function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; label?: string | JSX.Element; placeholder?: string; })
|
||||
{
|
||||
const field = useFieldContext<string>();
|
||||
return <OptionSpace label={<div className="flex flex-1 gap-2">
|
||||
return <OptionSpace id={`${field.name}-space`} label={<div className="flex flex-1 gap-2">
|
||||
{data.label}
|
||||
{field.getMeta().errors.length > 0 && <div className="badge badge-error">
|
||||
{field.state.meta.errors.map(e => e.message).join(',')}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export function SettingsOption (data: {
|
|||
}, [dirty, setDirty, localValue]);
|
||||
|
||||
return (
|
||||
<OptionSpace label={data.label}>
|
||||
<OptionSpace id={`${data.id}-space`} label={data.label}>
|
||||
<OptionInput
|
||||
icon={data.icon}
|
||||
name={data.id ?? ""}
|
||||
|
|
|
|||
76
src/mainview/components/store/EmulatorsSection.tsx
Normal file
76
src/mainview/components/store/EmulatorsSection.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useRef } from "react";
|
||||
import
|
||||
{
|
||||
useFocusable,
|
||||
FocusContext,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { ChevronRight, Joystick } from "lucide-react";
|
||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils";
|
||||
import FocusDots from "../FocusDots";
|
||||
import { Router } from "@/mainview";
|
||||
import { StoreEmulatorCard } from "./StoreEmulatorCard";
|
||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||
import { FrontEndEmulator } from "@/shared/constants";
|
||||
|
||||
function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant: boolean; }) => void; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: data.id,
|
||||
onFocus: (_l, _p, details) => data.onFocus?.({ node: ref.current, instant: details.instant }),
|
||||
onEnterPress: data.onAction
|
||||
});
|
||||
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "See All", action: data.onAction }], []);
|
||||
return <div
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={data.onAction}
|
||||
className={"flex focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:animate-scale-small p-4 justify-center items-center min-w-2xs gap-2 hover:bg-base-300 cursor-pointer"}
|
||||
>
|
||||
See All Emulators <ChevronRight />
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function EmulatorsSection (data: {
|
||||
id: string;
|
||||
emulators: FrontEndEmulator[];
|
||||
onSelect?: (id: string, focusKey: string) => void;
|
||||
header?: any;
|
||||
} & FocusParams)
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: FOCUS_KEYS.EMULATOR_SECTION(data.id),
|
||||
trackChildren: true,
|
||||
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details)
|
||||
});
|
||||
|
||||
const containerRef = useRef(null);
|
||||
useDragScroll(containerRef);
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<section ref={ref} className="px-2 py-4">
|
||||
<div className="flex items-center gap-3 px-4 mb-4 text-info">
|
||||
{data.header ?? <>
|
||||
<div className="w-2 h-5 rounded-full bg-info shadow-sm shadow-error/40" />
|
||||
<Joystick />
|
||||
<h2 className="font-bold uppercase tracking-widest">
|
||||
Recommended Emulators
|
||||
</h2>
|
||||
</>}
|
||||
</div>
|
||||
<div ref={containerRef} className="flex *:min-w-[18rem] overflow-y-hidden overflow-x-scroll scrollbar-none py-2 px-4 gap-4 select-none">
|
||||
{data.emulators?.map((em) => (
|
||||
<StoreEmulatorCard id={`${data.id}-${em.name}`} key={em.name} emulator={em} onSelect={(id, focusKey) => data.onSelect?.(em.name, focusKey)} onFocus={({ node, details }) =>
|
||||
{
|
||||
scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' });
|
||||
}} />
|
||||
))}
|
||||
<SeeAllCard id={`${FOCUS_KEYS.EMULATOR_SECTION}-see-all`} onAction={() => Router.navigate({ to: '/store/tab/emulators' })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} />
|
||||
</div>
|
||||
</section>
|
||||
{!!data.emulators && <FocusDots elements={data.emulators.map(e => FOCUS_KEYS.EMULATOR_CARD(e.name))} />}
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
49
src/mainview/components/store/GamesSection.tsx
Normal file
49
src/mainview/components/store/GamesSection.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useRef } from "react";
|
||||
import
|
||||
{
|
||||
useFocusable,
|
||||
FocusContext,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { Gamepad2 } from "lucide-react";
|
||||
import { useDragScroll } from "@/mainview/scripts/utils";
|
||||
import FocusDots from "../FocusDots";
|
||||
import { FrontEndGameType, FrontEndId } from "@/shared/constants";
|
||||
import FrontEndGameCard from "../FrontEndGameCard";
|
||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||
|
||||
export function GamesSection ({ games, onSelect, onFocus }: {
|
||||
games: FrontEndGameType[];
|
||||
onSelect?: (id: FrontEndId, focusKey: string) => void;
|
||||
} & FocusParams)
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: FOCUS_KEYS.GAME_SECTION,
|
||||
trackChildren: true,
|
||||
onFocus: (_l, _p, details) => onFocus?.(focusKey, ref.current, details)
|
||||
});
|
||||
const containerRef = useRef(null);
|
||||
useDragScroll(containerRef);
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<section ref={ref} className="px-6 py-3 select-none">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-2 h-5 rounded-full bg-accent shadow-sm shadow-error/40" />
|
||||
<Gamepad2 className="text-accent" />
|
||||
<h2 className="font-bold uppercase tracking-widest text-accent grow">
|
||||
Featured Games
|
||||
</h2>
|
||||
<div className="badge badge-xl badge-accent badge-soft">Curated picks</div>
|
||||
</div>
|
||||
<div ref={containerRef} className="grid grid-flow-col auto-cols-[18rem] overflow-y-hidden overflow-x-auto hide-scrollbar p-4 gap-4 justify-center-safe">
|
||||
{games.map((g, i) => <FrontEndGameCard
|
||||
key={g.id.id}
|
||||
game={g}
|
||||
onAction={() => onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id.id))}
|
||||
index={i} />)}
|
||||
</div>
|
||||
</section>
|
||||
<FocusDots elements={games.map(e => FOCUS_KEYS.GAME_CARD(e.id.id))} />
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
98
src/mainview/components/store/MissingEmulatorsSection.tsx
Normal file
98
src/mainview/components/store/MissingEmulatorsSection.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import
|
||||
{
|
||||
useFocusable,
|
||||
FocusContext,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { Button } from "../options/Button";
|
||||
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||
import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react";
|
||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { FrontEndEmulator, RPC_URL } from "@/shared/constants";
|
||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||
|
||||
// ── Single missing-emulator card ───────────────────────────────────────────
|
||||
interface MissingCardProps
|
||||
{
|
||||
emulator: FrontEndEmulator;
|
||||
onSelect?: (id: string, focusKey: string) => void;
|
||||
}
|
||||
|
||||
function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
|
||||
{
|
||||
const handleSelect = () => onSelect?.(em.name, focusKey);
|
||||
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: FOCUS_KEYS.MISSING_CARD(em.name),
|
||||
onEnterPress: handleSelect,
|
||||
});
|
||||
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
|
||||
const { isMouse } = useActiveControl();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleSelect}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSelect}
|
||||
className={"focusable focusable-accent bg-base-100 rounded-4xl transition-all focused:animate-scale-small shadow-lg"}
|
||||
>
|
||||
<div className="card-body p-5 gap-3">
|
||||
<div className="flex gap-4">
|
||||
<div
|
||||
className={`size-14 bg-base-content rounded-full flex items-center justify-center text-2xl shadow-md shrink-0 text-base-300`}
|
||||
>
|
||||
{em.logo ?
|
||||
<img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${em.logo}`}></img> :
|
||||
<CircleQuestionMark />
|
||||
}
|
||||
</div>
|
||||
<div className="grow">
|
||||
<p className="font-bold text-base-content text-xl leading-tight">{em.name}</p>
|
||||
<p className="text-base-content/40 mt-0.5">{em.systems?.map(s => s.name).join(',')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center grow h-8">
|
||||
<p className="text-xs text-error/80 leading-relaxed">{em.name}</p>
|
||||
{isMouse && <Button className="hover:btn-error hover:text-primary-content text-base-content/40 font-normal md:text-base" onAction={handleSelect} id={`details-${em.name}`}>Details<ChevronRight /></Button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MissingEmulatorsSection ({
|
||||
emulators,
|
||||
onSelect,
|
||||
}: {
|
||||
emulators: FrontEndEmulator[];
|
||||
onSelect?: (id: string, focusKey: string) => void;
|
||||
})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: FOCUS_KEYS.MISSING_SECTION,
|
||||
trackChildren: true,
|
||||
onFocus: (_l, _p, details) => (ref.current as HTMLElement)?.scrollIntoView({ behavior: details.instant ? 'instant' : 'smooth', block: 'end' })
|
||||
});
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<section ref={ref} className="px-6 pt-5 pb-2">
|
||||
<div className="flex items-center gap-3 mb-4 text-error">
|
||||
<div className="w-2 h-5 rounded-full bg-error shadow-sm shadow-error/40" />
|
||||
<SearchAlert />
|
||||
<h2 className="font-bold uppercase tracking-widest">
|
||||
Missing Emulators
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{emulators.map((em) => (
|
||||
<MissingCard key={em.name} emulator={em} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div className="divider opacity-20" />
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
52
src/mainview/components/store/StatsSection.tsx
Normal file
52
src/mainview/components/store/StatsSection.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { storeApi } from "@/mainview/scripts/clientApi";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Joystick, LibraryBig, Save, TriangleAlert } from "lucide-react";
|
||||
|
||||
interface StatsSectionProps
|
||||
{
|
||||
romCount: number;
|
||||
missingCount: number;
|
||||
}
|
||||
|
||||
export function StatsSection ({
|
||||
romCount,
|
||||
missingCount,
|
||||
}: StatsSectionProps)
|
||||
{
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['store', 'stats'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await storeApi.api.store.stats.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="px-6 pt-3 pb-4">
|
||||
<div className="stats stats-horizontal w-full rounded-2xl text-shadow-sm">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-2xl text-primary shadow-2xl"><Joystick /></div>
|
||||
<div className="stat-value text-xl font-black text-primary shadow-2xl">{stats?.storeEmulatorCount}</div>
|
||||
<div className="stat-desc ">Emulators Available</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-2xl text-secondary"><Save /></div>
|
||||
<div className="stat-value text-xl font-black text-secondary">{romCount.toLocaleString()}+</div>
|
||||
<div className="stat-desc">ROMs in Store</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-2xl text-success"><LibraryBig /></div>
|
||||
<div className="stat-value text-xl font-black text-success">{stats?.gameCount}</div>
|
||||
<div className="stat-desc">Your Library</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-2xl text-warning"><TriangleAlert /></div>
|
||||
<div className="stat-value text-xl font-black text-warning">{missingCount}</div>
|
||||
<div className="stat-desc">Missing Emulators</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
84
src/mainview/components/store/StoreEmulatorCard.tsx
Normal file
84
src/mainview/components/store/StoreEmulatorCard.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { FrontEndEmulator, RPC_URL } from "@/shared/constants";
|
||||
import { Button } from "../options/Button";
|
||||
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { ChevronRight, EllipsisVertical, HardDrive } from "lucide-react";
|
||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||
|
||||
export function StoreEmulatorCard (data: {
|
||||
id: string;
|
||||
emulator: FrontEndEmulator;
|
||||
onSelect?: (id: string, focusKey: string) => void;
|
||||
onFocus?: (data: { id: string; node: HTMLElement; details: Record<string, any>; }) => void;
|
||||
className?: string;
|
||||
})
|
||||
{
|
||||
const handleSelect = () => data.onSelect?.(data.emulator.name, focusKey);
|
||||
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: FOCUS_KEYS.EMULATOR_CARD(data.id),
|
||||
onEnterPress: handleSelect,
|
||||
onFocus: (_l, _p, details) =>
|
||||
{
|
||||
data.onFocus?.({ id: data.emulator.name, node: ref.current as HTMLElement, details });
|
||||
}
|
||||
});
|
||||
|
||||
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
|
||||
const { isMouse, isTouch } = useActiveControl();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-installed={data.emulator.exists ? true : undefined}
|
||||
onClick={isTouch ? handleSelect : undefined}
|
||||
className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)}
|
||||
>
|
||||
<div className="flex flex-col justify-between p-4 gap-2 h-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-start">
|
||||
<div
|
||||
data-installed={data.emulator.exists}
|
||||
className={`size-14 p-2 rounded-full bg-info flex items-center justify-center text-xl shadow-lg data-[installed=true]:bg-success`}
|
||||
>
|
||||
<img draggable={false} src={data.emulator.logo}></img>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p data-installed={data.emulator.exists} className="font-bold text-base-content text-xl leading-snug data-[installed=true]:text-success">{data.emulator.name}</p>
|
||||
<ul className="flex flex-wrap gap-1">
|
||||
{data.emulator.systems.map(({ id, name, icon }) =>
|
||||
{
|
||||
return <div key={id} className="flex gap-1 items-center text-base-content/35 mt-0.5">
|
||||
{!!icon && <img draggable={false} className="size-6 p-1 bg-base-200 rounded-full" src={`${RPC_URL(__HOST__)}${icon}`} />}
|
||||
<p className="text-nowrap text-ellipsis overflow-hidden">{name}</p>
|
||||
</div>;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-0.5 mt-1 h-10 items-center">
|
||||
{data.emulator.exists && <div className="tooltip" data-tip="Installed">
|
||||
<div className="flex items-center justify-center rounded-full p-1 size-8 bg-success text-success-content"><HardDrive /></div>
|
||||
</div>}
|
||||
{<div className="tooltip" data-tip="Game Count">
|
||||
<div className="flex items-center justify-center rounded-full font-semibold size-9 p-2 bg-base-200 text-base-content/40">{data.emulator.gameCount}</div>
|
||||
</div>}
|
||||
{isMouse && <>
|
||||
<Button onAction={handleSelect} style="base" className="grow text-base-content/40" id={`${data.emulator.name}-details`} >Details<ChevronRight /></Button>
|
||||
<Button className="bg-transparent border-none shadow-none w-6 p-0" id={`${data.emulator.name}-options`} ><EllipsisVertical /></Button>
|
||||
</>}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/mainview/emulatorjs/emulator.ts
Normal file
63
src/mainview/emulatorjs/emulator.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { RPC_URL } from '@/shared/constants';
|
||||
import { basename } from 'pathe';
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
Array.from(params.entries()).forEach(([key, value]) =>
|
||||
{
|
||||
(window as any)[`EJS_${key}`] = value;
|
||||
});
|
||||
|
||||
window.addEventListener('message', (e) =>
|
||||
{
|
||||
switch (e.data.type)
|
||||
{
|
||||
case 'pause':
|
||||
if (e.data.data === true)
|
||||
{
|
||||
window.EJS_emulator.pause();
|
||||
} else
|
||||
{
|
||||
window.EJS_emulator.play();
|
||||
}
|
||||
break;
|
||||
case 'restart':
|
||||
window.EJS_emulator.elements.bottomBar.restart[0].click();
|
||||
break;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
window.EJS_player = "#game";
|
||||
window.EJS_lightgun = false;
|
||||
window.EJS_startOnLoaded = true;
|
||||
// For core downloads, it either redirects to CDN or uses local if downloaded
|
||||
window.EJS_pathtodata = `${RPC_URL(__HOST__)}/api/romm/emulatorjs/data`;
|
||||
window.EJS_Buttons = {
|
||||
exitEmulation: {
|
||||
visible: true,
|
||||
displayName: "Exit",
|
||||
callback: () =>
|
||||
{
|
||||
window.parent.postMessage(
|
||||
{ type: "exit" },
|
||||
"*"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const moduleUrls = import.meta.glob
|
||||
(['../../../node_modules/@emulatorjs/emulatorjs/data/**/*.js',
|
||||
'../../../node_modules/@emulatorjs/emulatorjs/data/**/*.css',
|
||||
'../../../node_modules/@emulatorjs/emulatorjs/data/**/*.wasm',
|
||||
'../../../node_modules/@emulatorjs/emulatorjs/data/localization/en-US.json'
|
||||
], {
|
||||
query: '?url',
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
// emulatorjs expects basenames instead of paths for some reason
|
||||
window.EJS_paths = Object.fromEntries(await Promise.all(Object.entries(moduleUrls).map(async ([key, value]) => [basename(key), await value()])));
|
||||
|
||||
await import('@emulatorjs/emulatorjs/data/loader.js');
|
||||
20
src/mainview/emulatorjs/index.html
Normal file
20
src/mainview/emulatorjs/index.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="../assets/favicon.ico" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Alan+Sans:wght@300..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="./style.css" rel="stylesheet" />
|
||||
<title>GameFlow</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./emulator.ts"></script>
|
||||
<div id="game-wrapper">
|
||||
<div id="game"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
22
src/mainview/emulatorjs/style.css
Normal file
22
src/mainview/emulatorjs/style.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#game-wrapper {
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
62
src/mainview/emulatorjs/types.d.ts
vendored
Normal file
62
src/mainview/emulatorjs/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
export declare global
|
||||
{
|
||||
interface Window
|
||||
{
|
||||
EJS_emulator: any,
|
||||
EJS_player: string,
|
||||
EJS_gameUrl: string,
|
||||
EJS_pathtodata: string,
|
||||
EJS_language: string,
|
||||
EJS_disableAutoLang: boolean,
|
||||
EJS_paths: Record<string, string>,
|
||||
EJS_volume: number,
|
||||
EJS_gameName: string,
|
||||
EJS_cheats: string[][],
|
||||
EJS_fullscreenOnLoaded: boolean,
|
||||
EJS_startOnLoaded: boolean,
|
||||
EJS_core: string,
|
||||
EJS_lightgun: boolean,
|
||||
EJS_biosUrl: string,
|
||||
EJS_color: string,
|
||||
EJS_AdUrl: string,
|
||||
EJS_AdMode: string,
|
||||
EJS_AdTimer: number,
|
||||
EJS_AdSize: number,
|
||||
EJS_alignStartButton: boolean,
|
||||
EJS_VirtualGamepadSettings,
|
||||
EJS_Buttons,
|
||||
EJS_defaultControls,
|
||||
EJS_loadStateURL: string,
|
||||
EJS_CacheLimit: number,
|
||||
EJS_cacheConfig,
|
||||
EJS_cheatPath: string,
|
||||
EJS_defaultOptions,
|
||||
EJS_gamePatchUrl: string,
|
||||
EJS_gameParentUrl: string,
|
||||
EJS_netplayServer,
|
||||
EJS_netplayICEServers,
|
||||
EJS_gameID: string,
|
||||
EJS_backgroundImage: string,
|
||||
EJS_backgroundBlur,
|
||||
EJS_backgroundColor,
|
||||
EJS_controlScheme,
|
||||
EJS_threads: boolean,
|
||||
EJS_disableCue,
|
||||
EJS_startButtonName,
|
||||
EJS_softLoad,
|
||||
EJS_screenCapture,
|
||||
EJS_externalFiles,
|
||||
EJS_dontExtractRom,
|
||||
EJS_dontExtractBIOS,
|
||||
EJS_disableLocalStorage: boolean,
|
||||
EJS_forceLegacyCores: boolean,
|
||||
EJS_noAutoFocus: boolean,
|
||||
EJS_videoRotation,
|
||||
EJS_hideSettings,
|
||||
EJS_browserMode,
|
||||
EJS_shaders,
|
||||
EJS_fixedSaveInterval,
|
||||
EJS_disableAutoUnload,
|
||||
EJS_disableBatchBootup;
|
||||
}
|
||||
}
|
||||
|
|
@ -17,9 +17,15 @@ import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/di
|
|||
import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts'
|
||||
import { Route as SettingsAboutRouteImport } from './../routes/settings/about'
|
||||
import { Route as CollectionIdRouteImport } from './../routes/collection.$id'
|
||||
import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route'
|
||||
import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index'
|
||||
import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games'
|
||||
import { Route as StoreTabEmulatorsRouteImport } from './../routes/store/tab/emulators'
|
||||
import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$source.$id'
|
||||
import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id'
|
||||
import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id'
|
||||
import { Route as EmbeddedSourceIdRouteImport } from './../routes/embedded.$source.$id'
|
||||
import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id'
|
||||
|
||||
const SettingsRouteRoute = SettingsRouteRouteImport.update({
|
||||
id: '/settings',
|
||||
|
|
@ -61,6 +67,26 @@ const CollectionIdRoute = CollectionIdRouteImport.update({
|
|||
path: '/collection/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const StoreTabRouteRoute = StoreTabRouteRouteImport.update({
|
||||
id: '/store/tab',
|
||||
path: '/store/tab',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const StoreTabIndexRoute = StoreTabIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => StoreTabRouteRoute,
|
||||
} as any)
|
||||
const StoreTabGamesRoute = StoreTabGamesRouteImport.update({
|
||||
id: '/games',
|
||||
path: '/games',
|
||||
getParentRoute: () => StoreTabRouteRoute,
|
||||
} as any)
|
||||
const StoreTabEmulatorsRoute = StoreTabEmulatorsRouteImport.update({
|
||||
id: '/emulators',
|
||||
path: '/emulators',
|
||||
getParentRoute: () => StoreTabRouteRoute,
|
||||
} as any)
|
||||
const PlatformSourceIdRoute = PlatformSourceIdRouteImport.update({
|
||||
id: '/platform/$source/$id',
|
||||
path: '/platform/$source/$id',
|
||||
|
|
@ -76,19 +102,35 @@ const GameSourceIdRoute = GameSourceIdRouteImport.update({
|
|||
path: '/game/$source/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const EmbeddedSourceIdRoute = EmbeddedSourceIdRouteImport.update({
|
||||
id: '/embedded/$source/$id',
|
||||
path: '/embedded/$source/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({
|
||||
id: '/store/details/emulator/$id',
|
||||
path: '/store/details/emulator/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRouteRouteWithChildren
|
||||
'/store/tab': typeof StoreTabRouteRouteWithChildren
|
||||
'/collection/$id': typeof CollectionIdRoute
|
||||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||
'/settings/interface': typeof SettingsInterfaceRoute
|
||||
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
||||
'/store/tab/games': typeof StoreTabGamesRoute
|
||||
'/store/tab/': typeof StoreTabIndexRoute
|
||||
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
|
|
@ -99,38 +141,55 @@ export interface FileRoutesByTo {
|
|||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||
'/settings/interface': typeof SettingsInterfaceRoute
|
||||
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
||||
'/store/tab/games': typeof StoreTabGamesRoute
|
||||
'/store/tab': typeof StoreTabIndexRoute
|
||||
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/settings': typeof SettingsRouteRouteWithChildren
|
||||
'/store/tab': typeof StoreTabRouteRouteWithChildren
|
||||
'/collection/$id': typeof CollectionIdRoute
|
||||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||
'/settings/interface': typeof SettingsInterfaceRoute
|
||||
'/embedded/$source/$id': typeof EmbeddedSourceIdRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
||||
'/store/tab/games': typeof StoreTabGamesRoute
|
||||
'/store/tab/': typeof StoreTabIndexRoute
|
||||
'/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/store/tab'
|
||||
| '/collection/$id'
|
||||
| '/settings/about'
|
||||
| '/settings/accounts'
|
||||
| '/settings/directories'
|
||||
| '/settings/emulators'
|
||||
| '/settings/interface'
|
||||
| '/embedded/$source/$id'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
| '/store/tab/emulators'
|
||||
| '/store/tab/games'
|
||||
| '/store/tab/'
|
||||
| '/store/details/emulator/$id'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
|
|
@ -141,31 +200,45 @@ export interface FileRouteTypes {
|
|||
| '/settings/directories'
|
||||
| '/settings/emulators'
|
||||
| '/settings/interface'
|
||||
| '/embedded/$source/$id'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
| '/store/tab/emulators'
|
||||
| '/store/tab/games'
|
||||
| '/store/tab'
|
||||
| '/store/details/emulator/$id'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/settings'
|
||||
| '/store/tab'
|
||||
| '/collection/$id'
|
||||
| '/settings/about'
|
||||
| '/settings/accounts'
|
||||
| '/settings/directories'
|
||||
| '/settings/emulators'
|
||||
| '/settings/interface'
|
||||
| '/embedded/$source/$id'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
| '/store/tab/emulators'
|
||||
| '/store/tab/games'
|
||||
| '/store/tab/'
|
||||
| '/store/details/emulator/$id'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
SettingsRouteRoute: typeof SettingsRouteRouteWithChildren
|
||||
StoreTabRouteRoute: typeof StoreTabRouteRouteWithChildren
|
||||
CollectionIdRoute: typeof CollectionIdRoute
|
||||
EmbeddedSourceIdRoute: typeof EmbeddedSourceIdRoute
|
||||
GameSourceIdRoute: typeof GameSourceIdRoute
|
||||
LauncherSourceIdRoute: typeof LauncherSourceIdRoute
|
||||
PlatformSourceIdRoute: typeof PlatformSourceIdRoute
|
||||
StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
|
@ -226,6 +299,34 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof CollectionIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/store/tab': {
|
||||
id: '/store/tab'
|
||||
path: '/store/tab'
|
||||
fullPath: '/store/tab'
|
||||
preLoaderRoute: typeof StoreTabRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/store/tab/': {
|
||||
id: '/store/tab/'
|
||||
path: '/'
|
||||
fullPath: '/store/tab/'
|
||||
preLoaderRoute: typeof StoreTabIndexRouteImport
|
||||
parentRoute: typeof StoreTabRouteRoute
|
||||
}
|
||||
'/store/tab/games': {
|
||||
id: '/store/tab/games'
|
||||
path: '/games'
|
||||
fullPath: '/store/tab/games'
|
||||
preLoaderRoute: typeof StoreTabGamesRouteImport
|
||||
parentRoute: typeof StoreTabRouteRoute
|
||||
}
|
||||
'/store/tab/emulators': {
|
||||
id: '/store/tab/emulators'
|
||||
path: '/emulators'
|
||||
fullPath: '/store/tab/emulators'
|
||||
preLoaderRoute: typeof StoreTabEmulatorsRouteImport
|
||||
parentRoute: typeof StoreTabRouteRoute
|
||||
}
|
||||
'/platform/$source/$id': {
|
||||
id: '/platform/$source/$id'
|
||||
path: '/platform/$source/$id'
|
||||
|
|
@ -247,6 +348,20 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof GameSourceIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/embedded/$source/$id': {
|
||||
id: '/embedded/$source/$id'
|
||||
path: '/embedded/$source/$id'
|
||||
fullPath: '/embedded/$source/$id'
|
||||
preLoaderRoute: typeof EmbeddedSourceIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/store/details/emulator/$id': {
|
||||
id: '/store/details/emulator/$id'
|
||||
path: '/store/details/emulator/$id'
|
||||
fullPath: '/store/details/emulator/$id'
|
||||
preLoaderRoute: typeof StoreDetailsEmulatorIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,13 +385,32 @@ const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
|
|||
SettingsRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface StoreTabRouteRouteChildren {
|
||||
StoreTabEmulatorsRoute: typeof StoreTabEmulatorsRoute
|
||||
StoreTabGamesRoute: typeof StoreTabGamesRoute
|
||||
StoreTabIndexRoute: typeof StoreTabIndexRoute
|
||||
}
|
||||
|
||||
const StoreTabRouteRouteChildren: StoreTabRouteRouteChildren = {
|
||||
StoreTabEmulatorsRoute: StoreTabEmulatorsRoute,
|
||||
StoreTabGamesRoute: StoreTabGamesRoute,
|
||||
StoreTabIndexRoute: StoreTabIndexRoute,
|
||||
}
|
||||
|
||||
const StoreTabRouteRouteWithChildren = StoreTabRouteRoute._addFileChildren(
|
||||
StoreTabRouteRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
SettingsRouteRoute: SettingsRouteRouteWithChildren,
|
||||
StoreTabRouteRoute: StoreTabRouteRouteWithChildren,
|
||||
CollectionIdRoute: CollectionIdRoute,
|
||||
EmbeddedSourceIdRoute: EmbeddedSourceIdRoute,
|
||||
GameSourceIdRoute: GameSourceIdRoute,
|
||||
LauncherSourceIdRoute: LauncherSourceIdRoute,
|
||||
PlatformSourceIdRoute: PlatformSourceIdRoute,
|
||||
StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@ const assets = new Set<string>([
|
|||
]);
|
||||
|
||||
// Store basePath resolved from Vite config
|
||||
const BASE_PATH = "./";
|
||||
const BASE_PATH = "/";
|
||||
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -8,15 +8,18 @@
|
|||
--breakpoint-sm: 0px;
|
||||
--breakpoint-md: 1280px;
|
||||
--page-scroll-bg: transparent;
|
||||
--animation-size: 1;
|
||||
|
||||
--animate-wiggle: wiggle 0.3s ease-in-out 1;
|
||||
--animate-rotate: rotate 0.3s ease-in-out 1 0.2s;
|
||||
--animate-rotate-small: rotate-small 0.3s ease-in-out 1 0.2s;
|
||||
--animate-rotate-instant: rotate 0.3s ease-in-out 1;
|
||||
--animate-rotate: rotate 0.3s ease-in-out 1 200ms;
|
||||
--animate-rotate-small: rotate-small 0.3s ease-in-out 1 200ms;
|
||||
--animate-scale: scale 0.3s ease-in-out 1;
|
||||
--animate-slide-up: slide-up 0.2s ease-in-out 1;
|
||||
--animate-scale-delayed: scale 0.3s ease-in-out 1 100ms;
|
||||
--animate-scale-small: scale-small 0.3s ease-in-out 1;
|
||||
--animate-fade-out: fade-out 0.3s ease-out 1;
|
||||
--animate-fade-in: fade-out 0.6s ease-out 1 reverse forwards;
|
||||
--animate-bg-zoom: zoom-in-scale 0.6s ease-out 1 forwards;
|
||||
--animate-bg-zoom-big: zoom-in-scale-big 0.6s ease-out 1 forwards;
|
||||
--animate-bg-zoom-scroll: zoom-in-bg 0.6s ease-out 1 forwards;
|
||||
|
|
@ -116,11 +119,11 @@
|
|||
}
|
||||
|
||||
25% {
|
||||
transform: rotate(1deg);
|
||||
transform: rotate(calc(1deg * var(--animation-size)));
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: rotate(-1deg);
|
||||
transform: rotate(calc(-1deg * var(--animation-size)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,8 +200,88 @@ body {
|
|||
font-family: 'Alan Sans', sans-serif;
|
||||
}
|
||||
|
||||
@utility focusable-* {
|
||||
--focus-ring-color: --value(--color-*);
|
||||
}
|
||||
|
||||
@utility hide-scrollbar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@utility scrollbar-* {
|
||||
scrollbar-width: --value(integer);
|
||||
scrollbar-width: --value([integer]);
|
||||
scrollbar-width: --value("none");
|
||||
}
|
||||
|
||||
@utility animation-size-* {
|
||||
--animation-size: --value(number);
|
||||
}
|
||||
|
||||
@utility animation-delay-* {
|
||||
--tw-animation-delay: --value([*]);
|
||||
--tw-animation-delay: --value(integer)ms;
|
||||
animation-delay: var(--tw-animation-delay);
|
||||
}
|
||||
|
||||
@custom-variant focused {
|
||||
|
||||
&:where([data-active-control="gamepad"] &[data-focused=true]),
|
||||
&:where([data-active-control="keyboard"] &[data-focused=true]) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant focused-child {
|
||||
|
||||
&:where([data-active-control="gamepad"] &:has([data-focused=true])),
|
||||
&:where([data-active-control="keyboard"] &:has([data-focused=true])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant in-focused {
|
||||
|
||||
&:where([data-active-control="gamepad"] [data-focused=true] *),
|
||||
&:where([data-active-control="keyboard"] [data-focused=true] *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant control-mouse (&:where([data-active-control="mouse"] *));
|
||||
@custom-variant control-touch (&:where([data-active-control="touch"] *));
|
||||
@custom-variant control-keyboard (&:where([data-active-control="keyboard"] *));
|
||||
@custom-variant control-gamepad (&:where([data-active-control="gamepad"] *));
|
||||
@custom-variant control-pointer (&:where([data-active-control="mouse"] *), &:where([data-active-control="touch"] *));
|
||||
@custom-variant mobile (&:where([data-device="mobile"] *));
|
||||
|
||||
@container scroll-state(stuck: top) {
|
||||
.sticky-header {
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
.focusable {
|
||||
--focus-ring-color: --value(--color-accent);
|
||||
@apply md:ring-14 sm:ring-8 ring-transparent transition-shadow;
|
||||
}
|
||||
|
||||
[data-active-control="keyboard"] .group-focusable[data-focused="true"] .focusable,
|
||||
[data-active-control="gamepad"] .group-focusable[data-focused="true"] .focusable,
|
||||
[data-active-control="keyboard"] .focusable[data-focused=true],
|
||||
[data-active-control="gamepad"] .focusable[data-focused=true],
|
||||
[data-active-control="mouse"] .group-focusable:hover .focusable-hover,
|
||||
[data-active-control="mouse"] .focusable-hover:hover {
|
||||
@apply md:ring-7 sm:ring-4 ring-(--focus-ring-color);
|
||||
}
|
||||
|
||||
.background {
|
||||
-webkit-backface-visibility: hidden;
|
||||
-webkit-perspective: 1000;
|
||||
|
|
@ -210,6 +293,17 @@ body {
|
|||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.container-scroll {
|
||||
container-type: scroll-state;
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
@apply rounded-2xl;
|
||||
}
|
||||
|
|
@ -245,8 +339,52 @@ body {
|
|||
@apply grid pb-4;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
.bg-gradient {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
--bg-gradient-opacity: 15%;
|
||||
|
||||
background:
|
||||
radial-gradient(at 10% 20%, rgb(from var(--color-error) r g b / var(--bg-gradient-opacity)), transparent 60%),
|
||||
radial-gradient(at 80% 30%, rgb(from var(--color-info) r g b / var(--bg-gradient-opacity)), transparent 60%),
|
||||
radial-gradient(at 40% 90%, rgb(from var(--color-success) r g b / var(--bg-gradient-opacity)), transparent 60%),
|
||||
radial-gradient(at 90% 80%, rgb(from var(--color-warning) r g b / var(--bg-gradient-opacity)), transparent 60%);
|
||||
|
||||
background-blend-mode: lighten;
|
||||
background-repeat: repeat;
|
||||
background-color: var(--color-base-100);
|
||||
@apply mobile:hidden;
|
||||
}
|
||||
|
||||
.bg-noise {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
background-image: url("https://momentsingraphics.de/Media/BlueNoise/BlueNoise470.png");
|
||||
mix-blend-mode: color-dodge;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.bg-gradient-back {
|
||||
|
||||
--bg-opacity: 90%;
|
||||
background:
|
||||
radial-gradient(at 10% 20%, color-mix(in srgb, var(--color-secondary), transparent var(--bg-opacity)), transparent 60%),
|
||||
radial-gradient(at 80% 30%, color-mix(in srgb, var(--color-info), transparent var(--bg-opacity)), transparent 60%),
|
||||
radial-gradient(at 40% 90%, color-mix(in srgb, var(--color-success), transparent var(--bg-opacity)), transparent 60%),
|
||||
radial-gradient(at 90% 80%, color-mix(in srgb, var(--color-warning), transparent var(--bg-opacity)), transparent 60%);
|
||||
|
||||
background-blend-mode: screen;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
|
||||
@keyframes scroll-bg {
|
||||
to {
|
||||
transform: translateY(800px);
|
||||
}
|
||||
}
|
||||
|
||||
&::view-transition-old(.game-card),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import "./scripts/gamepads";
|
|||
import "./scripts/windowEvents";
|
||||
import { client as rommClient } from "../clients/romm/client.gen";
|
||||
import "./scripts/spatialNavigation";
|
||||
import NotFound from "./components/NotFound";
|
||||
import Error from "./components/Error";
|
||||
|
||||
const hashHistory = createHashHistory({});
|
||||
|
||||
|
|
@ -38,15 +40,9 @@ export const Router = createRouter({
|
|||
defaultPreload: "intent",
|
||||
context: { queryClient },
|
||||
scrollRestoration: false,
|
||||
defaultNotFoundComponent: () =>
|
||||
{
|
||||
return (
|
||||
<div>
|
||||
<p> {window.location.href} Not found!</p>
|
||||
<Link to="/">Go home</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
defaultNotFoundComponent: NotFound,
|
||||
defaultPendingMs: 300,
|
||||
defaultErrorComponent: Error
|
||||
});
|
||||
|
||||
// Register things for typesafety
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { RouterContext } from "..";
|
||||
import Notifications from "../components/Notifications";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { mobileCheck, useLocalSetting } from "../scripts/utils";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
|
|
@ -14,18 +13,19 @@ function RootComponent ()
|
|||
{
|
||||
const isMobile = mobileCheck();
|
||||
const theme = useLocalSetting('theme');
|
||||
const { control } = useActiveControl();
|
||||
|
||||
return (
|
||||
<div data-theme={theme === 'auto' ? undefined : theme} className="w-screen h-screen overflow-hidden">
|
||||
<div data-theme={theme === 'auto' ? undefined : theme} data-device={isMobile ? 'mobile' : ''} data-active-control={control} className="w-screen h-screen overflow-hidden">
|
||||
<Outlet />
|
||||
<Notifications />
|
||||
<Toaster containerStyle={{ viewTimelineName: 'toasters' }} />
|
||||
{import.meta.env.DEV && !isMobile &&
|
||||
{/*import.meta.env.DEV && !isMobile &&
|
||||
<>
|
||||
<TanStackRouterDevtools position="top-left" />
|
||||
<ReactQueryDevtools buttonPosition="top-right" />
|
||||
</>
|
||||
}
|
||||
*/}
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
import { CollectionsDetail } from '../components/CollectionsDetail';
|
||||
import { getCollectionApiCollectionsIdGetOptions, getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen';
|
||||
import { DefaultRommStaleTime } from '../../shared/constants';
|
||||
import { getCollectionApiCollectionsIdGetOptions, getRomsApiRomsGetOptions } from '@clients/romm/@tanstack/react-query.gen';
|
||||
import { DefaultRommStaleTime } from '@shared/constants';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useContext } from 'react';
|
||||
import { AnimatedBackgroundContext } from '../scripts/contexts';
|
||||
|
||||
export const Route = createFileRoute('/collection/$id')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -17,12 +18,9 @@ function RouteComponent ()
|
|||
{
|
||||
const { id } = Route.useParams();
|
||||
const { data: collection } = useQuery({ ...getCollectionApiCollectionsIdGetOptions({ path: { id: Number(id) } }) });
|
||||
const [, setBackground] = useSessionStorage<string | undefined>(
|
||||
"home-background",
|
||||
undefined,
|
||||
);
|
||||
const animatedBgContext = useContext(AnimatedBackgroundContext);
|
||||
|
||||
return (
|
||||
<CollectionsDetail setBackground={setBackground} title={<div className="divider font-semibold text-2xl">{collection?.name}</div>} filters={{ collection_id: Number(id) }} />
|
||||
<CollectionsDetail setBackground={animatedBgContext.setBackground} title={<div className="divider font-semibold text-2xl">{collection?.name}</div>} filters={{ collection_id: Number(id) }} />
|
||||
);
|
||||
}
|
||||
|
|
|
|||
185
src/mainview/routes/embedded.$source.$id.tsx
Normal file
185
src/mainview/routes/embedded.$source.$id.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { EMULATORJS_URL, RPC_URL, SERVER_URL } from '@/shared/constants';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { gameQuery } from '../scripts/queries';
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import z from 'zod';
|
||||
import { RefObject, useEffect, useRef, useState } from 'react';
|
||||
import { Router } from '..';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { Button, ButtonStyle } from '../components/options/Button';
|
||||
import { DoorOpen, Home, RefreshCw, Undo } from 'lucide-react';
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||
import Shortcuts from '../components/Shortcuts';
|
||||
import { useEventListener, useTimeout } from 'usehooks-ts';
|
||||
import { GetFocusedElement, useGlobalFocus } from '../scripts/spatialNavigation';
|
||||
import useActiveControl from '../scripts/gamepads';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { HeaderAccounts, HeaderStatusBar } from '../components/Header';
|
||||
import { RoundButton } from '../components/RoundButton';
|
||||
|
||||
export const Route = createFileRoute('/embedded/$source/$id')({
|
||||
component: RouteComponent,
|
||||
loader: async (ctx) =>
|
||||
{
|
||||
const data = await ctx.context.queryClient.fetchQuery(gameQuery(ctx.params.source, ctx.params.id));
|
||||
return { data };
|
||||
},
|
||||
validateSearch: zodValidator(z.record(z.string(), z.string().optional().nullable()))
|
||||
});
|
||||
|
||||
function OverlayButton (data: {
|
||||
id: string,
|
||||
style: ButtonStyle,
|
||||
tooltip: string, setTooltip: (tooltip: string) => void,
|
||||
className?: string;
|
||||
children?: any;
|
||||
} & InteractParams)
|
||||
{
|
||||
return <div className="tooltip tooltip-bottom" data-tip={data.tooltip}>
|
||||
<RoundButton external onFocus={() => data.setTooltip(data.tooltip)} style={data.style} className={twMerge("", data.className)} id={data.id} onAction={data.onAction} >
|
||||
{data.children}
|
||||
</RoundButton>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Overlay (data: {
|
||||
open: boolean;
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
close: () => void;
|
||||
goBack: () => void;
|
||||
})
|
||||
{
|
||||
const { ref, focusSelf, focusKey } = useFocusable({ focusable: data.open, focusKey: 'overlay', forceFocus: true, isFocusBoundary: true });
|
||||
const [tooltip, setTooltip] = useState<string | undefined>(undefined);
|
||||
|
||||
useShortcuts(focusKey, () => data.open ? [{ label: 'Return', button: GamePadButtonCode.B, action: data.close }] : [], [data.open, data.close]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (data.open)
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
}, [data.open]);
|
||||
|
||||
const { isPointer } = useActiveControl();
|
||||
const handleEvent = (type: string, value?: any) => data.iframeRef.current?.contentWindow?.postMessage({ type, data: value });
|
||||
|
||||
return <div data-open={data.open} className='flex group w-full flex-col gap-2 transition-opacity p-4 not-data-[open=true]:pointer-events-none not-data-[open=true]:opacity-0'>
|
||||
<div className='grid grid-cols-3 justify-between items-start'>
|
||||
<div className='flex justify-start'>
|
||||
<HeaderAccounts />
|
||||
</div>
|
||||
<div className='flex justify-center'>
|
||||
<ul ref={ref} className='flex rounded-4xl bg-base-100 justify-end gap-2 p-4 group-data-[open=true]:animate-scale'>
|
||||
<FocusContext value={focusKey}>
|
||||
<OverlayButton id="return" style='primary' tooltip='Return' setTooltip={setTooltip} onAction={data.close} ><Undo /></OverlayButton>
|
||||
<OverlayButton id="restart" style='secondary' tooltip='Restart' setTooltip={setTooltip} onAction={() =>
|
||||
{
|
||||
data.close();
|
||||
handleEvent('restart');
|
||||
}} ><RefreshCw /></OverlayButton>
|
||||
<OverlayButton id="exit" style='warning' tooltip='Exit' setTooltip={setTooltip} onAction={data.goBack} ><DoorOpen /></OverlayButton>
|
||||
</FocusContext>
|
||||
</ul>
|
||||
</div>
|
||||
<div className='flex justify-end'>
|
||||
<HeaderStatusBar />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-center'>
|
||||
{!!tooltip && data.open && !isPointer && <div className='bg-accent text-accent-content rounded-full font-semibold py-1 px-4'>{tooltip}</div>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Frame (data: { ref: RefObject<HTMLIFrameElement | null>; })
|
||||
{
|
||||
const { ref } = useFocusable({ focusKey: 'frame' });
|
||||
const { data: game } = Route.useLoaderData();
|
||||
|
||||
const search = Route.useSearch();
|
||||
search['gameName'] = game.name;
|
||||
search['backgroundImage'] = `${RPC_URL(__HOST__)}${game.path_cover}`;
|
||||
search['backgroundBlur'] = "true";
|
||||
|
||||
if (!__PUBLIC__)
|
||||
{
|
||||
search['threads'] = "true";
|
||||
}
|
||||
|
||||
const params = Object.entries(search)
|
||||
.filter(kvp => kvp[1] !== null && kvp[1] !== undefined)
|
||||
.map(kvp => `${kvp[0]}=${encodeURIComponent(kvp[1]!)}`).join('&');
|
||||
|
||||
return <iframe ref={r =>
|
||||
{
|
||||
ref.current = r;
|
||||
data.ref.current = r;
|
||||
}}
|
||||
allow='fullscreen; cross-origin-isolated'
|
||||
className='absolute w-full h-full transition-[padding]' src={
|
||||
__PUBLIC__ ? `${SERVER_URL(__HOST__)}/emulatorjs/?${params}` : `${EMULATORJS_URL(__HOST__)}/?${params}`
|
||||
}></iframe>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { ref, focusSelf, focusKey } = useFocusable({
|
||||
focusKey: 'emulatorjs',
|
||||
preferredChildFocusKey: 'frame',
|
||||
forceFocus: true
|
||||
});
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [overlayOpen, setOverlayOpen] = useState(false);
|
||||
const { source, id } = Route.useParams();
|
||||
|
||||
function HandleGoBack ()
|
||||
{
|
||||
Router.navigate({ to: '/game/$source/$id', viewTransition: { types: ['zoom-out'] }, params: { source, id } });
|
||||
}
|
||||
|
||||
useEventListener('message', e =>
|
||||
{
|
||||
if (e.data.type === 'exit')
|
||||
{
|
||||
HandleGoBack();
|
||||
}
|
||||
});
|
||||
|
||||
useShortcuts(focusKey, () => [{
|
||||
button: GamePadButtonCode.Steam, action: () =>
|
||||
{
|
||||
setOverlayOpen(!overlayOpen);
|
||||
}
|
||||
}], [overlayOpen, setOverlayOpen]);
|
||||
|
||||
const setPaused = (paused: boolean) =>
|
||||
{
|
||||
if (paused) iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: true });
|
||||
else
|
||||
{
|
||||
// we want to prevent input from closing the overlay spilling
|
||||
setTimeout(() => iframeRef.current?.contentWindow?.postMessage({ type: 'pause', data: false }), 100);
|
||||
}
|
||||
};
|
||||
useEffect(() => setPaused(overlayOpen), [overlayOpen]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
useEffect(() => { if (!overlayOpen) focusSelf(); }, [overlayOpen]);
|
||||
function handleClose ()
|
||||
{
|
||||
setOverlayOpen(false);
|
||||
}
|
||||
|
||||
return <div ref={ref} className='absolute w-full h-full'>
|
||||
<FocusContext value={focusKey}>
|
||||
<Frame ref={iframeRef} />
|
||||
<div className='flex fixed left-0 right-0 top-0'>
|
||||
<Overlay iframeRef={iframeRef} goBack={HandleGoBack} open={overlayOpen} close={handleClose} />
|
||||
</div>
|
||||
<div className='flex justify-end fixed bottom-4 right-4 left-4 z-10'>
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants";
|
||||
import { createFileRoute, ErrorComponentProps } from "@tanstack/react-router";
|
||||
import { CommandEntry, FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants";
|
||||
import { twJoin, twMerge } from "tailwind-merge";
|
||||
import { JSX, RefObject, useEffect, useRef, useState } from "react";
|
||||
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
|
|
@ -17,32 +17,124 @@ import { ContextDialog, ContextList, DialogEntry } from "../../components/Contex
|
|||
import Shortcuts from "../../components/Shortcuts";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { gameQuery } from "@/mainview/scripts/queries";
|
||||
import Screenshots from "@/mainview/components/Screenshots";
|
||||
import { delay, useSticky, useStickyDataAttr } from "@/mainview/scripts/utils";
|
||||
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||
|
||||
export const Route = createFileRoute("/game/$source/$id")({
|
||||
loader: ({ params, context }) =>
|
||||
loader: async ({ params, context }) =>
|
||||
{
|
||||
context.queryClient.prefetchQuery(gameQuery(params.source, Number(params.id)));
|
||||
const data = await context.queryClient.fetchQuery(gameQuery(params.source, params.id));
|
||||
return { data };
|
||||
},
|
||||
component: GameDetailsUI,
|
||||
pendingComponent: GameDetailsUIPending,
|
||||
errorComponent: Error
|
||||
});
|
||||
|
||||
function Error (data: ErrorComponentProps)
|
||||
{
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
useEffect(() =>
|
||||
{
|
||||
focusSelf();
|
||||
}, []);
|
||||
|
||||
return <AnimatedBackground ref={ref} backgroundKey="game-details">
|
||||
<div className="relative z-10 h-full">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="h-0" />
|
||||
<div className="fixed group top-0 left-0 right-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
|
||||
<HeaderUI />
|
||||
</div>
|
||||
<div className="absolute w-full flex flex-col justify-center items-center h-full overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
|
||||
<div className="flex gap-2 items-center text-4xl text-error"><TriangleAlert className="size-12" /> {data.error.message}</div>
|
||||
</div>
|
||||
<div className="bg-base-200">
|
||||
|
||||
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</footer>
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>
|
||||
</AnimatedBackground>;
|
||||
}
|
||||
|
||||
function GameDetailsUIPending ()
|
||||
{
|
||||
return <AnimatedBackground>
|
||||
<div className="flex flex-col p-2 px-3 w-full h-full">
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details-error", preferredChildFocusKey: "main-details" });
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
useEffect(() =>
|
||||
{
|
||||
focusSelf();
|
||||
}, []);
|
||||
|
||||
return <AnimatedBackground ref={ref} backgroundKey="game-details">
|
||||
<div className="z-10">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="h-0" />
|
||||
<div className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
|
||||
<HeaderUI />
|
||||
<div className="flex flex-col justify-center items-center grow">
|
||||
<span className="loading loading-dots loading-xl"></span>
|
||||
</div>
|
||||
<div className="flex flex-col h-[80vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
|
||||
<main ref={ref} className="flex p-3 flex-col flex-1 min-h-0">
|
||||
<section className="flex portrait:flex-col my-4 sm:p-0 md:px-12 md:pb-8 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
|
||||
<div className="flex gap-6 overflow-hidden bg-base-100 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24 p-4">
|
||||
<div className="skeleton w-full h-full"></div>
|
||||
</div>
|
||||
<div className="flex-2 flex flex-col sm:gap-1 md:gap-6 sm:pt-2 md:pt-16 min-h-0">
|
||||
<div className="flex flex-wrap sm:gap-4 md:gap-6 shrink-0">
|
||||
<Detail icon={<Clock />} ></Detail>
|
||||
<Detail icon={<div className="skeleton size-6" />} ><div className="skeleton h-4 w-32"></div></Detail>
|
||||
<Detail icon={
|
||||
<Store />
|
||||
} >
|
||||
|
||||
</Detail>
|
||||
</div>
|
||||
<div className="md:hidden divider divider-vertical m-0"></div>
|
||||
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden text-lg">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="skeleton h-4 w-[30%]"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
<div className="skeleton h-4 w-[60%]"></div>
|
||||
<div className="skeleton h-4 w-full"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<div className="bg-base-200">
|
||||
<div className="divider m-0 pb-12"><div className="flex items-center gap-3 opacity-60"><Image className="sm:size-4 md:size-6" />Screenshots</div></div>
|
||||
<div className="flex flex-col w-full z-0 min-h-0">
|
||||
<div
|
||||
className="flex gap-6 px-16 py-2 sm:overflow-scroll md:overflow-hidden no-scrollbar justify-center-safe"
|
||||
>
|
||||
{Array.from({ length: 5 }).map((s, i) => <div key={i} className="skeleton h-64 w-lg"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</footer>
|
||||
</div>
|
||||
</FocusContext>
|
||||
</div>
|
||||
</AnimatedBackground>;
|
||||
}
|
||||
|
||||
function HandleGoBack ()
|
||||
{
|
||||
const source = PopSource('details');
|
||||
Router.navigate({ to: source ?? '/', viewTransition: { types: ['zoom-out'] } });
|
||||
const { to, search } = PopSource('details');
|
||||
Router.navigate({ to: to ?? '/', viewTransition: { types: ['zoom-out'] }, search });
|
||||
}
|
||||
|
||||
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?: FrontEndGameTypeDetailed; })
|
||||
|
|
@ -50,7 +142,7 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
|
|||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'main-details', onFocus: () =>
|
||||
{
|
||||
data.mainAreaRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
data.mainAreaRef.current?.scrollIntoView({ block: 'end', behavior: 'smooth' });
|
||||
},
|
||||
preferredChildFocusKey: "play-btn",
|
||||
saveLastFocusedChild: false
|
||||
|
|
@ -77,10 +169,10 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
|
|||
|
||||
return <main ref={ref} className="flex p-3 flex-col flex-1 min-h-0">
|
||||
<FocusContext value={focusKey}>
|
||||
<section className="flex portrait:flex-col my-4 sm:p-0 md:p-12 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
|
||||
<div className="flex gap-6 overflow-hidden bg-base-300 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24">
|
||||
<section className="flex portrait:flex-col my-4 sm:p-0 md:px-12 md:pb-8 pt-4 sm:gap-8 md:gap-12 portrait:w-full h-full min-h-0 rounded-4xl flex-1 z-0 sm:text-sm md:text-base">
|
||||
<div className="flex gap-6 overflow-hidden bg-base-100 justify-end portrait:w-full rounded-3xl aspect-3/4 portrait:h-24 p-4">
|
||||
{gameCoverImg ?
|
||||
<img className="drop-shadow-2xl drop-shadow-base-300/40 w-full object-cover" src={gameCoverImg}></img> :
|
||||
<img className="drop-shadow-2xl drop-shadow-base-300/40 w-full object-cover rounded-2xl" src={gameCoverImg}></img> :
|
||||
<div className="skeleton w-full h-full"></div>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -101,7 +193,7 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
|
|||
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
|
||||
</div>
|
||||
<div className="md:hidden divider divider-vertical m-0"></div>
|
||||
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden ">
|
||||
<div className="text-base-content/80 flex-1 min-h-0 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden text-lg">
|
||||
{data.game?.summary ?? <div className="flex flex-col gap-4 w-full">
|
||||
<div className="skeleton h-4 w-[30%]"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
|
|
@ -118,60 +210,6 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
|
|||
</main>;
|
||||
}
|
||||
|
||||
function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; })
|
||||
{
|
||||
const { ref, focused, focusSelf } = useFocusable({
|
||||
focusKey: `screenshot-${data.index}`,
|
||||
onFocus: (e, p, details) =>
|
||||
{
|
||||
data.setFocused?.(data.index);
|
||||
(ref.current as HTMLElement).scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
|
||||
|
||||
|
||||
}
|
||||
}); 4096;
|
||||
return <img className={twJoin("max-h-[60vh] rounded-3xl", classNames({
|
||||
"sm:ring-4 md:ring-7 ring-primary": focused,
|
||||
"cursor-pointer": !focused
|
||||
}))} onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} ref={ref} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" />;
|
||||
}
|
||||
|
||||
function Screenshots (data: { screenshots: string[]; })
|
||||
{
|
||||
const scrollRef = useRef(null);
|
||||
const [focusedScreenshot, setFocusedScreenshot] = useState(-1);
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: 'screenshot-list',
|
||||
onFocus: (e, p, details) =>
|
||||
{
|
||||
if (!(details.nativeEvent instanceof TouchEvent))
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
onBlur: () => setFocusedScreenshot(-1)
|
||||
});
|
||||
|
||||
return <div ref={ref} className="flex flex-col w-full z-0">
|
||||
<FocusContext value={focusKey}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-6 px-16 py-2 sm:overflow-scroll md:overflow-hidden no-scrollbar justify-center-safe"
|
||||
>
|
||||
{data.screenshots.map((s, i) => <Screenshot key={s} setFocused={setFocusedScreenshot} index={i} path={s} />)}
|
||||
</div>
|
||||
<div className="flex gap-2 py-6 justify-center items-center h-3">{data.screenshots.map((s, i) =>
|
||||
{
|
||||
const focused = i === focusedScreenshot;
|
||||
return <button key={i} onClick={(e) => setFocus(`screenshot-${i}`, { nativeEvent: e.nativeEvent })}
|
||||
className={twMerge("cursor-pointer rounded-full size-2 bg-base-content/40 transition-all", classNames({
|
||||
"size-3 bg-base-content drop-shadow-lg drop-shadow-base-300/40": focused
|
||||
}))}></button>;
|
||||
})}</div>
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; })
|
||||
{
|
||||
if (!data.game.achievements)
|
||||
|
|
@ -221,6 +259,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
const [status, setStatus] = useState<GameStatusType | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [details, setDetails] = useState<string | undefined>(undefined);
|
||||
const [commands, setCommands] = useState<CommandEntry[] | undefined>(undefined);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() =>
|
||||
|
|
@ -233,13 +272,14 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
setProgress(stats.progress);
|
||||
setStatus(stats.status);
|
||||
setDetails(stats.details);
|
||||
setCommands(stats.commands);
|
||||
setError(stats.error);
|
||||
};
|
||||
|
||||
es.addEventListener('refresh', () =>
|
||||
{
|
||||
queryClient.invalidateQueries({ queryKey: ['game', data.game.id] });
|
||||
location.reload();
|
||||
Router.navigate({ to: '/game/$source/$id', params: { id, source } });
|
||||
});
|
||||
|
||||
es.addEventListener('error', (e) =>
|
||||
|
|
@ -248,6 +288,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
{
|
||||
const stats = JSON.parse((e as any).data) as GameInstallProgress;
|
||||
toast.error(stats.error);
|
||||
setError(stats.error);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -257,6 +298,7 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
if (error)
|
||||
{
|
||||
toast.error(error);
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -278,10 +320,19 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
if (status === 'installed')
|
||||
{
|
||||
mainButton = <ActionButton onAction={() =>
|
||||
{
|
||||
const firstValid = commands?.find(c => c.valid);
|
||||
if (firstValid?.emulator === 'emulatorjs')
|
||||
{
|
||||
const params = new URLSearchParams(firstValid.command);
|
||||
Router.navigate({ to: '/embedded/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id }, search: Object.fromEntries(params.entries()) });
|
||||
} else
|
||||
{
|
||||
playMutation.mutate();
|
||||
SaveSource('launch');
|
||||
Router.navigate({ to: '/launcher/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id } });
|
||||
}
|
||||
|
||||
}} tooltip={details} key="primary" type='primary' id="mainAction"><Play /></ActionButton>;
|
||||
}
|
||||
else if (error)
|
||||
|
|
@ -383,6 +434,8 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
|
|||
|
||||
}, ref);
|
||||
|
||||
const { isPointer } = useActiveControl();
|
||||
|
||||
const tooltipStyles = {
|
||||
base: 'bg-base-100 text-base-content',
|
||||
accent: 'bg-accent text-accent-content',
|
||||
|
|
@ -403,7 +456,7 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
|
|||
}}>
|
||||
<ContextList options={contextOptions} />
|
||||
</ContextDialog>
|
||||
{!!hoverText && <p className={twMerge("flex sm:hidden md:inline py-1 md:py-2 md:px-4 rounded-4xl text-wrap wrap-anywhere text-base", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>}
|
||||
{!!hoverText && !isPointer && <p className={twMerge("flex sm:hidden md:inline py-1 md:py-2 md:px-4 rounded-4xl text-wrap wrap-anywhere text-base", (tooltipStyles as any)[hoverTextType])}>{hoverText}</p>}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -434,69 +487,62 @@ function ActionButton (data: {
|
|||
{
|
||||
const { ref, focused } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true });
|
||||
const styles = {
|
||||
primary: twMerge("bg-primary text-primary-content",
|
||||
classNames({
|
||||
"bg-base-content text-base-300 sm:ring-4 md:ring-7 ring-primary": focused
|
||||
})),
|
||||
base: twMerge(" text-base-content border-dashed border-base-content/20 border-2", classNames({
|
||||
"bg-base-content text-base-300 sm:ring-4 md:ring-7 ring-primary": focused
|
||||
})),
|
||||
accent: twMerge("bg-primary text-primary-content ", classNames({
|
||||
"bg-base-content text-base-300 sm:ring-4 md:ring-7 ring-primary": focused
|
||||
})),
|
||||
error: twMerge("bg-error text-error-content ", classNames({
|
||||
"bg-error text-error-content sm:ring-4 md:ring-7 ring-primary": focused
|
||||
})),
|
||||
primary: "bg-primary text-primary-content focused:bg-base-content focused:text-base-300 focusable focusable-primary",
|
||||
base: " text-base-content border-dashed border-base-content/20 border-2 focused:bg-base-content focused:text-base-300 focusable focusable-primary",
|
||||
accent: "bg-primary text-primary-content focusable focusable-primary focusable:bg-base-content focusable:text-base-300",
|
||||
error: "bg-error text-error-content focused:bg-error focused:text-error-content",
|
||||
};
|
||||
return (
|
||||
<div className="tooltip tooltip-accent tooltip-right" data-tip={data.tooltip}>
|
||||
<button
|
||||
disabled={data.disabled}
|
||||
ref={ref}
|
||||
onClick={data.onAction}
|
||||
data-tooltip={data.tooltip}
|
||||
data-tooltip_type={data.tooltip_type}
|
||||
className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30",
|
||||
className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content",
|
||||
"hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}>
|
||||
{data.icon}
|
||||
{data.children}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GameDetailsUI ()
|
||||
{
|
||||
const { source, id } = Route.useParams();
|
||||
const { data, isSuccess } = useQuery(gameQuery(source, Number(id)));
|
||||
const { data } = Route.useLoaderData();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
|
||||
const backgroundImage = data?.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined;
|
||||
const headerRef = useRef(null);
|
||||
const sentinelRef = useRef(null);
|
||||
const backgroundImage = data.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined;
|
||||
const mainAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (isSuccess)
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
}, []);
|
||||
|
||||
}, [isSuccess]);
|
||||
useStickyDataAttr(headerRef, sentinelRef, ref);
|
||||
|
||||
return (
|
||||
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage} scrolling>
|
||||
<div className="z-0">
|
||||
<div className="z-10">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="flex flex-col px-3 py-2 h-[90vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
|
||||
<div ref={sentinelRef} className="h-0" />
|
||||
<div ref={headerRef} className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
|
||||
<HeaderUI />
|
||||
</div>
|
||||
<div className="flex flex-col h-[80vh] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40" ref={mainAreaRef}>
|
||||
<Details mainAreaRef={mainAreaRef} game={data} />
|
||||
</div>
|
||||
<div className="bg-base-200">
|
||||
<div className="divider m-0 pb-12"><div className="flex items-center gap-3 opacity-60"><Image className="sm:size-4 md:size-6" />Screenshots</div></div>
|
||||
{!!data && <Screenshots screenshots={data.paths_screenshots} />}
|
||||
<footer className="absolute left-0 bottom-0 w-full p-2 flex items-center justify-between z-10">
|
||||
<div className="flex gap-2 text-sm">
|
||||
</div>
|
||||
{!!data && <Screenshots screenshots={data.paths_screenshots} onFocus={(_, node) => node.scrollIntoView({ behavior: 'smooth', block: 'center' })} />}
|
||||
<footer className="fixed left-0 right-0 bottom-0 w-full p-4 flex items-center justify-end z-10">
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import
|
|||
Gamepad2,
|
||||
Settings,
|
||||
MessageSquare,
|
||||
ShoppingBag,
|
||||
Image,
|
||||
Search,
|
||||
Power,
|
||||
OctagonAlert,
|
||||
Maximize,
|
||||
Store,
|
||||
} from "lucide-react";
|
||||
import
|
||||
{
|
||||
|
|
@ -21,13 +21,14 @@ import
|
|||
{
|
||||
FocusContext,
|
||||
FocusDetails,
|
||||
getCurrentFocusKey,
|
||||
useFocusable,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import { HeaderAccounts, HeaderStatusBar, HeaderUI } from "../components/Header";
|
||||
import { HeaderAccounts, HeaderStatusBar } from "../components/Header";
|
||||
import { FilterUI } from "../components/Filters";
|
||||
import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground";
|
||||
import { AnimatedBackground } from "../components/AnimatedBackground";
|
||||
import { GameList } from "../components/GameList";
|
||||
import { SaveSource } from "../scripts/spatialNavigation";
|
||||
import LoadingCardList from "../components/LoadingCardList";
|
||||
|
|
@ -43,7 +44,9 @@ import z from "zod";
|
|||
import { Router } from "..";
|
||||
import CollectionList from "../components/CollectionList";
|
||||
import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import { mobileCheck } from "../scripts/utils";
|
||||
import { mobileCheck, useDragScroll } from "../scripts/utils";
|
||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||
import { FrontEndId } from "@/shared/constants";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: ConsoleHomeUI,
|
||||
|
|
@ -93,6 +96,7 @@ function HomeList (data: {
|
|||
{
|
||||
const [initFocus, setInitFocus] = useState(false);
|
||||
const bg = useContext(AnimatedBackgroundContext);
|
||||
const { } = Route.useSearch;
|
||||
const { ref, focused, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "home-list",
|
||||
preferredChildFocusKey: `${data.selectedFilter}-list`
|
||||
|
|
@ -103,18 +107,54 @@ function HomeList (data: {
|
|||
const isMounseEvent = details.nativeEvent instanceof MouseEvent;
|
||||
if (!isMounseEvent)
|
||||
{
|
||||
node?.scrollIntoView({ inline: 'center', behavior: initFocus ? 'smooth' : 'instant' });
|
||||
node?.scrollIntoView({ inline: 'center', block: 'center', behavior: initFocus ? 'smooth' : 'instant' });
|
||||
}
|
||||
|
||||
setInitFocus(true);
|
||||
};
|
||||
|
||||
const lists: Record<string, JSX.Element> = {
|
||||
consoles: <PlatformsList onFocus={handleNodeFocus} className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />,
|
||||
games: <GameList onFocus={handleNodeFocus} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />,
|
||||
collections: <CollectionList onFocus={handleNodeFocus} className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />,
|
||||
function handleGameSelect (id: FrontEndId, source: string | null, sourceId: string | null)
|
||||
{
|
||||
SaveSource('details', { search: { filter: data.selectedFilter } });
|
||||
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source }, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
const handleCollectionSelect = (id: string) =>
|
||||
{
|
||||
SaveSource('game-list', { search: { filter: data.selectedFilter } });
|
||||
Router.navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
const handlePlatformSelect = (source: string, id: string) =>
|
||||
{
|
||||
SaveSource('game-list', { search: { filter: data.selectedFilter } });
|
||||
Router.navigate({ to: `/platform/${source}/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
};
|
||||
|
||||
let activeList: JSX.Element;
|
||||
switch (data.selectedFilter)
|
||||
{
|
||||
case 'consoles':
|
||||
activeList = <>
|
||||
<PlatformsList onSelect={handlePlatformSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />
|
||||
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
|
||||
</>;
|
||||
break;
|
||||
case 'collections':
|
||||
activeList = <>
|
||||
<CollectionList onSelect={handleCollectionSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />
|
||||
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
|
||||
</>;
|
||||
break;
|
||||
default:
|
||||
activeList = <>
|
||||
<GameList onGameSelect={handleGameSelect} onFocus={handleNodeFocus} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />
|
||||
<AutoFocus parentKey={focusKey} focus={focusSelf} delay={10} />
|
||||
</>;
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
useEventListener('wheel', e =>
|
||||
{
|
||||
const deltaY = e.deltaY;
|
||||
|
|
@ -138,17 +178,18 @@ function HomeList (data: {
|
|||
}
|
||||
});
|
||||
|
||||
useDragScroll(ref);
|
||||
|
||||
return (
|
||||
<FocusContext value={focusKey}>
|
||||
<div ref={ref} className="flex h-full w-full landscape:overflow-x-scroll portrait:overflow-y-scroll overflow-hidden no-scrollbar justify-center-safe sm:pt-2 md:py-6 md:pb-3 md:mb-1" style={{
|
||||
<div ref={ref} className="flex h-full w-full landscape:overflow-x-scroll portrait:overflow-y-scroll overflow-hidden no-scrollbar justify-center-safe sm:py-2 md:py-6 md:pb-6 md:mb-1 not-mobile:sm:pb-4" style={{
|
||||
mask: `linear-gradient(to right, rgba(0,0,0,0.8) 0%, black 10%, black 90%, rgba(0,0,0,0.8) 100%)`
|
||||
}}>
|
||||
<div className="landscape:px-16 portrait:min-h-fit portrait:h-fit portrait:pb-32 portrait:w-full landscape:h-full">
|
||||
<div className="landscape:flex landscape:px-16 portrait:min-h-fit portrait:h-fit portrait:pb-32 portrait:w-full landscape:h-full landscape:items-center">
|
||||
<ErrorBoundary fallback={<HomeListError focused={focused} />}>
|
||||
<Suspense key={data.selectedFilter} fallback={<LoadingCardList placeholderCount={8} />}>
|
||||
{lists[data.selectedFilter]}
|
||||
{activeList}
|
||||
<SaveScroll id={`card-list-${data.selectedFilter}`} ref={ref} />
|
||||
<AutoFocus focus={focusSelf} delay={10} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
|
@ -179,7 +220,7 @@ function MainMenu (data: {})
|
|||
type="secondary"
|
||||
/>
|
||||
<CircleIcon icon={<MessageSquare />} label="News" />
|
||||
<CircleIcon icon={<ShoppingBag />} label="Shop" />
|
||||
<CircleIcon type="info" icon={<Store />} action={() => navigate({ to: "/store/tab", viewTransition: { types: ['zoom-in'] } })} label="Shop" />
|
||||
<CircleIcon icon={<Image />} label="Album" />
|
||||
<CircleIcon
|
||||
icon={<Gamepad2 />}
|
||||
|
|
@ -202,7 +243,7 @@ function MainMenu (data: {})
|
|||
|
||||
function CircleIcon (data: {
|
||||
action?: () => void;
|
||||
type?: "secondary" | "accent";
|
||||
type?: "secondary" | "accent" | "info";
|
||||
label?: string;
|
||||
icon?: JSX.Element;
|
||||
})
|
||||
|
|
@ -215,6 +256,7 @@ function CircleIcon (data: {
|
|||
const typeClasses = {
|
||||
secondary: "bg-secondary text-secondary-content",
|
||||
accent: "bg-accent text-accent-content",
|
||||
info: "bg-info text-info-content",
|
||||
none: "bg-base-content",
|
||||
};
|
||||
return (
|
||||
|
|
@ -222,15 +264,9 @@ function CircleIcon (data: {
|
|||
ref={ref}
|
||||
onClick={data.action}
|
||||
className={twMerge(
|
||||
`portrait:sm:size-12 sm:w-14 sm:h-10 menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all`,
|
||||
typeClasses[data.type ?? "none"], classNames(
|
||||
{
|
||||
"focus ring-7 ring-primary drop-shadow-2xl animate-scale": focused,
|
||||
"hover:ring-7 hover:ring-primary": true,
|
||||
})
|
||||
)}
|
||||
`portrait:sm:size-12 sm:w-14 sm:h-10 menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all focusable focusable-primary focused:drop-shadow-2xl focused:animate-scale focusable-hover bg-base-content border-6 md:border-12 border-base-content focused:border-0 hover:border-0 z-1 active:border-0 active:bg-base-300 active:text-base-content active:transition-none`, typeClasses[data.type ?? 'none'])}
|
||||
>
|
||||
{data.icon}
|
||||
<div className="in-focused:animate-rotate-instant animation-size-5">{data.icon}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -291,11 +327,11 @@ export default function ConsoleHomeUI ()
|
|||
<div className="sm:landscape:hidden md:landscape:inline sm:portrait:col-start-1 md:inline flex col-span-1 md:pl-2 md:pt-2">
|
||||
<HeaderAccounts />
|
||||
</div>
|
||||
<div className="sm:portrait:*:justify-center sm:portrait:col-span-3 sm:landscape:*:justify-start sm:px-2 sm:pt-2 md:row-start-2 md:col-start-1 sm:landscape:col-span-1 md:landscape:col-span-3 flex items-center md:*:justify-center! md:ml-0 gap-2 *:w-full *:flex">
|
||||
<div className=" sm:portrait:col-span-3 sm:px-2 sm:pt-2 md:row-start-2 md:col-start-1 sm:landscape:col-span-1 md:landscape:col-span-3 flex items-center md:ml-0 gap-2">
|
||||
<FilterUI
|
||||
id="home"
|
||||
options={filters}
|
||||
selected={filter ? filter : 'games'}
|
||||
containerClassName="flex w-full sm:landscape:justify-start sm:portrait:justify-center md:justify-center!"
|
||||
options={Object.fromEntries(Object.entries(filters).map(([key, value]) => [key, { ...value, selected: key === filter }]))}
|
||||
setSelected={setFilter}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -311,7 +347,7 @@ export default function ConsoleHomeUI ()
|
|||
<MainMenu />
|
||||
</div>
|
||||
<footer className={twMerge(
|
||||
"sm:portrait:hidden sm:col-span-1 md:col-start-2 md:col-span-2 md:relative px-2 pb-2 flex items-end justify-end",
|
||||
"fixed bottom-4 left-4 right-4 sm:portrait:hidden sm:col-span-1 md:col-start-2 md:col-span-2 flex items-end justify-end",
|
||||
)}>
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ import { AnimatedBackground } from '@/mainview/components/AnimatedBackground';
|
|||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { GameInstallProgress, RPC_URL } from '@/shared/constants';
|
||||
import DotsLoading from '../components/backgrounds/dots';
|
||||
import { useEventListener } from 'usehooks-ts';
|
||||
import { Router } from '..';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { rommApi } from '../scripts/clientApi';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEventListener, useSessionStorage } from "usehooks-ts";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { CollectionsDetail } from "../components/CollectionsDetail";
|
||||
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||
import { Suspense } from "react";
|
||||
import { useContext } from "react";
|
||||
import { rommApi } from "../scripts/clientApi";
|
||||
import { AnimatedBackgroundContext } from "../scripts/contexts";
|
||||
|
||||
export const Route = createFileRoute("/platform/$source/$id")({
|
||||
component: RouteComponent
|
||||
});
|
||||
|
||||
function PlatformTitle (data: { platformSlug?: string, platformName?: string; })
|
||||
function PlatformTitle (data: { pathCover: string | null, platformName?: string; })
|
||||
{
|
||||
return <div className="sm:landscape:hidden flex flex-col gap-2 pl-2 text-2xl font-semibold text-base-content justify-center drop-shadow">
|
||||
|
||||
<div className="divider mb-6 mt-0">
|
||||
{!!data.platformSlug && <img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}/api/romm/assets/platforms/${data.platformSlug.toLocaleLowerCase()}.svg`} ></img>}
|
||||
{!!data.pathCover && <img className="size-14 rounded-full p-2" src={`${RPC_URL(__HOST__)}${data.pathCover}`} ></img>}
|
||||
{data.platformName}
|
||||
</div>
|
||||
</div>;
|
||||
|
|
@ -33,16 +33,13 @@ function RouteComponent ()
|
|||
}, staleTime: DefaultRommStaleTime
|
||||
});
|
||||
|
||||
const [, setBackground] = useSessionStorage<string | undefined>(
|
||||
"home-background",
|
||||
undefined,
|
||||
);
|
||||
const animatedBgContext = useContext(AnimatedBackgroundContext);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
{!!platform && <CollectionsDetail
|
||||
title={<PlatformTitle platformSlug={platform.slug} platformName={platform.name} />}
|
||||
setBackground={setBackground}
|
||||
title={<PlatformTitle pathCover={platform.path_cover} platformName={platform.name} />}
|
||||
setBackground={animatedBgContext.setBackground}
|
||||
filters={{ platform_id: Number(id), platform_slug: platform.slug, platform_source: source }}
|
||||
/>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { systemApi } from '@/mainview/scripts/clientApi';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
|
||||
export const Route = createFileRoute('/settings/about')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -9,8 +10,7 @@ export const Route = createFileRoute('/settings/about')({
|
|||
function RouteComponent ()
|
||||
{
|
||||
const { data: systemInfo } = useQuery({ queryKey: ['system-info'], queryFn: () => systemApi.api.system.info.get() });
|
||||
return <div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
return <table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
|
|
@ -50,6 +50,10 @@ function RouteComponent ()
|
|||
<th>Machine</th>
|
||||
<td>{systemInfo?.data?.machine}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Sizes</th>
|
||||
<td>Cache: {prettyBytes(systemInfo?.data?.cacheSize ?? 0)}, Store: {prettyBytes(systemInfo?.data?.storeSize ?? 0)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<td>{systemInfo?.data?.source}</td>
|
||||
|
|
@ -59,6 +63,5 @@ function RouteComponent ()
|
|||
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>;
|
||||
</table>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,17 +7,18 @@ import
|
|||
import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import classNames from "classnames";
|
||||
import { Key, Link, Lock, Save, ScanQrCode, Trash, User, X } from "lucide-react";
|
||||
import { Key, Link, Lock, LogOut, Save, ScanQrCode, Trash, User, X } from "lucide-react";
|
||||
import
|
||||
{
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { RPC_URL } from "../../../shared/constants";
|
||||
import { RPC_URL } from "@shared/constants";
|
||||
import
|
||||
{
|
||||
getCurrentUserApiUsersMeGetOptions,
|
||||
statsApiStatsGetOptions,
|
||||
} from "../../../clients/romm/@tanstack/react-query.gen";
|
||||
} from "@clients/romm/@tanstack/react-query.gen";
|
||||
import toast from "react-hot-toast";
|
||||
import z from "zod";
|
||||
import { OptionSpace } from "../../components/options/OptionSpace";
|
||||
|
|
@ -26,20 +27,95 @@ import { rommApi, settingsApi } from "../../scripts/clientApi";
|
|||
import { Button } from "../../components/options/Button";
|
||||
import { ContextDialog } from "@/mainview/components/ContextDialog";
|
||||
import QRCode from "react-qr-code";
|
||||
import { useAsyncGenerator } from "@/mainview/scripts/utils";
|
||||
import { useJobStatus } from "@/mainview/scripts/utils";
|
||||
import { useInterval } from "usehooks-ts";
|
||||
import { TwitchIcon } from "@/mainview/scripts/brandIcons";
|
||||
|
||||
export const Route = createFileRoute("/settings/accounts")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: string; endsAt: Date; })
|
||||
function LoginQR (data: { id: string, isOpen: boolean, cancel: () => void, url: string; endsAt: Date; startedAt: Date; code?: string; })
|
||||
{
|
||||
const progressRef = useRef<HTMLProgressElement>(null);
|
||||
useInterval(() =>
|
||||
{
|
||||
if (progressRef.current)
|
||||
{
|
||||
const time = data.endsAt.getTime() - data.startedAt.getTime();
|
||||
progressRef.current.value = ((data.endsAt.getTime() - new Date().getTime()) / time) * 100;
|
||||
}
|
||||
|
||||
}, 1000);
|
||||
|
||||
return <ContextDialog id={data.id} open={data.isOpen} close={() => data.cancel()} className="flex flex-col justify-center items-center gap-2">
|
||||
<QRCode value={data.url} />
|
||||
<progress ref={progressRef} className="progress w-56" max="100"></progress>
|
||||
{!!data.code && <p> Code: {data.code} </p>}
|
||||
<Button id="qr-login-cancel" focusClassName="btn-warning" type="button" onAction={() => data.cancel()}><X /> Cancel</Button>
|
||||
</ContextDialog>;
|
||||
}
|
||||
|
||||
function TwitchLogin (data: {})
|
||||
{
|
||||
|
||||
const loginStatus = useQuery({
|
||||
queryKey: ['twitch', 'login', 'status'],
|
||||
retry (failureCount, error)
|
||||
{
|
||||
if (error.status === 404)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return failureCount < 3;
|
||||
},
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data, error, status } = await rommApi.api.romm.login.twitch.get();
|
||||
if (error) throw { ...error, status };
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationKey: ['twitch', 'login'],
|
||||
mutationFn: (openInBrowser: boolean) =>
|
||||
{
|
||||
return rommApi.api.romm.login.twitch.post({ openInBrowser });
|
||||
},
|
||||
onSuccess: () => loginStatus.refetch()
|
||||
});
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationKey: ['twitch', 'logout'],
|
||||
mutationFn: () =>
|
||||
{
|
||||
return rommApi.api.romm.logout.twitch.post();
|
||||
},
|
||||
onSuccess: () => loginStatus.refetch()
|
||||
});
|
||||
|
||||
const { data: loginData, wsRef } = useJobStatus('twitch-login-job', { onEnded: () => loginStatus.refetch() });
|
||||
|
||||
return <div className="flex flex-wrap gap-1 items-center justify-center-safe">
|
||||
{loginStatus.isSuccess ?
|
||||
<div className="badge badge-success badge-lg rounded-full gap-2"><b>{loginStatus.data.login}</b></div> :
|
||||
<div className={classNames("badge gap-2 tooltip", { "badge-error": loginStatus.error })} data-tip={loginStatus.error?.message}>
|
||||
{loginStatus.isError || loginStatus.isRefetchError ? <Lock className="size-4" /> : <span className="loading loading-spinner loading-sm"></span>}
|
||||
</div>
|
||||
}
|
||||
<Button id="twitch-login-btn-qr" disabled={loginMutation.isPending} onAction={() => loginMutation.mutate(false)} >
|
||||
<ScanQrCode />
|
||||
</Button>
|
||||
<Button id="twitch-login-btn" disabled={loginMutation.isPending} onAction={() => loginMutation.mutate(true)} >
|
||||
{TwitchIcon}
|
||||
Login
|
||||
</Button>
|
||||
{loginStatus.isSuccess && <Button id="twitch-logout-btn" onAction={() => logoutMutation.mutate()} ><LogOut /> Logout</Button>}
|
||||
{!!loginData && <LoginQR code={loginData.user_code} url={loginData.url} cancel={() => wsRef.current?.send({ type: 'cancel' })} id='twitch-login-qr' isOpen={true} endsAt={loginData.expires_at} startedAt={loginData.started_at} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function LoginControls (data: { hasPassword: boolean; })
|
||||
{
|
||||
const user = useQuery({
|
||||
|
|
@ -48,42 +124,30 @@ function LoginControls (data: { hasPassword: boolean; })
|
|||
refetchOnWindowFocus: false,
|
||||
retry: 0
|
||||
});
|
||||
const { data: qrLoginStatusGen, refetch } = useQuery({
|
||||
queryKey: ['login', 'qr'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.login.remote.status.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
const statusValue = useAsyncGenerator(qrLoginStatusGen, [qrLoginStatusGen]);
|
||||
const cancelQrMutation = useMutation({
|
||||
const loginMutation = useMutation({
|
||||
mutationKey: ['login', 'qr', 'cancel'],
|
||||
mutationFn: () => rommApi.api.romm.login.remote.cancel.post(),
|
||||
onSuccess: () => refetch()
|
||||
});
|
||||
const requestQrLoginMutation = useMutation({
|
||||
mutationKey: ['login', 'qr'],
|
||||
mutationFn: () => rommApi.api.romm.login.remote.start.post(),
|
||||
onSuccess: () => refetch()
|
||||
mutationFn: () => rommApi.api.romm.login.romm.post()
|
||||
});
|
||||
const { data: statusValue, error: loginError, wsRef } = useJobStatus('login-job');
|
||||
const context = useSettingsFormContext({});
|
||||
const isMutatingRomm = useIsMutating({ mutationKey: ["romm", "auth"] }) > 0;
|
||||
const logoutMutation = useMutation({
|
||||
mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.post(),
|
||||
onSuccess: async (d, v, r, c) =>
|
||||
{
|
||||
user.refetch();
|
||||
c.client.invalidateQueries({ queryKey: ["romm", "auth"] });
|
||||
}
|
||||
});
|
||||
return <div className="flex gap-2 items-center flex-wrap">
|
||||
{user.isError && <div className="badge badge-error gap-2 tooltip" data-tip={(user.error as any)?.detail ?? ''}>
|
||||
<Lock className="size-4" /></div>}
|
||||
{user.isSuccess && <>
|
||||
<div className="badge badge-success badge-lg rounded-full gap-2"> <p className="sm:hidden md:inline">Logged In As:</p> <img className="size-6 rounded-full" src={`${RPC_URL(__HOST__)}/api/romm/assets/romm/assets/${user.data?.avatar_path}`} /><b>{user.data?.username}</b></div>
|
||||
</>}
|
||||
<Button id="qr-login" type="button" onAction={() => requestQrLoginMutation.mutate()}><ScanQrCode /> </Button>
|
||||
return <div className="flex gap-2 items-center flex-wrap justify-center-safe">
|
||||
{user.isSuccess ?
|
||||
<div className="badge badge-success badge-lg rounded-full gap-2"> <p className="sm:hidden md:inline">Logged In As:</p> <img className="size-6 rounded-full" src={`${RPC_URL(__HOST__)}/api/romm/assets/romm/assets/${user.data?.avatar_path}`} /><b>{user.data?.username}</b></div> :
|
||||
<div className={classNames("badge gap-2 tooltip", { "badge-error": user.error })} data-tip={user.error?.message}>
|
||||
{user.isError ? <Lock className="size-4" /> : <span className="loading loading-spinner loading-sm"></span>}
|
||||
</div>
|
||||
}
|
||||
<Button id="qr-login" type="button" disabled={loginMutation.isPending} onAction={() => loginMutation.mutate()}><ScanQrCode /> </Button>
|
||||
<Button id="can-submit" disabled={!context.state.canSubmit || !context.state.isDirty} type="submit" onAction={() => context.handleSubmit()} >
|
||||
<Save /> Save
|
||||
</Button>
|
||||
|
|
@ -99,11 +163,11 @@ function LoginControls (data: { hasPassword: boolean; })
|
|||
<Button id="cancel" disabled={context.state.isDefaultValue} type="reset" onAction={() => context.reset()}>
|
||||
<X /> Cancel
|
||||
</Button>
|
||||
{statusValue?.data?.endsAt && <LoginQR id="qr-login-context" endsAt={statusValue.data.endsAt} isOpen={true} cancel={() =>
|
||||
{!!statusValue && <LoginQR startedAt={statusValue.startedAt} id="qr-login-context" endsAt={statusValue.endsAt} isOpen={true} cancel={() =>
|
||||
{
|
||||
setFocus(`qr-login`);
|
||||
cancelQrMutation.mutate();
|
||||
}} url={statusValue?.data?.url ?? ''} />}
|
||||
wsRef.current?.send({ type: 'cancel' });
|
||||
}} url={statusValue?.url ?? ''} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +247,7 @@ function RouteComponent ()
|
|||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<ul ref={ref} className="list rounded-box gap-2">
|
||||
<ul ref={ref} className="list relative rounded-box gap-2">
|
||||
<div className="divider text-2xl mt-0 md:mt-4">
|
||||
<div className="flex flex-col">
|
||||
<h3>Romm</h3>
|
||||
|
|
@ -218,12 +282,24 @@ function RouteComponent ()
|
|||
<loginForm.AppField name="password" children={(field) =>
|
||||
<field.FormOption label={"Romm Password"} icon={<Key />} type="password" placeholder={hasPassword ? '*****' : "Password"} />} />
|
||||
<loginForm.Subscribe children={(form) =>
|
||||
<OptionSpace className="justify-end">
|
||||
<OptionSpace id="login-controls-space" className="justify-end border-0">
|
||||
<LoginControls hasPassword={hasPassword === true} />
|
||||
</OptionSpace>} />
|
||||
</form>
|
||||
</loginForm.AppForm>
|
||||
<div className="divider text-2xl mt-0 md:mt-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
{TwitchIcon}
|
||||
<h3> Twitch</h3>
|
||||
</div>
|
||||
</div>
|
||||
</ul>
|
||||
<OptionSpace label={<div className="flex flex-col">
|
||||
Twitch Login
|
||||
<small className="text-base-content/40">for IGDB Metadata</small>
|
||||
</div>} id="twitch-login-space" className="justify-end border-0">
|
||||
<TwitchLogin />
|
||||
</OptionSpace>
|
||||
</FocusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spat
|
|||
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||
import FilePicker from '@/mainview/components/FilePicker';
|
||||
import { dirname } from 'pathe';
|
||||
import { autoEmulatorsQuery } from '@/mainview/scripts/queries';
|
||||
|
||||
export const Route = createFileRoute('/settings/emulators')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -75,7 +76,7 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd
|
|||
};
|
||||
|
||||
|
||||
return <OptionSpace label={"Custom Emulator Path"}>
|
||||
return <OptionSpace id={'custom-emulator-path-option'} label={"Custom Emulator Path"}>
|
||||
<Button disabled={data.isAddingOverride} id='emulator' type='button' onAction={() => setNewEmulatorTypeOpen(true)} >
|
||||
Emulator
|
||||
<ChevronDown />
|
||||
|
|
@ -155,7 +156,7 @@ function EmulatorPath (data: { id: string; })
|
|||
};
|
||||
|
||||
return (
|
||||
<OptionSpace label={
|
||||
<OptionSpace id={`${data.id}-space`} label={
|
||||
focus => <>
|
||||
<p className='font-semibold'>{data.id}</p>
|
||||
<small className='opacity-40'>{emulators[data.id]}</small>
|
||||
|
|
@ -211,6 +212,7 @@ function EmulatorBadge (data: {
|
|||
path?: string,
|
||||
exists: boolean,
|
||||
emulator: string;
|
||||
isCritical: boolean;
|
||||
pathCover?: string;
|
||||
addOverride: (emulator: string) => void;
|
||||
})
|
||||
|
|
@ -229,16 +231,16 @@ function EmulatorBadge (data: {
|
|||
|
||||
return <div className={classNames("tooltip tooltip-primary", { "tooltip-open": focused })} data-tip={`${emulators[data.emulator]}`}>
|
||||
<div ref={ref} className={
|
||||
twMerge('flex flex-col rounded-3xl bg-base-300 w-64 h-16 justify-center items-center p-4 overflow-hidden',
|
||||
twMerge('flex flex-col rounded-3xl bg-base-300 justify-center items-center p-4 overflow-hidden h-full',
|
||||
classNames({
|
||||
"bg-base-200": !data.path,
|
||||
"border-dashed border-base-content/40 border-2": !data.path && !focused,
|
||||
"border-dashed border-base-content/40 border-2": !data.path && data.isCritical && !focused,
|
||||
"border-dashed border-accent border-4": focused
|
||||
|
||||
}))
|
||||
}>
|
||||
<p className='flex gap-2 font-semibold'>
|
||||
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className='text-warning' />}
|
||||
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className={data.isCritical ? 'text-warning' : 'text-base-content/40'} />}
|
||||
{!!data.pathCover && <img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${data.pathCover}`}></img>}
|
||||
{data.emulator}
|
||||
</p>
|
||||
|
|
@ -249,11 +251,11 @@ function EmulatorBadge (data: {
|
|||
|
||||
function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; })
|
||||
{
|
||||
const { data: autoEmulators } = useQuery({ queryKey: ['auto-emulators'], queryFn: async () => settingsApi.api.settings.emulators.automatic.get() });
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators?.data && autoEmulators.data.length > 0 });
|
||||
return <div ref={ref} className='flex flex-wrap gap-2 justify-center-safe'>
|
||||
const { data: autoEmulators } = useQuery(autoEmulatorsQuery);
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators && autoEmulators.length > 0 });
|
||||
return <div ref={ref} className='grid grid-cols-[repeat(auto-fit,14rem)] auto-rows-[4rem] gap-2 justify-center-safe'>
|
||||
<FocusContext value={focusKey}>
|
||||
{autoEmulators?.data?.map(e => <EmulatorBadge key={e.emulator} addOverride={data.addOverride} pathCover={e.path_cover ?? undefined} path={e.path} exists={e.exists} emulator={e.emulator} />)}
|
||||
{autoEmulators?.map(e => <EmulatorBadge key={e.emulator} isCritical={e.isCritical} addOverride={data.addOverride} pathCover={e.path_cover ?? undefined} path={e.path?.path} exists={e.exists} emulator={e.emulator} />)}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ function RouteComponent ()
|
|||
return <ul ref={ref} className="list rounded-box gap-2">
|
||||
<FocusContext value={focusKey}>
|
||||
<LocalOption id="backgroundBlur" label="Background Blur" type='checkbox'></LocalOption>
|
||||
<LocalOption id="backgroundAnimation" label="Background Animation" type='checkbox'></LocalOption>
|
||||
<LocalOption id="theme" label="Theme" type='dropdown' values={['dark', 'light', 'auto']}></LocalOption>
|
||||
</FocusContext>
|
||||
</ul>;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue