mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-22 23:18:05 +03:00
more (#53)
* checkpoint * server compiles again * fmt * checkpoint * last one out, get the lights * euston station * like, mostly good. I'm under the ocean right now. * fix: some nonsense AI stuff * fix: new eslint rules, and cleanup imports across the board * feat: folder slug rewrite, email logic change, too * fix: register must use txn not db * checkpoint
This commit is contained in:
@@ -1,19 +1,10 @@
|
||||
---
|
||||
name: no-oldtest-edits
|
||||
description: Treats `*.oldtest.ts` files as read-only legacy snapshots. Never edits them. Use when changing tests, game implementations, or when any path matches `*.oldtest.ts`.
|
||||
description: Legacy. `*.oldtest.ts` snapshots were removed from the Tachi server after migration to Vitest `*.test.ts`. No action needed unless oldtest files are reintroduced.
|
||||
---
|
||||
|
||||
# No edits to `*.oldtest.ts`
|
||||
# Legacy note
|
||||
|
||||
## Hard rule
|
||||
Server tests live in `*.test.ts` under `typescript/server/src/`. The former Tap `*.oldtest.ts` tree has been deleted.
|
||||
|
||||
**Do not modify** any file whose name matches `*.oldtest.ts` (for example `jubeat.oldtest.ts`). These are frozen reference tests.
|
||||
|
||||
## What to do instead
|
||||
|
||||
- Make test or implementation changes in the corresponding non-`oldtest` files (for example `*.test.ts` next to the implementation, or the main `*.ts` source).
|
||||
- If behavior must be verified against old expectations, add or update tests outside `*.oldtest.ts`.
|
||||
|
||||
## Checklist before finishing a task
|
||||
|
||||
- [ ] No file edited under a `*.oldtest.ts` path.
|
||||
If `*.oldtest.ts` files appear again, treat them as read-only snapshots and add or change behavior only in `*.test.ts` or source files.
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
"service": "tachi-dev",
|
||||
"workspaceFolder": "/tachi",
|
||||
"containerEnv": {
|
||||
"MINIO_ENDPOINT": "http://tachi-s3:9000"
|
||||
"MINIO_ENDPOINT": "http://tachi-s3:9000",
|
||||
"TACHI_EMAIL_FROM": "dev@localhost",
|
||||
"TACHI_EMAIL_AUTH_POSTMARK": "false",
|
||||
"TACHI_EMAIL_HOST": "tachi-mailpit",
|
||||
"TACHI_EMAIL_PORT": "1025",
|
||||
"TACHI_EMAIL_SECURE": "false"
|
||||
},
|
||||
"postAttachCommand": "./dev/bootstrap.sh",
|
||||
"updateRemoteUserUID": true,
|
||||
|
||||
Vendored
+2
-1
@@ -44,5 +44,6 @@
|
||||
"files.associations": {
|
||||
"Justfile-*": "makefile"
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"remote.autoForwardPortsFallback": 0
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ start:
|
||||
|
||||
|
||||
server:
|
||||
cd typescript/server && \
|
||||
cd typescript/server && \
|
||||
VERSION=$(jq -r .version package.json) \
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD) \
|
||||
bun --watch src/main.ts
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Misc nonsense
|
||||
|
||||
count-lines-of-code:
|
||||
tokei -e node_modules/ -e vendor/ -e pnpm-lock.yaml
|
||||
tokei -e node_modules/ -e vendor/ -e bun.lock
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ test-typescript FILTER="*":
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
bun run --elide-lines=0 --sequential --filter '{{FILTER}}' test -- --coverage --coverage.reporter=lcov --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml
|
||||
bun run --elide-lines=0 --sequential --filter '{{FILTER}}' test -- --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml
|
||||
|
||||
# Summarize Vitest v8 coverage across workspaces (see typescript/coverage-tools).
|
||||
coverage-report *ARGS:
|
||||
|
||||
@@ -58,7 +58,7 @@ That said, if you're against using vscode you can:
|
||||
|
||||
If those instructions don't make sense to you, I really recommend just using dev containers.
|
||||
|
||||
Alternatively **if you want to work outside of docker you're on your own**. Figure out the correct versions for everything (npm, pnpm, mongo, redis, ts-node...) and `dev/bootstrap.sh`.
|
||||
Alternatively **if you want to work outside of docker you're on your own**. Figure out the correct versions for everything (bun, postgres, redis, ts-node...) and `dev/bootstrap.sh`.
|
||||
|
||||
## Repository Info
|
||||
|
||||
|
||||
@@ -238,6 +238,7 @@
|
||||
"eslint-plugin-react": "catalog:",
|
||||
"eslint-plugin-react-hooks": "catalog:",
|
||||
"eslint-plugin-react-refresh": "catalog:",
|
||||
"eslint-plugin-unused-imports": "catalog:",
|
||||
"globals": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
},
|
||||
@@ -310,7 +311,7 @@
|
||||
},
|
||||
"typescript/server": {
|
||||
"name": "tachi-server",
|
||||
"version": "2.5.1",
|
||||
"version": "3.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "catalog:",
|
||||
"@types/bcryptjs": "catalog:",
|
||||
@@ -393,6 +394,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"eslint-config-tachi": "workspace:*",
|
||||
"vitest": "catalog:",
|
||||
},
|
||||
@@ -487,6 +489,7 @@
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"express": "4.20.0",
|
||||
"express-async-errors": "3.1.1",
|
||||
"express-prom-bundle": "^7.0.0",
|
||||
@@ -1029,6 +1032,8 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
|
||||
|
||||
"@package-json/types": ["@package-json/types@0.0.12", "", {}, "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw=="],
|
||||
|
||||
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
|
||||
|
||||
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
|
||||
@@ -1581,6 +1586,8 @@
|
||||
|
||||
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"comment-parser": ["comment-parser@1.4.6", "", {}, "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg=="],
|
||||
|
||||
"commist": ["commist@1.1.0", "", { "dependencies": { "leven": "^2.1.0", "minimist": "^1.1.0" } }, "sha512-rraC8NXWOEjhADbZe9QBNzLAN5Q3fsTPQtBV+fEVj6xKIgDgNiEVE6ZNfHpZOqfQ21YUzfVNUXLOEZquYvQPPg=="],
|
||||
|
||||
"component-emitter": ["component-emitter@1.3.1", "", {}, "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ=="],
|
||||
@@ -1825,6 +1832,8 @@
|
||||
|
||||
"eslint-config-tachi": ["eslint-config-tachi@workspace:typescript/eslint-config"],
|
||||
|
||||
"eslint-import-context": ["eslint-import-context@0.1.9", "", { "dependencies": { "get-tsconfig": "^4.10.1", "stable-hash-x": "^0.2.0" }, "peerDependencies": { "unrs-resolver": "^1.0.0" }, "optionalPeers": ["unrs-resolver"] }, "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg=="],
|
||||
|
||||
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
|
||||
|
||||
"eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="],
|
||||
@@ -1833,6 +1842,8 @@
|
||||
|
||||
"eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="],
|
||||
|
||||
"eslint-plugin-import-x": ["eslint-plugin-import-x@4.16.2", "", { "dependencies": { "@package-json/types": "^0.0.12", "@typescript-eslint/types": "^8.56.0", "comment-parser": "^1.4.1", "debug": "^4.4.1", "eslint-import-context": "^0.1.9", "is-glob": "^4.0.3", "minimatch": "^9.0.3 || ^10.1.2", "semver": "^7.7.2", "stable-hash-x": "^0.2.0", "unrs-resolver": "^1.9.2" }, "peerDependencies": { "@typescript-eslint/utils": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint-import-resolver-node": "*" }, "optionalPeers": ["@typescript-eslint/utils", "eslint-import-resolver-node"] }, "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw=="],
|
||||
|
||||
"eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="],
|
||||
|
||||
"eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@3.9.1", "", { "dependencies": { "@typescript-eslint/types": "^8.9.0", "@typescript-eslint/utils": "^8.9.0", "minimatch": "^9.0.5", "natural-compare-lite": "^1.4.0" }, "peerDependencies": { "astro-eslint-parser": "^1.0.2", "eslint": ">=8.0.0", "svelte": ">=3.0.0", "svelte-eslint-parser": "^0.41.1", "vue-eslint-parser": ">=9.0.0" }, "optionalPeers": ["astro-eslint-parser", "svelte", "svelte-eslint-parser", "vue-eslint-parser"] }, "sha512-9WRzf6XaAxF4Oi5t/3TqKP5zUjERhasHmLFHin2Yw6ZAp/EP/EVA2dr3BhQrrHWCm5SzTMZf0FcjDnBkO2xFkA=="],
|
||||
@@ -1843,6 +1854,8 @@
|
||||
|
||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="],
|
||||
|
||||
"eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.4.1", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin"] }, "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
"eslint-utils": ["eslint-utils@3.0.0", "", { "dependencies": { "eslint-visitor-keys": "^2.0.0" }, "peerDependencies": { "eslint": ">=5" } }, "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA=="],
|
||||
@@ -2757,6 +2770,8 @@
|
||||
|
||||
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
||||
|
||||
"stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
@@ -3207,6 +3222,10 @@
|
||||
|
||||
"eslint-plugin-import/tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
|
||||
|
||||
"eslint-plugin-import-x/@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="],
|
||||
|
||||
"eslint-plugin-import-x/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"eslint-plugin-perfectionist/@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="],
|
||||
|
||||
"eslint-plugin-perfectionist/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
@@ -3425,6 +3444,8 @@
|
||||
|
||||
"duplexify/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"eslint-plugin-import-x/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"eslint-plugin-perfectionist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"express-session/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
@@ -3487,6 +3508,8 @@
|
||||
|
||||
"@vitest/mocker/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.15.18", "", { "os": "linux", "cpu": "none" }, "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ=="],
|
||||
|
||||
"eslint-plugin-import-x/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
@@ -415,7 +415,7 @@ CREATE TABLE "bms_course_lookup" (
|
||||
),
|
||||
title TEXT NOT NULL UNIQUE,
|
||||
set TEXT NOT NULL,
|
||||
playtype TEXT NOT NULL,
|
||||
game GAME NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
@@ -427,7 +427,7 @@ CREATE TABLE "folder" (
|
||||
title TEXT NOT NULL,
|
||||
|
||||
-- Used in URLs. should be short, but must be unique per game!
|
||||
slug TEXT,
|
||||
slug TEXT NOT NULL,
|
||||
|
||||
-- SQL predicate (no leading WHERE) for charts in this folder; matches seeds `where`.
|
||||
-- Quoted: "where" is a reserved word in SQL.
|
||||
@@ -437,7 +437,7 @@ CREATE TABLE "folder" (
|
||||
version_filter TEXT[],
|
||||
search_terms TEXT[] NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX folder_unique_slug_game ON "folder" (game, slug) WHERE slug IS NOT NULL;
|
||||
CREATE UNIQUE INDEX folder_unique_slug_game ON "folder" (game, slug);
|
||||
|
||||
CREATE TABLE "table" ( -- heh
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
@@ -458,6 +458,8 @@ CREATE UNIQUE INDEX one_default_table_per_game ON "table" (game) WHERE default_v
|
||||
CREATE TABLE "table_folder" (
|
||||
table_id TEXT REFERENCES "table"(id) NOT NULL,
|
||||
folder_id TEXT REFERENCES folder(id) NOT NULL,
|
||||
-- Order within the table; matches the `folders` array index in `tables.json`.
|
||||
ordering INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (table_id, folder_id)
|
||||
);
|
||||
@@ -522,7 +524,11 @@ CREATE TABLE "chart" (
|
||||
|
||||
versions TEXT[] NOT NULL,
|
||||
|
||||
data JSONB NOT NULL -- game specific payload
|
||||
data JSONB NOT NULL, -- game specific payload
|
||||
|
||||
-- SHA-256 hex digest of chart fields that feed scoreDeriver/scoreCalcs. When this
|
||||
-- changes, scores on the chart need re-derivation.
|
||||
derivation_checksum TEXT
|
||||
);
|
||||
|
||||
-- Denormalized chart → folders cache (rebuilt by app; see rebuildFolderChartLookup).
|
||||
@@ -750,6 +756,58 @@ CREATE TABLE "score" (
|
||||
committed BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Mark here when PBs become dirty and need to be recalculated.
|
||||
CREATE TABLE pb_dirty (
|
||||
user_id BIGINT NOT NULL,
|
||||
chart_id TEXT NOT NULL,
|
||||
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, chart_id)
|
||||
);
|
||||
|
||||
-- Mark here when scores become dirty and need to be recalculated.
|
||||
CREATE TABLE "score_rederive" (
|
||||
chart_id TEXT NOT NULL PRIMARY KEY,
|
||||
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Trigger function: on any score INSERT/UPDATE/DELETE, mark the (user_id, chart_id)
|
||||
-- pair as needing PB recalculation.
|
||||
CREATE FUNCTION enqueue_pb_dirty() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
INSERT INTO pb_dirty (user_id, chart_id)
|
||||
VALUES (OLD.user_id, OLD.chart_id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
ELSE
|
||||
INSERT INTO pb_dirty (user_id, chart_id)
|
||||
VALUES (NEW.user_id, NEW.chart_id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "score_pb_dirty"
|
||||
AFTER INSERT OR UPDATE OR DELETE ON score
|
||||
FOR EACH ROW EXECUTE FUNCTION enqueue_pb_dirty();
|
||||
|
||||
-- Trigger function: on chart UPDATE, if derivation_checksum changed, enqueue the chart
|
||||
-- for score re-derivation.
|
||||
CREATE FUNCTION enqueue_score_rederive() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF OLD.derivation_checksum IS DISTINCT FROM NEW.derivation_checksum THEN
|
||||
INSERT INTO score_rederive (chart_id)
|
||||
VALUES (NEW.id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "chart_score_rederive"
|
||||
AFTER UPDATE ON chart
|
||||
FOR EACH ROW EXECUTE FUNCTION enqueue_score_rederive();
|
||||
|
||||
CREATE TABLE "pb" (
|
||||
row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
-- Derivation checksum on chart: a SHA-256 hex digest of the chart fields
|
||||
-- that feed into scoreDeriver/scoreCalcs. When this changes, all scores
|
||||
-- on the chart need re-derivation.
|
||||
ALTER TABLE chart ADD COLUMN derivation_checksum TEXT;
|
||||
|
||||
-- Dirty queue: score mutations enqueue (user_id, chart_id) pairs that
|
||||
-- need PB recalculation. Deduplication via the PK.
|
||||
CREATE TABLE pb_dirty (
|
||||
user_id BIGINT NOT NULL,
|
||||
chart_id TEXT NOT NULL,
|
||||
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, chart_id)
|
||||
);
|
||||
|
||||
-- Dirty queue: chart mutations (when derivation_checksum changes) enqueue
|
||||
-- chart_ids whose scores need re-derivation. One row per chart.
|
||||
CREATE TABLE score_rederive (
|
||||
chart_id TEXT NOT NULL PRIMARY KEY,
|
||||
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Trigger function: on any score INSERT/UPDATE/DELETE, mark the
|
||||
-- (user_id, chart_id) pair as needing PB recalculation.
|
||||
CREATE FUNCTION enqueue_pb_dirty() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
INSERT INTO pb_dirty (user_id, chart_id)
|
||||
VALUES (OLD.user_id, OLD.chart_id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
ELSE
|
||||
INSERT INTO pb_dirty (user_id, chart_id)
|
||||
VALUES (NEW.user_id, NEW.chart_id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER score_pb_dirty
|
||||
AFTER INSERT OR UPDATE OR DELETE ON score
|
||||
FOR EACH ROW EXECUTE FUNCTION enqueue_pb_dirty();
|
||||
|
||||
-- Trigger function: on chart UPDATE, if derivation_checksum changed,
|
||||
-- enqueue the chart for score re-derivation.
|
||||
CREATE FUNCTION enqueue_score_rederive() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF OLD.derivation_checksum IS DISTINCT FROM NEW.derivation_checksum THEN
|
||||
INSERT INTO score_rederive (chart_id)
|
||||
VALUES (NEW.id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER chart_score_rederive
|
||||
AFTER UPDATE ON chart
|
||||
FOR EACH ROW EXECUTE FUNCTION enqueue_score_rederive();
|
||||
+136
-136
File diff suppressed because it is too large
Load Diff
+9748
-5838
File diff suppressed because it is too large
Load Diff
+3899
-3911
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@
|
||||
defnew g=git
|
||||
defnew j=just
|
||||
defnew c=cargo
|
||||
defnew p=pnpm
|
||||
|
||||
defnew gp=git push
|
||||
defnew gsw=git switch
|
||||
|
||||
@@ -35,11 +35,6 @@ function cmd
|
||||
end
|
||||
|
||||
function fish_greeting
|
||||
# check $PATH is correct
|
||||
if not contains ~/.local/pnpm $PATH
|
||||
fish_add_path ~/.local/pnpm
|
||||
end
|
||||
|
||||
if test (random 0 100) -lt 10; and test -d /host-pc/dl # entropy
|
||||
perl /tachi/dev/eye.pl
|
||||
end
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
#!/bin/fish
|
||||
|
||||
# add a local bin for ts-node
|
||||
fish_add_path ~/.local/pnpm
|
||||
fish_add_path /tachi/node_modules/.bin
|
||||
|
||||
source ./dev/fish-plugins/fisher/functions/fisher.fish
|
||||
|
||||
@@ -64,12 +64,22 @@ services:
|
||||
- tachi-minio:/data
|
||||
entrypoint: /usr/bin/minio server /data --console-address=':9001'
|
||||
|
||||
# Local SMTP capture + web UI (http://localhost:8025). Server uses tachi-mailpit:1025 via env.
|
||||
tachi-mailpit:
|
||||
container_name: tachi-mailpit
|
||||
image: axllent/mailpit:v1.29.7
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8025:8025"
|
||||
- "1025:1025"
|
||||
|
||||
tachi-dev:
|
||||
user: "1000:1000"
|
||||
tty: true
|
||||
container_name: tachi-dev
|
||||
depends_on:
|
||||
- tachi-s3
|
||||
- tachi-mailpit
|
||||
build:
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
|
||||
@@ -8,7 +8,7 @@ For user-specific target endpoints, such as subscriptions, see [UGPT-Target Endp
|
||||
|
||||
## Retrieve this game's recently achieved targets
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/targets/recently-achieved`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/targets/recently-achieved`
|
||||
|
||||
!!! info
|
||||
This endpoint returns the 100 most recently achieved goal subscriptions, and 50 most recently achieved quest subscriptions.
|
||||
@@ -69,7 +69,7 @@ GET /api/v1/games/iidx/SP/targets/recently-achieved
|
||||
|
||||
## Retrieve this game's recently interacted-with targets
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/targets/recently-raised`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/targets/recently-raised`
|
||||
|
||||
!!! info
|
||||
This endpoint returns the 100 most recently interacted-with goal subscriptions, and 50 most recently interacted-with quest subscriptions.
|
||||
@@ -135,7 +135,7 @@ GET /api/v1/games/iidx/SP/targets/recently-raised
|
||||
|
||||
## Get the most popular goals for this GPT.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/targets/goals/popular`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/targets/goals/popular`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -152,7 +152,7 @@ N/A
|
||||
#### Request
|
||||
|
||||
```
|
||||
GET /api/v1/games/:game/:playtype/targets/goals/popular
|
||||
GET /api/v1/games/:gameGroup/:playtype/targets/goals/popular
|
||||
```
|
||||
|
||||
#### Response
|
||||
@@ -174,7 +174,7 @@ GET /api/v1/games/:game/:playtype/targets/goals/popular
|
||||
|
||||
## Retrieve information about a specific goal and its subscribers.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/targets/goals/:goalID`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/targets/goals/:goalID`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -193,7 +193,7 @@ None.
|
||||
|
||||
## Evaluate a goal upon a user.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/targets/goals/:goalID/evaluate-for`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/targets/goals/:goalID/evaluate-for`
|
||||
|
||||
!!! note
|
||||
This endpoint is notably in a bit of a strange position. It can't go under UGPT because
|
||||
@@ -259,7 +259,7 @@ GET /api/v1/games/iidx/SP/targets/goals/some_goal_id/evaluate-for?userID=zkldi
|
||||
|
||||
## Search quests for this GPT.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/targets/quests`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/targets/quests`
|
||||
|
||||
!!! note
|
||||
You might notice that there's no equivalent endpoint for goals.
|
||||
@@ -284,7 +284,7 @@ You might notice that there's no equivalent endpoint for goals.
|
||||
|
||||
## Retrieve information about a specific quest, and who is subscribed to it.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/targets/quests/:questID`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/targets/quests/:questID`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -304,7 +304,7 @@ N/A
|
||||
|
||||
## Evaluate a quest for a user, even if they aren't subscribed to it.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/targets/quests/:questID/evaluate-for`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/targets/quests/:questID/evaluate-for`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -337,7 +337,7 @@ N/A
|
||||
|
||||
## Search Questlines
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/targets/questlines`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/targets/questlines`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -355,7 +355,7 @@ N/A
|
||||
|
||||
## Retrieve a questline with a specific ID.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/targets/questlines/:questlineID`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/targets/questlines/:questlineID`
|
||||
|
||||
### Parameters
|
||||
|
||||
|
||||
+18
-18
@@ -8,7 +8,7 @@ programmatically, you should see [Game Endpoints](./games.md).
|
||||
|
||||
## Retrieve Game:Playtype Configuration.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype`
|
||||
`GET /api/v1/games/:gameGroup/:playtype`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -54,7 +54,7 @@ GET /api/v1/games/iidx/SP
|
||||
|
||||
## Retrieve the player leaderboard.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/leaderboard`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/leaderboard`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -104,7 +104,7 @@ GET /api/v1/games/iidx/SP/leaderboard
|
||||
|
||||
## Retrieve a song and its charts.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/songs/:songID`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/songs/:songID`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -154,7 +154,7 @@ GET /api/v1/games/iidx/SP/songs/1
|
||||
|
||||
## Get popular charts for this game + playtype.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/charts`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/charts`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -227,7 +227,7 @@ GET /api/v1/games/iidx/SP/charts?search=AA
|
||||
|
||||
## Retrieve a chart at a specific ID.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/charts/:chartID`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/charts/:chartID`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -270,7 +270,7 @@ GET /api/v1/games/iidx/SP/charts/some_chart_id
|
||||
|
||||
## Retrieve playcount for this chart.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/charts/:chartID/playcount`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/charts/:chartID/playcount`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -290,7 +290,7 @@ Self-explanatory.
|
||||
|
||||
## Retrieve leaderboards for this chart.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/charts/:chartID/pbs`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/charts/:chartID/pbs`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -342,7 +342,7 @@ GET /api/v1/games/iidx/SP/charts/some_chart/pbs
|
||||
|
||||
## Search for a user's PB on this chart.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/charts/:chartID/pbs/search`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/charts/:chartID/pbs/search`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -352,7 +352,7 @@ GET /api/v1/games/iidx/SP/charts/some_chart/pbs
|
||||
|
||||
### Response
|
||||
|
||||
Same as `/api/v1/games/:game/:playtype/charts/:chartID/pbs`.
|
||||
Same as `/api/v1/games/:gameGroup/:playtype/charts/:chartID/pbs`.
|
||||
|
||||
### Example
|
||||
|
||||
@@ -362,7 +362,7 @@ See Above.
|
||||
|
||||
## Search a GPT's folders.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/folders`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/folders`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -399,7 +399,7 @@ GET /api/v1/games/iidx/SP/folders?search=12
|
||||
|
||||
## Retrieve information on a specific folderID
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/folders/:folderID`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/folders/:folderID`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -449,7 +449,7 @@ GET /api/v1/games/iidx/SP/folders/some_folder_id
|
||||
|
||||
## Return all the tables for this game
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/tables`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/tables`
|
||||
|
||||
!!! note
|
||||
Unlike the folders endpoint, this one doesn't have a search parameter. This is because we expect
|
||||
@@ -496,7 +496,7 @@ GET /api/v1/games/bms/7K/tables
|
||||
|
||||
## Retrieve folder documents for a specific table.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/tables/:tableID`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/tables/:tableID`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -536,7 +536,7 @@ GET /api/v1/games/bms/7K/tableID/insane
|
||||
|
||||
## Retrieve the PB leaderboard for this Game.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/pb-leaderboard`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/pb-leaderboard`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -558,7 +558,7 @@ GET /api/v1/games/bms/7K/tableID/insane
|
||||
|
||||
## Get the distribution of players for a provided class.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/player-distribution`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/player-distribution`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -596,7 +596,7 @@ GET /api/v1/games/bms/7K/player-distribution?class=stslDan
|
||||
```
|
||||
|
||||
!!! info
|
||||
You can find the humanised conversions for these classes in the gptConfig for this GPT.
|
||||
You can find the humanised conversions for these classes in the gameConfig for this GPT.
|
||||
|
||||
See [tachi/common](https://github.com/zkldi/Tachi/tree/main/common) for more information.
|
||||
|
||||
@@ -604,7 +604,7 @@ You can find the humanised conversions for these classes in the gptConfig for th
|
||||
|
||||
## Retrieve recent class updates from all users on this game.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/player-distribution`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/player-distribution`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -623,7 +623,7 @@ You can find the humanised conversions for these classes in the gptConfig for th
|
||||
|
||||
## Retrieve the most recent highlighted scores for this GPT.
|
||||
|
||||
`GET /api/v1/games/:game/:playtype/scores/highlighted`
|
||||
`GET /api/v1/games/:gameGroup/:playtype/scores/highlighted`
|
||||
|
||||
### Parameters
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ These endpoints are related to the Statistic Showcase feature.
|
||||
|
||||
## Evaluate this users set stats
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/showcase`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/showcase`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -53,7 +53,7 @@ GET /api/v1/users/1/games/iidx/SP/showcase
|
||||
|
||||
## Replace a user's stat showcase.
|
||||
|
||||
`PATCH /api/v1/users/:userID/games/:game/:playtype/showcase`
|
||||
`PATCH /api/v1/users/:userID/games/:gameGroup/:playtype/showcase`
|
||||
|
||||
### Permissions
|
||||
|
||||
@@ -101,7 +101,7 @@ PATCH /api/v1/users/1/games/iidx/SP/showcase
|
||||
|
||||
## Evaluate a custom stat on this user.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/showcase/custom`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/showcase/custom`
|
||||
|
||||
### Parameters
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ For user-specific target endpoints, such as subscriptions, see [UGPT-Target Endp
|
||||
|
||||
## Retrieve a user's recently achieved targets.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-achieved`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/targets/recently-achieved`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -30,7 +30,7 @@ None.
|
||||
|
||||
## Retrieve a user's recently raised targets.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/targets/recently-raised`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/targets/recently-raised`
|
||||
|
||||
!!! info
|
||||
Recently raised means that the user recently increased their `progress` on the target. It explicitly excludes achieved goals -- only things that they are getting closer to.
|
||||
@@ -53,7 +53,7 @@ None.
|
||||
|
||||
## Retrieve this user's quest subscriptions.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/targets/quests`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/targets/quests`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -70,7 +70,7 @@ None.
|
||||
|
||||
## Get a user's progress on a specific quest they are subscribed to.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/targets/quests/:questID`
|
||||
|
||||
!!! info
|
||||
If you're looking to evaluate a quest against a user, without knowing whether they're
|
||||
@@ -106,7 +106,7 @@ None.
|
||||
|
||||
## Subscribe to a quest.
|
||||
|
||||
`PUT /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID`
|
||||
`PUT /api/v1/users/:userID/games/:gameGroup/:playtype/targets/quests/:questID`
|
||||
|
||||
### Permissions
|
||||
|
||||
@@ -130,7 +130,7 @@ None. (All relevant info is in the URL.)
|
||||
|
||||
## Unsubscribe from a quest.
|
||||
|
||||
`DELETE /api/v1/users/:userID/games/:game/:playtype/targets/quests/:questID`
|
||||
`DELETE /api/v1/users/:userID/games/:gameGroup/:playtype/targets/quests/:questID`
|
||||
|
||||
### Permissions
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ This scenario appears frequently, and is typically shortened to UGPT.
|
||||
|
||||
## Get information about a user's plays on a game + playtype.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -71,7 +71,7 @@ GET /api/v1/users/zkldi/games/iidx/SP
|
||||
|
||||
## Search a user's personal bests.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/pbs`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/pbs`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -125,7 +125,7 @@ GET /api/v1/users/zkldimes/iidx/SP/pbs?search=Verfl
|
||||
|
||||
## Get a user's best 100 personal bests.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/pbs/best`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/pbs/best`
|
||||
|
||||
This returns the users' best 100 personal bests according
|
||||
to the [Default Rating Algorithm](../../codebase/implementation-details/game-configuration) for this game.
|
||||
@@ -204,7 +204,7 @@ GET /api/v1/users/zkldimes/iidx/SP/pbs/best?alg=BPI
|
||||
|
||||
## Returns all of a users personal bests.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/pbs/all`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/pbs/all`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -258,7 +258,7 @@ GET /api/v1/users/zkldimes/iidx/SP/pbs/all
|
||||
|
||||
## Get A User's PB for a given chart.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/pbs/:chartID`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/pbs/:chartID`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -302,7 +302,7 @@ GET /api/v1/users/1/games/iidx/SP/pbs/some_chart_id
|
||||
|
||||
## Search a user's individual scores.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/scores`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/scores`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -365,7 +365,7 @@ GET /api/v1/users/zkldimes/iidx/SP/scores?search=Verfl
|
||||
|
||||
## Get a user's most recent 100 scores.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/scores/recent`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/scores/recent`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -419,7 +419,7 @@ GET /api/v1/users/zkldimes/iidx/SP/scores/recent
|
||||
|
||||
## Search a user's sessions.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/sessions`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions`
|
||||
|
||||
Searches the names of sessions from a given user. This
|
||||
does not search session descriptions, nor does it search
|
||||
@@ -461,7 +461,7 @@ GET /api/v1/users/zkldimes/iidx/SP/sessions?search=epic%20session
|
||||
|
||||
## Get a user's best 100 sessions.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/sessions/best`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions/best`
|
||||
|
||||
Retrieves a user's best 100 sessions according to the
|
||||
game + playtypes default algorithm. The algorithm can
|
||||
@@ -520,7 +520,7 @@ The default rating algorithm for IIDX:SP is `ktRating`.
|
||||
|
||||
## Get a user's most recent 100 sessions.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions/recent`
|
||||
|
||||
Retrieves a user's most recent 100 sessions for this game.
|
||||
|
||||
@@ -540,7 +540,7 @@ None.
|
||||
|
||||
## Get a user's most recent session.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/sessions/last`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions/last`
|
||||
|
||||
!!! info
|
||||
This endpoint will return 404 if the user has never had a
|
||||
@@ -576,7 +576,7 @@ GET /api/v1/users/zkldimes/iidx/SP/sessions/last
|
||||
|
||||
## Get a user's most recent 100 highlighted sessions.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/sessions/highlighted`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/sessions/highlighted`
|
||||
|
||||
Retrieves a user's most recent 100 highlighted sessions for this game.
|
||||
|
||||
@@ -622,7 +622,7 @@ GET /api/v1/users/zkldimes/iidx/SP/sessions/highlighted
|
||||
|
||||
## Get a user's most played charts.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/most-played`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/most-played`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -689,7 +689,7 @@ GET /api/v1/users/zkldimes/iidx/SP/most-played
|
||||
|
||||
## Retrieve a leaderboard around a user.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/leaderboard-adjacent`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/leaderboard-adjacent`
|
||||
|
||||
### Parameters
|
||||
|
||||
@@ -768,7 +768,7 @@ GET /api/v1/users/zkldimes/iidx/SP/leaderboard-adjacent
|
||||
|
||||
## Retrieve this user's GPT stat history.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/history`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/history`
|
||||
|
||||
Every day, a user's game stats are snapshotted and saved. This returns the recent ones.
|
||||
|
||||
@@ -815,7 +815,7 @@ GET /api/v1/users/1/games/iidx/SP/history
|
||||
|
||||
## Retrieve this user's GPT settings.
|
||||
|
||||
`GET /api/v1/users/:userID/games/:game/:playtype/settings`
|
||||
`GET /api/v1/users/:userID/games/:gameGroup/:playtype/settings`
|
||||
|
||||
!!! warning
|
||||
Unlike most other applications, your settings are completely public. GPT Settings only concern
|
||||
@@ -854,7 +854,7 @@ GET /api/v1/users/1/games/iidx/SP/settings
|
||||
|
||||
## Modify your UGPT settings.
|
||||
|
||||
`PATCH /api/v1/users/:userID/games/:game/:playtype/settings`
|
||||
`PATCH /api/v1/users/:userID/games/:gameGroup/:playtype/settings`
|
||||
|
||||
!!! note
|
||||
Although `stats` are part of your settings, they are not modifiable under these endpoints,
|
||||
|
||||
@@ -228,30 +228,27 @@ How many unique players have to have played a chart on the beatoraja IR for it t
|
||||
- Type: String
|
||||
|
||||
Where *this* server is hosted. This is used to
|
||||
provide callback URLs inside emails. You may stub
|
||||
it out if emails are unsupported.
|
||||
provide callback URLs inside emails.
|
||||
|
||||
### EMAIL_CONFIG
|
||||
|
||||
- Type: EMAIL_CONFIG | undefined.
|
||||
- Type: EMAIL_CONFIG (required)
|
||||
|
||||
Configures how emails will be sent by Tachi.
|
||||
If not present, email calls will become no-ops, and
|
||||
certain features (such as resetting passwords)
|
||||
will be disabled.
|
||||
SMTP is always configured. Set:
|
||||
|
||||
`FROM` determines the email `From` header, and optionally
|
||||
`SENDMAIL_BIN` can override the location of the `sendmail`
|
||||
binary. Defaults to `/usr/bin/sendmail`, but some distros
|
||||
may have it in `sbin`.
|
||||
- `TACHI_EMAIL_FROM` — `From` header (must match a verified sender when using Postmark).
|
||||
- `TACHI_EMAIL_AUTH_POSTMARK` — `true` or `false`. When `true`, uses Postmark’s SMTP
|
||||
endpoint; set `TACHI_EMAIL_AUTH_USER` and `TACHI_EMAIL_AUTH_PASS` to your server API token
|
||||
(both are the token for Postmark SMTP).
|
||||
- When Postmark is `false`: `TACHI_EMAIL_HOST`, `TACHI_EMAIL_PORT`, `TACHI_EMAIL_SECURE`
|
||||
(`true` / `false`), and optionally `TACHI_EMAIL_AUTH_USER` / `TACHI_EMAIL_AUTH_PASS`
|
||||
for servers that require auth (local Mailpit typically needs no auth).
|
||||
|
||||
`TRANSPORT_OPS` Passes a set of options to the email transport. For more
|
||||
information, see the nodemailer documentation for SMTPTransport.Options.
|
||||
`TRANSPORT_OPS` is derived from these variables and passed to Nodemailer.
|
||||
|
||||
```ts
|
||||
interface EMAIL_CONFIG {
|
||||
FROM: string;
|
||||
SENDMAIL_BIN?: string
|
||||
TRANSPORT_OPS: any;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
# Tachi landing page
|
||||
|
||||
This is the landing page for [https://tachi.ac](https://tachi.ac).
|
||||
|
||||
## Contributing
|
||||
|
||||
Basic landing page with SCSS compile step.
|
||||
|
||||
```shell
|
||||
pnpm install
|
||||
pnpm run watch # Refresh dist/ on src/ change
|
||||
pnpm run compile # Compile site to dist/
|
||||
|
||||
# then open `dist/index.html` in your browser.
|
||||
```
|
||||
|
||||
Tested with Node.js `20.10.0` & pnpm `8.12.0` - Please ensure you are using these versions.
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.7.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-perfectionist": "^3.9.1",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
|
||||
@@ -95,9 +95,10 @@ export function MakeActionGuts({
|
||||
const inputParsed = inputSchema.safeParse(input);
|
||||
|
||||
if (!inputParsed.success) {
|
||||
throw new Error(
|
||||
`Action ${kind} received invalid input: ${inputParsed.error.message}`,
|
||||
);
|
||||
const msg = inputParsed.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join("; ");
|
||||
throw new ExpectedErr(400, msg || `Action ${kind} received invalid input.`);
|
||||
}
|
||||
|
||||
retval = await actionBodyFn(taker, input);
|
||||
|
||||
@@ -7,13 +7,11 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "tsgo -b",
|
||||
"start": "pnpm build && node -r ts-node/register -r tsconfig-paths/register js/main.js",
|
||||
"start-no-build": "node -r ts-node/register -r tsconfig-paths/register js/main.js",
|
||||
"lint": "eslint .",
|
||||
"lint-fix": "eslint . --fix",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest",
|
||||
"bench": "vitest bench --passWithNoTests"
|
||||
"bench": "vitest bench"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "catalog:",
|
||||
|
||||
@@ -21,7 +21,7 @@ export const ActionSignatures = {
|
||||
session_count: z.number().int(),
|
||||
error_count: z.number().int(),
|
||||
user_id: z.number().int(),
|
||||
game: z.string(),
|
||||
games: z.string().array(),
|
||||
}),
|
||||
},
|
||||
} satisfies Record<string, ActionSignature>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MONGO_ImportDocument } from "tachi-common";
|
||||
import type { ImportDocument } from "tachi-common";
|
||||
|
||||
import db from "#services/pg/db";
|
||||
import { createTestAccount } from "#test-utils/db";
|
||||
@@ -13,11 +13,12 @@ vi.mock("../utils/api-requests");
|
||||
|
||||
const { PerformScoreImport } = await import("../utils/api-requests");
|
||||
|
||||
function makeImportDoc(overrides: Partial<MONGO_ImportDocument> = {}): MONGO_ImportDocument {
|
||||
function makeImportDoc(overrides: Partial<ImportDocument> = {}): ImportDocument {
|
||||
return {
|
||||
importID: "import-abc-123",
|
||||
userID: 1,
|
||||
game: "iidx-sp",
|
||||
gameGroup: "iidx",
|
||||
games: ["iidx-sp"],
|
||||
scoreIDs: ["s1", "s2"],
|
||||
createdSessions: [{ sessionID: "sess1", type: "Created" }],
|
||||
errors: [],
|
||||
@@ -25,7 +26,7 @@ function makeImportDoc(overrides: Partial<MONGO_ImportDocument> = {}): MONGO_Imp
|
||||
goalProgress: [],
|
||||
questProgress: [],
|
||||
...overrides,
|
||||
} as unknown as MONGO_ImportDocument;
|
||||
} as unknown as ImportDocument;
|
||||
}
|
||||
|
||||
describe("ACTION_Sync", () => {
|
||||
@@ -50,7 +51,7 @@ describe("ACTION_Sync", () => {
|
||||
session_count: 1,
|
||||
error_count: 0,
|
||||
user_id: 1,
|
||||
game: "iidx-sp",
|
||||
games: ["iidx-sp"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,7 +83,7 @@ describe("ACTION_Sync", () => {
|
||||
|
||||
it("throws ExpectedErr when PerformScoreImport returns an error string", async () => {
|
||||
vi.mocked(PerformScoreImport).mockResolvedValue(
|
||||
"Your API key has expired." as unknown as MONGO_ImportDocument,
|
||||
"Your API key has expired." as unknown as ImportDocument,
|
||||
);
|
||||
|
||||
await expect(ACTION_Sync(taker, syncInput)).rejects.toMatchObject({
|
||||
@@ -93,7 +94,7 @@ describe("ACTION_Sync", () => {
|
||||
|
||||
it("the ExpectedErr thrown on failure is an ExpectedErr instance", async () => {
|
||||
vi.mocked(PerformScoreImport).mockResolvedValue(
|
||||
"Import failed." as unknown as MONGO_ImportDocument,
|
||||
"Import failed." as unknown as ImportDocument,
|
||||
);
|
||||
|
||||
await expect(ACTION_Sync(taker, syncInput)).rejects.toBeInstanceOf(ExpectedErr);
|
||||
@@ -120,9 +121,7 @@ describe("ACTION_Sync", () => {
|
||||
});
|
||||
|
||||
it("writes a BAD action row when PerformScoreImport returns an error string", async () => {
|
||||
vi.mocked(PerformScoreImport).mockResolvedValue(
|
||||
"Bad import." as unknown as MONGO_ImportDocument,
|
||||
);
|
||||
vi.mocked(PerformScoreImport).mockResolvedValue("Bad import." as unknown as ImportDocument);
|
||||
|
||||
await expect(ACTION_Sync(taker, syncInput)).rejects.toBeInstanceOf(ExpectedErr);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export const ACTION_Sync = MakeAction(
|
||||
session_count: importDoc.createdSessions.length,
|
||||
error_count: importDoc.errors.length,
|
||||
user_id: importDoc.userID,
|
||||
game: importDoc.game,
|
||||
games: importDoc.games,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GameGroup, integer, TachiServerCoreConfig } from "tachi-common";
|
||||
import type { GameGroup, integer, TachiServerCoreConfig, V3Game } from "tachi-common";
|
||||
|
||||
import { log } from "bliss/log";
|
||||
import { config } from "dotenv";
|
||||
@@ -49,7 +49,7 @@ export interface ProcessEnvironment {
|
||||
OAUTH_CLIENT_SECRET: string;
|
||||
DISCORD_TOKEN: string;
|
||||
DISCORD_SERVER_ID: string;
|
||||
DISCORD_GAME_CHANNELS: Partial<Record<GameGroup, string>>;
|
||||
DISCORD_GAME_CHANNELS: Partial<Record<V3Game, string>>;
|
||||
DISCORD_ADMIN_USERS: string[];
|
||||
DISCORD_APPROVED_ROLE: string | undefined;
|
||||
DISCORD_LIMBO_CHANNEL: string | undefined;
|
||||
@@ -135,7 +135,7 @@ export const ServerConfig = await GetServerConfig();
|
||||
|
||||
// General warnings for config misuse.
|
||||
// This warns people if their parent server supports games that they aren't acknowledging.
|
||||
for (const game of ServerConfig.GAMES) {
|
||||
for (const game of ServerConfig.GAME_GROUPS) {
|
||||
if (!Object.prototype.hasOwnProperty.call(Env.DISCORD_GAME_CHANNELS, game)) {
|
||||
log.warn(
|
||||
`${ServerConfig.NAME} declares support for ${game}, but no channel is mapped to it. Set DISCORD_GAME_CHANNELS to include it.`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MONGO_APITokenDocument, MONGO_UserDocument, WebhookEvents } from "tachi-common";
|
||||
import type { APITokenDocument, UserDocument, WebhookEvents } from "tachi-common";
|
||||
|
||||
import { log } from "#utils/log";
|
||||
import { HandleQuestAchievedV1 } from "#webhook-handlers/quest-achieved";
|
||||
@@ -58,7 +58,7 @@ app.get("/oauth/callback", async (req, res) => {
|
||||
return res.status(400).send("Bad Request.");
|
||||
}
|
||||
|
||||
const tokenRes = await TachiServerV1Request<MONGO_APITokenDocument>(
|
||||
const tokenRes = await TachiServerV1Request<APITokenDocument>(
|
||||
RequestTypes.POST,
|
||||
"/oauth/token",
|
||||
null,
|
||||
@@ -84,7 +84,7 @@ app.get("/oauth/callback", async (req, res) => {
|
||||
const discordID = req.query.context;
|
||||
const apiToken = tokenRes.body.token!;
|
||||
|
||||
const whoamiRes = await TachiServerV1Get<MONGO_UserDocument>("/users/me", apiToken);
|
||||
const whoamiRes = await TachiServerV1Get<UserDocument>("/users/me", apiToken);
|
||||
|
||||
if (!whoamiRes.success) {
|
||||
log.error({ discordID }, "Failed to request user with token we just got?");
|
||||
|
||||
@@ -66,7 +66,7 @@ const command: SlashCommand = {
|
||||
.addField("Errors", result.error_count.toString(), true)
|
||||
.addField(
|
||||
"Your Profile",
|
||||
`${Env.TACHI_SERVER_LOCATION}/u/${result.user_id}/games/${result.game}`,
|
||||
`${Env.TACHI_SERVER_LOCATION}/u/${result.user_id}/games/${result.games[0]}`,
|
||||
);
|
||||
} catch (e) {
|
||||
if (ExpectedErr.is(e)) {
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import type { CommandInteraction } from "discord.js";
|
||||
import type {
|
||||
GameGroup,
|
||||
integer,
|
||||
MONGO_ChartDocument,
|
||||
MONGO_GoalDocument,
|
||||
MONGO_ImportDocument,
|
||||
MONGO_PBScoreDocument,
|
||||
MONGO_QuestDocument,
|
||||
MONGO_SongDocument,
|
||||
MONGO_UserDocument,
|
||||
Playtype,
|
||||
} from "tachi-common";
|
||||
|
||||
import { Env } from "#config";
|
||||
import { log } from "#utils/log";
|
||||
import {
|
||||
type ChartDocument,
|
||||
type GoalDocument,
|
||||
type ImportDocument,
|
||||
type integer,
|
||||
type PBScoreDocument,
|
||||
type QuestDocument,
|
||||
type SongDocument,
|
||||
type UserDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
import type { ImportDeferred, ImportPollStatus, UGPTStats } from "./return-types";
|
||||
|
||||
@@ -21,7 +20,7 @@ import { RequestTypes, TachiServerV1Get, TachiServerV1Request } from "./fetch-ta
|
||||
import { Sleep } from "./misc";
|
||||
|
||||
export async function GetUserInfo(userID: string | integer) {
|
||||
const res = await TachiServerV1Get<MONGO_UserDocument>(`/users/${userID}`, null);
|
||||
const res = await TachiServerV1Get<UserDocument>(`/users/${userID}`, null);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Failed to fetch user with userID ${userID}.`);
|
||||
@@ -30,22 +29,19 @@ export async function GetUserInfo(userID: string | integer) {
|
||||
return res.body;
|
||||
}
|
||||
|
||||
export async function GetUGPTStats(userID: string | integer, game: GameGroup, playtype: Playtype) {
|
||||
const res = await TachiServerV1Get<UGPTStats>(
|
||||
`/users/${userID}/games/${game}/${playtype}`,
|
||||
null,
|
||||
);
|
||||
export async function GetUGPTStats(userID: string | integer, game: V3Game) {
|
||||
const res = await TachiServerV1Get<UGPTStats>(`/users/${userID}/games/${game}`, null);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Failed to fetch UGPT stats for userID ${userID}, ${game}, ${playtype}.`);
|
||||
throw new Error(`Failed to fetch UGPT stats for userID ${userID}, ${game}.`);
|
||||
}
|
||||
|
||||
return res.body;
|
||||
}
|
||||
|
||||
export async function GetGoalWithID(goalID: string, game: GameGroup, playtype: Playtype) {
|
||||
const res = await TachiServerV1Get<{ goal: MONGO_GoalDocument }>(
|
||||
`/games/${game}/${playtype}/targets/goals/${goalID}`,
|
||||
export async function GetGoalWithID(goalID: string, game: V3Game) {
|
||||
const res = await TachiServerV1Get<{ goal: GoalDocument }>(
|
||||
`/games/${game}/targets/goals/${goalID}`,
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -56,9 +52,9 @@ export async function GetGoalWithID(goalID: string, game: GameGroup, playtype: P
|
||||
return res.body.goal;
|
||||
}
|
||||
|
||||
export async function GetQuestWithID(questID: string, game: GameGroup, playtype: Playtype) {
|
||||
const res = await TachiServerV1Get<{ quest: MONGO_QuestDocument }>(
|
||||
`/games/${game}/${playtype}/targets/quests/${questID}`,
|
||||
export async function GetQuestWithID(questID: string, game: V3Game) {
|
||||
const res = await TachiServerV1Get<{ quest: QuestDocument }>(
|
||||
`/games/${game}/targets/quests/${questID}`,
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -69,14 +65,9 @@ export async function GetQuestWithID(questID: string, game: GameGroup, playtype:
|
||||
return res.body.quest;
|
||||
}
|
||||
|
||||
export async function GetChartInfoForUser(
|
||||
userID: string | integer,
|
||||
chartID: string,
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
) {
|
||||
const res = await TachiServerV1Get<{ chart: MONGO_ChartDocument; song: MONGO_SongDocument }>(
|
||||
`/games/${game}/${playtype}/charts/${chartID}`,
|
||||
export async function GetChartInfoForUser(userID: string | integer, chartID: string, game: V3Game) {
|
||||
const res = await TachiServerV1Get<{ chart: ChartDocument; song: SongDocument }>(
|
||||
`/games/${game}/charts/${chartID}`,
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -84,8 +75,8 @@ export async function GetChartInfoForUser(
|
||||
throw new Error(`Failed to fetch song/chart with chartID ${chartID}.`);
|
||||
}
|
||||
|
||||
const pbRes = await TachiServerV1Get<{ chart: MONGO_ChartDocument; pb: MONGO_PBScoreDocument }>(
|
||||
`/users/${userID}/games/${game}/${playtype}/pbs/${chartID}`,
|
||||
const pbRes = await TachiServerV1Get<{ chart: ChartDocument; pb: PBScoreDocument }>(
|
||||
`/users/${userID}/games/${game}/pbs/${chartID}`,
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -104,7 +95,7 @@ export async function PerformScoreImport(
|
||||
body: Record<string, unknown>,
|
||||
interaction?: CommandInteraction,
|
||||
) {
|
||||
const initRes = await TachiServerV1Request<ImportDeferred | MONGO_ImportDocument>(
|
||||
const initRes = await TachiServerV1Request<ImportDeferred | ImportDocument>(
|
||||
RequestTypes.POST,
|
||||
url,
|
||||
authToken,
|
||||
@@ -122,7 +113,7 @@ export async function PerformScoreImport(
|
||||
|
||||
// this server does not defer imports to a scorequeue
|
||||
if (initRes.statusCode === 200) {
|
||||
const result = initRes.body as MONGO_ImportDocument;
|
||||
const result = initRes.body as ImportDocument;
|
||||
|
||||
return result;
|
||||
} else if (initRes.statusCode === 202) {
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import type { CommandInteraction } from "discord.js";
|
||||
import type { PrivDiscordUserMap } from "tachi-db";
|
||||
|
||||
import { type GameGroup, type MONGO_UserDocument, type Playtype } from "tachi-common";
|
||||
import { IsValidGame, type UserDocument, type V3Game } from "tachi-common";
|
||||
|
||||
import type { Emittable } from "../slash-commands/types";
|
||||
|
||||
import { GetUserInfo } from "./api-requests";
|
||||
import { ParseGPT } from "./misc";
|
||||
|
||||
/**
|
||||
* Utility parser for getting the game, playtype and requesting user, since this is
|
||||
* Utility parser for getting the game and requesting user, since this is
|
||||
* a common pattern in the bot.
|
||||
*/
|
||||
export async function GetGPTAndUser(
|
||||
export async function GetGameAndUser(
|
||||
interaction: CommandInteraction,
|
||||
requestingUser: PrivDiscordUserMap,
|
||||
): Promise<
|
||||
| { content: { game: GameGroup; playtype: Playtype; userDoc: MONGO_UserDocument }; error: null }
|
||||
| { error: Emittable }
|
||||
{ content: { game: V3Game; userDoc: UserDocument }; error: null } | { error: Emittable }
|
||||
> {
|
||||
const userID = interaction.options.getString("other_user") ?? requestingUser.user_id.toString();
|
||||
|
||||
@@ -33,7 +31,11 @@ export async function GetGPTAndUser(
|
||||
return { error: `This user does not exist.` };
|
||||
}
|
||||
|
||||
const { game, playtype } = ParseGPT(interaction.options.getString("game", true));
|
||||
const game = interaction.options.getString("game", true);
|
||||
|
||||
return { error: null, content: { userDoc, game, playtype } };
|
||||
if (!IsValidGame(game)) {
|
||||
return { error: `Invalid game: ${game}.` };
|
||||
}
|
||||
|
||||
return { error: null, content: { userDoc, game } };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { integer, MONGO_ImportDocument, MONGO_UserDocument } from "tachi-common";
|
||||
import type { ImportDocument, integer, UserDocument } from "tachi-common";
|
||||
|
||||
import { MessageEmbed } from "discord.js";
|
||||
|
||||
@@ -18,7 +18,7 @@ export function CreateEmbed(userID?: integer) {
|
||||
return embed;
|
||||
}
|
||||
|
||||
export function CreateImportEmbed(importDoc: MONGO_ImportDocument) {
|
||||
export function CreateImportEmbed(importDoc: ImportDocument) {
|
||||
return CreateEmbed()
|
||||
.setTitle(
|
||||
`Imported ${importDoc.scoreIDs.length} ${Pluralise(
|
||||
@@ -30,11 +30,11 @@ export function CreateImportEmbed(importDoc: MONGO_ImportDocument) {
|
||||
.addField("Errors", importDoc.errors.length.toString(), true)
|
||||
.addField(
|
||||
"Your Profile",
|
||||
`${Env.TACHI_SERVER_LOCATION}/u/${importDoc.userID}/games/${importDoc.game}`,
|
||||
`${Env.TACHI_SERVER_LOCATION}/u/${importDoc.userID}/games/${importDoc.games[0]}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateUserEmbed(userDoc: MONGO_UserDocument) {
|
||||
export function CreateUserEmbed(userDoc: UserDocument) {
|
||||
return CreateEmbed()
|
||||
.setTitle(`${userDoc.username} (ID: ${userDoc.id})`)
|
||||
.setThumbnail(PrependTachiUrl(`/users/${userDoc.id}/pfp`))
|
||||
|
||||
@@ -4,15 +4,11 @@ import type { ClassInfo } from "tachi-common/types/game-config-utils";
|
||||
import _ from "lodash";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
type ChartDocument,
|
||||
type Classes,
|
||||
type GameGroup,
|
||||
type GameGroupConfig,
|
||||
GetGameGroupConfig,
|
||||
GetGamePTConfig,
|
||||
type GPTString,
|
||||
GetGameConfig,
|
||||
type integer,
|
||||
type MONGO_ChartDocument,
|
||||
type Playtype,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
import { Env } from "../config";
|
||||
@@ -40,22 +36,6 @@ export function Entries<K extends string, V>(rec: Partial<Record<K, V>>): Array<
|
||||
return Object.entries(rec) as Array<[K, V]>;
|
||||
}
|
||||
|
||||
export function ParseGPT(str: string) {
|
||||
const spl = str.split(":");
|
||||
|
||||
const game = spl[0] as GameGroup;
|
||||
const playtype = spl[1] as Playtype;
|
||||
|
||||
// this is an interesting way of game checking noah
|
||||
const gameConfig = GetGameGroupConfig(game) as GameGroupConfig | undefined;
|
||||
|
||||
if (!gameConfig?.playtypes.includes(playtype)) {
|
||||
throw new Error(`Invalid GPT Combination '${str}'.`);
|
||||
}
|
||||
|
||||
return { game, playtype };
|
||||
}
|
||||
|
||||
export function UppercaseFirst(str: string) {
|
||||
if (!str[0]) {
|
||||
return "";
|
||||
@@ -72,24 +52,17 @@ export function FormatDate(ms: number) {
|
||||
return DateTime.fromMillis(ms).toLocaleString(DateTime.DATE_HUGE);
|
||||
}
|
||||
|
||||
export function FormatClass(
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
classSet: Classes[GPTString],
|
||||
classValue: string,
|
||||
) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
export function FormatClass(game: V3Game, classSet: Classes[V3Game], classValue: string) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
// @ts-expect-error hacky access
|
||||
|
||||
const classInfo: ClassInfo = gptConfig.classes[classSet]?.values?.find?.(
|
||||
const classInfo: ClassInfo = gameConfig.classes[classSet]?.values?.find?.(
|
||||
(k) => k.id === classValue,
|
||||
);
|
||||
|
||||
if (!classInfo) {
|
||||
throw new Error(
|
||||
`Couldn't find a class at index ${classValue} for ${game} ${playtype} ${classSet}?`,
|
||||
);
|
||||
throw new Error(`Couldn't find a class at index ${classValue} for ${game} ${classSet}?`);
|
||||
}
|
||||
|
||||
return classInfo.display as string;
|
||||
@@ -98,7 +71,7 @@ export function FormatClass(
|
||||
/**
|
||||
* Given a game, return the discord channel it's associated with.
|
||||
*/
|
||||
export function GetGameChannel(client: Client, game: GameGroup) {
|
||||
export function GetGameChannel(client: Client, game: V3Game) {
|
||||
const gameChannelID = Env.DISCORD_GAME_CHANNELS[game];
|
||||
|
||||
if (!gameChannelID) {
|
||||
@@ -145,14 +118,8 @@ export function GetLimboChannel(client: Client) {
|
||||
/**
|
||||
* Given a chart and a game, return a link to the site for that chart.
|
||||
*/
|
||||
export function CreateChartLink(chart: MONGO_ChartDocument, game: GameGroup) {
|
||||
if (chart.isPrimary) {
|
||||
return `${Env.TACHI_SERVER_LOCATION}/games/${game}/${chart.playtype}/songs/${
|
||||
chart.songID
|
||||
}/${encodeURIComponent(chart.difficulty)}`;
|
||||
}
|
||||
|
||||
return `${Env.TACHI_SERVER_LOCATION}/games/${game}/${chart.playtype}/songs/${chart.songID}/${chart.chartID}`;
|
||||
export function CreateChartLink(chart: ChartDocument) {
|
||||
return `${Env.TACHI_SERVER_LOCATION}/games/${chart.game}/charts/${chart.chartID}`;
|
||||
}
|
||||
|
||||
export function ConvertInputIntoGenerousRegex(input: string) {
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import type { SlashCommandStringOption } from "@discordjs/builders";
|
||||
|
||||
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
|
||||
import { FormatGame, GetGameGroupConfig } from "tachi-common";
|
||||
|
||||
import { ServerConfig } from "../config";
|
||||
|
||||
/**
|
||||
* Game Playtype options. Frequently used by things that might need
|
||||
* Game options. Frequently used by things that might need
|
||||
* game specific listening.
|
||||
*/
|
||||
const GPTChoices: Array<[string, string]> = [];
|
||||
const GameChoices: Array<[string, string]> = [];
|
||||
|
||||
for (const game of ServerConfig.GAMES) {
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
for (const gameGroup of ServerConfig.GAME_GROUPS) {
|
||||
const gameGroupConfig = GetGameGroupConfig(gameGroup);
|
||||
|
||||
for (const playtype of gameConfig.playtypes) {
|
||||
GPTChoices.push([FormatGameGroup(game, playtype), `${game}:${playtype}`]);
|
||||
for (const game of gameGroupConfig.games) {
|
||||
GameChoices.push([FormatGame(game), game]);
|
||||
}
|
||||
}
|
||||
|
||||
export const GPTOptions = (str: SlashCommandStringOption) =>
|
||||
str.setName("game").setDescription("Pick the relevant game.").addChoices(GPTChoices);
|
||||
export const GameOptions = (str: SlashCommandStringOption) =>
|
||||
str.setName("game").setDescription("Pick the relevant game.").addChoices(GameChoices);
|
||||
|
||||
export function MakeRequired(fn: (str: SlashCommandStringOption) => SlashCommandStringOption) {
|
||||
return (str: SlashCommandStringOption) => fn(str).setRequired(true);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type {
|
||||
APIPermissions,
|
||||
GPTString,
|
||||
ChartDocument,
|
||||
ImportDocument,
|
||||
integer,
|
||||
MONGO_ChartDocument,
|
||||
MONGO_ImportDocument,
|
||||
MONGO_ScoreDocument,
|
||||
MONGO_SongDocument,
|
||||
MONGO_UserGameStats,
|
||||
ProfileRatingAlgorithms,
|
||||
ScoreDocument,
|
||||
SongDocument,
|
||||
UserGameStats,
|
||||
V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
export interface ServerStatus {
|
||||
@@ -25,7 +25,7 @@ export interface ImportDeferred {
|
||||
|
||||
export type ImportPollStatus =
|
||||
| {
|
||||
import: MONGO_ImportDocument;
|
||||
import: ImportDocument;
|
||||
importStatus: "completed";
|
||||
}
|
||||
| {
|
||||
@@ -36,15 +36,15 @@ export type ImportPollStatus =
|
||||
};
|
||||
};
|
||||
|
||||
export interface UGPTStats<GPT extends GPTString = GPTString> {
|
||||
gameStats: MONGO_UserGameStats;
|
||||
firstScore: MONGO_ScoreDocument;
|
||||
mostRecentScore: MONGO_ScoreDocument;
|
||||
export interface UGPTStats<TGame extends V3Game = V3Game> {
|
||||
gameStats: UserGameStats;
|
||||
firstScore: ScoreDocument;
|
||||
mostRecentScore: ScoreDocument;
|
||||
totalScores: integer;
|
||||
rankingData: Record<ProfileRatingAlgorithms[GPT], { outOf: integer; ranking: integer }>;
|
||||
rankingData: Record<ProfileRatingAlgorithms[TGame], { outOf: integer; ranking: integer }>;
|
||||
}
|
||||
|
||||
export interface ChartQueryReturns {
|
||||
charts: Array<{ __playcount: integer } & MONGO_ChartDocument>;
|
||||
songs: Array<MONGO_SongDocument>;
|
||||
charts: Array<{ __playcount: integer } & ChartDocument>;
|
||||
songs: Array<SongDocument>;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { log } from "#utils/log";
|
||||
import {
|
||||
type Classes,
|
||||
FormatGameGroup,
|
||||
type GameGroup,
|
||||
GetGamePTConfig,
|
||||
type GPTString,
|
||||
FormatGame,
|
||||
GetGameConfig,
|
||||
type integer,
|
||||
type Playtype,
|
||||
type V3Game,
|
||||
type WebhookEventClassUpdateV1,
|
||||
} from "tachi-common";
|
||||
|
||||
@@ -20,7 +18,7 @@ import { FormatClass, GetGameChannel } from "../utils/misc";
|
||||
export async function HandleClassUpdateV1(
|
||||
event: WebhookEventClassUpdateV1["content"],
|
||||
): Promise<integer> {
|
||||
const { game, playtype } = event;
|
||||
const { game } = event;
|
||||
|
||||
let channel;
|
||||
|
||||
@@ -33,7 +31,7 @@ export async function HandleClassUpdateV1(
|
||||
return 500;
|
||||
}
|
||||
|
||||
if (!ShouldRenderUpdate(game, playtype, event.set, event.new)) {
|
||||
if (!ShouldRenderUpdate(game, event.set, event.new)) {
|
||||
log.info(
|
||||
`Not rendering class update ${event.set}: ${event.old} -> ${event.new} (not relevant).`,
|
||||
);
|
||||
@@ -42,10 +40,10 @@ export async function HandleClassUpdateV1(
|
||||
|
||||
const userDoc = await GetUserInfo(event.userID);
|
||||
|
||||
const minimumNecessaryScores = GetMinimumScores(game, playtype, event.set);
|
||||
const minimumNecessaryScores = GetMinimumScores(game, event.set);
|
||||
|
||||
if (minimumNecessaryScores !== null) {
|
||||
const { totalScores } = await GetUGPTStats(userDoc.id, game, playtype);
|
||||
const { totalScores } = await GetUGPTStats(userDoc.id, game);
|
||||
|
||||
// Do not render if the user hasn't hit the score cap.
|
||||
if (totalScores < minimumNecessaryScores) {
|
||||
@@ -56,19 +54,15 @@ export async function HandleClassUpdateV1(
|
||||
}
|
||||
}
|
||||
|
||||
const newClass = FormatClass(game, playtype, event.set, event.new);
|
||||
const newClass = FormatClass(game, event.set, event.new);
|
||||
|
||||
const embed = CreateEmbed()
|
||||
.setTitle(
|
||||
`${userDoc.username} just achieved ${newClass} in ${FormatGameGroup(game, playtype)}!`,
|
||||
)
|
||||
.setURL(`${Env.TACHI_SERVER_LOCATION}/users/${userDoc.username}/games/${game}/${playtype}`)
|
||||
.setTitle(`${userDoc.username} just achieved ${newClass} in ${FormatGame(game)}!`)
|
||||
.setURL(`${Env.TACHI_SERVER_LOCATION}/u/${userDoc.username}/games/${game}`)
|
||||
.setThumbnail(PrependTachiUrl(`/users/${userDoc.id}/pfp`));
|
||||
|
||||
if (event.old !== null) {
|
||||
embed.setDescription(
|
||||
`(This was raised from ${FormatClass(game, playtype, event.set, event.old)}.)`,
|
||||
);
|
||||
embed.setDescription(`(This was raised from ${FormatClass(game, event.set, event.old)}.)`);
|
||||
}
|
||||
|
||||
await channel.send({ embeds: [embed] });
|
||||
@@ -79,17 +73,12 @@ export async function HandleClassUpdateV1(
|
||||
/**
|
||||
* Returns Whether this class update is notable enough to be rendered or not.
|
||||
*/
|
||||
function ShouldRenderUpdate(
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
classSet: Classes[GPTString],
|
||||
classValue: string,
|
||||
) {
|
||||
const config = GetGamePTConfig(game, playtype);
|
||||
function ShouldRenderUpdate(game: V3Game, classSet: Classes[V3Game], classValue: string) {
|
||||
const config = GetGameConfig(game);
|
||||
const classSpec = config.classes[classSet];
|
||||
|
||||
if (classSpec === undefined) {
|
||||
log.error(`Invalid class ${classSet} for ${game} ${playtype}`);
|
||||
log.error(`Invalid class ${classSet} for ${game}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -103,28 +92,24 @@ function ShouldRenderUpdate(
|
||||
const minimumId = ids.indexOf(classSpec.minimumRelevantValue);
|
||||
|
||||
if (currentId < 0) {
|
||||
log.error(`Invalid classValue ${classValue} for ${game} ${playtype}`);
|
||||
log.error(`Invalid classValue ${classValue} for ${game}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (minimumId < 0) {
|
||||
log.error(`Invalid minimum classValue ${classValue} for ${game} ${playtype}`);
|
||||
log.error(`Invalid minimum classValue ${classValue} for ${game}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return currentId >= minimumId;
|
||||
}
|
||||
|
||||
function GetMinimumScores(
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
classSet: Classes[GPTString],
|
||||
): integer | null {
|
||||
const config = GetGamePTConfig(game, playtype);
|
||||
function GetMinimumScores(game: V3Game, classSet: Classes[V3Game]): integer | null {
|
||||
const config = GetGameConfig(game);
|
||||
const classSpec = config.classes[classSet];
|
||||
|
||||
if (classSpec === undefined) {
|
||||
log.error(`Invalid class ${classSet} for ${game} ${playtype}`);
|
||||
log.error(`Invalid class ${classSet} for ${game}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Env } from "#config";
|
||||
import { PrependTachiUrl } from "#utils/fetch-tachi";
|
||||
import { log } from "#utils/log";
|
||||
import {
|
||||
GetGameGroupConfig,
|
||||
FormatGame,
|
||||
type GoalDocument,
|
||||
type integer,
|
||||
type MONGO_GoalDocument,
|
||||
type WebhookEventGoalAchievedV1,
|
||||
} from "tachi-common";
|
||||
|
||||
@@ -32,19 +32,15 @@ export async function HandleGoalAchievedV1(
|
||||
const userDoc = await GetUserInfo(event.userID);
|
||||
|
||||
const goalDocuments = await Promise.all(
|
||||
event.goals.map((e) => GetGoalWithID(e.goalID, game, e.playtype)),
|
||||
event.goals.map((e) => GetGoalWithID(e.goalID, e.game)),
|
||||
);
|
||||
|
||||
const goalMap = new Map<string, MONGO_GoalDocument>();
|
||||
const goalMap = new Map<string, GoalDocument>();
|
||||
|
||||
for (const goalDoc of goalDocuments) {
|
||||
goalMap.set(goalDoc.goalID, goalDoc);
|
||||
}
|
||||
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
|
||||
const shouldShowPlaytype = gameConfig.playtypes.length !== 1;
|
||||
|
||||
const embed = CreateEmbed(userDoc.id)
|
||||
.setTitle(
|
||||
`${userDoc.username} just achieved ${event.goals.length} ${Pluralise(
|
||||
@@ -66,7 +62,7 @@ export async function HandleGoalAchievedV1(
|
||||
: `${e.old.progressHuman}/${e.old.outOfHuman} -> ${e.new.progressHuman}/${e.new.outOfHuman}`;
|
||||
|
||||
return {
|
||||
name: `${goal.name}${shouldShowPlaytype ? ` (${e.playtype})` : ""}`,
|
||||
name: `${goal.name} (${FormatGame(e.game)})`,
|
||||
value,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Env } from "#config";
|
||||
import { PrependTachiUrl } from "#utils/fetch-tachi";
|
||||
import { log } from "#utils/log";
|
||||
import { GetGameGroupConfig, type integer, type WebhookEventQuestAchievedV1 } from "tachi-common";
|
||||
import { FormatGame, type integer, type WebhookEventQuestAchievedV1 } from "tachi-common";
|
||||
|
||||
import { client } from "../main";
|
||||
import { GetQuestWithID, GetUserInfo } from "../utils/api-requests";
|
||||
@@ -11,7 +11,7 @@ import { GetGameChannel } from "../utils/misc";
|
||||
export async function HandleQuestAchievedV1(
|
||||
event: WebhookEventQuestAchievedV1["content"],
|
||||
): Promise<integer> {
|
||||
const { game, playtype } = event;
|
||||
const { game } = event;
|
||||
|
||||
let channel;
|
||||
|
||||
@@ -26,16 +26,13 @@ export async function HandleQuestAchievedV1(
|
||||
|
||||
const userDoc = await GetUserInfo(event.userID);
|
||||
|
||||
const quest = await GetQuestWithID(event.questID, game, playtype);
|
||||
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
const shouldShowPlaytype = gameConfig.playtypes.length > 1 ? ` (${playtype})` : "";
|
||||
const quest = await GetQuestWithID(event.questID, game);
|
||||
|
||||
const embed = CreateEmbed(userDoc.id)
|
||||
.setThumbnail(PrependTachiUrl(`/users/${userDoc.id}/pfp`))
|
||||
.setURL(`${Env.TACHI_SERVER_LOCATION}/u/${userDoc.username}`)
|
||||
.setTitle(
|
||||
`${userDoc.username} just completed the ${quest.name}${shouldShowPlaytype} quest!`,
|
||||
`${userDoc.username} just completed the ${quest.name} (${FormatGame(game)}) quest!`,
|
||||
);
|
||||
|
||||
await channel.send({ embeds: [embed] });
|
||||
|
||||
@@ -17,6 +17,8 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
|
||||
// Static env vars. POSTGRES_URL is set dynamically per-worker in vitest.setup.ts
|
||||
// so each worker gets its own isolated database.
|
||||
env: {
|
||||
@@ -38,7 +40,9 @@ export default defineConfig({
|
||||
pool: "forks",
|
||||
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: "v8",
|
||||
reporter: ["text", "html", "clover", "json", "lcov"],
|
||||
include: ["src/actions/**"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { allImportTypes } from "tachi-common/constants/import-types";
|
||||
|
||||
const WORKER_ID = crypto.randomUUID().slice(0, 8);
|
||||
const WORKER_DB_NAME = `tachi_bot_test_${WORKER_ID}`;
|
||||
@@ -35,10 +36,10 @@ globalThis.fetch = (url: string | Request | URL): Promise<Response> => {
|
||||
success: true,
|
||||
body: {
|
||||
NAME: "Test Tachi",
|
||||
GAMES: [],
|
||||
VERSIONS: {},
|
||||
SUPPORTS_OAUTH2: false,
|
||||
OAUTH_CLIENT_ID: null,
|
||||
TYPE: "boku",
|
||||
SIGNUPS_ENABLED: true,
|
||||
GAME_GROUPS: ["iidx"],
|
||||
IMPORT_TYPES: [...allImportTypes],
|
||||
},
|
||||
}),
|
||||
} as unknown as Response);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { TachiConfig } from "#lib/config";
|
||||
import { APIFetchV1 } from "#util/api";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { type MONGO_APITokenDocument, type MONGO_TachiAPIClientDocument } from "tachi-common";
|
||||
import { type APITokenDocument, type TachiAPIClientDocument } from "tachi-common";
|
||||
|
||||
export default function ClientFileFlowPage() {
|
||||
const { clientID } = useParams<{ clientID: string }>();
|
||||
@@ -17,7 +17,7 @@ export default function ClientFileFlowPage() {
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const { data, error } = useApiQuery<Omit<MONGO_TachiAPIClientDocument, "clientSecret">>(
|
||||
const { data, error } = useApiQuery<Omit<TachiAPIClientDocument, "clientSecret">>(
|
||||
`/clients/${clientID}`,
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function ClientFileFlowPage() {
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={async () => {
|
||||
const res = await APIFetchV1<MONGO_APITokenDocument>(
|
||||
const res = await APIFetchV1<APITokenDocument>(
|
||||
`/users/${user?.id}/api-tokens/create`,
|
||||
{
|
||||
method: "POST",
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Alert, Button, Form } from "react-bootstrap";
|
||||
import ReCAPTCHA from "react-google-recaptcha";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { type MONGO_UserDocument } from "tachi-common";
|
||||
import { type UserDocument } from "tachi-common";
|
||||
|
||||
export default function LoginPage() {
|
||||
useSetSubheader("Login");
|
||||
@@ -83,7 +83,7 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const userRJ = await APIFetchV1<MONGO_UserDocument>("/users/me");
|
||||
const userRJ = await APIFetchV1<UserDocument>("/users/me");
|
||||
|
||||
if (userRJ.statusCode === 403) {
|
||||
setErr("You are banned.");
|
||||
|
||||
@@ -8,7 +8,7 @@ import useQueryString from "#components/util/useQueryString";
|
||||
import { APIFetchV1 } from "#util/api";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { type integer, type MONGO_TachiAPIClientDocument } from "tachi-common";
|
||||
import { type integer, type TachiAPIClientDocument } from "tachi-common";
|
||||
|
||||
export default function OAuthRequestAuthPage() {
|
||||
const params = useQueryString();
|
||||
@@ -32,7 +32,7 @@ export default function OAuthRequestAuthPage() {
|
||||
}
|
||||
|
||||
function OAuthRequestAuthLoader({ clientID }: { clientID: string }) {
|
||||
const { error, data } = useApiQuery<Omit<MONGO_TachiAPIClientDocument, "clientSecret">>(
|
||||
const { error, data } = useApiQuery<Omit<TachiAPIClientDocument, "clientSecret">>(
|
||||
`/clients/${clientID}`,
|
||||
);
|
||||
|
||||
@@ -61,7 +61,7 @@ function OAuthRequestAuthLoader({ clientID }: { clientID: string }) {
|
||||
function OAuthRequestAuthMain({
|
||||
client,
|
||||
}: {
|
||||
client: Omit<MONGO_TachiAPIClientDocument, "clientSecret">;
|
||||
client: Omit<TachiAPIClientDocument, "clientSecret">;
|
||||
}) {
|
||||
const params = useQueryString();
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Alert, Button, Form } from "react-bootstrap";
|
||||
import ReCAPTCHA from "react-google-recaptcha";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { type MONGO_UserDocument } from "tachi-common";
|
||||
import { type UserDocument } from "tachi-common";
|
||||
|
||||
// seconds it takes for a user to actually read the rules.
|
||||
const RULES_READ_TIME = Number(import.meta.env.VITE_RULES_READ_TIME) || 30;
|
||||
@@ -62,7 +62,7 @@ export default function RegisterPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const rj = await APIFetchV1<MONGO_UserDocument>(
|
||||
const rj = await APIFetchV1<UserDocument>(
|
||||
"/auth/register",
|
||||
{
|
||||
method: "POST",
|
||||
|
||||
@@ -54,22 +54,11 @@ function resultVariant(result: string): "danger" | "secondary" | "success" | "wa
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function JsonCell({ label, value }: { label: string; value: unknown }) {
|
||||
if (value === null || value === undefined) {
|
||||
return <span className="text-body-secondary">—</span>;
|
||||
function formatJson(value: unknown): string {
|
||||
if (value === undefined) {
|
||||
return "";
|
||||
}
|
||||
const json = JSON.stringify(value, null, 2);
|
||||
return (
|
||||
<details>
|
||||
<summary className="small text-body-secondary">{label}</summary>
|
||||
<pre
|
||||
className="small mb-0 mt-1 p-2 bg-body-secondary rounded"
|
||||
style={{ maxWidth: "20rem" }}
|
||||
>
|
||||
{json}
|
||||
</pre>
|
||||
</details>
|
||||
);
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
export default function AdminActionsPage() {
|
||||
@@ -78,8 +67,22 @@ export default function AdminActionsPage() {
|
||||
const history = useHistory();
|
||||
const apiUrl = `/admin/actions${location.search}`;
|
||||
|
||||
const [expandedIds, setExpandedIds] = React.useState(() => new Set<string>());
|
||||
|
||||
const { data, error, isLoading } = useApiQuery<ActionsResponse>(apiUrl);
|
||||
|
||||
function toggleIoExpanded(rowId: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(rowId)) {
|
||||
next.delete(rowId);
|
||||
} else {
|
||||
next.add(rowId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className="text-danger">Failed to load actions.</p>;
|
||||
}
|
||||
@@ -159,50 +162,102 @@ export default function AdminActionsPage() {
|
||||
<th>Result</th>
|
||||
<th>User</th>
|
||||
<th>IP</th>
|
||||
<th>Input</th>
|
||||
<th>Output</th>
|
||||
<th className="text-nowrap">Input / output</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{actions.items.length === 0 ? (
|
||||
<tr>
|
||||
<td className="text-body-secondary" colSpan={8}>
|
||||
<td className="text-body-secondary" colSpan={7}>
|
||||
No actions found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
actions.items.map((action) => (
|
||||
<tr key={action.row_id}>
|
||||
<td className="small text-nowrap">
|
||||
{MillisToSince(Date.parse(action.ts_start))}
|
||||
</td>
|
||||
<td className="font-monospace small">
|
||||
{durationMs(action.ts_start, action.ts_end)}
|
||||
</td>
|
||||
<td className="small">{action.kind}</td>
|
||||
<td>
|
||||
<Badge bg={resultVariant(action.result)}>
|
||||
{action.result}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
{action.username ? (
|
||||
<Link to={`/u/${action.username}`}>
|
||||
{action.username}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-body-secondary">—</span>
|
||||
actions.items.map((action) => {
|
||||
const ioOpen = expandedIds.has(action.row_id);
|
||||
return (
|
||||
<React.Fragment key={action.row_id}>
|
||||
<tr>
|
||||
<td className="small text-nowrap">
|
||||
{MillisToSince(Date.parse(action.ts_start))}
|
||||
</td>
|
||||
<td className="font-monospace small">
|
||||
{durationMs(action.ts_start, action.ts_end)}
|
||||
</td>
|
||||
<td className="small">{action.kind}</td>
|
||||
<td>
|
||||
<Badge bg={resultVariant(action.result)}>
|
||||
{action.result}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
{action.username ? (
|
||||
<Link to={`/u/${action.username}`}>
|
||||
{action.username}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-body-secondary">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="font-monospace small">
|
||||
{action.ip ?? "—"}
|
||||
</td>
|
||||
<td className="align-middle">
|
||||
<Button
|
||||
aria-expanded={ioOpen}
|
||||
className="text-nowrap"
|
||||
onClick={() => toggleIoExpanded(action.row_id)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline-secondary"
|
||||
>
|
||||
{ioOpen ? "Hide I/O" : "Show I/O"}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{ioOpen && (
|
||||
<tr className="table-light">
|
||||
<td className="border-top-0 p-3" colSpan={7}>
|
||||
<div className="d-flex flex-column flex-lg-row gap-3 align-items-stretch">
|
||||
<div className="flex-fill d-flex flex-column min-w-0">
|
||||
<Form.Label className="small mb-1 text-body-secondary">
|
||||
Input
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
className="font-monospace small"
|
||||
readOnly
|
||||
rows={14}
|
||||
spellCheck={false}
|
||||
value={formatJson(action.input)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="d-flex align-items-center justify-content-center px-1 text-body-secondary fs-4"
|
||||
>
|
||||
→
|
||||
</div>
|
||||
<div className="flex-fill d-flex flex-column min-w-0">
|
||||
<Form.Label className="small mb-1 text-body-secondary">
|
||||
Output
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
className="font-monospace small"
|
||||
readOnly
|
||||
rows={14}
|
||||
spellCheck={false}
|
||||
value={formatJson(action.output)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</td>
|
||||
<td className="font-monospace small">{action.ip ?? "—"}</td>
|
||||
<td>
|
||||
<JsonCell label="Input" value={action.input} />
|
||||
</td>
|
||||
<td>
|
||||
<JsonCell label="Output" value={action.output} />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
@@ -12,14 +12,14 @@ export default function AdminDestructivePage() {
|
||||
const [deleteSessionId, setDeleteSessionId] = useState("");
|
||||
|
||||
const [ugptUserId, setUgptUserId] = useState("");
|
||||
const [ugptGame, setUgptGame] = useState<GameGroup>(TachiConfig.GAMES[0]);
|
||||
const [ugptGame, setUgptGame] = useState<GameGroup>(TachiConfig.GAME_GROUPS[0]);
|
||||
const ugptGameConfig = useMemo(() => GetGameGroupConfig(ugptGame), [ugptGame]);
|
||||
const [ugptPlaytype, setUgptPlaytype] = useState<string>(
|
||||
() => GetGameGroupConfig(TachiConfig.GAMES[0]).playtypes[0],
|
||||
() => GetGameGroupConfig(TachiConfig.GAME_GROUPS[0]).playtypes[0],
|
||||
);
|
||||
|
||||
const [destroyChartId, setDestroyChartId] = useState("");
|
||||
const [destroyChartGame, setDestroyChartGame] = useState<GameGroup>(TachiConfig.GAMES[0]);
|
||||
const [destroyChartGame, setDestroyChartGame] = useState<GameGroup>(TachiConfig.GAME_GROUPS[0]);
|
||||
|
||||
function confirmDelete(message: string): boolean {
|
||||
return window.confirm(message);
|
||||
@@ -138,7 +138,7 @@ export default function AdminDestructivePage() {
|
||||
}}
|
||||
value={ugptGame}
|
||||
>
|
||||
{TachiConfig.GAMES.map((g) => (
|
||||
{TachiConfig.GAME_GROUPS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
@@ -208,7 +208,7 @@ export default function AdminDestructivePage() {
|
||||
onChange={(e) => setDestroyChartGame(e.target.value as GameGroup)}
|
||||
value={destroyChartGame}
|
||||
>
|
||||
{TachiConfig.GAMES.map((g) => (
|
||||
{TachiConfig.GAME_GROUPS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
|
||||
@@ -3,7 +3,12 @@ import { TachiConfig } from "#lib/config";
|
||||
import { APIFetchV1 } from "#util/api";
|
||||
import React, { useState } from "react";
|
||||
import { Button, Card, Col, Form, Row } from "react-bootstrap";
|
||||
import { FormatGameGroup, type GameGroup, GetGameGroupConfig } from "tachi-common";
|
||||
import {
|
||||
FormatGame,
|
||||
type GameGroup,
|
||||
GetGameGroupConfig,
|
||||
LEGACY_GameGroupPTToGame,
|
||||
} from "tachi-common";
|
||||
|
||||
export default function AdminOperationsPage() {
|
||||
useSetSubheader(["Admin", "Operations"]);
|
||||
@@ -46,7 +51,7 @@ export default function AdminOperationsPage() {
|
||||
value={announcementGame === "" ? "" : announcementGame}
|
||||
>
|
||||
<option value="">— Site-wide —</option>
|
||||
{TachiConfig.GAMES.map((g) => (
|
||||
{TachiConfig.GAME_GROUPS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
@@ -63,7 +68,12 @@ export default function AdminOperationsPage() {
|
||||
<option value="">—</option>
|
||||
{announcementGameConfig.playtypes.map((pt) => (
|
||||
<option key={pt} value={pt}>
|
||||
{FormatGameGroup(announcementGame as GameGroup, pt)}
|
||||
{FormatGame(
|
||||
LEGACY_GameGroupPTToGame(
|
||||
announcementGame as GameGroup,
|
||||
pt,
|
||||
),
|
||||
)}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
|
||||
@@ -24,7 +24,7 @@ import React, { useContext, useMemo } from "react";
|
||||
import Alert from "react-bootstrap/Alert";
|
||||
import Stack from "react-bootstrap/Stack";
|
||||
import { Link, Route, Switch } from "react-router-dom";
|
||||
import { type MONGO_UserDocument } from "tachi-common";
|
||||
import { type UserDocument } from "tachi-common";
|
||||
|
||||
import SupportBanner from "./misc/SupportBanner";
|
||||
|
||||
@@ -42,7 +42,7 @@ export function DashboardPage() {
|
||||
return <DashboardLoggedIn user={user} />;
|
||||
}
|
||||
|
||||
function DashboardLoggedIn({ user }: { user: MONGO_UserDocument }) {
|
||||
function DashboardLoggedIn({ user }: { user: UserDocument }) {
|
||||
const splash = useMemo(() => RFA(heySplashes), []);
|
||||
|
||||
return (
|
||||
@@ -78,7 +78,7 @@ function DashboardLoggedIn({ user }: { user: MONGO_UserDocument }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RecentInfo({ user }: { user: MONGO_UserDocument }) {
|
||||
function RecentInfo({ user }: { user: UserDocument }) {
|
||||
const { data, error } = useApiQuery<UserRecentSummary>(`/users/${user.id}/recent-summary`);
|
||||
|
||||
if (error) {
|
||||
@@ -91,7 +91,7 @@ function RecentInfo({ user }: { user: MONGO_UserDocument }) {
|
||||
|
||||
const folderInfoMap = new Map();
|
||||
for (const folderInfo of data.recentFolderStats) {
|
||||
folderInfoMap.set(folderInfo.folderID, folderInfo);
|
||||
folderInfoMap.set(folderInfo.slug, folderInfo);
|
||||
}
|
||||
|
||||
const goalMap = CreateGoalMap(data.recentGoals);
|
||||
|
||||
@@ -35,31 +35,36 @@ import Stack from "react-bootstrap/Stack";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link, Route, Switch } from "react-router-dom";
|
||||
import {
|
||||
type ChartDocument,
|
||||
FormatDifficulty,
|
||||
GameToGameGroup,
|
||||
GetGameGroupConfig,
|
||||
type integer,
|
||||
type MONGO_ChartDocument,
|
||||
type MONGO_PBScoreDocument,
|
||||
type MONGO_SongDocument,
|
||||
type MONGO_UserDocument,
|
||||
type PBScoreDocument,
|
||||
type SongDocument,
|
||||
type UserDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
// Wrapper around the chart leaderboard UI; `chart` comes from the `/charts/:chartID` route.
|
||||
export default function GPTChartPage({
|
||||
chart,
|
||||
game,
|
||||
song,
|
||||
playtype,
|
||||
}: {
|
||||
chart: MONGO_ChartDocument | null;
|
||||
song: MONGO_SongDocument;
|
||||
chart: ChartDocument | null;
|
||||
song: SongDocument;
|
||||
} & GamePT) {
|
||||
const formatSongTitle = `${song.artist} - ${song.title}`;
|
||||
const formatDiff = chart ? FormatDifficulty(chart, game) : "Loading...";
|
||||
const formatDiff = chart ? FormatDifficulty(chart) : "Loading...";
|
||||
|
||||
useSetSubheader(
|
||||
["Games", GetGameGroupConfig(game).name, playtype, "Songs", formatSongTitle, formatDiff],
|
||||
[game, playtype, chart],
|
||||
[
|
||||
"Games",
|
||||
GetGameGroupConfig(GameToGameGroup(game)).name,
|
||||
"Songs",
|
||||
formatSongTitle,
|
||||
formatDiff,
|
||||
],
|
||||
[game, chart],
|
||||
`${formatSongTitle} (${formatDiff})`,
|
||||
);
|
||||
|
||||
@@ -67,7 +72,7 @@ export default function GPTChartPage({
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return <InternalGPTChartPage chart={chart} game={game} playtype={playtype} song={song} />;
|
||||
return <InternalGPTChartPage chart={chart} game={game} song={song} />;
|
||||
}
|
||||
|
||||
interface ChartPBData {
|
||||
@@ -81,10 +86,9 @@ function InternalGPTChartPage({
|
||||
chart,
|
||||
game,
|
||||
song,
|
||||
playtype,
|
||||
}: {
|
||||
chart: MONGO_ChartDocument;
|
||||
song: MONGO_SongDocument;
|
||||
chart: ChartDocument;
|
||||
song: SongDocument;
|
||||
} & GamePT) {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
@@ -92,7 +96,7 @@ function InternalGPTChartPage({
|
||||
["PBInfo", chart.chartID],
|
||||
async () => {
|
||||
const lRes = await APIFetchV1<ChartPBLeaderboardReturn>(
|
||||
`/games/${game}/${playtype}/charts/${chart.chartID}/pbs`,
|
||||
`/games/${game}/charts/${chart.chartID}/pbs`,
|
||||
);
|
||||
|
||||
if (!lRes.success) {
|
||||
@@ -100,7 +104,7 @@ function InternalGPTChartPage({
|
||||
}
|
||||
|
||||
const pRes = await APIFetchV1<{ count: integer }>(
|
||||
`/games/${game}/${playtype}/charts/${chart.chartID}/playcount`,
|
||||
`/games/${game}/charts/${chart.chartID}/playcount`,
|
||||
);
|
||||
|
||||
if (!pRes.success) {
|
||||
@@ -109,11 +113,11 @@ function InternalGPTChartPage({
|
||||
|
||||
if (user) {
|
||||
const nRes = await APIFetchV1<UGPTChartLeaderboardAdjacent>(
|
||||
`/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}/leaderboard-adjacent`,
|
||||
`/users/${user.id}/games/${game}/pbs/${chart.chartID}/leaderboard-adjacent`,
|
||||
);
|
||||
|
||||
const rRes = await APIFetchV1<ChartRivalsReturn>(
|
||||
`/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}/rivals`,
|
||||
`/users/${user.id}/games/${game}/pbs/${chart.chartID}/rivals`,
|
||||
);
|
||||
|
||||
const returnValue: ChartPBData = {
|
||||
@@ -163,11 +167,10 @@ function InternalGPTChartPage({
|
||||
}
|
||||
|
||||
if (user) {
|
||||
// Add current user, since there's no guarantee they are returned from either API.
|
||||
userMap.set(user.id, user);
|
||||
}
|
||||
|
||||
const base = CreateChartLink(chart, game);
|
||||
const base = CreateChartLink(chart);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -203,16 +206,15 @@ function InternalGPTChartPage({
|
||||
</Row>
|
||||
<div className="mt-4">
|
||||
<Switch>
|
||||
<Route exact path="/games/:game/:playtype/charts/:chartID/targets">
|
||||
<ChartTargetInfo {...{ chart, game, playtype, song, user: user! }} />
|
||||
<Route exact path="/games/:game/charts/:chartID/targets">
|
||||
<ChartTargetInfo {...{ chart, game, song, user: user! }} />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/games/:game/:playtype/charts/:chartID">
|
||||
<Route exact path="/games/:game/charts/:chartID">
|
||||
<ChartLeaderboardTable
|
||||
{...{
|
||||
data,
|
||||
game,
|
||||
playtype,
|
||||
user,
|
||||
userMap,
|
||||
chart,
|
||||
@@ -222,12 +224,11 @@ function InternalGPTChartPage({
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route exact path="/games/:game/:playtype/charts/:chartID/me">
|
||||
<Route exact path="/games/:game/charts/:chartID/me">
|
||||
<ChartLeaderboardTable
|
||||
{...{
|
||||
data,
|
||||
game,
|
||||
playtype,
|
||||
user,
|
||||
userMap,
|
||||
chart,
|
||||
@@ -237,12 +238,11 @@ function InternalGPTChartPage({
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route exact path="/games/:game/:playtype/charts/:chartID/rivals">
|
||||
<Route exact path="/games/:game/charts/:chartID/rivals">
|
||||
<ChartLeaderboardTable
|
||||
{...{
|
||||
data,
|
||||
game,
|
||||
playtype,
|
||||
user,
|
||||
userMap,
|
||||
chart,
|
||||
@@ -260,21 +260,19 @@ function InternalGPTChartPage({
|
||||
function ChartTargetInfo({
|
||||
user,
|
||||
game,
|
||||
playtype,
|
||||
chart,
|
||||
song,
|
||||
}: {
|
||||
chart: MONGO_ChartDocument;
|
||||
song: MONGO_SongDocument;
|
||||
user: MONGO_UserDocument;
|
||||
chart: ChartDocument;
|
||||
song: SongDocument;
|
||||
user: UserDocument;
|
||||
} & GamePT) {
|
||||
const { reloadTargets } = useContext(TargetsContext);
|
||||
const [shouldReload, setShouldReload] = useState(0);
|
||||
|
||||
const { error, data } = useApiQuery<GoalsOnChartReturn>(
|
||||
`/users/${user.id ?? ""}/games/${game}/${playtype}/targets/on-chart/${chart.chartID}`,
|
||||
`/users/${user.id ?? ""}/games/${game}/targets/on-chart/${chart.chartID}`,
|
||||
undefined,
|
||||
// force a reload of this data when the user adds a new goal
|
||||
[shouldReload.toString()],
|
||||
);
|
||||
|
||||
@@ -287,11 +285,9 @@ function ChartTargetInfo({
|
||||
data,
|
||||
error,
|
||||
game,
|
||||
playtype,
|
||||
reqUser: user,
|
||||
song,
|
||||
onGoalSet: () => {
|
||||
// reload local query, then reload global targets.
|
||||
setShouldReload(shouldReload + 1);
|
||||
reloadTargets();
|
||||
},
|
||||
@@ -306,24 +302,23 @@ function ChartLeaderboardTable({
|
||||
userMap,
|
||||
user: _user,
|
||||
game,
|
||||
playtype,
|
||||
mode,
|
||||
chart,
|
||||
song,
|
||||
}: {
|
||||
chart: MONGO_ChartDocument;
|
||||
chart: ChartDocument;
|
||||
data: ChartPBData;
|
||||
mode: "adjacent" | "leaderboard" | "rivals";
|
||||
song: MONGO_SongDocument;
|
||||
user: MONGO_UserDocument | null;
|
||||
userMap: Map<integer, MONGO_UserDocument>;
|
||||
song: SongDocument;
|
||||
user: UserDocument | null;
|
||||
userMap: Map<integer, UserDocument>;
|
||||
} & GamePT) {
|
||||
const { settings } = useLUGPTSettings();
|
||||
|
||||
const dataset: PBDataset = useMemo(() => {
|
||||
const ds: PBDataset = [];
|
||||
|
||||
let pbs: Array<MONGO_PBScoreDocument> = [];
|
||||
let pbs: Array<PBScoreDocument> = [];
|
||||
if (mode === "leaderboard") {
|
||||
pbs = data.leaderboard.pbs;
|
||||
} else if (mode === "adjacent") {
|
||||
@@ -358,7 +353,6 @@ function ChartLeaderboardTable({
|
||||
defaultRankingViewMode={mode === "rivals" ? "global-no-switch" : "both-if-self"}
|
||||
game={game}
|
||||
key={mode}
|
||||
playtype={playtype}
|
||||
showChart={false}
|
||||
showUser
|
||||
/>
|
||||
@@ -371,20 +365,14 @@ function TopShowcase({
|
||||
userMap,
|
||||
chart,
|
||||
}: {
|
||||
chart: MONGO_ChartDocument;
|
||||
chart: ChartDocument;
|
||||
data: ChartPBData;
|
||||
user: MONGO_UserDocument | null;
|
||||
userMap: Map<integer, MONGO_UserDocument>;
|
||||
user: UserDocument | null;
|
||||
userMap: Map<integer, UserDocument>;
|
||||
}) {
|
||||
// We have a couple of conditions.
|
||||
// User is #1: col-12 #1,
|
||||
// User has played: col-6 col-6,
|
||||
// User has not played: col-12 #1,
|
||||
|
||||
const bestPlay = data.leaderboard.pbs[0]!;
|
||||
const bestUser = userMap.get(bestPlay.userID)!;
|
||||
|
||||
// User hasn't played, or isn't logged in or something.
|
||||
if (user?.id === bestPlay.userID) {
|
||||
return (
|
||||
<Col xs={12}>
|
||||
@@ -428,28 +416,29 @@ function PlayCard({
|
||||
name,
|
||||
chart,
|
||||
}: {
|
||||
chart: MONGO_ChartDocument;
|
||||
chart: ChartDocument;
|
||||
name: string;
|
||||
pb: MONGO_PBScoreDocument;
|
||||
user: MONGO_UserDocument;
|
||||
pb: PBScoreDocument;
|
||||
user: UserDocument;
|
||||
}) {
|
||||
const {
|
||||
breakpoint: { isLg },
|
||||
} = useContext(WindowContext);
|
||||
const pbGame = pb.game;
|
||||
return (
|
||||
<Card cardBodyClassName="vstack gap-4" header={name}>
|
||||
<Stack
|
||||
className="flex-grow-1 align-items-lg-start align-items-center justify-content-around"
|
||||
direction={isLg ? "horizontal" : "vertical"}
|
||||
>
|
||||
<ProfilePicture toGPT={{ game: pb.game, playtype: pb.playtype }} user={user} />
|
||||
<ProfilePicture toGPT={{ game: pbGame }} user={user} />
|
||||
<div
|
||||
className="d-flex flex-column align-self-stretch justify-content-between align-items-center"
|
||||
style={{ maxHeight: 128, minWidth: 256 }}
|
||||
>
|
||||
<Link
|
||||
className="text-decoration-none fs-4 fw-bold text-break text-center"
|
||||
to={`/u/${user.username}/games/${pb.game}/${pb.playtype}`}
|
||||
to={`/u/${user.username}/games/${pbGame}`}
|
||||
>
|
||||
{user.username}
|
||||
</Link>
|
||||
@@ -465,7 +454,7 @@ function PlayCard({
|
||||
<Col>
|
||||
<MiniTable colSpan={100} headers={["PB Info"]}>
|
||||
<tr>
|
||||
<ScoreCoreCells chart={chart} game={pb.game} score={pb} short />
|
||||
<ScoreCoreCells chart={chart} game={pbGame} score={pb} short />
|
||||
</tr>
|
||||
</MiniTable>
|
||||
<div className="text-center">
|
||||
|
||||
@@ -14,18 +14,19 @@ import { NumericSOV, StrSOV } from "#util/sorts";
|
||||
import React, { useState } from "react";
|
||||
import { Col, Row } from "react-bootstrap";
|
||||
import {
|
||||
FormatGameGroup,
|
||||
type ChartDocument,
|
||||
FormatGame,
|
||||
GameToGameGroup,
|
||||
GetGameGroupConfig,
|
||||
type integer,
|
||||
type MONGO_ChartDocument,
|
||||
type MONGO_SongDocument,
|
||||
type SongDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
export default function GPTChartsPage({ game, playtype }: GamePT) {
|
||||
export default function GPTChartsPage({ game }: GamePT) {
|
||||
useSetSubheader(
|
||||
["Games", GetGameGroupConfig(game).name, playtype, "Songs"],
|
||||
[game, playtype],
|
||||
`${FormatGameGroup(game, playtype)} Songs`,
|
||||
["Games", GetGameGroupConfig(GameToGameGroup(game)).name, "Songs"],
|
||||
[game],
|
||||
`${FormatGame(game)} Songs`,
|
||||
);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -37,19 +38,19 @@ export default function GPTChartsPage({ game, playtype }: GamePT) {
|
||||
<Divider />
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<SearchSongsTable game={game} playtype={playtype} search={search} />
|
||||
<SearchSongsTable game={game} search={search} />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchSongsTable({ game, playtype, search }: { search: string } & GamePT) {
|
||||
function SearchSongsTable({ game, search }: { search: string } & GamePT) {
|
||||
const params = new URLSearchParams({ search });
|
||||
|
||||
const { data, error } = useApiQuery<{
|
||||
charts: ({ __playcount: integer } & MONGO_ChartDocument)[];
|
||||
songs: MONGO_SongDocument[];
|
||||
}>(`/games/${game}/${playtype}/charts${search !== "" ? `?${params.toString()}` : ""}`);
|
||||
charts: ({ __playcount: integer } & ChartDocument)[];
|
||||
songs: SongDocument[];
|
||||
}>(`/games/${game}/charts${search !== "" ? `?${params.toString()}` : ""}`);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
@@ -67,7 +68,7 @@ function SearchSongsTable({ game, playtype, search }: { search: string } & GameP
|
||||
dataset.push({
|
||||
...chart,
|
||||
__related: {
|
||||
song: songMap.get(chart.songID)!,
|
||||
song: songMap.get(chart.song.id)!,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -76,7 +77,7 @@ function SearchSongsTable({ game, playtype, search }: { search: string } & GameP
|
||||
<>
|
||||
{search === "" && (
|
||||
<div className="w-100 text-center">
|
||||
<h4>Displaying the most played charts for {FormatGameGroup(game, playtype)}</h4>
|
||||
<h4>Displaying the most played charts for {FormatGame(game)}</h4>
|
||||
<Divider />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,33 +7,30 @@ import { type GamePT } from "#types/react";
|
||||
import React from "react";
|
||||
import {
|
||||
type Classes,
|
||||
FormatGameGroup,
|
||||
FormatGame,
|
||||
GameToGameGroup,
|
||||
GetGameConfig,
|
||||
GetGameGroupConfig,
|
||||
GetGamePTConfig,
|
||||
type GPTString,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
export default function GPTDevInfo({ game, playtype }: GamePT) {
|
||||
export default function GPTDevInfo({ game }: GamePT) {
|
||||
useSetSubheader(
|
||||
["Games", GetGameGroupConfig(game).name, playtype, "Dev Info"],
|
||||
[game, playtype],
|
||||
`${FormatGameGroup(game, playtype)} Dev Info`,
|
||||
["Games", GetGameGroupConfig(GameToGameGroup(game)).name, "Dev Info"],
|
||||
[game],
|
||||
`${FormatGame(game)} Dev Info`,
|
||||
);
|
||||
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
const gameGroupConfig = GetGameConfig(game);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card header="Game Configuration">
|
||||
<DebugContent data={gameConfig} />
|
||||
</Card>
|
||||
<Card className="mt-4" header="GPT Configuration">
|
||||
<DebugContent data={gptConfig} />
|
||||
<Card header="GPT Configuration">
|
||||
<DebugContent data={gameGroupConfig} />
|
||||
</Card>
|
||||
<Card className="mt-4" header="Class Badges">
|
||||
<div className="d-flex w-100 justify-content-center" style={{ gap: "30px" }}>
|
||||
{Object.entries(gptConfig.classes).map(([classSet, conf]) => (
|
||||
{Object.entries(gameGroupConfig.classes).map(([classSet, conf]) => (
|
||||
<div key={classSet}>
|
||||
<MiniTable colSpan={2} headers={[classSet]}>
|
||||
{conf.values.map((e) => (
|
||||
@@ -41,10 +38,9 @@ export default function GPTDevInfo({ game, playtype }: GamePT) {
|
||||
<td>{e.id}</td>
|
||||
<td>
|
||||
<ClassBadge
|
||||
classSet={classSet as Classes[GPTString]}
|
||||
classSet={classSet as Classes[V3Game]}
|
||||
classValue={e.id}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+27
-27
@@ -22,16 +22,19 @@ import React, { useState } from "react";
|
||||
import { Col, Form, Row } from "react-bootstrap";
|
||||
import {
|
||||
type AnyProfileRatingAlg,
|
||||
FormatGameGroup,
|
||||
type Classes,
|
||||
FormatGame,
|
||||
GameToGameGroup,
|
||||
GetGameConfig,
|
||||
GetGameGroupConfig,
|
||||
GetGamePTConfig,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
export default function GPTLeaderboardsPage({ game, playtype }: GamePT) {
|
||||
export default function GPTLeaderboardsPage({ game }: GamePT) {
|
||||
useSetSubheader(
|
||||
["Games", GetGameGroupConfig(game).name, playtype, "Leaderboards"],
|
||||
[game, playtype],
|
||||
`${FormatGameGroup(game, playtype)} Leaderboards`,
|
||||
["Games", GetGameGroupConfig(GameToGameGroup(game)).name, "Leaderboards"],
|
||||
[game],
|
||||
`${FormatGame(game)} Leaderboards`,
|
||||
);
|
||||
|
||||
const [mode, setMode] = useState<"profile" | "score">("profile");
|
||||
@@ -51,39 +54,35 @@ export default function GPTLeaderboardsPage({ game, playtype }: GamePT) {
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
{mode === "profile" ? (
|
||||
<ProfileLeaderboard game={game} playtype={playtype} />
|
||||
<ProfileLeaderboard game={game} />
|
||||
) : (
|
||||
<ScoreLeaderboard
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
url={`/games/${game}/${playtype}/pb-leaderboard`}
|
||||
/>
|
||||
<ScoreLeaderboard game={game} url={`/games/${game}/pb-leaderboard`} />
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileLeaderboard({ game, playtype }: GamePT) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
function ProfileLeaderboard({ game }: GamePT) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
const defaultAlg = useProfileRatingAlg(game, playtype);
|
||||
const defaultAlg = useProfileRatingAlg(game);
|
||||
|
||||
const [alg, setAlg] = useState(defaultAlg);
|
||||
|
||||
const SelectComponent =
|
||||
Object.keys(gptConfig.profileRatingAlgs).length > 1 ? (
|
||||
Object.keys(gameConfig.profileRatingAlgs).length > 1 ? (
|
||||
<Form.Select onChange={(e) => setAlg(e.target.value as any)} value={alg}>
|
||||
{Object.keys(gptConfig.profileRatingAlgs).map((e) => (
|
||||
{Object.keys(gameConfig.profileRatingAlgs).map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{FormatGPTProfileRatingName(game, playtype, e)}
|
||||
{FormatGPTProfileRatingName(game, e)}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
) : null;
|
||||
|
||||
const { data, error } = useApiQuery<UserLeaderboardReturns>(
|
||||
`/games/${game}/${playtype}/leaderboard?alg=${alg}&limit=500`,
|
||||
`/games/${game}/leaderboard?alg=${alg}&limit=500`,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@@ -128,11 +127,13 @@ function ProfileLeaderboard({ game, playtype }: GamePT) {
|
||||
headers={[
|
||||
["Ranking", "Rank", NumericSOV((x) => x.__related.index)],
|
||||
["User", "User", StrSOV((x) => x.__related.user.username)],
|
||||
...(Object.keys(gptConfig.profileRatingAlgs) as Array<AnyProfileRatingAlg>).map(
|
||||
...(
|
||||
Object.keys(gameConfig.profileRatingAlgs) as Array<AnyProfileRatingAlg>
|
||||
).map(
|
||||
(e) =>
|
||||
[
|
||||
FormatGPTProfileRatingName(game, playtype, e),
|
||||
FormatGPTProfileRatingName(game, playtype, e),
|
||||
FormatGPTProfileRatingName(game, e),
|
||||
FormatGPTProfileRatingName(game, e),
|
||||
NumericSOV((x) => x.ratings[e] ?? -Infinity),
|
||||
] as Header<UGSDataset[0]>,
|
||||
),
|
||||
@@ -141,13 +142,13 @@ function ProfileLeaderboard({ game, playtype }: GamePT) {
|
||||
rowFunction={(r) => (
|
||||
<tr>
|
||||
<IndexCell index={r.__related.index} />
|
||||
<UserCell game={game} playtype={playtype} user={r.__related.user} />
|
||||
<UserCell game={game} user={r.__related.user} />
|
||||
{(
|
||||
Object.keys(gptConfig.profileRatingAlgs) as Array<AnyProfileRatingAlg>
|
||||
Object.keys(gameConfig.profileRatingAlgs) as Array<AnyProfileRatingAlg>
|
||||
).map((e) => (
|
||||
<td key={e}>
|
||||
{r.ratings[e]
|
||||
? FormatGPTProfileRating(game, playtype, e, r.ratings[e]!)
|
||||
? FormatGPTProfileRating(game, e, r.ratings[e]!)
|
||||
: "No Data."}
|
||||
</td>
|
||||
))}
|
||||
@@ -161,11 +162,10 @@ function ProfileLeaderboard({ game, playtype }: GamePT) {
|
||||
([k, v]) =>
|
||||
v && (
|
||||
<ClassBadge
|
||||
classSet={k as keyof typeof r.classes}
|
||||
classSet={k as Classes[V3Game]}
|
||||
classValue={v}
|
||||
game={game}
|
||||
key={k}
|
||||
playtype={playtype}
|
||||
/>
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,14 +2,14 @@ import Activity from "#components/activity/Activity";
|
||||
import useSetSubheader from "#components/layout/header/useSetSubheader";
|
||||
import { type GamePT } from "#types/react";
|
||||
import React from "react";
|
||||
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
|
||||
import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common";
|
||||
|
||||
export default function GPTMainPage({ game, playtype }: GamePT) {
|
||||
export default function GPTMainPage({ game }: GamePT) {
|
||||
useSetSubheader(
|
||||
["Games", GetGameGroupConfig(game).name, playtype],
|
||||
[game, playtype],
|
||||
FormatGameGroup(game, playtype),
|
||||
["Games", GetGameGroupConfig(GameToGameGroup(game)).name],
|
||||
[game],
|
||||
FormatGame(game),
|
||||
);
|
||||
|
||||
return <Activity url={`/games/${game}/${playtype}/activity`} />;
|
||||
return <Activity url={`/games/${game}/activity`} />;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import ImportFileInfo from "#components/imports/ImportFileInfo";
|
||||
import useSetSubheader from "#components/layout/header/useSetSubheader";
|
||||
import { TachiConfig } from "#lib/config";
|
||||
import { p } from "prudence";
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
type BatchManual,
|
||||
FormatGameGroup,
|
||||
FormatGame,
|
||||
FormatPrError,
|
||||
GetGameGroupConfig,
|
||||
GetGamePTConfig,
|
||||
LEGACY_GameGroupPTToGame,
|
||||
} from "tachi-common";
|
||||
import { PR_BATCH_MANUAL } from "tachi-common/lib/schemas";
|
||||
|
||||
@@ -23,23 +23,23 @@ export default function BatchManualPage() {
|
||||
parseFunction={(text: string) => {
|
||||
const data: BatchManual = JSON.parse(text);
|
||||
|
||||
const gameConfig = GetGameGroupConfig(data.meta.game);
|
||||
const gameGroupConfig = GetGameGroupConfig(data.meta.game);
|
||||
|
||||
if (!gameConfig) {
|
||||
if (!gameGroupConfig) {
|
||||
throw new Error(
|
||||
`Invalid game ${data.meta.game}. Expected any of ${TachiConfig.GAMES}.`,
|
||||
`Invalid game ${data.meta.game}. Expected any of ${TachiConfig.GAME_GROUPS}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const gptConfig = GetGamePTConfig(data.meta.game, data.meta.playtype);
|
||||
|
||||
if (!gptConfig) {
|
||||
if (!gameGroupConfig.playtypes.includes(data.meta.playtype as any)) {
|
||||
throw new Error(
|
||||
`Invalid Playtype ${data.meta.playtype}. Expected any of ${gameConfig.playtypes}.`,
|
||||
`Invalid Playtype ${data.meta.playtype}. Expected any of ${gameGroupConfig.playtypes}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const err = p(data, PR_BATCH_MANUAL(data.meta.game, data.meta.playtype));
|
||||
const game = LEGACY_GameGroupPTToGame(data.meta.game, data.meta.playtype);
|
||||
|
||||
const err = p(data, PR_BATCH_MANUAL(game));
|
||||
|
||||
if (err) {
|
||||
throw new Error(FormatPrError(err, "Invalid BATCH-MANUAL: "));
|
||||
@@ -48,7 +48,7 @@ export default function BatchManualPage() {
|
||||
return {
|
||||
valid: true,
|
||||
info: {
|
||||
Game: FormatGameGroup(data.meta.game, data.meta.playtype),
|
||||
Game: FormatGame(game),
|
||||
Scores: data.scores.length,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,10 +23,10 @@ import {
|
||||
GetGameGroupConfig,
|
||||
type ImportTypes,
|
||||
type integer,
|
||||
type MONGO_UserDocument,
|
||||
type UserDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
export default function ImportPage({ user }: { user: MONGO_UserDocument }) {
|
||||
export default function ImportPage({ user }: { user: UserDocument }) {
|
||||
useSetSubheader(["Import Scores"]);
|
||||
|
||||
const [game, setGame] = useState<GameGroup | null>(null);
|
||||
@@ -66,7 +66,7 @@ export default function ImportPage({ user }: { user: MONGO_UserDocument }) {
|
||||
value={game ?? ""}
|
||||
>
|
||||
<option value="">Please select a game.</option>
|
||||
{TachiConfig.GAMES.map((e) => (
|
||||
{TachiConfig.GAME_GROUPS.map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{GetGameGroupConfig(e).name}
|
||||
</option>
|
||||
@@ -89,7 +89,7 @@ function ShowRecentImports() {
|
||||
return <InnerShowRecentImports user={user} />;
|
||||
}
|
||||
|
||||
function InnerShowRecentImports({ user }: { user: MONGO_UserDocument }) {
|
||||
function InnerShowRecentImports({ user }: { user: UserDocument }) {
|
||||
const { data, error } = useApiQuery<{ count: integer; importType: ImportTypes }[]>(
|
||||
`/users/${user.id}/recent-imports`,
|
||||
);
|
||||
|
||||
@@ -5,9 +5,9 @@ import useApiQuery from "#components/util/query/useApiQuery";
|
||||
import { TachiConfig } from "#lib/config";
|
||||
import { ONE_DAY } from "#util/constants/time";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { type integer, type MONGO_UserDocument } from "tachi-common";
|
||||
import { type integer, type UserDocument } from "tachi-common";
|
||||
|
||||
export default function SupportBanner({ user }: { user: MONGO_UserDocument }) {
|
||||
export default function SupportBanner({ user }: { user: UserDocument }) {
|
||||
const { data, error } = useApiQuery<{ scores: integer; sessions: integer }>(
|
||||
`/users/${user.id}/stats`,
|
||||
);
|
||||
|
||||
@@ -19,13 +19,12 @@ import React, { useContext, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Badge, Col, Form, Row } from "react-bootstrap";
|
||||
import { Link, useHistory, useLocation } from "react-router-dom";
|
||||
import {
|
||||
FormatGameGroup,
|
||||
type GPTString,
|
||||
type ChartDocument,
|
||||
FormatGame,
|
||||
type integer,
|
||||
type MONGO_ChartDocument,
|
||||
type MONGO_SongDocument,
|
||||
type MONGO_UserDocument,
|
||||
SplitGPT,
|
||||
type SongDocument,
|
||||
type UserDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
function parseSearchParams(search: string) {
|
||||
@@ -48,48 +47,40 @@ export default function SearchPage() {
|
||||
const [hasPlayedGame, setHasPlayedGame] = useState(initialHasPlayed);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const { q, hasPlayedGame: h } = parseSearchParams(location.search);
|
||||
setSearch(q);
|
||||
setHasPlayedGame(h);
|
||||
const params = parseSearchParams(location.search);
|
||||
setSearch(params.q);
|
||||
setHasPlayedGame(params.hasPlayedGame);
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new URLSearchParams();
|
||||
if (search) {
|
||||
next.set("q", search);
|
||||
const qs = `?q=${encodeURIComponent(search)}&hasPlayedGame=${hasPlayedGame}`;
|
||||
if (location.search !== qs) {
|
||||
history.replace(`/search${qs}`);
|
||||
}
|
||||
if (!hasPlayedGame) {
|
||||
next.set("hasPlayedGame", "false");
|
||||
}
|
||||
const nextStr = next.toString();
|
||||
const current = new URLSearchParams(history.location.search).toString();
|
||||
if (nextStr !== current) {
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: nextStr ? `?${nextStr}` : "",
|
||||
});
|
||||
}
|
||||
}, [search, hasPlayedGame, history, location.pathname]);
|
||||
}, [search, hasPlayedGame]);
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
<DebounceSearch
|
||||
autoFocus
|
||||
committedSearch={search}
|
||||
placeholder="Search songs, users..."
|
||||
setSearch={setSearch}
|
||||
/>
|
||||
{user && (
|
||||
<div className="w-100 mt-4 ms-1">
|
||||
<Form.Check
|
||||
checked={hasPlayedGame}
|
||||
label="Hide games you haven't played?"
|
||||
onChange={(e) => setHasPlayedGame(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Divider />
|
||||
<Card className="mt-8" header="Search Tachi">
|
||||
<DebounceSearch
|
||||
autoFocus
|
||||
className="form-control-lg"
|
||||
committedSearch={search}
|
||||
placeholder="Search songs, users..."
|
||||
setSearch={setSearch}
|
||||
/>
|
||||
{user && (
|
||||
<div className="w-100 mt-4 ms-1">
|
||||
<Form.Check
|
||||
checked={hasPlayedGame}
|
||||
label="Hide games you haven't played?"
|
||||
onChange={(e) => setHasPlayedGame(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Divider />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<SearchResults hasPlayedGame={hasPlayedGame} search={search} />
|
||||
@@ -100,7 +91,7 @@ export default function SearchPage() {
|
||||
|
||||
function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; search: string }) {
|
||||
const { data, error } = useTachiSearch(search, hasPlayedGame);
|
||||
const [mode, setMode] = useState<"users" | GPTString | null>(null);
|
||||
const [mode, setMode] = useState<"users" | V3Game | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -109,7 +100,7 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear
|
||||
.filter((k) => k[1].length > 0);
|
||||
|
||||
if (thingsWithCharts.length > 0) {
|
||||
setMode(thingsWithCharts[0][0] as GPTString);
|
||||
setMode(thingsWithCharts[0][0] as V3Game);
|
||||
} else if (data.users.length > 0) {
|
||||
setMode("users");
|
||||
} else {
|
||||
@@ -161,13 +152,11 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear
|
||||
return null;
|
||||
}
|
||||
|
||||
const gpt = g as GPTString;
|
||||
|
||||
const [game, playtype] = SplitGPT(gpt);
|
||||
const game = g as V3Game;
|
||||
|
||||
return (
|
||||
<SelectButton id={gpt} key={gpt} setValue={setMode} value={mode}>
|
||||
{FormatGameGroup(game, playtype)}
|
||||
<SelectButton id={game} key={game} setValue={setMode} value={mode}>
|
||||
{FormatGame(game)}
|
||||
<Badge bg="secondary" className="ms-2 text-light">
|
||||
{charts.length}
|
||||
</Badge>
|
||||
@@ -198,7 +187,7 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear
|
||||
{mode === "users" ? (
|
||||
<UsersView users={data.users} />
|
||||
) : (
|
||||
<ChartView charts={data.charts[mode]!} gpt={mode} />
|
||||
<ChartView charts={data.charts[mode]!} game={mode} />
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
@@ -208,17 +197,15 @@ function SearchResults({ search, hasPlayedGame }: { hasPlayedGame: boolean; sear
|
||||
|
||||
function ChartView({
|
||||
charts,
|
||||
gpt,
|
||||
game,
|
||||
}: {
|
||||
charts: Array<{
|
||||
chart: MONGO_ChartDocument;
|
||||
chart: ChartDocument;
|
||||
playcount: integer;
|
||||
song: MONGO_SongDocument;
|
||||
song: SongDocument;
|
||||
}>;
|
||||
gpt: GPTString;
|
||||
game: V3Game;
|
||||
}) {
|
||||
const [game, playtype] = SplitGPT(gpt);
|
||||
|
||||
return (
|
||||
<TachiTable
|
||||
dataset={charts}
|
||||
@@ -248,52 +235,72 @@ function ChartView({
|
||||
);
|
||||
}
|
||||
|
||||
function UsersView({ users }: { users: Array<MONGO_UserDocument> }) {
|
||||
function UsersView({ users }: { users: Array<UserDocument> }) {
|
||||
return (
|
||||
<Row>
|
||||
<div
|
||||
className="w-100 d-flex"
|
||||
style={{
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
></div>
|
||||
{users.map((user) => (
|
||||
<Col key={user.id} lg={6} xs={12}>
|
||||
<Card className="mb-4">
|
||||
<div className="d-flex h-100">
|
||||
<div>
|
||||
<ProfilePicture user={user} />
|
||||
</div>
|
||||
<div
|
||||
className="ms-4 d-flex w-100 h-100"
|
||||
style={{
|
||||
flexWrap: "wrap",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h4>
|
||||
<Link
|
||||
className="text-decoration-none"
|
||||
to={`/u/${user.username}`}
|
||||
>
|
||||
{user.username}
|
||||
</Link>
|
||||
</h4>
|
||||
</div>
|
||||
<div>
|
||||
<Muted>{user.status ?? "I haven't set my status."}</Muted>
|
||||
</div>
|
||||
{Date.now() - user.lastSeen < ONE_MINUTE * 5 && (
|
||||
<div className="mt-2">
|
||||
<Badge bg="success">ONLINE</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<TachiTable
|
||||
dataset={users}
|
||||
defaultSortMode="Last Seen"
|
||||
entryName="Users"
|
||||
headers={[
|
||||
["User", "User", StrSOV((x) => x.username)],
|
||||
["Status", "Status", StrSOV((x) => x.status ?? "")],
|
||||
["Last Seen", "Last Seen", NumericSOV((x) => x.lastSeen)],
|
||||
]}
|
||||
rowFunction={(u) => (
|
||||
<tr>
|
||||
<td>
|
||||
<Link to={`/u/${u.username}`}>
|
||||
<ProfilePicture size="sm" user={u} />
|
||||
{u.username}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<Muted>{u.status ?? "No status."}</Muted>
|
||||
</td>
|
||||
<td>
|
||||
{u.lastSeen === 0
|
||||
? "Never"
|
||||
: new Date(u.lastSeen).toLocaleDateString("en-GB", {
|
||||
timeZone: "UTC",
|
||||
year: "2-digit",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
searchFunctions={{
|
||||
username: (x) => x.username,
|
||||
status: (x) => x.status,
|
||||
lastSeen: (x) => {
|
||||
if (x.lastSeen === 0) {
|
||||
return null;
|
||||
}
|
||||
return new Date(x.lastSeen).toLocaleString("en-GB", {
|
||||
timeZone: "UTC",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LastSeenCell({ time }: { time: number }) {
|
||||
if (time === 0) {
|
||||
return <td>Never</td>;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (now - time < ONE_MINUTE) {
|
||||
return <td>Just Now</td>;
|
||||
}
|
||||
|
||||
const d = new Date(time);
|
||||
|
||||
return <td>{d.toLocaleDateString("en-GB")}</td>;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import { isCardIDValid } from "#util/misc";
|
||||
import React, { useState } from "react";
|
||||
import { Button, Col, Form } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { type MONGO_FervidexSettingsDocument, type MONGO_UserDocument } from "tachi-common";
|
||||
import { type FervidexSettingsDocument, type UserDocument } from "tachi-common";
|
||||
|
||||
export default function FervidexIntegrationPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
const { data: settings, error } = useApiQuery<MONGO_FervidexSettingsDocument | null>(
|
||||
export default function FervidexIntegrationPage({ reqUser }: { reqUser: UserDocument }) {
|
||||
const { data: settings, error } = useApiQuery<FervidexSettingsDocument | null>(
|
||||
`/users/${reqUser.id}/integrations/fervidex/settings`,
|
||||
);
|
||||
|
||||
@@ -48,12 +48,10 @@ function FervidexForm({
|
||||
reqUser,
|
||||
settings,
|
||||
}: {
|
||||
reqUser: MONGO_UserDocument;
|
||||
settings: MONGO_FervidexSettingsDocument | null;
|
||||
reqUser: UserDocument;
|
||||
settings: FervidexSettingsDocument | null;
|
||||
}) {
|
||||
const [formSettings, setFormSettings] = useState<
|
||||
Omit<MONGO_FervidexSettingsDocument, "userID">
|
||||
>(
|
||||
const [formSettings, setFormSettings] = useState<Omit<FervidexSettingsDocument, "userID">>(
|
||||
settings
|
||||
? { forceStaticImport: settings.forceStaticImport, cards: settings.cards }
|
||||
: {
|
||||
|
||||
@@ -7,13 +7,13 @@ import React, { useState } from "react";
|
||||
import { Button, Col, Form } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
type MONGO_FervidexSettingsDocument,
|
||||
type MONGO_KsHookSettingsDocument,
|
||||
type MONGO_UserDocument,
|
||||
type FervidexSettingsDocument,
|
||||
type KsHookSettingsDocument,
|
||||
type UserDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
export default function KsHookSV6CIntegrationPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
const { data: settings, error } = useApiQuery<MONGO_FervidexSettingsDocument | null>(
|
||||
export default function KsHookSV6CIntegrationPage({ reqUser }: { reqUser: UserDocument }) {
|
||||
const { data: settings, error } = useApiQuery<FervidexSettingsDocument | null>(
|
||||
`/users/${reqUser.id}/integrations/kshook-sv6c/settings`,
|
||||
);
|
||||
|
||||
@@ -51,10 +51,10 @@ function KsHookSV6CForm({
|
||||
reqUser,
|
||||
settings,
|
||||
}: {
|
||||
reqUser: MONGO_UserDocument;
|
||||
settings: MONGO_KsHookSettingsDocument | null;
|
||||
reqUser: UserDocument;
|
||||
settings: KsHookSettingsDocument | null;
|
||||
}) {
|
||||
const [formSettings, setFormSettings] = useState<Omit<MONGO_KsHookSettingsDocument, "userID">>(
|
||||
const [formSettings, setFormSettings] = useState<Omit<KsHookSettingsDocument, "userID">>(
|
||||
settings ? { forceStaticImport: settings.forceStaticImport } : { forceStaticImport: false },
|
||||
);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { UserContext } from "#context/UserContext";
|
||||
import { APIFetchV1 } from "#util/api";
|
||||
import React, { useContext, useEffect, useMemo } from "react";
|
||||
import { Button, Col, Row } from "react-bootstrap";
|
||||
import { type MONGO_UserDocument } from "tachi-common";
|
||||
import { type UserDocument } from "tachi-common";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { user } = useContext(UserContext);
|
||||
@@ -22,7 +22,7 @@ export default function NotificationsPage() {
|
||||
return <NotificationsInnerPage user={user} />;
|
||||
}
|
||||
|
||||
function NotificationsInnerPage({ user }: { user: MONGO_UserDocument }) {
|
||||
function NotificationsInnerPage({ user }: { user: UserDocument }) {
|
||||
const { notifications, reload } = useContext(NotificationsContext);
|
||||
|
||||
const unread = useMemo(() => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import useSetSubheader from "#components/layout/header/useSetSubheader";
|
||||
import UGPTProfiles from "#components/user/UGPTProfiles";
|
||||
import React from "react";
|
||||
import { type MONGO_UserDocument } from "tachi-common";
|
||||
import { type UserDocument } from "tachi-common";
|
||||
|
||||
export default function UserGamesPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
export default function UserGamesPage({ reqUser }: { reqUser: UserDocument }) {
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games"],
|
||||
[reqUser],
|
||||
|
||||
@@ -10,13 +10,9 @@ import SelectLinkButton from "#components/util/SelectLinkButton";
|
||||
import { type FailedImportDataset, type ImportDataset } from "#types/tables";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import {
|
||||
type ImportTrackerFailed,
|
||||
type MONGO_ImportDocument,
|
||||
type MONGO_UserDocument,
|
||||
} from "tachi-common";
|
||||
import { type ImportDocument, type ImportTrackerFailed, type UserDocument } from "tachi-common";
|
||||
|
||||
export default function UserImportsPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
export default function UserImportsPage({ reqUser }: { reqUser: UserDocument }) {
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Imports"],
|
||||
[reqUser],
|
||||
@@ -73,9 +69,9 @@ function ViewRecentImports({
|
||||
reqUser,
|
||||
}: {
|
||||
params: URLSearchParams;
|
||||
reqUser: MONGO_UserDocument;
|
||||
reqUser: UserDocument;
|
||||
}) {
|
||||
const { data, error } = useApiQuery<Array<MONGO_ImportDocument>>(
|
||||
const { data, error } = useApiQuery<Array<ImportDocument>>(
|
||||
`/users/${reqUser.id}/imports?${params.toString()}`,
|
||||
);
|
||||
|
||||
@@ -102,7 +98,7 @@ function ViewRecentFailedImports({
|
||||
reqUser,
|
||||
}: {
|
||||
params: URLSearchParams;
|
||||
reqUser: MONGO_UserDocument;
|
||||
reqUser: UserDocument;
|
||||
}) {
|
||||
const { data, error } = useApiQuery<Array<ImportTrackerFailed>>(
|
||||
`/users/${reqUser.id}/imports/failed?${params.toString()}`,
|
||||
|
||||
@@ -20,18 +20,18 @@ import { Alert, Button, Col, Form, Modal, Row } from "react-bootstrap";
|
||||
import { Link, Route, Switch } from "react-router-dom";
|
||||
import {
|
||||
type APIPermissions,
|
||||
type APITokenDocument,
|
||||
type CGCardInfo,
|
||||
type integer,
|
||||
type MONGO_APITokenDocument,
|
||||
type MONGO_CGCardInfo,
|
||||
type MONGO_MytCardInfo,
|
||||
type MONGO_TachiAPIClientDocument,
|
||||
type MONGO_UserDocument,
|
||||
type MytCardInfo,
|
||||
type TachiAPIClientDocument,
|
||||
type UserDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
import FervidexIntegrationPage from "./FervidexIntegrationPage";
|
||||
import KsHookSV6CIntegrationPage from "./KsHookSV6CIntegrationPage";
|
||||
|
||||
export default function UserIntegrationsPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
export default function UserIntegrationsPage({ reqUser }: { reqUser: UserDocument }) {
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Integrations"],
|
||||
[reqUser],
|
||||
@@ -102,9 +102,9 @@ function OAuthClientPage() {
|
||||
}
|
||||
|
||||
function OAuthClientInfo() {
|
||||
const { data, error } = useApiQuery<MONGO_TachiAPIClientDocument[]>("/clients");
|
||||
const { data, error } = useApiQuery<TachiAPIClientDocument[]>("/clients");
|
||||
|
||||
const [clients, setClients] = useState<MONGO_TachiAPIClientDocument[]>([]);
|
||||
const [clients, setClients] = useState<TachiAPIClientDocument[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setClients(data ?? []);
|
||||
@@ -144,11 +144,7 @@ function OAuthClientInfo() {
|
||||
);
|
||||
}
|
||||
|
||||
function CreateNewOAuthClient({
|
||||
setClients,
|
||||
}: {
|
||||
setClients: SetState<MONGO_TachiAPIClientDocument[]>;
|
||||
}) {
|
||||
function CreateNewOAuthClient({ setClients }: { setClients: SetState<TachiAPIClientDocument[]> }) {
|
||||
const [show, setShow] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [redirectUri, setRedirectUri] = useState("");
|
||||
@@ -310,9 +306,9 @@ function CreateNewOAuthClient({
|
||||
}
|
||||
|
||||
interface OAuthClientProps {
|
||||
client: MONGO_TachiAPIClientDocument;
|
||||
clients: MONGO_TachiAPIClientDocument[];
|
||||
setClients: SetState<MONGO_TachiAPIClientDocument[]>;
|
||||
client: TachiAPIClientDocument;
|
||||
clients: TachiAPIClientDocument[];
|
||||
setClients: SetState<TachiAPIClientDocument[]>;
|
||||
}
|
||||
|
||||
function OAuthClientRow({ client, clients, setClients }: OAuthClientProps) {
|
||||
@@ -392,7 +388,7 @@ function OAuthClientRow({ client, clients, setClients }: OAuthClientProps) {
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const res = await APIFetchV1<MONGO_TachiAPIClientDocument>(
|
||||
const res = await APIFetchV1<TachiAPIClientDocument>(
|
||||
`/clients/${client.clientID}/reset-secret`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -506,7 +502,7 @@ function EditClientModal({
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const res = await APIFetchV1<MONGO_TachiAPIClientDocument>(
|
||||
const res = await APIFetchV1<TachiAPIClientDocument>(
|
||||
`/clients/${client.clientID}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
@@ -621,7 +617,7 @@ function EditClientModal({
|
||||
);
|
||||
}
|
||||
|
||||
function ServicesPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
function ServicesPage({ reqUser }: { reqUser: UserDocument }) {
|
||||
if (TachiConfig.TYPE === "boku") {
|
||||
return (
|
||||
<Row className="text-center">
|
||||
@@ -761,13 +757,11 @@ function KAIIntegrationStatus({
|
||||
);
|
||||
}
|
||||
|
||||
function APIKeysPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
const [apiKeys, setApiKeys] = useState<MONGO_APITokenDocument[]>([]);
|
||||
function APIKeysPage({ reqUser }: { reqUser: UserDocument }) {
|
||||
const [apiKeys, setApiKeys] = useState<APITokenDocument[]>([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const { data, error } = useApiQuery<MONGO_APITokenDocument[]>(
|
||||
`/users/${reqUser.id}/api-tokens`,
|
||||
);
|
||||
const { data, error } = useApiQuery<APITokenDocument[]>(`/users/${reqUser.id}/api-tokens`);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -827,9 +821,9 @@ function CreateAPIKeyModal({
|
||||
apiKeys,
|
||||
setApiKeys,
|
||||
}: {
|
||||
apiKeys: MONGO_APITokenDocument[];
|
||||
reqUser: MONGO_UserDocument;
|
||||
setApiKeys: SetState<MONGO_APITokenDocument[]>;
|
||||
apiKeys: APITokenDocument[];
|
||||
reqUser: UserDocument;
|
||||
setApiKeys: SetState<APITokenDocument[]>;
|
||||
setShowModal: SetState<boolean>;
|
||||
showModal: boolean;
|
||||
}) {
|
||||
@@ -843,7 +837,7 @@ function CreateAPIKeyModal({
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const res = await APIFetchV1<MONGO_APITokenDocument>(
|
||||
const res = await APIFetchV1<APITokenDocument>(
|
||||
`/users/${reqUser.id}/api-tokens/create`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -914,9 +908,9 @@ function APIKeyRow({
|
||||
setApiKeys,
|
||||
apiKeys,
|
||||
}: {
|
||||
apiKey: MONGO_APITokenDocument;
|
||||
apiKeys: MONGO_APITokenDocument[];
|
||||
setApiKeys: SetState<MONGO_APITokenDocument[]>;
|
||||
apiKey: APITokenDocument;
|
||||
apiKeys: APITokenDocument[];
|
||||
setApiKeys: SetState<APITokenDocument[]>;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
const [sure, setSure] = useState(false);
|
||||
@@ -962,7 +956,7 @@ function APIKeyRow({
|
||||
function CGIntegrationInfo({ cgType, userID }: { cgType: "dev" | "gan" | "nag"; userID: integer }) {
|
||||
const [reload, shouldReloadCardInfo] = useReducer((x) => x + 1, 0);
|
||||
|
||||
const { data, error } = useApiQuery<MONGO_CGCardInfo | null>(
|
||||
const { data, error } = useApiQuery<CGCardInfo | null>(
|
||||
`/users/${userID}/integrations/cg/${cgType}`,
|
||||
undefined,
|
||||
[reload],
|
||||
@@ -1007,7 +1001,7 @@ function CGIntegrationInfo({ cgType, userID }: { cgType: "dev" | "gan" | "nag";
|
||||
function MytIntegrationInfo({ userID }: { userID: integer }) {
|
||||
const [reload, shouldReloadCardInfo] = useReducer((x) => x + 1, 0);
|
||||
|
||||
const { data, error } = useApiQuery<MONGO_MytCardInfo | null>(
|
||||
const { data, error } = useApiQuery<MytCardInfo | null>(
|
||||
`/users/${userID}/integrations/myt`,
|
||||
undefined,
|
||||
[reload],
|
||||
|
||||
@@ -14,12 +14,12 @@ import { Button, Col, Row } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
type integer,
|
||||
type MONGO_InviteCodeDocument,
|
||||
type MONGO_UserDocument,
|
||||
type InviteCodeDocument,
|
||||
UserAuthLevels,
|
||||
type UserDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
export default function UserInvitesPage({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
export default function UserInvitesPage({ reqUser }: { reqUser: UserDocument }) {
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Invites"],
|
||||
[reqUser],
|
||||
@@ -91,10 +91,10 @@ export default function UserInvitesPage({ reqUser }: { reqUser: MONGO_UserDocume
|
||||
);
|
||||
}
|
||||
|
||||
function InviteList({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
function InviteList({ reqUser }: { reqUser: UserDocument }) {
|
||||
const { data, error } = useApiQuery<{
|
||||
consumers: MONGO_UserDocument[];
|
||||
invites: MONGO_InviteCodeDocument[];
|
||||
consumers: UserDocument[];
|
||||
invites: InviteCodeDocument[];
|
||||
}>(`/users/${reqUser.id}/invites`);
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -9,10 +9,10 @@ import { APIFetchV1 } from "#util/api";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { type MONGO_UserDocument } from "tachi-common";
|
||||
import { type UserDocument } from "tachi-common";
|
||||
|
||||
interface Props {
|
||||
reqUser: MONGO_UserDocument;
|
||||
reqUser: UserDocument;
|
||||
}
|
||||
|
||||
export default function UserPage({ reqUser }: Props) {
|
||||
|
||||
@@ -19,14 +19,10 @@ import Form from "react-bootstrap/Form";
|
||||
import InputGroup from "react-bootstrap/InputGroup";
|
||||
import Stack from "react-bootstrap/Stack";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
type integer,
|
||||
type MONGO_UserDocument,
|
||||
type MONGO_UserSettingsDocument,
|
||||
} from "tachi-common";
|
||||
import { type integer, type UserDocument, type UserSettingsDocument } from "tachi-common";
|
||||
|
||||
interface Props {
|
||||
reqUser: MONGO_UserDocument;
|
||||
reqUser: UserDocument;
|
||||
}
|
||||
|
||||
export default function UserSettingsDocumentPage({ reqUser }: Props) {
|
||||
@@ -101,7 +97,7 @@ export default function UserSettingsDocumentPage({ reqUser }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountSettings({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
export function AccountSettings({ reqUser }: { reqUser: UserDocument }) {
|
||||
const [page, setPage] = useState<"email" | "password" | "username">("email");
|
||||
|
||||
return (
|
||||
@@ -129,7 +125,7 @@ export function AccountSettings({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeEmailForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
function ChangeEmailForm({ reqUser }: { reqUser: UserDocument }) {
|
||||
const formikEmail = useFormik({
|
||||
initialValues: {
|
||||
"!password": "",
|
||||
@@ -137,7 +133,7 @@ function ChangeEmailForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
confEmail: "",
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const r = await APIFetchV1<MONGO_UserSettingsDocument>(
|
||||
const r = await APIFetchV1<UserSettingsDocument>(
|
||||
`/users/${reqUser.id}/change-email`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -217,7 +213,7 @@ function ChangeEmailForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ChangePasswordForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
function ChangePasswordForm({ reqUser }: { reqUser: UserDocument }) {
|
||||
const formikPassword = useFormik({
|
||||
initialValues: {
|
||||
"!oldPassword": "",
|
||||
@@ -225,7 +221,7 @@ function ChangePasswordForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
confPass: "",
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const r = await APIFetchV1<MONGO_UserSettingsDocument>(
|
||||
const r = await APIFetchV1<UserSettingsDocument>(
|
||||
`/users/${reqUser.id}/change-password`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -305,7 +301,7 @@ function ChangePasswordForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeUsernameForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
function ChangeUsernameForm({ reqUser }: { reqUser: UserDocument }) {
|
||||
const lastUsernameChange = useApiQuery<
|
||||
{ canChange: false; nextAvailableChange: integer | null } | { canChange: true }
|
||||
>(`/users/${reqUser.id}/last-username-change`);
|
||||
@@ -367,7 +363,7 @@ function ChangeUsernameForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
}
|
||||
|
||||
const handle = window.setTimeout(async () => {
|
||||
const usernameQuery = await APIFetchV1<MONGO_UserDocument | null>(
|
||||
const usernameQuery = await APIFetchV1<UserDocument | null>(
|
||||
`/users/${nameChangeFormik.values.newUsername}`,
|
||||
);
|
||||
|
||||
@@ -447,7 +443,7 @@ function ChangeUsernameForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PreferencesForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
function PreferencesForm({ reqUser }: { reqUser: UserDocument }) {
|
||||
const { settings, setSettings } = useContext(UserSettingsContext);
|
||||
const theme = getStoredTheme() || "system";
|
||||
const [themeSetting, setThemeSetting] = useState<"system" | Themes>(theme);
|
||||
@@ -461,7 +457,7 @@ function PreferencesForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
deletableScores: settings?.preferences.deletableScores ?? false,
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const res = await APIFetchV1<MONGO_UserSettingsDocument>(
|
||||
const res = await APIFetchV1<UserSettingsDocument>(
|
||||
`/users/${reqUser.id}/settings`,
|
||||
{
|
||||
method: "PATCH",
|
||||
@@ -535,7 +531,7 @@ function PreferencesForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ImageForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
function ImageForm({ reqUser }: { reqUser: UserDocument }) {
|
||||
const [pfp, setPfp] = useState<File | undefined>();
|
||||
|
||||
const pfpInput = useRef<HTMLInputElement>(null);
|
||||
@@ -611,7 +607,7 @@ function ImageForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SocialMediaForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
function SocialMediaForm({ reqUser }: { reqUser: UserDocument }) {
|
||||
const placeholders = {
|
||||
discord: "Discord Username",
|
||||
twitter: "Twitter Handle",
|
||||
@@ -638,7 +634,7 @@ function SocialMediaForm({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
valuesClone[vx] = values[vx] || null;
|
||||
}
|
||||
|
||||
const rj = await APIFetchV1<MONGO_UserDocument>(
|
||||
const rj = await APIFetchV1<UserDocument>(
|
||||
"/users/me",
|
||||
{
|
||||
method: "PATCH",
|
||||
@@ -716,7 +712,7 @@ function FileUploadController({
|
||||
reset,
|
||||
}: {
|
||||
file?: File;
|
||||
reqUser: MONGO_UserDocument;
|
||||
reqUser: UserDocument;
|
||||
reset: () => void;
|
||||
setFile: SetState<File | undefined>;
|
||||
type: "banner" | "pfp";
|
||||
|
||||
+31
-36
@@ -17,14 +17,15 @@ import { useQuery } from "react-query";
|
||||
import {
|
||||
type Classes,
|
||||
COLOUR_SET,
|
||||
FormatGameGroup,
|
||||
FormatGame,
|
||||
GameToGameGroup,
|
||||
GetGameConfig,
|
||||
GetGameGroupConfig,
|
||||
GetGamePTConfig,
|
||||
type GPTString,
|
||||
type integer,
|
||||
type MONGO_UserDocument,
|
||||
type MONGO_UserGameStats,
|
||||
type ProfileRatingAlgorithms,
|
||||
type UserDocument,
|
||||
type UserGameStats,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
interface LeaderboardsData {
|
||||
@@ -32,18 +33,23 @@ interface LeaderboardsData {
|
||||
leaderboard: GPTLeaderboard;
|
||||
}
|
||||
|
||||
export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) {
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
export default function LeaderboardsPage({ reqUser, game }: UGPT) {
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Leaderboard"],
|
||||
[reqUser, game, playtype],
|
||||
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Leaderboard`,
|
||||
[
|
||||
"Users",
|
||||
reqUser.username,
|
||||
"Games",
|
||||
GetGameGroupConfig(GameToGameGroup(game)).name,
|
||||
"Leaderboard",
|
||||
],
|
||||
[reqUser, game],
|
||||
`${reqUser.username}'s ${FormatGame(game)} Leaderboard`,
|
||||
);
|
||||
|
||||
const defaultRating = useProfileRatingAlg(game, playtype);
|
||||
const defaultRating = useProfileRatingAlg(game);
|
||||
const [alg, setAlg] = useState(defaultRating);
|
||||
|
||||
const url = `/users/${reqUser.id}/games/${game}/${playtype}/leaderboard-adjacent?alg=${alg}`;
|
||||
const url = `/users/${reqUser.id}/games/${game}/leaderboard-adjacent?alg=${alg}`;
|
||||
|
||||
const { data, error } = useQuery<LeaderboardsData, UnsuccessfulAPIFetchResponse>(
|
||||
url,
|
||||
@@ -55,7 +61,7 @@ export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) {
|
||||
}
|
||||
|
||||
const lRes = await APIFetchV1<GPTLeaderboard>(
|
||||
`/games/${game}/${playtype}/leaderboard?limit=3&alg=${alg}`,
|
||||
`/games/${game}/leaderboard?limit=3&alg=${alg}`,
|
||||
);
|
||||
|
||||
if (!lRes.success) {
|
||||
@@ -71,7 +77,7 @@ export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) {
|
||||
|
||||
return (
|
||||
<LoadingWrapper {...{ dataset: data, error }}>
|
||||
<LeaderboardsPageContent {...{ reqUser, game, playtype, data: data!, alg, setAlg }} />
|
||||
<LeaderboardsPageContent {...{ reqUser, game, data: data!, alg, setAlg }} />
|
||||
</LoadingWrapper>
|
||||
);
|
||||
}
|
||||
@@ -79,20 +85,19 @@ export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) {
|
||||
function LeaderboardsPageContent({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
data,
|
||||
alg,
|
||||
}: {
|
||||
alg: ProfileRatingAlgorithms[GPTString];
|
||||
alg: ProfileRatingAlgorithms[V3Game];
|
||||
data: LeaderboardsData;
|
||||
reqUser: MONGO_UserDocument;
|
||||
setAlg: SetState<ProfileRatingAlgorithms[GPTString]>;
|
||||
reqUser: UserDocument;
|
||||
setAlg: SetState<ProfileRatingAlgorithms[V3Game]>;
|
||||
} & GamePT) {
|
||||
const { stats, leaderboard } = data;
|
||||
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
const userMap = new Map<integer, MONGO_UserDocument>();
|
||||
const userMap = new Map<integer, UserDocument>();
|
||||
|
||||
for (const u of stats.users) {
|
||||
userMap.set(u.id, u);
|
||||
@@ -102,12 +107,11 @@ function LeaderboardsPageContent({
|
||||
userMap.set(u.id, u);
|
||||
}
|
||||
|
||||
// hack - we aren't returned from this api call for some reason.
|
||||
userMap.set(reqUser.id, reqUser);
|
||||
|
||||
const bestNearbyUser = stats.thisUsersRanking.ranking - stats.above.length - 1;
|
||||
|
||||
function LeaderboardRow({ s, i }: { i: integer; s: MONGO_UserGameStats }) {
|
||||
function LeaderboardRow({ s, i }: { i: integer; s: UserGameStats }) {
|
||||
return (
|
||||
<tr
|
||||
style={{
|
||||
@@ -125,18 +129,15 @@ function LeaderboardsPageContent({
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<GentleLink
|
||||
to={`/u/${userMap.get(s.userID)!.username}/games/${game}/${playtype}`}
|
||||
>
|
||||
<GentleLink to={`/u/${userMap.get(s.userID)!.username}/games/${game}`}>
|
||||
{userMap.get(s.userID)?.username}
|
||||
</GentleLink>
|
||||
</td>
|
||||
<td>
|
||||
{IsNotNullish(s.ratings[alg])
|
||||
? FormatGPTProfileRating(game, playtype, alg, s.ratings[alg]!)
|
||||
? FormatGPTProfileRating(game, alg, s.ratings[alg]!)
|
||||
: "No Data."}
|
||||
</td>
|
||||
{/* temp */}
|
||||
<td>
|
||||
{Object.entries(s.classes).length
|
||||
? Object.entries(s.classes)
|
||||
@@ -145,11 +146,10 @@ function LeaderboardsPageContent({
|
||||
([k, v]) =>
|
||||
v && (
|
||||
<ClassBadge
|
||||
classSet={k as Classes[GPTString]}
|
||||
classSet={k as Classes[V3Game]}
|
||||
classValue={v}
|
||||
game={game}
|
||||
key={`${k}:${v}`}
|
||||
playtype={playtype}
|
||||
/>
|
||||
),
|
||||
)
|
||||
@@ -163,7 +163,7 @@ function LeaderboardsPageContent({
|
||||
<Card
|
||||
cardBodyClassName="overflow-x-auto d-flex flex-column justify-content-center p-4"
|
||||
footer={
|
||||
<LinkButton className="float-end" to={`/games/${game}/${playtype}/leaderboards`}>
|
||||
<LinkButton className="float-end" to={`/games/${game}/leaderboards`}>
|
||||
View Global Leaderboards
|
||||
</LinkButton>
|
||||
}
|
||||
@@ -171,12 +171,7 @@ function LeaderboardsPageContent({
|
||||
>
|
||||
<MiniTable
|
||||
className="text-center"
|
||||
headers={[
|
||||
"Position",
|
||||
"User",
|
||||
FormatGPTProfileRatingName(game, playtype, alg),
|
||||
"Classes",
|
||||
]}
|
||||
headers={["Position", "User", FormatGPTProfileRatingName(game, alg), "Classes"]}
|
||||
>
|
||||
<>
|
||||
{bestNearbyUser >= 1 &&
|
||||
|
||||
+40
-63
@@ -20,50 +20,39 @@ import { FormatDate, MillisToSince } from "#util/time";
|
||||
import { DateTime } from "luxon";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import FormSelect from "react-bootstrap/FormSelect";
|
||||
import {
|
||||
FormatGameGroup,
|
||||
type GameGroup,
|
||||
GetGameGroupConfig,
|
||||
GetGamePTConfig,
|
||||
type MONGO_UserGameStats,
|
||||
type Playtype,
|
||||
} from "tachi-common";
|
||||
import { FormatGame, GetGameConfig, type UserGameStats, type V3Game } from "tachi-common";
|
||||
|
||||
export default function OverviewPage({ reqUser, game, playtype }: UGPT) {
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
export default function OverviewPage({ reqUser, game }: UGPT) {
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype],
|
||||
[reqUser, game, playtype],
|
||||
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Overview`,
|
||||
["Users", reqUser.username, "Games", FormatGame(game)],
|
||||
[reqUser, game],
|
||||
`${reqUser.username}'s ${FormatGame(game)} Overview`,
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${game}:${playtype}`}>
|
||||
<UGPTStatShowcase game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<RankingInfo game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<RecentActivity game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<React.Fragment key={game}>
|
||||
<UGPTStatShowcase game={game} reqUser={reqUser} />
|
||||
<RankingInfo game={game} reqUser={reqUser} />
|
||||
<RecentActivity game={game} reqUser={reqUser} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentActivity({ reqUser, game, playtype }: UGPT) {
|
||||
function RecentActivity({ reqUser, game }: UGPT) {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<Activity
|
||||
handleNoActivity={null}
|
||||
url={`/users/${reqUser.id}/games/${game}/${playtype}/activity`}
|
||||
/>
|
||||
<Activity handleNoActivity={null} url={`/users/${reqUser.id}/games/${game}/activity`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RankingDurations = "3mo" | "year";
|
||||
type RankingDurations = "3mo" | "all" | "month" | "week" | "year";
|
||||
|
||||
function RankingInfo({ reqUser, game, playtype }: UGPT) {
|
||||
function RankingInfo({ reqUser, game }: UGPT) {
|
||||
const [duration, setDuration] = useState<RankingDurations>("3mo");
|
||||
|
||||
const { data, error } = useApiQuery<UGPTHistory>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/history?duration=${duration}`,
|
||||
`/users/${reqUser.id}/games/${game}/history?duration=${duration}`,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -75,7 +64,6 @@ function RankingInfo({ reqUser, game, playtype }: UGPT) {
|
||||
data={data}
|
||||
duration={duration}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
setDuration={setDuration}
|
||||
/>
|
||||
) : (
|
||||
@@ -88,7 +76,6 @@ function RankingInfo({ reqUser, game, playtype }: UGPT) {
|
||||
function UserHistory({
|
||||
data,
|
||||
game,
|
||||
playtype,
|
||||
duration,
|
||||
setDuration,
|
||||
}: {
|
||||
@@ -96,23 +83,23 @@ function UserHistory({
|
||||
duration: RankingDurations;
|
||||
setDuration: SetState<RankingDurations>;
|
||||
} & GamePT) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
const [mode, setMode] = useState<"playcount" | "ranking" | "rating">("rating");
|
||||
|
||||
const preferredRating = useProfileRatingAlg(game, playtype);
|
||||
const preferredRating = useProfileRatingAlg(game);
|
||||
|
||||
const [rating, setRating] = useState<keyof MONGO_UserGameStats["ratings"]>(preferredRating);
|
||||
const [rating, setRating] = useState<keyof UserGameStats["ratings"]>(preferredRating);
|
||||
|
||||
const propName = useMemo(() => {
|
||||
if (mode === "rating" && rating) {
|
||||
return FormatGPTProfileRatingName(game, playtype, rating);
|
||||
return FormatGPTProfileRatingName(game, rating);
|
||||
} else if (mode === "ranking") {
|
||||
return `${FormatGPTProfileRatingName(game, playtype, rating)} Ranking`;
|
||||
return `${FormatGPTProfileRatingName(game, rating)} Ranking`;
|
||||
}
|
||||
|
||||
return UppercaseFirst(mode);
|
||||
}, [mode, rating, game, playtype]);
|
||||
}, [mode, rating, game]);
|
||||
|
||||
const currentPropValue = useMemo(() => {
|
||||
if (mode === "rating" && rating) {
|
||||
@@ -122,7 +109,7 @@ function UserHistory({
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
return FormatGPTProfileRating(game, playtype, rating, ratingValue);
|
||||
return FormatGPTProfileRating(game, rating, ratingValue);
|
||||
} else if (mode === "ranking") {
|
||||
return (
|
||||
<>
|
||||
@@ -133,7 +120,7 @@ function UserHistory({
|
||||
}
|
||||
|
||||
return data[0].playcount;
|
||||
}, [mode, rating, data, game, playtype]);
|
||||
}, [mode, rating, data, game]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -144,6 +131,7 @@ function UserHistory({
|
||||
<option value="month">Past Month</option>
|
||||
<option value="3mo">Past 3 Months</option>
|
||||
<option value="year">Past Year</option>
|
||||
<option value="all">All Time</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6 align-self-center">
|
||||
@@ -170,19 +158,17 @@ function UserHistory({
|
||||
<Divider className="mt-6 mb-2" />
|
||||
{mode === "ranking" ? (
|
||||
<>
|
||||
{Object.keys(gptConfig.profileRatingAlgs).length > 1 && (
|
||||
{Object.keys(gameConfig.profileRatingAlgs).length > 1 && (
|
||||
<div className="col-12 offset-md-4 col-md-4 mt-4">
|
||||
<FormSelect
|
||||
onChange={(e) =>
|
||||
setRating(
|
||||
e.target.value as keyof MONGO_UserGameStats["ratings"],
|
||||
)
|
||||
setRating(e.target.value as keyof UserGameStats["ratings"])
|
||||
}
|
||||
value={rating}
|
||||
>
|
||||
{Object.keys(gptConfig.profileRatingAlgs).map((e) => (
|
||||
{Object.keys(gameConfig.profileRatingAlgs).map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{FormatGPTProfileRatingName(game, playtype, e)}
|
||||
{FormatGPTProfileRatingName(game, e)}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
@@ -225,26 +211,24 @@ function UserHistory({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(gptConfig.profileRatingAlgs).length > 1 && (
|
||||
{Object.keys(gameConfig.profileRatingAlgs).length > 1 && (
|
||||
<div className="col-12 offset-md-4 col-md-4 mt-4">
|
||||
<FormSelect
|
||||
onChange={(e) =>
|
||||
setRating(
|
||||
e.target.value as keyof MONGO_UserGameStats["ratings"],
|
||||
)
|
||||
setRating(e.target.value as keyof UserGameStats["ratings"])
|
||||
}
|
||||
value={rating}
|
||||
>
|
||||
{Object.keys(gptConfig.profileRatingAlgs).map((e) => (
|
||||
{Object.keys(gameConfig.profileRatingAlgs).map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{FormatGPTProfileRatingName(game, playtype, e)}
|
||||
{FormatGPTProfileRatingName(game, e)}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RatingTimeline {...{ game, playtype, data, rating }} />
|
||||
<RatingTimeline {...{ game, data, rating }} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -253,14 +237,12 @@ function UserHistory({
|
||||
|
||||
function RatingTimeline({
|
||||
game,
|
||||
playtype,
|
||||
data,
|
||||
rating,
|
||||
}: {
|
||||
data: UGPTHistory;
|
||||
game: GameGroup;
|
||||
playtype: Playtype;
|
||||
rating: keyof MONGO_UserGameStats["ratings"];
|
||||
game: V3Game;
|
||||
rating: keyof UserGameStats["ratings"];
|
||||
}) {
|
||||
const ratingDataset = [
|
||||
{ id: rating, data: data.map((e) => ({ x: e.timestamp, y: e.ratings[rating] })) },
|
||||
@@ -270,13 +252,13 @@ function RatingTimeline({
|
||||
<TimelineChart
|
||||
axisBottom={{
|
||||
format: (x) => DateTime.fromJSDate(x).toLocaleString(DateTime.DATE_FULL),
|
||||
tickValues: 3, // temp
|
||||
tickValues: 3,
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 5,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
format: (y) => (y ? FormatGPTProfileRating(game, playtype, rating, y) : "N/A"),
|
||||
format: (y) => (y ? FormatGPTProfileRating(game, rating, y) : "N/A"),
|
||||
}}
|
||||
data={ratingDataset}
|
||||
height="30rem"
|
||||
@@ -285,14 +267,9 @@ function RatingTimeline({
|
||||
<ChartTooltip>
|
||||
<div>
|
||||
{p.point.data.y
|
||||
? FormatGPTProfileRating(
|
||||
game,
|
||||
playtype,
|
||||
rating,
|
||||
p.point.data.y as number,
|
||||
)
|
||||
? FormatGPTProfileRating(game, rating, p.point.data.y as number)
|
||||
: "N/A"}{" "}
|
||||
{FormatGPTProfileRatingName(game, playtype, rating)}
|
||||
{FormatGPTProfileRatingName(game, rating)}
|
||||
</div>
|
||||
<small className="text-body-secondary">
|
||||
{MillisToSince(+p.point.data.xFormatted)}
|
||||
@@ -308,13 +285,13 @@ function RankingTimeline({
|
||||
rating,
|
||||
}: {
|
||||
data: UGPTHistory;
|
||||
rating: keyof MONGO_UserGameStats["ratings"];
|
||||
rating: keyof UserGameStats["ratings"];
|
||||
}) {
|
||||
return (
|
||||
<TimelineChart
|
||||
axisBottom={{
|
||||
format: (x) => DateTime.fromJSDate(x).toLocaleString(DateTime.DATE_FULL),
|
||||
tickValues: 3, // temp
|
||||
tickValues: 3,
|
||||
}}
|
||||
axisLeft={{
|
||||
tickSize: 5,
|
||||
|
||||
+63
-76
@@ -15,41 +15,45 @@ import React, { useState } from "react";
|
||||
import { Col, Form, Row } from "react-bootstrap";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import {
|
||||
FormatGameGroup,
|
||||
type GameGroup,
|
||||
type ChartDocument,
|
||||
FormatGame,
|
||||
GameToGameGroup,
|
||||
GetGameConfig,
|
||||
GetGameGroupConfig,
|
||||
GetGamePTConfig,
|
||||
type GPTString,
|
||||
type MONGO_ChartDocument,
|
||||
type MONGO_PBScoreDocument,
|
||||
type MONGO_ScoreDocument,
|
||||
type MONGO_SongDocument,
|
||||
type MONGO_UserDocument,
|
||||
type PBScoreDocument,
|
||||
type ScoreDocument,
|
||||
type ScoreRatingAlgorithms,
|
||||
type SongDocument,
|
||||
type UnsuccessfulAPIResponse,
|
||||
type UserDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
export default function ScoresPage({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
}: {
|
||||
reqUser: MONGO_UserDocument;
|
||||
reqUser: UserDocument;
|
||||
} & GamePT) {
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
const defaultRating = useScoreRatingAlg(game, playtype);
|
||||
const defaultRating = useScoreRatingAlg(game);
|
||||
|
||||
const [alg, setAlg] = useState(defaultRating);
|
||||
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Scores"],
|
||||
[
|
||||
"Users",
|
||||
reqUser.username,
|
||||
"Games",
|
||||
GetGameGroupConfig(GameToGameGroup(game)).name,
|
||||
"Scores",
|
||||
],
|
||||
[reqUser],
|
||||
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Scores`,
|
||||
`${reqUser.username}'s ${FormatGame(game)} Scores`,
|
||||
);
|
||||
|
||||
const base = useUGPTBase({ reqUser, game, playtype });
|
||||
const base = useUGPTBase({ reqUser, game });
|
||||
|
||||
return (
|
||||
<Row xs={{ cols: 1 }}>
|
||||
@@ -71,39 +75,37 @@ export default function ScoresPage({
|
||||
</Col>
|
||||
<Col className="d-flex flex-column gap-4">
|
||||
<Switch>
|
||||
<Route exact path="/u/:userID/games/:game/:playtype/scores">
|
||||
<Route exact path="/u/:userID/games/:game/scores">
|
||||
<>
|
||||
{Object.keys(gptConfig.scoreRatingAlgs).length > 1 && (
|
||||
<AlgSelector {...{ alg, setAlg, game, playtype }} />
|
||||
{Object.keys(gameConfig.scoreRatingAlgs).length > 1 && (
|
||||
<AlgSelector {...{ alg, setAlg, game }} />
|
||||
)}
|
||||
<PBsOverview
|
||||
url={`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=${alg}`}
|
||||
{...{ reqUser, game, playtype, alg }}
|
||||
url={`/users/${reqUser.id}/games/${game}/pbs/best?alg=${alg}`}
|
||||
{...{ reqUser, game, alg }}
|
||||
/>
|
||||
</>
|
||||
</Route>
|
||||
<Route path="/u/:userID/games/:game/:playtype/scores/history">
|
||||
<ScoresOverview {...{ reqUser, game, playtype }} />
|
||||
<Route path="/u/:userID/games/:game/scores/history">
|
||||
<ScoresOverview {...{ reqUser, game }} />
|
||||
</Route>
|
||||
<Route path="/u/:userID/games/:game/:playtype/scores/all">
|
||||
<Route path="/u/:userID/games/:game/scores/all">
|
||||
<PBsOverview
|
||||
game={game}
|
||||
indexCol={false}
|
||||
key="all-pbs"
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
url={`/users/${reqUser.id}/games/${game}/${playtype}/pbs/all`}
|
||||
url={`/users/${reqUser.id}/games/${game}/pbs/all`}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/u/:userID/games/:game/:playtype/scores/most-played">
|
||||
<Route path="/u/:userID/games/:game/scores/most-played">
|
||||
<PBsOverview
|
||||
game={game}
|
||||
indexCol
|
||||
key="most-played-pbs"
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
showPlaycount
|
||||
url={`/users/${reqUser.id}/games/${game}/${playtype}/most-played`}
|
||||
url={`/users/${reqUser.id}/games/${game}/most-played`}
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
@@ -114,21 +116,20 @@ export default function ScoresPage({
|
||||
|
||||
function AlgSelector({
|
||||
game,
|
||||
playtype,
|
||||
alg,
|
||||
setAlg,
|
||||
}: {
|
||||
alg: ScoreRatingAlgorithms[GPTString];
|
||||
setAlg: SetState<ScoreRatingAlgorithms[GPTString]>;
|
||||
alg: ScoreRatingAlgorithms[V3Game];
|
||||
setAlg: SetState<ScoreRatingAlgorithms[V3Game]>;
|
||||
} & GamePT) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
const gameConfig = GetGameConfig(game);
|
||||
return (
|
||||
<Form.Group className="d-flex flex-column gap-1">
|
||||
<div>Best 100 PBs according to</div>
|
||||
<Form.Select onChange={(e) => setAlg(e.target.value as any)} value={alg}>
|
||||
{Object.keys(gptConfig.scoreRatingAlgs).map((e) => (
|
||||
{Object.keys(gameConfig.scoreRatingAlgs).map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{FormatGPTScoreRatingName(game, playtype, e)}
|
||||
{FormatGPTScoreRatingName(game, e)}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
@@ -136,11 +137,11 @@ function AlgSelector({
|
||||
);
|
||||
}
|
||||
|
||||
function useFetchPBs(url: string, reqUser: MONGO_UserDocument) {
|
||||
function useFetchPBs(url: string, reqUser: UserDocument) {
|
||||
const { data, error } = useApiQuery<{
|
||||
charts: MONGO_ChartDocument[];
|
||||
pbs: MONGO_PBScoreDocument[];
|
||||
songs: MONGO_SongDocument[];
|
||||
charts: ChartDocument[];
|
||||
pbs: PBScoreDocument[];
|
||||
songs: SongDocument[];
|
||||
}>(url);
|
||||
|
||||
return {
|
||||
@@ -152,15 +153,14 @@ function useFetchPBs(url: string, reqUser: MONGO_UserDocument) {
|
||||
function PBsOverview({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
indexCol = true,
|
||||
showPlaycount = false,
|
||||
url,
|
||||
alg,
|
||||
}: {
|
||||
alg?: ScoreRatingAlgorithms[GPTString];
|
||||
alg?: ScoreRatingAlgorithms[V3Game];
|
||||
indexCol?: boolean;
|
||||
reqUser: MONGO_UserDocument;
|
||||
reqUser: UserDocument;
|
||||
showPlaycount?: boolean;
|
||||
url: string;
|
||||
} & GamePT) {
|
||||
@@ -184,27 +184,22 @@ function PBsOverview({
|
||||
defaultRankingViewMode={preferredRanking}
|
||||
game={game}
|
||||
indexCol={indexCol}
|
||||
playtype={playtype}
|
||||
showPlaycount={showPlaycount}
|
||||
/>
|
||||
</LoadingWrapper>
|
||||
) : (
|
||||
<PBsSearch {...{ reqUser, game, playtype, search }} />
|
||||
<PBsSearch {...{ reqUser, game, search }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormatData<
|
||||
D extends MONGO_PBScoreDocument | MONGO_ScoreDocument,
|
||||
GPT extends GPTString = GPTString,
|
||||
G extends GameGroup = GameGroup,
|
||||
>(
|
||||
function FormatData<D extends PBScoreDocument | ScoreDocument>(
|
||||
d: D[],
|
||||
songs: MONGO_SongDocument<G>[],
|
||||
charts: MONGO_ChartDocument<GPT>[],
|
||||
reqUser: MONGO_UserDocument,
|
||||
songs: SongDocument[],
|
||||
charts: ChartDocument[],
|
||||
reqUser: UserDocument,
|
||||
) {
|
||||
const songMap = new Map();
|
||||
const chartMap = new Map();
|
||||
@@ -230,11 +225,11 @@ function FormatData<
|
||||
return data;
|
||||
}
|
||||
|
||||
function useFetchScores(url: string, reqUser: MONGO_UserDocument) {
|
||||
function useFetchScores(url: string, reqUser: UserDocument) {
|
||||
const { data, error } = useApiQuery<{
|
||||
charts: MONGO_ChartDocument[];
|
||||
scores: MONGO_ScoreDocument[];
|
||||
songs: MONGO_SongDocument[];
|
||||
charts: ChartDocument[];
|
||||
scores: ScoreDocument[];
|
||||
songs: SongDocument[];
|
||||
}>(url);
|
||||
|
||||
return {
|
||||
@@ -246,37 +241,30 @@ function useFetchScores(url: string, reqUser: MONGO_UserDocument) {
|
||||
function PBsSearch({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
search,
|
||||
alg,
|
||||
}: {
|
||||
alg?: ScoreRatingAlgorithms[GPTString];
|
||||
reqUser: MONGO_UserDocument;
|
||||
alg?: ScoreRatingAlgorithms[V3Game];
|
||||
reqUser: UserDocument;
|
||||
search: string;
|
||||
} & GamePT) {
|
||||
const { data, error } = useFetchPBs(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/pbs?search=${search}`,
|
||||
`/users/${reqUser.id}/games/${game}/pbs?search=${search}`,
|
||||
reqUser,
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingWrapper style={{ height: 500 }} {...{ error, dataset: data }}>
|
||||
<PBTable
|
||||
alg={alg}
|
||||
dataset={data!}
|
||||
game={game}
|
||||
indexCol={false}
|
||||
playtype={playtype as "DP" | "SP"}
|
||||
/>
|
||||
<PBTable alg={alg} dataset={data!} game={game} indexCol={false} />
|
||||
</LoadingWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoresOverview({ reqUser, game, playtype }: UGPT) {
|
||||
function ScoresOverview({ reqUser, game }: UGPT) {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data, error } = useFetchScores(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/scores/recent`,
|
||||
`/users/${reqUser.id}/games/${game}/scores/recent`,
|
||||
reqUser,
|
||||
);
|
||||
|
||||
@@ -292,10 +280,10 @@ function ScoresOverview({ reqUser, game, playtype }: UGPT) {
|
||||
<div className="col-12 mt-4">
|
||||
{search === "" ? (
|
||||
<LoadingWrapper style={{ height: 500 }} {...{ dataset: data, error }}>
|
||||
<ScoreTable dataset={data!} game={game} playtype={playtype as any} />
|
||||
<ScoreTable dataset={data!} game={game} />
|
||||
</LoadingWrapper>
|
||||
) : (
|
||||
<ScoresSearch {...{ reqUser, game, playtype, search }} />
|
||||
<ScoresSearch {...{ reqUser, game, search }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,17 +293,16 @@ function ScoresOverview({ reqUser, game, playtype }: UGPT) {
|
||||
function ScoresSearch({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
search,
|
||||
}: { reqUser: MONGO_UserDocument; search: string } & GamePT) {
|
||||
}: { reqUser: UserDocument; search: string } & GamePT) {
|
||||
const { data, error } = useFetchScores(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/scores?search=${search}`,
|
||||
`/users/${reqUser.id}/games/${game}/scores?search=${search}`,
|
||||
reqUser,
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingWrapper style={{ height: 500 }} {...{ error, dataset: data }}>
|
||||
<ScoreTable dataset={data!} game={game} playtype={playtype as any} />
|
||||
<ScoreTable dataset={data!} game={game} />
|
||||
</LoadingWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
+21
-23
@@ -15,35 +15,40 @@ import { NumericSOV } from "#util/sorts";
|
||||
import React, { useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import {
|
||||
FormatGameGroup,
|
||||
FormatGame,
|
||||
GameToGameGroup,
|
||||
GetGameGroupConfig,
|
||||
type MONGO_SessionDocument,
|
||||
type MONGO_UserDocument,
|
||||
type SessionDocument,
|
||||
type SessionScoreInfo,
|
||||
type UnsuccessfulAPIResponse,
|
||||
type UserDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
export default function SessionsPage({ reqUser, game, playtype }: UGPT) {
|
||||
export default function SessionsPage({ reqUser, game }: UGPT) {
|
||||
const [sessionSet, setSessionSet] = useState<"best" | "highlighted" | "recent">("best");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Sessions"],
|
||||
[
|
||||
"Users",
|
||||
reqUser.username,
|
||||
"Games",
|
||||
GetGameGroupConfig(GameToGameGroup(game)).name,
|
||||
"Sessions",
|
||||
],
|
||||
[reqUser],
|
||||
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Sessions`,
|
||||
`${reqUser.username}'s ${FormatGame(game)} Sessions`,
|
||||
);
|
||||
|
||||
const baseUrl = `/users/${reqUser.id}/games/${game}/${playtype}/sessions`;
|
||||
const baseUrl = `/users/${reqUser.id}/games/${game}/sessions`;
|
||||
|
||||
const rating = useSessionRatingAlg(game, playtype);
|
||||
const rating = useSessionRatingAlg(game);
|
||||
|
||||
const { data, error } = useQuery<SessionDataset, UnsuccessfulAPIResponse>(
|
||||
`${baseUrl}/${sessionSet}`,
|
||||
async () => {
|
||||
const res = await APIFetchV1<
|
||||
({ __scoreInfo: Array<SessionScoreInfo> } & MONGO_SessionDocument)[]
|
||||
({ __scoreInfo: Array<SessionScoreInfo> } & SessionDocument)[]
|
||||
>(`${baseUrl}/${sessionSet}`);
|
||||
|
||||
if (!res.success) {
|
||||
@@ -70,7 +75,7 @@ export default function SessionsPage({ reqUser, game, playtype }: UGPT) {
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<SessionCalendar
|
||||
url={`/users/${reqUser.id}/games/${game}/${playtype}/sessions/calendar`}
|
||||
url={`/users/${reqUser.id}/games/${game}/sessions/calendar`}
|
||||
user={reqUser}
|
||||
/>
|
||||
<Divider />
|
||||
@@ -113,12 +118,11 @@ export default function SessionsPage({ reqUser, game, playtype }: UGPT) {
|
||||
dataset={data!}
|
||||
game={game}
|
||||
indexCol={sessionSet === "best"}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
</LoadingWrapper>
|
||||
) : (
|
||||
<SearchSessionsTable {...{ game, playtype, reqUser, baseUrl, search }} />
|
||||
<SearchSessionsTable {...{ game, reqUser, baseUrl, search }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,14 +132,13 @@ export default function SessionsPage({ reqUser, game, playtype }: UGPT) {
|
||||
function SearchSessionsTable({
|
||||
search,
|
||||
game,
|
||||
playtype,
|
||||
reqUser,
|
||||
baseUrl,
|
||||
}: { baseUrl: string; reqUser: MONGO_UserDocument; search: string } & GamePT) {
|
||||
}: { baseUrl: string; reqUser: UserDocument; search: string } & GamePT) {
|
||||
const { data, error } = useQuery<SessionDataset, UnsuccessfulAPIResponse>(
|
||||
`${baseUrl}?search=${search}`,
|
||||
async () => {
|
||||
const res = await APIFetchV1<MONGO_SessionDocument[]>(`${baseUrl}?search=${search}`);
|
||||
const res = await APIFetchV1<SessionDocument[]>(`${baseUrl}?search=${search}`);
|
||||
|
||||
if (!res.success) {
|
||||
throw res;
|
||||
@@ -152,12 +155,7 @@ function SearchSessionsTable({
|
||||
|
||||
return (
|
||||
<LoadingWrapper {...{ error, dataset: data }}>
|
||||
<GenericSessionTable
|
||||
dataset={data!}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
<GenericSessionTable dataset={data!} game={game} reqUser={reqUser} />
|
||||
</LoadingWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-13
@@ -17,9 +17,9 @@ import { CreateChartMap, CreateScoreIDMap, CreateSongMap } from "#util/data";
|
||||
import React, { useContext, useMemo, useState } from "react";
|
||||
import { Badge, Button, Col, Row } from "react-bootstrap";
|
||||
import { Redirect, useParams } from "react-router-dom";
|
||||
import { GetGameGroupConfig } from "tachi-common";
|
||||
import { GameToGameGroup, GetGameGroupConfig } from "tachi-common";
|
||||
|
||||
export default function SpecificSessionPage({ reqUser, game, playtype }: UGPT) {
|
||||
export default function SpecificSessionPage({ reqUser, game }: UGPT) {
|
||||
const { sessionID } = useParams<{ sessionID: string }>();
|
||||
|
||||
const { data, error } = useApiQuery<SessionReturns>(`/sessions/${sessionID}`);
|
||||
@@ -32,22 +32,20 @@ export default function SpecificSessionPage({ reqUser, game, playtype }: UGPT) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (
|
||||
data.user.id !== reqUser.id ||
|
||||
game !== data.session.game ||
|
||||
playtype !== data.session.playtype
|
||||
) {
|
||||
const sessionV3Game = data.session.game;
|
||||
|
||||
if (data.user.id !== reqUser.id || game !== sessionV3Game) {
|
||||
return (
|
||||
<Redirect
|
||||
to={`/u/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
|
||||
to={`/u/${data.user.username}/games/${sessionV3Game}/sessions/${sessionID}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <SessionPage {...{ data, game, playtype, reqUser }} />;
|
||||
return <SessionPage {...{ data, game, reqUser }} />;
|
||||
}
|
||||
|
||||
function SessionPage({ data, game, playtype }: { data: SessionReturns } & UGPT) {
|
||||
function SessionPage({ data, game }: { data: SessionReturns } & UGPT) {
|
||||
const { settings } = useContext(UserSettingsContext);
|
||||
|
||||
const [sessionData, setSessionData] = useState(data);
|
||||
@@ -60,12 +58,11 @@ function SessionPage({ data, game, playtype }: { data: SessionReturns } & UGPT)
|
||||
"Users",
|
||||
user.username,
|
||||
"Games",
|
||||
GetGameGroupConfig(game).name,
|
||||
playtype,
|
||||
GetGameGroupConfig(GameToGameGroup(game)).name,
|
||||
"Sessions",
|
||||
session.name,
|
||||
],
|
||||
[session.name, game, playtype, user],
|
||||
[session.name, game, user],
|
||||
`${user.username}: ${session.name}`,
|
||||
);
|
||||
|
||||
|
||||
+58
-60
@@ -30,31 +30,38 @@ import { Alert, Button, Col, Form, Row } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
BMS_TABLES,
|
||||
FormatGameGroup,
|
||||
FormatGame,
|
||||
GameToGameGroup,
|
||||
GetGameConfig,
|
||||
GetGameGroupConfig,
|
||||
GetGamePTConfig,
|
||||
GetScoreMetrics,
|
||||
type MONGO_TableDocument,
|
||||
type MONGO_UGPTSettingsDocument,
|
||||
type MONGO_UserDocument,
|
||||
type ShowcaseStatDetails,
|
||||
type TableDocument,
|
||||
type UGPTSettingsDocument,
|
||||
type UserDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
export default function UGPTSettingsPage({ reqUser, game, playtype }: UGPT) {
|
||||
export default function UGPTSettingsPage({ reqUser, game }: UGPT) {
|
||||
const query = useQueryString();
|
||||
|
||||
const [page, setPage] = useState<"manage" | "preferences" | "showcase">(
|
||||
query.get("showcase") ? "showcase" : "preferences",
|
||||
);
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Settings"],
|
||||
[
|
||||
"Users",
|
||||
reqUser.username,
|
||||
"Games",
|
||||
GetGameGroupConfig(GameToGameGroup(game)).name,
|
||||
"Settings",
|
||||
],
|
||||
[reqUser],
|
||||
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Settings`,
|
||||
`${reqUser.username}'s ${FormatGame(game)} Settings`,
|
||||
);
|
||||
|
||||
const UGPT = { reqUser, game, playtype };
|
||||
const UGPT = { reqUser, game };
|
||||
|
||||
const { loggedInData } = useContext(UGPTContext);
|
||||
if (!loggedInData) {
|
||||
@@ -139,33 +146,32 @@ export default function UGPTSettingsPage({ reqUser, game, playtype }: UGPT) {
|
||||
function PreferencesForm({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
loggedInData,
|
||||
}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) {
|
||||
}: { loggedInData: { settings: UGPTSettingsDocument } & UGPTData } & UGPT) {
|
||||
const { setLoggedInData } = useContext(UGPTContext);
|
||||
|
||||
const settings = loggedInData.settings;
|
||||
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
preferredScoreAlg:
|
||||
settings.preferences.preferredScoreAlg || gptConfig.defaultScoreRatingAlg,
|
||||
settings.preferences.preferredScoreAlg || gameConfig.defaultScoreRatingAlg,
|
||||
preferredProfileAlg:
|
||||
settings.preferences.preferredProfileAlg || gptConfig.defaultProfileRatingAlg,
|
||||
settings.preferences.preferredProfileAlg || gameConfig.defaultProfileRatingAlg,
|
||||
preferredSessionAlg:
|
||||
settings.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg,
|
||||
settings.preferences.preferredSessionAlg || gameConfig.defaultSessionRatingAlg,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
gameSpecific: settings.preferences.gameSpecific as any,
|
||||
defaultTable: settings.preferences.defaultTable,
|
||||
preferredDefaultEnum:
|
||||
settings.preferences.preferredDefaultEnum ?? gptConfig.preferredDefaultEnum,
|
||||
settings.preferences.preferredDefaultEnum ?? gameConfig.preferredDefaultEnum,
|
||||
preferredRanking: settings.preferences.preferredRanking ?? "global",
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const rj = await APIFetchV1<MONGO_UserDocument>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/settings`,
|
||||
const rj = await APIFetchV1<UserDocument>(
|
||||
`/users/${reqUser.id}/games/${game}/settings`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(values),
|
||||
@@ -180,7 +186,7 @@ function PreferencesForm({
|
||||
if (rj.success) {
|
||||
setLoggedInData({
|
||||
...loggedInData,
|
||||
settings: deepmerge(settings as MONGO_UGPTSettingsDocument, {
|
||||
settings: deepmerge(settings as UGPTSettingsDocument, {
|
||||
preferences: values,
|
||||
}),
|
||||
});
|
||||
@@ -188,8 +194,8 @@ function PreferencesForm({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: tables, error } = useApiQuery<MONGO_TableDocument[]>(
|
||||
`/games/${game}/${playtype}/tables?showInactive=true`,
|
||||
const { data: tables, error } = useApiQuery<TableDocument[]>(
|
||||
`/games/${game}/tables?showInactive=true`,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@@ -208,7 +214,7 @@ function PreferencesForm({
|
||||
|
||||
return (
|
||||
<Form className="d-flex flex-column gap-4" onSubmit={formik.handleSubmit}>
|
||||
{Object.keys(gptConfig.scoreRatingAlgs).length > 1 && (
|
||||
{Object.keys(gameConfig.scoreRatingAlgs).length > 1 && (
|
||||
<Form.Group className={formGroupClassNames}>
|
||||
<Form.Label>Preferred Score Algorithm</Form.Label>
|
||||
<Form.Select
|
||||
@@ -216,9 +222,9 @@ function PreferencesForm({
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.preferredScoreAlg}
|
||||
>
|
||||
{Object.keys(gptConfig.scoreRatingAlgs).map((e) => (
|
||||
{Object.keys(gameConfig.scoreRatingAlgs).map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{FormatGPTScoreRatingName(game, playtype, e)}
|
||||
{FormatGPTScoreRatingName(game, e)}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
@@ -228,7 +234,7 @@ function PreferencesForm({
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
)}
|
||||
{Object.keys(gptConfig.sessionRatingAlgs).length > 1 && (
|
||||
{Object.keys(gameConfig.sessionRatingAlgs).length > 1 && (
|
||||
<Form.Group className={formGroupClassNames}>
|
||||
<Form.Label>Preferred Session Algorithm</Form.Label>
|
||||
<Form.Select
|
||||
@@ -236,9 +242,9 @@ function PreferencesForm({
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.preferredSessionAlg}
|
||||
>
|
||||
{Object.keys(gptConfig.sessionRatingAlgs).map((e) => (
|
||||
{Object.keys(gameConfig.sessionRatingAlgs).map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{FormatGPTSessionRatingName(game, playtype, e)}
|
||||
{FormatGPTSessionRatingName(game, e)}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
@@ -248,7 +254,7 @@ function PreferencesForm({
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
)}
|
||||
{Object.keys(gptConfig.profileRatingAlgs).length > 1 && (
|
||||
{Object.keys(gameConfig.profileRatingAlgs).length > 1 && (
|
||||
<Form.Group className={formGroupClassNames}>
|
||||
<Form.Label>Preferred Profile Algorithm</Form.Label>
|
||||
<Form.Select
|
||||
@@ -256,9 +262,9 @@ function PreferencesForm({
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.preferredProfileAlg}
|
||||
>
|
||||
{Object.keys(gptConfig.profileRatingAlgs).map((e) => (
|
||||
{Object.keys(gameConfig.profileRatingAlgs).map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{FormatGPTProfileRatingName(game, playtype, e)}
|
||||
{FormatGPTProfileRatingName(game, e)}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
@@ -275,7 +281,7 @@ function PreferencesForm({
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.preferredDefaultEnum}
|
||||
>
|
||||
{GetScoreMetrics(gptConfig, "ENUM").map((e) => (
|
||||
{GetScoreMetrics(gameConfig, "ENUM").map((e) => (
|
||||
<option key={e} value={e}>
|
||||
{UppercaseFirst(e)}
|
||||
</option>
|
||||
@@ -322,7 +328,7 @@ function PreferencesForm({
|
||||
What folders would you like to see by default?
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
{game === "iidx" && (
|
||||
{(game === "iidx-sp" || game === "iidx-dp") && (
|
||||
<>
|
||||
<Form.Group className={formGroupClassNames}>
|
||||
<Form.Check
|
||||
@@ -356,7 +362,7 @@ function PreferencesForm({
|
||||
</Form.Group>
|
||||
</>
|
||||
)}
|
||||
{(game === "sdvx" || game === "usc") && (
|
||||
{(game === "sdvx" || game === "usc-controller" || game === "usc-keyboard") && (
|
||||
<Form.Group className={formGroupClassNames}>
|
||||
<Form.Label>VF6 Target</Form.Label>
|
||||
<Form.Control
|
||||
@@ -379,11 +385,11 @@ function PreferencesForm({
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
)}
|
||||
{game === "bms" && (
|
||||
{(game === "bms-7k" || game === "bms-14k") && (
|
||||
<Form.Group className={formGroupClassNames}>
|
||||
<Form.Label>Preferred Tables</Form.Label>
|
||||
|
||||
{BMS_TABLES.filter((e) => e.playtype === playtype).map((e) => (
|
||||
{BMS_TABLES.filter((e) => e.game === game).map((e) => (
|
||||
<Form.Check
|
||||
checked={
|
||||
formik.values.gameSpecific.displayTables?.includes(e.prefix) ??
|
||||
@@ -394,9 +400,9 @@ function PreferencesForm({
|
||||
onChange={(event) => {
|
||||
const base: Array<string> =
|
||||
formik.values.gameSpecific.displayTables ??
|
||||
BMS_TABLES.filter(
|
||||
(e) => e.playtype === playtype && !e.notDefault,
|
||||
).map((e) => e.prefix);
|
||||
BMS_TABLES.filter((e) => e.game === game && !e.notDefault).map(
|
||||
(e) => e.prefix,
|
||||
);
|
||||
|
||||
if (event.target.checked) {
|
||||
formik.setFieldValue("gameSpecific.displayTables", [
|
||||
@@ -429,9 +435,8 @@ function PreferencesForm({
|
||||
function ShowcaseForm({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
loggedInData,
|
||||
}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) {
|
||||
}: { loggedInData: { settings: UGPTSettingsDocument } & UGPTData } & UGPT) {
|
||||
const { setLoggedInData } = useContext(UGPTContext);
|
||||
|
||||
const settings = loggedInData.settings;
|
||||
@@ -440,11 +445,11 @@ function ShowcaseForm({
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const SaveChanges = async () => {
|
||||
const r = await APIFetchV1<MONGO_UGPTSettingsDocument>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/showcase`,
|
||||
const r = await APIFetchV1<UGPTSettingsDocument>(
|
||||
`/users/${reqUser.id}/games/${game}/showcase`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(stats),
|
||||
body: JSON.stringify({ showcase: stats }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
true,
|
||||
@@ -478,13 +483,12 @@ function ShowcaseForm({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<RenderCurrentStats {...{ reqUser, game, playtype, stats, setStats }} />
|
||||
<RenderCurrentStats {...{ reqUser, game, stats, setStats }} />
|
||||
<UGPTStatCreator
|
||||
game={game}
|
||||
onCreate={(stat) => {
|
||||
setStats([...stats, stat]);
|
||||
}}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
setShow={setShow}
|
||||
show={show}
|
||||
@@ -498,7 +502,6 @@ function RenderCurrentStats({
|
||||
setStats,
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
}: {
|
||||
setStats: SetState<ShowcaseStatDetails[]>;
|
||||
stats: ShowcaseStatDetails[];
|
||||
@@ -519,7 +522,7 @@ function RenderCurrentStats({
|
||||
<Row className="w-100 row-gap-4" lg={{ cols: 2 }}>
|
||||
{stats.map((e, i) => (
|
||||
<Col className="d-flex flex-column gap-4" key={i}>
|
||||
<UGPTStatContainer game={game} playtype={playtype} reqUser={reqUser} stat={e} />
|
||||
<UGPTStatContainer game={game} reqUser={reqUser} stat={e} />
|
||||
<Button className="w-100" onClick={() => RemoveStatAtIndex(i)} variant="danger">
|
||||
Delete
|
||||
</Button>
|
||||
@@ -529,7 +532,7 @@ function RenderCurrentStats({
|
||||
);
|
||||
}
|
||||
|
||||
function ManageAccount({ reqUser, game, playtype }: UGPT) {
|
||||
function ManageAccount({ reqUser, game }: UGPT) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -549,13 +552,12 @@ function ManageAccount({ reqUser, game, playtype }: UGPT) {
|
||||
<Col className="mt-8" xs={12}>
|
||||
<h3>Completely Wipe Profile</h3>
|
||||
If you've <i>really</i> messed up, you can wipe your entire profile for{" "}
|
||||
{FormatGameGroup(game, playtype)}.
|
||||
{FormatGame(game)}.
|
||||
<br />
|
||||
<Alert className="mt-4" style={{ fontSize: "1.5rem" }} variant="warning">
|
||||
It is very important to know that this is <b>NOT REVERSIBLE.</b> Wiping your
|
||||
profile will <b>COMPLETELY DELETE</b> all of your{" "}
|
||||
{FormatGameGroup(game, playtype)} scores from our server. We will not be able to
|
||||
retrieve them.
|
||||
profile will <b>COMPLETELY DELETE</b> all of your {FormatGame(game)} scores from
|
||||
our server. We will not be able to retrieve them.
|
||||
</Alert>
|
||||
<br />
|
||||
<Form.Group>
|
||||
@@ -573,16 +575,13 @@ function ManageAccount({ reqUser, game, playtype }: UGPT) {
|
||||
onClick={async () => {
|
||||
if (
|
||||
confirm(
|
||||
`You are really about to delete all of your ${FormatGameGroup(
|
||||
game,
|
||||
playtype,
|
||||
)} scores. This is your last chance to turn back.`,
|
||||
`You are really about to delete all of your ${FormatGame(game)} scores. This is your last chance to turn back.`,
|
||||
)
|
||||
) {
|
||||
setDeleting(true);
|
||||
|
||||
const res = await APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}`,
|
||||
`/users/${reqUser.id}/games/${game}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ "!password": password }),
|
||||
@@ -597,14 +596,13 @@ function ManageAccount({ reqUser, game, playtype }: UGPT) {
|
||||
setDeleting(false);
|
||||
|
||||
if (res.success) {
|
||||
// bye!
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
}}
|
||||
variant="outline-danger"
|
||||
>
|
||||
Yes, I want to delete my {FormatGameGroup(game, playtype)} account.
|
||||
Yes, I want to delete my {FormatGame(game)} account.
|
||||
</Button>
|
||||
{deleting && (
|
||||
<>
|
||||
|
||||
+19
-37
@@ -21,28 +21,24 @@ import Form from "react-bootstrap/Form";
|
||||
import Row from "react-bootstrap/Row";
|
||||
import Stack from "react-bootstrap/Stack";
|
||||
import { Link } from "react-router-dom";
|
||||
import { type MONGO_FolderDocument, type MONGO_UserDocument } from "tachi-common";
|
||||
import { type FolderDocument, type UserDocument } from "tachi-common";
|
||||
|
||||
export default function RivalCompareFolderPage({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
folder,
|
||||
}: {
|
||||
folder: MONGO_FolderDocument;
|
||||
reqUser: MONGO_UserDocument;
|
||||
folder: FolderDocument;
|
||||
reqUser: UserDocument;
|
||||
} & GamePT) {
|
||||
const { settings } = useLUGPTSettings();
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<MONGO_UserDocument | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<UserDocument | null>(null);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
// honestly don't care if this errors or not
|
||||
const { data, error } = useApiQuery<Array<MONGO_UserDocument>>(
|
||||
`/users/${settings?.userID}/games/${game}/${playtype}/rivals`,
|
||||
undefined,
|
||||
// [settings?.rivals.join(",")]
|
||||
const { data, error } = useApiQuery<Array<UserDocument>>(
|
||||
`/users/${settings?.userID}/games/${game}/rivals`,
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
@@ -51,9 +47,6 @@ export default function RivalCompareFolderPage({
|
||||
|
||||
let suggestUsers = data ?? [];
|
||||
|
||||
// if the user viewing this page is logged in and *is not*
|
||||
// the person this page is for
|
||||
// show them in the suggestions.
|
||||
if (user && user.id !== reqUser.id && data) {
|
||||
suggestUsers = [...suggestUsers, user];
|
||||
}
|
||||
@@ -65,7 +58,7 @@ export default function RivalCompareFolderPage({
|
||||
<>
|
||||
<Col className="d-flex justify-content-center flex-wrap" xs={12}>
|
||||
{suggestUsers.map((e) => (
|
||||
<UserIcon game={game} key={e.id} playtype={playtype} user={e}>
|
||||
<UserIcon game={game} key={e.id} user={e}>
|
||||
<Button
|
||||
onClick={() => setSelectedUser(e)}
|
||||
variant={
|
||||
@@ -91,7 +84,7 @@ export default function RivalCompareFolderPage({
|
||||
excludeSet={[reqUser.id]}
|
||||
setShow={setShow}
|
||||
show={show}
|
||||
url={`/games/${game}/${playtype}/players`}
|
||||
url={`/games/${game}/players`}
|
||||
/>
|
||||
<Button
|
||||
className={suggestUsers.length === 0 ? "d-flex mx-auto" : ""}
|
||||
@@ -107,7 +100,6 @@ export default function RivalCompareFolderPage({
|
||||
<FolderCompare
|
||||
folder={folder}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
withUser={selectedUser}
|
||||
/>
|
||||
@@ -120,26 +112,24 @@ export default function RivalCompareFolderPage({
|
||||
function FolderCompare({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
withUser,
|
||||
folder,
|
||||
}: {
|
||||
folder: MONGO_FolderDocument;
|
||||
reqUser: MONGO_UserDocument;
|
||||
withUser: MONGO_UserDocument;
|
||||
folder: FolderDocument;
|
||||
reqUser: UserDocument;
|
||||
withUser: UserDocument;
|
||||
} & GamePT) {
|
||||
const { data: baseData, error: baseError } = useApiQuery<UGPTFolderReturns>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${folder.folderID}`,
|
||||
`/users/${reqUser.id}/games/${game}/folders/${folder.slug}`,
|
||||
);
|
||||
|
||||
const { data: compareData, error: compareError } = useApiQuery<UGPTFolderReturns>(
|
||||
`/users/${withUser.id}/games/${game}/${playtype}/folders/${folder.folderID}`,
|
||||
`/users/${withUser.id}/games/${game}/folders/${folder.slug}`,
|
||||
);
|
||||
|
||||
const [shouldIncludeNotPlayed, setShouldIncludeNotPlayed] = useState(false);
|
||||
|
||||
const dataset = useMemo(() => {
|
||||
// i *LOVE* the rules of hooks! they're so convenient!
|
||||
if (!baseData || !compareData) {
|
||||
return [];
|
||||
}
|
||||
@@ -153,7 +143,7 @@ function FolderCompare({
|
||||
chart,
|
||||
base: basePBLookup.get(chart.chartID) ?? null,
|
||||
compare: comparePBLookup.get(chart.chartID) ?? null,
|
||||
song: songMap.get(chart.songID)!,
|
||||
song: songMap.get(chart.song.id)!,
|
||||
}));
|
||||
|
||||
if (!shouldIncludeNotPlayed) {
|
||||
@@ -177,14 +167,9 @@ function FolderCompare({
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Row lg={{ cols: 2 }} xs={{ cols: 1 }}>
|
||||
<UserCard game={game} playtype={playtype} user={reqUser} />
|
||||
<UserCard game={game} playtype={playtype} user={withUser} />
|
||||
<UserCard game={game} user={reqUser} />
|
||||
<UserCard game={game} user={withUser} />
|
||||
</Row>
|
||||
{/* <CompareCard
|
||||
dataset={dataset}
|
||||
baseUsername={reqUser.username}
|
||||
otherUsername={withUser.username}
|
||||
/> */}
|
||||
<Form.Check
|
||||
checked={shouldIncludeNotPlayed}
|
||||
label="Include charts without plays?"
|
||||
@@ -196,16 +181,13 @@ function FolderCompare({
|
||||
compareUser={withUser.username}
|
||||
dataset={dataset}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function UserCard({ user, game, playtype }: { user: MONGO_UserDocument } & GamePT) {
|
||||
const { data, error } = useApiQuery<UGPTStatsReturn>(
|
||||
`/users/${user.username}/games/${game}/${playtype}`,
|
||||
);
|
||||
function UserCard({ user, game }: { user: UserDocument } & GamePT) {
|
||||
const { data, error } = useApiQuery<UGPTStatsReturn>(`/users/${user.username}/games/${game}`);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
@@ -217,7 +199,7 @@ function UserCard({ user, game, playtype }: { user: MONGO_UserDocument } & GameP
|
||||
<div className="d-flex flex-column">
|
||||
<Link
|
||||
className="fw-bold fs-4 text-center text-lg-start"
|
||||
to={`/u/${user.username}/games/${game}/${playtype}`}
|
||||
to={`/u/${user.username}/games/${game}`}
|
||||
>
|
||||
{user.username}
|
||||
</Link>
|
||||
|
||||
+9
-21
@@ -5,26 +5,25 @@ import Divider from "#components/util/Divider";
|
||||
import Loading from "#components/util/Loading";
|
||||
import useApiQuery from "#components/util/query/useApiQuery";
|
||||
import { TargetsContext } from "#context/TargetsContext";
|
||||
import { type GoalsOnChartReturn, type GoalsOnFolderReturn } from "#types/api-returns";
|
||||
import { type GoalsOnFolderReturn } from "#types/api-returns";
|
||||
import { type UGPT } from "#types/react";
|
||||
import { CreateGoalSubDataset, CreateUserMap } from "#util/data";
|
||||
import React, { useContext, useReducer, useState } from "react";
|
||||
import { Button, Col } from "react-bootstrap";
|
||||
import { type MONGO_FolderDocument } from "tachi-common";
|
||||
import { type FolderDocument } from "tachi-common";
|
||||
|
||||
export default function FolderQuestsPage({
|
||||
folder,
|
||||
game,
|
||||
playtype,
|
||||
reqUser,
|
||||
}: {
|
||||
folder: MONGO_FolderDocument;
|
||||
folder: FolderDocument;
|
||||
} & UGPT) {
|
||||
const [refresh, forceRefresh] = useReducer((x) => x + 1, 0);
|
||||
const { reloadTargets } = useContext(TargetsContext);
|
||||
|
||||
const { data, error } = useApiQuery<GoalsOnChartReturn>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/targets/on-folder/${folder.folderID}`,
|
||||
const { data, error } = useApiQuery<GoalsOnFolderReturn>(
|
||||
`/users/${reqUser.id}/games/${game}/targets/on-folder/${folder.slug}`,
|
||||
undefined,
|
||||
[refresh],
|
||||
);
|
||||
@@ -40,15 +39,11 @@ export default function FolderQuestsPage({
|
||||
</Col>
|
||||
<Divider />
|
||||
{error && <ApiError error={error} />}
|
||||
{data ? (
|
||||
<FolderQuestsInner {...{ reqUser, game, playtype, folder, data }} />
|
||||
) : (
|
||||
<Loading />
|
||||
)}
|
||||
{data ? <FolderQuestsInner {...{ reqUser, game, folder, data }} /> : <Loading />}
|
||||
|
||||
{show && (
|
||||
<SetNewGoalModal
|
||||
{...{ game, playtype, reqUser, show, setShow }}
|
||||
{...{ game, reqUser, show, setShow }}
|
||||
onNewGoalSet={() => {
|
||||
forceRefresh();
|
||||
reloadTargets();
|
||||
@@ -63,20 +58,13 @@ export default function FolderQuestsPage({
|
||||
function FolderQuestsInner({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
folder,
|
||||
data,
|
||||
}: {
|
||||
data: GoalsOnFolderReturn;
|
||||
folder: MONGO_FolderDocument;
|
||||
folder: FolderDocument;
|
||||
} & UGPT) {
|
||||
const userMap = CreateUserMap([reqUser]);
|
||||
|
||||
return (
|
||||
<GoalSubInfo
|
||||
dataset={CreateGoalSubDataset(data, userMap)}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
/>
|
||||
);
|
||||
return <GoalSubInfo dataset={CreateGoalSubDataset(data, userMap)} game={game} />;
|
||||
}
|
||||
|
||||
+16
-19
@@ -17,21 +17,20 @@ import { APIFetchV1 } from "#util/api";
|
||||
import { Reverse, UppercaseFirst } from "#util/misc";
|
||||
import React, { useContext, useMemo, useState } from "react";
|
||||
import {
|
||||
GetGamePTConfig,
|
||||
GetGPTString,
|
||||
type FolderDocument,
|
||||
GetGameConfig,
|
||||
GetScoreMetricConf,
|
||||
GetScoreMetrics,
|
||||
type MONGO_FolderDocument,
|
||||
} from "tachi-common";
|
||||
import { type ConfEnumScoreMetric } from "tachi-common/types/metrics";
|
||||
|
||||
export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
|
||||
export default function FoldersSearch({ reqUser, game }: UGPT) {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const params = useMemo(() => new URLSearchParams({ search }), [search]);
|
||||
|
||||
const { data, error } = useApiQuery<UGPTFolderSearch>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/folders?${params.toString()}`,
|
||||
`/users/${reqUser.id}/games/${game}/folders?${params.toString()}`,
|
||||
);
|
||||
|
||||
let body = <></>;
|
||||
@@ -44,7 +43,7 @@ export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
|
||||
const statMap = new Map();
|
||||
|
||||
for (const stat of data.stats) {
|
||||
statMap.set(stat.folderID, stat);
|
||||
statMap.set(stat.slug, stat);
|
||||
}
|
||||
|
||||
body = (
|
||||
@@ -55,10 +54,9 @@ export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
|
||||
{data.folders.map((e) => (
|
||||
<FolderInfoComponent
|
||||
folder={e}
|
||||
folderStats={statMap.get(e.folderID)!}
|
||||
folderStats={statMap.get(e.slug)!}
|
||||
game={game}
|
||||
key={e.folderID}
|
||||
playtype={playtype}
|
||||
key={e.slug}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
))}
|
||||
@@ -81,21 +79,20 @@ export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
|
||||
export function FolderInfoComponent({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
folderStats,
|
||||
folder,
|
||||
}: { folder: MONGO_FolderDocument; folderStats: FolderStatsInfo } & UGPT) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)];
|
||||
}: { folder: FolderDocument; folderStats: FolderStatsInfo } & UGPT) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
|
||||
|
||||
const preferredDefaultEnum = useBucket(game, playtype);
|
||||
const preferredDefaultEnum = useBucket(game);
|
||||
|
||||
const [metric, setMetric] = useState<string>(preferredDefaultEnum);
|
||||
|
||||
const base = useUGPTBase({ reqUser, game, playtype });
|
||||
const base = useUGPTBase({ reqUser, game });
|
||||
|
||||
const dataset = useMemo(() => {
|
||||
const conf = GetScoreMetricConf(gptConfig, metric) as ConfEnumScoreMetric<string>;
|
||||
const conf = GetScoreMetricConf(gameConfig, metric) as ConfEnumScoreMetric<string>;
|
||||
|
||||
return (
|
||||
<DistributionTable
|
||||
@@ -119,12 +116,12 @@ export function FolderInfoComponent({
|
||||
onClick={() => {
|
||||
if (user?.id === reqUser.id) {
|
||||
APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${folder.folderID}/viewed`,
|
||||
`/users/${reqUser.id}/games/${game}/folders/${folder.slug}/viewed`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
}}
|
||||
to={`${base}/folders/${folder.folderID}`}
|
||||
to={`${base}/folders/${folder.slug}`}
|
||||
variant="outline-info"
|
||||
>
|
||||
View
|
||||
@@ -136,7 +133,7 @@ export function FolderInfoComponent({
|
||||
<div className="row text-center">
|
||||
<div className="col-12">
|
||||
<div className="btn-group">
|
||||
{GetScoreMetrics(gptConfig, "ENUM").map((e) => (
|
||||
{GetScoreMetrics(gameConfig, "ENUM").map((e) => (
|
||||
<SelectButton id={e} key={e} setValue={setMetric} value={metric}>
|
||||
{/* @ts-expect-error this access is legal zzz */}
|
||||
<Icon type={gptImpl.enumIcons[e]} /> {UppercaseFirst(e)}s
|
||||
|
||||
+34
-52
@@ -2,7 +2,6 @@ import { BarChartTooltip } from "#components/charts/ChartTooltip";
|
||||
import Card from "#components/layout/page/Card";
|
||||
import TachiTable from "#components/tables/components/TachiTable";
|
||||
import ApiError from "#components/util/ApiError";
|
||||
import DebugContent from "#components/util/DebugContent";
|
||||
import Divider from "#components/util/Divider";
|
||||
import Icon from "#components/util/Icon";
|
||||
import LinkButton from "#components/util/LinkButton";
|
||||
@@ -25,29 +24,24 @@ import React, { useContext, useEffect, useMemo, useState } from "react";
|
||||
import Form from "react-bootstrap/Form";
|
||||
import InputGroup from "react-bootstrap/InputGroup";
|
||||
import {
|
||||
type GameGroup,
|
||||
type GamePTConfig,
|
||||
GetGamePTConfig,
|
||||
GetGPTString,
|
||||
type FolderDocument,
|
||||
type GameConfig,
|
||||
GetGameConfig,
|
||||
GetScoreMetricConf,
|
||||
GetScoreMetrics,
|
||||
type MONGO_FolderDocument,
|
||||
type MONGO_TableDocument,
|
||||
type MONGO_UserDocument,
|
||||
type Playtype,
|
||||
type TableDocument,
|
||||
type UserDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
import { type ConfEnumScoreMetric } from "tachi-common/types/metrics";
|
||||
|
||||
interface Props {
|
||||
reqUser: MONGO_UserDocument;
|
||||
game: GameGroup;
|
||||
playtype: Playtype;
|
||||
reqUser: UserDocument;
|
||||
game: V3Game;
|
||||
}
|
||||
|
||||
export default function FolderTablePage({ reqUser, game, playtype }: Props) {
|
||||
const { data, error } = useApiQuery<MONGO_TableDocument[]>(
|
||||
`/games/${game}/${playtype}/tables?showInactive=true`,
|
||||
);
|
||||
export default function FolderTablePage({ reqUser, game }: Props) {
|
||||
const { data, error } = useApiQuery<TableDocument[]>(`/games/${game}/tables?showInactive=true`);
|
||||
|
||||
const { settings } = useLUGPTSettings();
|
||||
|
||||
@@ -114,24 +108,19 @@ export default function FolderTablePage({ reqUser, game, playtype }: Props) {
|
||||
</Form.Select>
|
||||
</InputGroup>
|
||||
<Divider />
|
||||
{table && <TableFolderViewer {...{ reqUser, game, playtype, table }} />}
|
||||
{table && <TableFolderViewer {...{ reqUser, game, table }} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface UGPTFolderStats {
|
||||
folder: MONGO_FolderDocument;
|
||||
folder: FolderDocument;
|
||||
stats: FolderStatsInfo;
|
||||
}
|
||||
|
||||
function TableFolderViewer({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
table,
|
||||
}: { table: MONGO_TableDocument } & Props) {
|
||||
function TableFolderViewer({ reqUser, game, table }: { table: TableDocument } & Props) {
|
||||
const { data, error } = useApiQuery<UGPTTableReturns>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/tables/${table.tableID}`,
|
||||
`/users/${reqUser.id}/games/${game}/tables/${table.tableID}`,
|
||||
);
|
||||
|
||||
const [dataMap, setDataMap] = useState<Map<string, UGPTFolderStats>>(new Map());
|
||||
@@ -141,13 +130,13 @@ function TableFolderViewer({
|
||||
if (data) {
|
||||
const statMap = new Map();
|
||||
for (const stat of data.stats) {
|
||||
statMap.set(stat.folderID, stat);
|
||||
statMap.set(stat.slug, stat);
|
||||
}
|
||||
|
||||
const newMap = new Map();
|
||||
for (const folder of data.folders) {
|
||||
const stats = statMap.get(folder.folderID)!;
|
||||
newMap.set(folder.folderID, { folder, stats });
|
||||
const stats = statMap.get(folder.slug)!;
|
||||
newMap.set(folder.slug, { folder, stats });
|
||||
}
|
||||
setDataMap(newMap);
|
||||
setHasLoadedFolderMap(true);
|
||||
@@ -166,13 +155,7 @@ function TableFolderViewer({
|
||||
<>
|
||||
<TableBarChart dataMap={dataMap} table={table} />
|
||||
<Divider />
|
||||
<TableFolderTable
|
||||
dataMap={dataMap}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
table={table}
|
||||
/>
|
||||
<TableFolderTable dataMap={dataMap} game={game} reqUser={reqUser} table={table} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -182,10 +165,9 @@ function TableFolderTable({
|
||||
dataMap,
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
}: {
|
||||
dataMap: Map<string, UGPTFolderStats>;
|
||||
table: MONGO_TableDocument;
|
||||
table: TableDocument;
|
||||
} & Props) {
|
||||
const dataset = useMemo(() => {
|
||||
const arr = [];
|
||||
@@ -224,12 +206,12 @@ function TableFolderTable({
|
||||
onClick={() => {
|
||||
if (user?.id === reqUser.id) {
|
||||
APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${data.folder.folderID}/viewed`,
|
||||
`/users/${reqUser.id}/games/${game}/folders/${data.folder.slug}/viewed`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
}}
|
||||
to={`/u/${reqUser.username}/games/${game}/${playtype}/folders/${data.folder.folderID}`}
|
||||
to={`/u/${reqUser.username}/games/${game}/folders/${data.folder.slug}`}
|
||||
>
|
||||
View Breakdown
|
||||
</LinkButton>
|
||||
@@ -250,23 +232,23 @@ function TableBarChart({
|
||||
dataMap,
|
||||
}: {
|
||||
dataMap: Map<string, UGPTFolderStats>;
|
||||
table: MONGO_TableDocument;
|
||||
table: TableDocument;
|
||||
}) {
|
||||
const gptConfig = GetGamePTConfig(table.game, table.playtype);
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(table.game, table.playtype)];
|
||||
const tableGame = table.game;
|
||||
const gameConfig = GetGameConfig(tableGame);
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[tableGame];
|
||||
|
||||
const bucket = useBucket(table.game, table.playtype);
|
||||
const bucket = useBucket(tableGame);
|
||||
|
||||
const [enumMetric, setEnumMetric] = useState<string>(bucket);
|
||||
|
||||
// @ts-expect-error hack!
|
||||
const colours = useMemo(() => gptImpl.enumColours[enumMetric], [enumMetric]);
|
||||
const colours = useMemo(() => (gptImpl.enumColours as any)[enumMetric], [enumMetric]);
|
||||
|
||||
const dataset = useMemo(() => {
|
||||
const arr = [];
|
||||
|
||||
for (const folderID of table.folders) {
|
||||
const data = dataMap.get(folderID)!;
|
||||
for (const folderSlug of table.folders) {
|
||||
const data = dataMap.get(folderSlug)!;
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
@@ -301,7 +283,7 @@ function TableBarChart({
|
||||
<div className="row">
|
||||
<div className="col-12 d-flex justify-content-center">
|
||||
<div className="btn-group">
|
||||
{GetScoreMetrics(gptConfig, "ENUM").map((e) => (
|
||||
{GetScoreMetrics(gameConfig, "ENUM").map((e) => (
|
||||
<SelectButton
|
||||
id={e}
|
||||
key={e}
|
||||
@@ -327,7 +309,7 @@ function TableBarChart({
|
||||
<OverviewBarChart
|
||||
colours={colours}
|
||||
dataset={dataset}
|
||||
gptConfig={gptConfig}
|
||||
gameConfig={gameConfig}
|
||||
// This hack is necessary because otherwise nivo gets caught in a render loop and dies
|
||||
key={enumMetric}
|
||||
mode={enumMetric}
|
||||
@@ -340,14 +322,14 @@ function TableBarChart({
|
||||
}
|
||||
|
||||
function OverviewBarChart({
|
||||
gptConfig,
|
||||
gameConfig,
|
||||
mode: metric,
|
||||
dataset,
|
||||
colours,
|
||||
}: {
|
||||
colours: any;
|
||||
dataset: any;
|
||||
gptConfig: GamePTConfig;
|
||||
gameConfig: GameConfig;
|
||||
mode: string;
|
||||
}) {
|
||||
const longestTitle = useMemo(
|
||||
@@ -360,7 +342,7 @@ function OverviewBarChart({
|
||||
[dataset],
|
||||
);
|
||||
|
||||
const conf = GetScoreMetricConf(gptConfig, metric) as ConfEnumScoreMetric<string>;
|
||||
const conf = GetScoreMetricConf(gameConfig, metric) as ConfEnumScoreMetric<string>;
|
||||
|
||||
return (
|
||||
<ResponsiveBar
|
||||
|
||||
+21
-18
@@ -1,5 +1,4 @@
|
||||
import useSetSubheader from "#components/layout/header/useSetSubheader";
|
||||
import Divider from "#components/util/Divider";
|
||||
import Icon from "#components/util/Icon";
|
||||
import SelectLinkButton from "#components/util/SelectLinkButton";
|
||||
import useUGPTBase from "#components/util/useUGPTBase";
|
||||
@@ -8,32 +7,36 @@ import { UserContext } from "#context/UserContext";
|
||||
import { type UGPT } from "#types/react";
|
||||
import React, { useContext } from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
|
||||
import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common";
|
||||
|
||||
import FolderSelectPage from "./FolderSelectPage";
|
||||
import FolderTablePage from "./FolderTablePage";
|
||||
import RecentFoldersPage from "./RecentFoldersPage";
|
||||
import SpecificFolderPage from "./SpecificFolderPage";
|
||||
|
||||
export default function FoldersMainPage({ reqUser, game, playtype }: UGPT) {
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
|
||||
export default function FoldersMainPage({ reqUser, game }: UGPT) {
|
||||
const { user } = useContext(UserContext);
|
||||
const { ugs } = useContext(AllLUGPTStatsContext);
|
||||
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Folders"],
|
||||
[reqUser, game, playtype],
|
||||
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Folders`,
|
||||
[
|
||||
"Users",
|
||||
reqUser.username,
|
||||
"Games",
|
||||
GetGameGroupConfig(GameToGameGroup(game)).name,
|
||||
"Folders",
|
||||
],
|
||||
[reqUser, game],
|
||||
`${reqUser.username}'s ${FormatGame(game)} Folders`,
|
||||
);
|
||||
|
||||
const base = useUGPTBase({ reqUser, game, playtype });
|
||||
const base = useUGPTBase({ reqUser, game });
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12 text-center">
|
||||
<div className="btn-group d-flex justify-content-center mb-8">
|
||||
{user && ugs?.find((x) => x.game === game && x.playtype === playtype) && (
|
||||
{user && ugs?.find((x) => x.game === game) && (
|
||||
<SelectLinkButton className="text-wrap" to={`${base}/folders/recent`}>
|
||||
<Icon type="clock" />{" "}
|
||||
{user.id === reqUser.id
|
||||
@@ -52,17 +55,17 @@ export default function FoldersMainPage({ reqUser, game, playtype }: UGPT) {
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<Switch>
|
||||
<Route exact path="/u/:userID/games/:game/:playtype/folders">
|
||||
<FolderTablePage {...{ reqUser, game, playtype }} />
|
||||
<Route exact path="/u/:userID/games/:game/folders">
|
||||
<FolderTablePage {...{ reqUser, game }} />
|
||||
</Route>
|
||||
<Route exact path="/u/:userID/games/:game/:playtype/folders/search">
|
||||
<FolderSelectPage {...{ reqUser, game, playtype }} />
|
||||
<Route exact path="/u/:userID/games/:game/folders/search">
|
||||
<FolderSelectPage {...{ reqUser, game }} />
|
||||
</Route>
|
||||
<Route exact path="/u/:userID/games/:game/:playtype/folders/recent">
|
||||
<RecentFoldersPage {...{ reqUser, game, playtype }} />
|
||||
<Route exact path="/u/:userID/games/:game/folders/recent">
|
||||
<RecentFoldersPage {...{ reqUser, game }} />
|
||||
</Route>
|
||||
<Route path="/u/:userID/games/:game/:playtype/folders/:folderID">
|
||||
<SpecificFolderPage {...{ reqUser, game, playtype }} />
|
||||
<Route path="/u/:userID/games/:game/folders/:folderSlug">
|
||||
<SpecificFolderPage {...{ reqUser, game }} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
+14
-20
@@ -8,29 +8,29 @@ import React, { useContext } from "react";
|
||||
import { Row } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
type MONGO_FolderDocument,
|
||||
type MONGO_RecentlyViewedFolderDocument,
|
||||
type MONGO_UserDocument,
|
||||
type FolderDocument,
|
||||
type RecentlyViewedFolderDocument,
|
||||
type UserDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
import { FolderInfoComponent } from "./FolderSelectPage";
|
||||
|
||||
export default function RecentFoldersPage({ reqUser, game, playtype }: UGPT) {
|
||||
export default function RecentFoldersPage({ reqUser, game }: UGPT) {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
if (!user) {
|
||||
return <>Hey, you're not logged in. How did you get here!</>;
|
||||
}
|
||||
|
||||
return <Inner game={game} playtype={playtype} reqUser={reqUser} user={user} />;
|
||||
return <Inner game={game} reqUser={reqUser} user={user} />;
|
||||
}
|
||||
|
||||
function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } & UGPT) {
|
||||
function Inner({ reqUser, game, user }: { user: UserDocument } & UGPT) {
|
||||
const { data, error } = useApiQuery<{
|
||||
folders: MONGO_FolderDocument[];
|
||||
folders: FolderDocument[];
|
||||
stats: FolderStatsInfo[];
|
||||
views: MONGO_RecentlyViewedFolderDocument[];
|
||||
}>(`/users/${user.id}/games/${game}/${playtype}/folders/recent`);
|
||||
views: RecentlyViewedFolderDocument[];
|
||||
}>(`/users/${user.id}/games/${game}/folders/recent`);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
@@ -44,9 +44,7 @@ function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } &
|
||||
return (
|
||||
<div className="text-center">
|
||||
Looks like you've not recently interacted with any folders.{" "}
|
||||
<Link to={`/u/${user.username}/games/${game}/${playtype}/folders`}>
|
||||
Go do that!
|
||||
</Link>
|
||||
<Link to={`/u/${user.username}/games/${game}/folders`}>Go do that!</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,9 +54,8 @@ function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } &
|
||||
for (const recent of data.views) {
|
||||
dataset.push({
|
||||
view: recent,
|
||||
// Is it really O(n^2) if the input is capped at 4?
|
||||
folder: data.folders.find((x) => x.folderID === recent.folderID),
|
||||
stats: data.stats.find((x) => x.folderID === recent.folderID),
|
||||
folder: data.folders.find((x) => x.slug === recent.slug),
|
||||
stats: data.stats.find((x) => x.slug === recent.slug),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,9 +63,7 @@ function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } &
|
||||
<Row>
|
||||
{dataset.map((e) => {
|
||||
if (e.folder === undefined || e.stats === undefined) {
|
||||
return (
|
||||
<>Failed to load folder {e.view.folderID}. This is a bug. Report this!</>
|
||||
);
|
||||
return <>Failed to load folder {e.view.slug}. This is a bug. Report this!</>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -76,8 +71,7 @@ function Inner({ reqUser, game, playtype, user }: { user: MONGO_UserDocument } &
|
||||
folder={e.folder}
|
||||
folderStats={e.stats}
|
||||
game={game}
|
||||
key={e.folder.folderID}
|
||||
playtype={playtype}
|
||||
key={e.folder.slug}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
);
|
||||
|
||||
+58
-101
@@ -32,19 +32,16 @@ import React, { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Col, Form, Row } from "react-bootstrap";
|
||||
import { Link, Route, Switch, useParams } from "react-router-dom";
|
||||
import {
|
||||
type ChartDocument,
|
||||
COLOUR_SET,
|
||||
FormatDifficultyShort,
|
||||
type GameGroup,
|
||||
GetGamePTConfig,
|
||||
GetGPTString,
|
||||
GetGameConfig,
|
||||
GetScoreEnumConfs,
|
||||
type GPTString,
|
||||
type integer,
|
||||
type MONGO_ChartDocument,
|
||||
type MONGO_ScoreDocument,
|
||||
type MONGO_SongDocument,
|
||||
type MONGO_UserDocument,
|
||||
type Playtype,
|
||||
type ScoreDocument,
|
||||
type SongDocument,
|
||||
type UserDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
import { type ConfEnumScoreMetric } from "tachi-common/types/metrics";
|
||||
|
||||
@@ -52,16 +49,15 @@ import FolderComparePage from "./FolderComparePage";
|
||||
import FolderQuestsPage from "./FolderQuestsPage";
|
||||
|
||||
interface Props {
|
||||
reqUser: MONGO_UserDocument;
|
||||
game: GameGroup;
|
||||
playtype: Playtype;
|
||||
reqUser: UserDocument;
|
||||
game: V3Game;
|
||||
}
|
||||
|
||||
export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
|
||||
const { folderID } = useParams<{ folderID: string }>();
|
||||
export default function SpecificFolderPage({ reqUser, game }: Props) {
|
||||
const { folderSlug } = useParams<{ folderSlug: string }>();
|
||||
|
||||
const { data, error } = useApiQuery<UGPTFolderReturns>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${folderID}`,
|
||||
`/users/${reqUser.id}/games/${game}/folders/${folderSlug}`,
|
||||
);
|
||||
|
||||
const folderDataset = useMemo(() => {
|
||||
@@ -79,7 +75,7 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
|
||||
...chart,
|
||||
__related: {
|
||||
pb: pbMap.get(chart.chartID) ?? null,
|
||||
song: songMap.get(chart.songID)!,
|
||||
song: songMap.get(chart.song.id)!,
|
||||
user: reqUser,
|
||||
},
|
||||
});
|
||||
@@ -100,13 +96,12 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
|
||||
folderDataset={folderDataset}
|
||||
folderTitle={data.folder.title}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
);
|
||||
}, [folderDataset]);
|
||||
|
||||
const base = `${useUGPTBase({ reqUser, game, playtype })}/folders/${folderID}`;
|
||||
const base = `${useUGPTBase({ reqUser, game })}/folders/${folderSlug}`;
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
@@ -116,7 +111,7 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[`${game}:${playtype}` as GPTString];
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
@@ -131,8 +126,10 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
|
||||
</SelectLinkButton>
|
||||
{gptImpl.ratingSystems.length !== 0 &&
|
||||
// temp: tierlist view sucks for BMS and PMS
|
||||
game !== "bms" &&
|
||||
game !== "pms" && (
|
||||
game !== "bms-7k" &&
|
||||
game !== "bms-14k" &&
|
||||
game !== "pms-controller" &&
|
||||
game !== "pms-keyboard" && (
|
||||
<SelectLinkButton className="text-wrap" to={`${base}/tierlist`}>
|
||||
<Icon type="sort-alpha-up" /> Tierlist View
|
||||
</SelectLinkButton>
|
||||
@@ -154,40 +151,24 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
|
||||
<div className="col-12">
|
||||
<Switch>
|
||||
<Route exact path={base}>
|
||||
<FolderTable dataset={folderDataset} game={game} playtype={playtype} />
|
||||
<FolderTable dataset={folderDataset} game={game} />
|
||||
</Route>
|
||||
<Route exact path={`${base}/tierlist`}>
|
||||
<TierlistBreakdown
|
||||
data={data}
|
||||
folderDataset={folderDataset}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
</Route>
|
||||
<Route exact path={`${base}/timeline`}>
|
||||
<TimelineView
|
||||
folderID={folderID}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
<TimelineView folderSlug={folderSlug} game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
<Route exact path={`${base}/compare`}>
|
||||
<FolderComparePage
|
||||
folder={data.folder}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
<FolderComparePage folder={data.folder} game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
<Route exact path={`${base}/targets`}>
|
||||
<FolderQuestsPage
|
||||
folder={data.folder}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
<FolderQuestsPage folder={data.folder} game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
@@ -195,11 +176,11 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineView({ game, playtype, reqUser, folderID }: { folderID: string } & Props) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
const enumConfs = GetScoreEnumConfs(gptConfig);
|
||||
function TimelineView({ game, reqUser, folderSlug }: { folderSlug: string } & Props) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
const enumConfs = GetScoreEnumConfs(gameConfig);
|
||||
|
||||
const [selectedEnum, setSelectedEnum] = useState<string>(gptConfig.preferredDefaultEnum);
|
||||
const [selectedEnum, setSelectedEnum] = useState<string>(gameConfig.preferredDefaultEnum);
|
||||
const [enumConf, setEnumConf] = useState<ConfEnumScoreMetric<string>>(enumConfs[selectedEnum]!);
|
||||
|
||||
const [value, setValue] = useState<string>(enumConf.minimumRelevantValue);
|
||||
@@ -237,9 +218,7 @@ function TimelineView({ game, playtype, reqUser, folderID }: { folderID: string
|
||||
</div>
|
||||
</Card>
|
||||
<hr />
|
||||
<TimelineMain
|
||||
{...{ reqUser, game, playtype, folderID, enumMetric: selectedEnum, value }}
|
||||
/>
|
||||
<TimelineMain {...{ reqUser, game, folderSlug, enumMetric: selectedEnum, value }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -247,23 +226,22 @@ function TimelineView({ game, playtype, reqUser, folderID }: { folderID: string
|
||||
function TimelineMain({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
folderID,
|
||||
folderSlug,
|
||||
enumMetric: enumMetric,
|
||||
value,
|
||||
}: {
|
||||
enumMetric: string;
|
||||
folderID: string;
|
||||
folderSlug: string;
|
||||
value: string;
|
||||
} & Props) {
|
||||
const { data, error } = useApiQuery<{
|
||||
charts: MONGO_ChartDocument[];
|
||||
scores: MONGO_ScoreDocument[];
|
||||
songs: MONGO_SongDocument[];
|
||||
charts: ChartDocument[];
|
||||
scores: ScoreDocument[];
|
||||
songs: SongDocument[];
|
||||
}>(
|
||||
`/users/${
|
||||
reqUser.id
|
||||
}/games/${game}/${playtype}/folders/${folderID}/timeline?criteriaValue=${encodeURIComponent(
|
||||
}/games/${game}/folders/${folderSlug}/timeline?criteriaValue=${encodeURIComponent(
|
||||
value,
|
||||
)}&criteriaType=${encodeURIComponent(enumMetric)}`,
|
||||
);
|
||||
@@ -369,10 +347,10 @@ function TimelineElement({
|
||||
index: integer;
|
||||
scoreData: {
|
||||
__related: {
|
||||
chart: MONGO_ChartDocument;
|
||||
song: MONGO_SongDocument;
|
||||
chart: ChartDocument;
|
||||
song: SongDocument;
|
||||
};
|
||||
} & MONGO_ScoreDocument;
|
||||
} & ScoreDocument;
|
||||
}) {
|
||||
return (
|
||||
<div className="timeline-item">
|
||||
@@ -423,24 +401,20 @@ type InfoProps = {
|
||||
folderDataset: FolderDataset;
|
||||
} & Props;
|
||||
|
||||
function TierlistBreakdown({ game, folderDataset, playtype, reqUser }: InfoProps) {
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[`${game}:${playtype}` as GPTString];
|
||||
function TierlistBreakdown({ game, folderDataset, reqUser }: InfoProps) {
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
|
||||
|
||||
const [tierlist, setTierlist] = useState<string>(gptImpl.ratingSystems[0].name);
|
||||
const [useFancyColour, setUseFancyColour] = useState(false);
|
||||
const [forceGridView, setForceGridView] = useState(false);
|
||||
|
||||
const playerStats = useMemo(
|
||||
() => FolderDatasetAchievedStatus(folderDataset, game, playtype, tierlist),
|
||||
() => FolderDatasetAchievedStatus(folderDataset, game, tierlist),
|
||||
[tierlist],
|
||||
);
|
||||
|
||||
// @ts-expect-error Typescript doesn't like our unioning here for some reason
|
||||
// and i can't be bothered to figure it out
|
||||
const tierlistImpl: GPTRatingSystem<GPTString> = gptImpl.ratingSystems.find(
|
||||
// @ts-expect-error Typescript doesn't like our unioning here for some reason
|
||||
// and i can't be bothered to figure it out
|
||||
(rs: GPTRatingSystem<GPTString>) => rs.name === tierlist,
|
||||
const tierlistImpl = (gptImpl.ratingSystems as GPTRatingSystem<V3Game>[]).find(
|
||||
(rs) => rs.name === tierlist,
|
||||
);
|
||||
|
||||
if (!tierlistImpl) {
|
||||
@@ -501,7 +475,6 @@ function TierlistBreakdown({ game, folderDataset, playtype, reqUser }: InfoProps
|
||||
forceGridView={forceGridView}
|
||||
game={game}
|
||||
playerStats={playerStats}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
tierlistImpl={tierlistImpl}
|
||||
useFancyColour={useFancyColour}
|
||||
@@ -514,7 +487,6 @@ function TierlistBreakdown({ game, folderDataset, playtype, reqUser }: InfoProps
|
||||
function TierlistInfoLadder({
|
||||
playerStats,
|
||||
game,
|
||||
playtype,
|
||||
reqUser,
|
||||
folderDataset,
|
||||
tierlistImpl,
|
||||
@@ -523,11 +495,10 @@ function TierlistInfoLadder({
|
||||
}: {
|
||||
folderDataset: FolderDataset;
|
||||
forceGridView: boolean;
|
||||
game: GameGroup;
|
||||
game: V3Game;
|
||||
playerStats: Record<string, { score: string | null; status: AchievedStatuses }>;
|
||||
playtype: Playtype;
|
||||
reqUser: MONGO_UserDocument;
|
||||
tierlistImpl: GPTRatingSystem<GPTString>;
|
||||
reqUser: UserDocument;
|
||||
tierlistImpl: GPTRatingSystem<V3Game>;
|
||||
useFancyColour: boolean;
|
||||
}) {
|
||||
const buckets: TierlistInfo[][] = useMemo(() => {
|
||||
@@ -609,7 +580,6 @@ function TierlistInfoLadder({
|
||||
{...{
|
||||
bucket,
|
||||
game,
|
||||
playtype,
|
||||
reqUser,
|
||||
useFancyColour,
|
||||
tierlistImpl,
|
||||
@@ -625,7 +595,6 @@ function TierlistInfoLadder({
|
||||
function TierlistBucket({
|
||||
bucket,
|
||||
game,
|
||||
playtype,
|
||||
reqUser,
|
||||
useFancyColour,
|
||||
forceGridView,
|
||||
@@ -633,10 +602,9 @@ function TierlistBucket({
|
||||
}: {
|
||||
bucket: TierlistInfo[];
|
||||
forceGridView: boolean;
|
||||
game: GameGroup;
|
||||
playtype: Playtype;
|
||||
reqUser: MONGO_UserDocument;
|
||||
tierlistImpl: GPTRatingSystem<GPTString>;
|
||||
game: V3Game;
|
||||
reqUser: UserDocument;
|
||||
tierlistImpl: GPTRatingSystem<V3Game>;
|
||||
useFancyColour: boolean;
|
||||
}) {
|
||||
const {
|
||||
@@ -653,7 +621,6 @@ function TierlistBucket({
|
||||
game={game}
|
||||
i={i}
|
||||
key={`${tierlistInfo.chart.chartID}-${tierlistInfo.text}`}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
tierlistImpl={tierlistImpl}
|
||||
tierlistInfo={tierlistInfo}
|
||||
@@ -673,7 +640,6 @@ function TierlistBucket({
|
||||
game={game}
|
||||
i={i}
|
||||
key={`${tierlistInfo.chart.chartID}-${tierlistInfo.text}`}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
tierlistImpl={tierlistImpl}
|
||||
tierlistInfo={tierlistInfo}
|
||||
@@ -687,7 +653,6 @@ function TierlistBucket({
|
||||
function TierlistInfoBucketValues({
|
||||
tierlistInfo,
|
||||
game,
|
||||
playtype,
|
||||
reqUser,
|
||||
useFancyColour,
|
||||
forceGridView,
|
||||
@@ -695,11 +660,10 @@ function TierlistInfoBucketValues({
|
||||
}: {
|
||||
bucket: TierlistInfo[];
|
||||
forceGridView: boolean;
|
||||
game: GameGroup;
|
||||
game: V3Game;
|
||||
i: integer;
|
||||
playtype: Playtype;
|
||||
reqUser: MONGO_UserDocument;
|
||||
tierlistImpl: GPTRatingSystem<GPTString>;
|
||||
reqUser: UserDocument;
|
||||
tierlistImpl: GPTRatingSystem<V3Game>;
|
||||
tierlistInfo: TierlistInfo;
|
||||
useFancyColour: boolean;
|
||||
}) {
|
||||
@@ -716,7 +680,7 @@ function TierlistInfoBucketValues({
|
||||
let colourCss: string | undefined;
|
||||
|
||||
if (useFancyColour) {
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)];
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
|
||||
|
||||
// @ts-expect-error lol
|
||||
colourCss = gptImpl.enumColours[tierlistImpl.enumName][tierlistInfo.score];
|
||||
@@ -736,7 +700,7 @@ function TierlistInfoBucketValues({
|
||||
<tr>
|
||||
<DifficultyCell alwaysShort chart={tierlistInfo.chart} game={game} noTierlist />
|
||||
<td className="text-start">
|
||||
<Link className="text-decoration-none" to={CreateChartLink(data, game)}>
|
||||
<Link className="text-decoration-none" to={CreateChartLink(data)}>
|
||||
{tierlistInfo.chart.__related.song.title}
|
||||
</Link>{" "}
|
||||
<br />
|
||||
@@ -771,10 +735,10 @@ function TierlistInfoBucketValues({
|
||||
className={`${colourClass} bg-opacity-50 rounded p-2`}
|
||||
style={{ backgroundColor: colourCss ? ChangeOpacity(colourCss, 0.5) : undefined }}
|
||||
>
|
||||
<Link className="text-decoration-none" to={CreateChartLink(data, game)}>
|
||||
<Link className="text-decoration-none" to={CreateChartLink(data)}>
|
||||
{data.__related.song.title}
|
||||
</Link>{" "}
|
||||
{FormatDifficultyShort(data, game)}
|
||||
{FormatDifficultyShort(data)}
|
||||
<Divider className="my-2" />
|
||||
{tierlistInfo.value} ({tierlistInfo.text ?? "No Info"})
|
||||
{tierlistInfo.idvDiff && (
|
||||
@@ -851,17 +815,11 @@ enum AchievedStatuses {
|
||||
SCORE_BASED,
|
||||
}
|
||||
|
||||
function FolderDatasetAchievedStatus(
|
||||
folderDataset: FolderDataset,
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
tierlist: string,
|
||||
) {
|
||||
function FolderDatasetAchievedStatus(folderDataset: FolderDataset, game: V3Game, tierlist: string) {
|
||||
const tierlistInfo: Record<string, { score: string | null; status: AchievedStatuses }> = {};
|
||||
|
||||
const fn = GPT_CLIENT_IMPLEMENTATIONS[`${game}:${playtype}` as GPTString].ratingSystems.find(
|
||||
// @ts-expect-error i'm sick of this language and i'm sick of type hacks
|
||||
(e: GPTRatingSystem<GPTString>) => e.name === tierlist,
|
||||
const fn = (GPT_CLIENT_IMPLEMENTATIONS[game].ratingSystems as GPTRatingSystem<V3Game>[]).find(
|
||||
(e) => e.name === tierlist,
|
||||
)?.achievementFn;
|
||||
|
||||
for (const data of folderDataset) {
|
||||
@@ -871,8 +829,7 @@ function FolderDatasetAchievedStatus(
|
||||
if (!data.__related.pb) {
|
||||
achieved = AchievedStatuses.NOT_PLAYED;
|
||||
} else if (fn) {
|
||||
// @ts-expect-error i'm sick of this language and i'm sick of type hacks
|
||||
const v = fn(data.__related.pb);
|
||||
const v = fn(data.__related.pb as any);
|
||||
|
||||
achieved = v[1] ? AchievedStatuses.ACHIEVED : AchievedStatuses.FAILED;
|
||||
score = typeof v[0] === "number" ? v[0].toString() : v[0];
|
||||
|
||||
+3
-5
@@ -4,7 +4,7 @@ import { type UGPT } from "#types/react";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) {
|
||||
export default function RivalsActivityPage({ reqUser, game }: UGPT) {
|
||||
const { settings } = useLUGPTSettings();
|
||||
|
||||
if (!settings) {
|
||||
@@ -15,12 +15,10 @@ export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
You have no rivals set.{" "}
|
||||
<Link to={`/u/${reqUser.id}/games/${game}/${playtype}/rivals/manage`}>
|
||||
Go set some!
|
||||
</Link>
|
||||
<Link to={`/u/${reqUser.id}/games/${game}/rivals/manage`}>Go set some!</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Activity url={`/users/${reqUser.id}/games/${game}/${playtype}/rivals/activity`} />;
|
||||
return <Activity url={`/users/${reqUser.id}/games/${game}/rivals/activity`} />;
|
||||
}
|
||||
|
||||
+22
-32
@@ -7,36 +7,32 @@ import useLUGPTSettings from "#components/util/useLUGPTSettings";
|
||||
import useUGPTBase from "#components/util/useUGPTBase";
|
||||
import React from "react";
|
||||
import { Col, Row } from "react-bootstrap";
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import {
|
||||
FormatGameGroup,
|
||||
type GameGroup,
|
||||
FormatGame,
|
||||
GameToGameGroup,
|
||||
GetGameGroupConfig,
|
||||
type MONGO_UserDocument,
|
||||
type Playtype,
|
||||
type UserDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
import RivalsActivityPage from "./RivalsActivityPage";
|
||||
import RivalsManagePage from "./RivalsManagePage";
|
||||
|
||||
export default function RivalsMainPage({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
}: {
|
||||
game: GameGroup;
|
||||
playtype: Playtype;
|
||||
reqUser: MONGO_UserDocument;
|
||||
}) {
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
|
||||
export default function RivalsMainPage({ reqUser, game }: { game: V3Game; reqUser: UserDocument }) {
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Rivals"],
|
||||
[reqUser, game, playtype],
|
||||
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Rivals`,
|
||||
[
|
||||
"Users",
|
||||
reqUser.username,
|
||||
"Games",
|
||||
GetGameGroupConfig(GameToGameGroup(game)).name,
|
||||
"Rivals",
|
||||
],
|
||||
[reqUser, game],
|
||||
`${reqUser.username}'s ${FormatGame(game)} Rivals`,
|
||||
);
|
||||
|
||||
const base = useUGPTBase({ reqUser, game, playtype });
|
||||
const base = useUGPTBase({ reqUser, game });
|
||||
|
||||
const { settings } = useLUGPTSettings();
|
||||
|
||||
@@ -48,11 +44,6 @@ export default function RivalsMainPage({
|
||||
<Row>
|
||||
<Col className="text-center" xs={12}>
|
||||
<div className="btn-group d-flex justify-content-center">
|
||||
{/* this ui sucks and i don't like it. come up with something better? */}
|
||||
{/* <SelectLinkButton to={`${base}/rivals/pb-leaderboard`}>
|
||||
<Icon type="sort-amount-up" />
|
||||
Rival's Bests
|
||||
</SelectLinkButton> */}
|
||||
<SelectLinkButton to={`${base}/rivals`}>
|
||||
<Icon type="list" /> Rival Activity
|
||||
</SelectLinkButton>
|
||||
@@ -64,20 +55,19 @@ export default function RivalsMainPage({
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<Switch>
|
||||
<Route exact path="/u/:userID/games/:game/:playtype/rivals">
|
||||
<RivalsActivityPage game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<Route exact path="/u/:userID/games/:game/rivals">
|
||||
<RivalsActivityPage game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/u/:userID/games/:game/:playtype/rivals/manage">
|
||||
<RivalsManagePage game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<Route exact path="/u/:userID/games/:game/rivals/manage">
|
||||
<RivalsManagePage game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/u/:userID/games/:game/:playtype/rivals/pb-leaderboard">
|
||||
<Route exact path="/u/:userID/games/:game/rivals/pb-leaderboard">
|
||||
<ScoreLeaderboard
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
refreshDeps={[`rivals-pb-leaderboard-${settings.rivals.join(",")}`]}
|
||||
url={`/users/${reqUser.id}/games/${game}/${playtype}/rivals/pb-leaderboard`}
|
||||
url={`/users/${reqUser.id}/games/${game}/rivals/pb-leaderboard`}
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
|
||||
+27
-31
@@ -16,35 +16,38 @@ import React, { useContext, useState } from "react";
|
||||
import { Alert, Button, Col } from "react-bootstrap";
|
||||
import { Prompt } from "react-router-dom";
|
||||
import {
|
||||
FormatGameGroup,
|
||||
type GameGroup,
|
||||
FormatGame,
|
||||
GameToGameGroup,
|
||||
GetGameGroupConfig,
|
||||
type integer,
|
||||
type MONGO_UserDocument,
|
||||
type Playtype,
|
||||
type UserDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
|
||||
export default function RivalsManagePage({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
}: {
|
||||
game: GameGroup;
|
||||
playtype: Playtype;
|
||||
reqUser: MONGO_UserDocument;
|
||||
game: V3Game;
|
||||
reqUser: UserDocument;
|
||||
}) {
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Rivals", "Manage"],
|
||||
[reqUser, game, playtype],
|
||||
`Managing ${reqUser.username}'s ${FormatGameGroup(game, playtype)} Rivals`,
|
||||
[
|
||||
"Users",
|
||||
reqUser.username,
|
||||
"Games",
|
||||
GetGameGroupConfig(GameToGameGroup(game)).name,
|
||||
"Rivals",
|
||||
"Manage",
|
||||
],
|
||||
[reqUser, game],
|
||||
`Managing ${reqUser.username}'s ${FormatGame(game)} Rivals`,
|
||||
);
|
||||
|
||||
const { settings } = useLUGPTSettings();
|
||||
|
||||
const { data, error } = useApiQuery<MONGO_UserDocument[]>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/rivals`,
|
||||
const { data, error } = useApiQuery<UserDocument[]>(
|
||||
`/users/${reqUser.id}/games/${game}/rivals`,
|
||||
{},
|
||||
[`fetch-rivals-${settings?.rivals.join(",")}`],
|
||||
);
|
||||
@@ -53,9 +56,7 @@ export default function RivalsManagePage({
|
||||
data: challengers,
|
||||
isLoading: cIsLoading,
|
||||
error: cError,
|
||||
} = useApiQuery<MONGO_UserDocument[]>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/rivals/challengers`,
|
||||
);
|
||||
} = useApiQuery<UserDocument[]>(`/users/${reqUser.id}/games/${game}/rivals/challengers`);
|
||||
|
||||
if (error) {
|
||||
<ApiError error={error} />;
|
||||
@@ -78,7 +79,6 @@ export default function RivalsManagePage({
|
||||
challengers={challengers}
|
||||
game={game}
|
||||
initialRivals={data}
|
||||
playtype={playtype}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
);
|
||||
@@ -87,15 +87,13 @@ export default function RivalsManagePage({
|
||||
function RivalsOverviewPage({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
initialRivals,
|
||||
challengers,
|
||||
}: {
|
||||
challengers: Array<MONGO_UserDocument>;
|
||||
game: GameGroup;
|
||||
initialRivals: Array<MONGO_UserDocument>;
|
||||
playtype: Playtype;
|
||||
reqUser: MONGO_UserDocument;
|
||||
challengers: Array<UserDocument>;
|
||||
game: V3Game;
|
||||
initialRivals: Array<UserDocument>;
|
||||
reqUser: UserDocument;
|
||||
}) {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
@@ -123,7 +121,6 @@ function RivalsOverviewPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* kind of a stupid way to check whether the array has changed or not, but who cares. */}
|
||||
{isRequestingUser && currentRivals.toString() !== rivals.toString() && (
|
||||
<Alert className="vstack" variant="warning">
|
||||
<Prompt
|
||||
@@ -136,7 +133,7 @@ function RivalsOverviewPage({
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const res = await APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/rivals`,
|
||||
`/users/${reqUser.id}/games/${game}/rivals`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
@@ -168,7 +165,7 @@ function RivalsOverviewPage({
|
||||
<Card header={`${isRequestingUser ? "Your" : `${reqUser.username}'s`} Rivals`}>
|
||||
<Col className="d-flex justify-content-center flex-wrap" xs={12}>
|
||||
{rivals.map((e) => (
|
||||
<UserIcon game={game} key={e.id} playtype={playtype} user={e}>
|
||||
<UserIcon game={game} key={e.id} user={e}>
|
||||
<Button
|
||||
onClick={() => setRivals(rivals.filter((u) => u.id !== e.id))}
|
||||
variant="outline-danger"
|
||||
@@ -209,7 +206,6 @@ function RivalsOverviewPage({
|
||||
} else {
|
||||
setRivals([...rivals, user]);
|
||||
|
||||
// if we're now at max rivals, exit.
|
||||
if (rivals.length + 1 >= MAX_RIVALS) {
|
||||
setShow(false);
|
||||
}
|
||||
@@ -219,7 +215,7 @@ function RivalsOverviewPage({
|
||||
excludeSet={rivals.map((e) => e.id)}
|
||||
setShow={setShow}
|
||||
show={show}
|
||||
url={`/games/${game}/${playtype}/players`}
|
||||
url={`/games/${game}/players`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -246,7 +242,7 @@ function RivalsOverviewPage({
|
||||
>
|
||||
<Col className="d-flex justify-content-center flex-wrap" xs={12}>
|
||||
{challengers.map((e) => (
|
||||
<UserIcon game={game} key={e.id} playtype={playtype} user={e}>
|
||||
<UserIcon game={game} key={e.id} user={e}>
|
||||
{isRequestingUser &&
|
||||
!rivals.map((e) => e.id).includes(e.id) &&
|
||||
(rivals.length < MAX_RIVALS ? (
|
||||
|
||||
+16
-12
@@ -7,21 +7,25 @@ import { type UGPT } from "#types/react";
|
||||
import React from "react";
|
||||
import { Col, Row } from "react-bootstrap";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
|
||||
import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common";
|
||||
|
||||
import UGPTGoalsPage from "./UGPTGoalsPage";
|
||||
import UGPTQuestsPage from "./UGPTQuestsPage";
|
||||
|
||||
export default function TargetsPage({ reqUser, game, playtype }: UGPT) {
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
|
||||
export default function TargetsPage({ reqUser, game }: UGPT) {
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Goals & Quests"],
|
||||
[reqUser, game, playtype],
|
||||
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Goals & Quests`,
|
||||
[
|
||||
"Users",
|
||||
reqUser.username,
|
||||
"Games",
|
||||
GetGameGroupConfig(GameToGameGroup(game)).name,
|
||||
"Goals & Quests",
|
||||
],
|
||||
[reqUser, game],
|
||||
`${reqUser.username}'s ${FormatGame(game)} Goals & Quests`,
|
||||
);
|
||||
|
||||
const base = useUGPTBase({ reqUser, game, playtype });
|
||||
const base = useUGPTBase({ reqUser, game });
|
||||
|
||||
return (
|
||||
<Row>
|
||||
@@ -38,11 +42,11 @@ export default function TargetsPage({ reqUser, game, playtype }: UGPT) {
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<Switch>
|
||||
<Route exact path="/u/:userID/games/:game/:playtype/targets/goals">
|
||||
<UGPTGoalsPage {...{ reqUser, game, playtype }} />
|
||||
<Route exact path="/u/:userID/games/:game/targets/goals">
|
||||
<UGPTGoalsPage {...{ reqUser, game }} />
|
||||
</Route>
|
||||
<Route exact path="/u/:userID/games/:game/:playtype/targets">
|
||||
<UGPTQuestsPage {...{ reqUser, game, playtype }} />
|
||||
<Route exact path="/u/:userID/games/:game/targets">
|
||||
<UGPTQuestsPage {...{ reqUser, game }} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Col>
|
||||
|
||||
+7
-11
@@ -14,16 +14,16 @@ import { CreateGoalSubDataset } from "#util/data";
|
||||
import React, { useContext, useReducer, useState } from "react";
|
||||
import { Button, Col } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FormatGameGroup } from "tachi-common";
|
||||
import { FormatGame } from "tachi-common";
|
||||
|
||||
export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
|
||||
export default function UGPTGoalsPage({ reqUser, game }: UGPT) {
|
||||
const [show, setShow] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const { reloadTargets } = useContext(TargetsContext);
|
||||
const [refresh, refetchGoals] = useReducer((x) => x + 1, 0);
|
||||
|
||||
const { data, error } = useApiQuery<AllUGPTGoalsReturn>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/targets/goals`,
|
||||
`/users/${reqUser.id}/games/${game}/targets/goals`,
|
||||
undefined,
|
||||
[refresh.toString()],
|
||||
);
|
||||
@@ -61,13 +61,10 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
|
||||
</Button>
|
||||
<div>
|
||||
Looking for goal recommendations?{" "}
|
||||
<Link to={`/games/${game}/${playtype}/quests`}>
|
||||
Check out {FormatGameGroup(game, playtype)}'s Quests
|
||||
</Link>
|
||||
.
|
||||
<Link to={`/games/${game}/quests`}>Check out {FormatGame(game)}'s Quests</Link>.
|
||||
</div>
|
||||
<Divider />
|
||||
<GoalSubInfo dataset={dataset} game={game} playtype={playtype} />
|
||||
<GoalSubInfo dataset={dataset} game={game} />
|
||||
</Col>
|
||||
{show && (
|
||||
<AddNewGoalForQuestModal
|
||||
@@ -75,7 +72,7 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
|
||||
noNote
|
||||
onCreate={async (rawGoal) => {
|
||||
await APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/targets/goals/add-goal`,
|
||||
`/users/${reqUser.id}/games/${game}/targets/goals/add-goal`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -91,7 +88,6 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
|
||||
refetchGoals();
|
||||
reloadTargets();
|
||||
}}
|
||||
playtype={playtype}
|
||||
setShow={setShow}
|
||||
show={show}
|
||||
/>
|
||||
@@ -101,7 +97,7 @@ export default function UGPTGoalsPage({ reqUser, game, playtype }: UGPT) {
|
||||
dataset={dataset}
|
||||
onDelete={async (goalID) => {
|
||||
await APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/targets/goals/${goalID}`,
|
||||
`/users/${reqUser.id}/games/${game}/targets/goals/${goalID}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
|
||||
+10
-15
@@ -12,19 +12,19 @@ import React, { useMemo, useState } from "react";
|
||||
import { Col, Row } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
type MONGO_GoalDocument,
|
||||
type MONGO_QuestDocument,
|
||||
type MONGO_QuestSubscriptionDocument,
|
||||
type GoalDocument,
|
||||
type QuestDocument,
|
||||
type QuestSubscriptionDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
export default function UGPTQuestsPage({ reqUser, game, playtype }: UGPT) {
|
||||
export default function UGPTQuestsPage({ reqUser, game }: UGPT) {
|
||||
const [show, setShow] = useState<"achieved" | "all" | "unachieved">("all");
|
||||
|
||||
const { data, error } = useApiQuery<{
|
||||
goals: Array<MONGO_GoalDocument>;
|
||||
quests: Array<MONGO_QuestDocument>;
|
||||
questSubs: Array<MONGO_QuestSubscriptionDocument>;
|
||||
}>(`/users/${reqUser.id}/games/${game}/${playtype}/targets/quests`);
|
||||
goals: Array<GoalDocument>;
|
||||
quests: Array<QuestDocument>;
|
||||
questSubs: Array<QuestSubscriptionDocument>;
|
||||
}>(`/users/${reqUser.id}/games/${game}/targets/quests`);
|
||||
|
||||
const questsToShow = useMemo(() => {
|
||||
if (!data || error) {
|
||||
@@ -33,27 +33,22 @@ export default function UGPTQuestsPage({ reqUser, game, playtype }: UGPT) {
|
||||
|
||||
const questSubMap = CreateQuestSubMap(data.questSubs);
|
||||
|
||||
// slice and sort based on progress
|
||||
let base = data.quests.slice(0).sort(
|
||||
NumericSOV((quest) => {
|
||||
const sub = questSubMap.get(quest.questID);
|
||||
|
||||
if (!sub) {
|
||||
return -Infinity; // shouldn't happen
|
||||
return -Infinity;
|
||||
}
|
||||
|
||||
// sink achieved things below goals in progress
|
||||
if (sub.achieved) {
|
||||
return -100;
|
||||
}
|
||||
|
||||
// since this is always ostensibly positive, the magic numbers -99 and
|
||||
// -100 should be fine.
|
||||
return sub.progress / GetGoalIDsFromQuest(quest).length;
|
||||
}, true),
|
||||
);
|
||||
|
||||
// filter on "show" setting
|
||||
switch (show) {
|
||||
case "all":
|
||||
break;
|
||||
@@ -98,7 +93,7 @@ export default function UGPTQuestsPage({ reqUser, game, playtype }: UGPT) {
|
||||
<div className="text-center">
|
||||
Looks like you have no quests set.
|
||||
<br />
|
||||
<Link to={`/games/${game}/${playtype}/quests`}>Go set some!</Link>
|
||||
<Link to={`/games/${game}/quests`}>Go set some!</Link>
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
+18
-13
@@ -8,34 +8,39 @@ import { type UGPT } from "#types/react";
|
||||
import React, { useContext } from "react";
|
||||
import { Col, Row } from "react-bootstrap";
|
||||
import { Link, Route, Switch } from "react-router-dom";
|
||||
import { FormatGameGroup, GetGameGroupConfig } from "tachi-common";
|
||||
import { FormatGame, GameToGameGroup, GetGameGroupConfig } from "tachi-common";
|
||||
|
||||
export default function UGPTUtilsPage({ reqUser, game, playtype }: UGPT) {
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
export default function UGPTUtilsPage({ reqUser, game }: UGPT) {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const isViewingOwnProfile = user?.id === reqUser.id;
|
||||
|
||||
const utils = GetGPTUtils(game, playtype);
|
||||
const pageName = GetGPTUtilsName(game, playtype, isViewingOwnProfile);
|
||||
const utils = GetGPTUtils(game);
|
||||
const pageName = GetGPTUtilsName(game, isViewingOwnProfile);
|
||||
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype, pageName ?? "Utils"],
|
||||
[reqUser, game, playtype],
|
||||
`${reqUser.username}'s ${FormatGameGroup(game, playtype)} ${pageName ?? "Utils"}`,
|
||||
[
|
||||
"Users",
|
||||
reqUser.username,
|
||||
"Games",
|
||||
GetGameGroupConfig(GameToGameGroup(game)).name,
|
||||
pageName ?? "Utils",
|
||||
],
|
||||
[reqUser, game],
|
||||
`${reqUser.username}'s ${FormatGame(game)} ${pageName ?? "Utils"}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Switch>
|
||||
<Route exact path="/u/:userID/games/:game/:playtype/utils">
|
||||
<Route exact path="/u/:userID/games/:game/utils">
|
||||
{utils.map((util) => (
|
||||
<Col className="my-4" key={util.urlPath} lg={6} xs={12}>
|
||||
<Card
|
||||
footer={
|
||||
<div className="d-flex w-100 justify-content-end">
|
||||
<LinkButton
|
||||
to={`/u/${reqUser.username}/games/${game}/${playtype}/utils/${util.urlPath}`}
|
||||
to={`/u/${reqUser.username}/games/${game}/utils/${util.urlPath}`}
|
||||
>
|
||||
View
|
||||
</LinkButton>
|
||||
@@ -53,14 +58,14 @@ export default function UGPTUtilsPage({ reqUser, game, playtype }: UGPT) {
|
||||
<Route
|
||||
exact
|
||||
key={tool.urlPath}
|
||||
path={`/u/:userID/games/:game/:playtype/utils/${tool.urlPath}`}
|
||||
path={`/u/:userID/games/:game/utils/${tool.urlPath}`}
|
||||
>
|
||||
<Col className="mt-4" xs={12}>
|
||||
<Card
|
||||
footer={
|
||||
<Link
|
||||
className="text-body-secondary text-hover-white"
|
||||
to={`/u/${reqUser.username}/games/${game}/${playtype}/utils`}
|
||||
to={`/u/${reqUser.username}/games/${game}/utils`}
|
||||
>
|
||||
< Back to all tools...
|
||||
</Link>
|
||||
@@ -71,7 +76,7 @@ export default function UGPTUtilsPage({ reqUser, game, playtype }: UGPT) {
|
||||
</Card>
|
||||
<Divider />
|
||||
</Col>
|
||||
<Col xs={12}>{tool.component({ reqUser, game, playtype })}</Col>
|
||||
<Col xs={12}>{tool.component({ reqUser, game })}</Col>
|
||||
</Route>
|
||||
))}
|
||||
</Switch>
|
||||
|
||||
@@ -17,7 +17,7 @@ const LOCAL_QUEST_KEY = "LOCAL_QUESTS";
|
||||
const PR_LOCAL_QUESTS_SCHEMA: PrudenceSchema = {
|
||||
json: [
|
||||
{
|
||||
game: p.isIn(TachiConfig.GAMES),
|
||||
game: p.isIn(TachiConfig.GAME_GROUPS),
|
||||
playtype: (self, parent) => {
|
||||
const gameConfig = GetGameGroupConfig(parent.game as any);
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import GPTChartsPage from "#app/pages/dashboard/games/_game/_playtype/GPTChartsP
|
||||
import GPTDevInfo from "#app/pages/dashboard/games/_game/_playtype/GPTDevInfo";
|
||||
import GPTLeaderboardsPage from "#app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage";
|
||||
import GPTMainPage from "#app/pages/dashboard/games/_game/_playtype/GPTMainPage";
|
||||
import PlaytypeSelect from "#app/pages/dashboard/games/_game/PlaytypeSelect";
|
||||
import { ErrorPage } from "#app/pages/ErrorPage";
|
||||
import ChartInfoFormat from "#components/game/charts/ChartInfoFormat";
|
||||
import { GPTBottomNav } from "#components/game/GPTHeader";
|
||||
@@ -28,7 +27,7 @@ import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations";
|
||||
import { type SongsReturn } from "#types/api-returns";
|
||||
import { type GamePT, type SetState } from "#types/react";
|
||||
import { ToCDNURL } from "#util/api";
|
||||
import { IsSupportedGame, IsSupportedPlaytype } from "#util/asserts";
|
||||
import { IsSupportedGame } from "#util/asserts";
|
||||
import { ChangeOpacity } from "#util/color-opacity";
|
||||
import { CreateChartLink } from "#util/data";
|
||||
import { SelectRightChart } from "#util/misc";
|
||||
@@ -37,109 +36,97 @@ import React, { useContext, useEffect, useState } from "react";
|
||||
import { Col, Row } from "react-bootstrap";
|
||||
import { Redirect, Route, Switch, useParams } from "react-router-dom";
|
||||
import {
|
||||
type ChartDocument,
|
||||
COLOUR_SET,
|
||||
FormatDifficulty,
|
||||
type GameGroup,
|
||||
GetGameGroupConfig,
|
||||
GetGamePTConfig,
|
||||
GetGPTString,
|
||||
type MONGO_ChartDocument,
|
||||
type MONGO_SongDocument,
|
||||
type GamesForGroup,
|
||||
GameToGameGroup,
|
||||
GetGameConfig,
|
||||
type SongDocument,
|
||||
type V3Game,
|
||||
} from "tachi-common";
|
||||
import { type FixedDifficulties } from "tachi-common/types/game-config-utils";
|
||||
|
||||
export default function GameRoutes() {
|
||||
const { game } = useParams<{ game: string }>();
|
||||
const { game: gameParam } = useParams<{ game: string }>();
|
||||
const { setBackground } = useContext(BackgroundContext);
|
||||
|
||||
useEffect(() => {
|
||||
setBackground(ToCDNURL(`/game-banners/${game}`));
|
||||
}, [game, setBackground]);
|
||||
|
||||
if (!IsSupportedGame(game)) {
|
||||
return <ErrorPage customMessage={`The game ${game} is not supported.`} statusCode={404} />;
|
||||
}
|
||||
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
setBackground(ToCDNURL(`/game-banners/${gameParam}`));
|
||||
}, [gameParam, setBackground]);
|
||||
|
||||
// Support legacy URLs like /games/iidx/SP → redirect to /games/iidx-sp
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/games/:game">
|
||||
{gameConfig.playtypes.length === 1 ? (
|
||||
<Redirect to={`/games/${game}/${gameConfig.playtypes[0]}`} />
|
||||
) : (
|
||||
<PlaytypeSelect
|
||||
base={`/games/${game}`}
|
||||
game={game}
|
||||
subheaderCrumbs={["Games", gameConfig.name]}
|
||||
subheaderTitle={`${gameConfig.name} Playtype Select`}
|
||||
/>
|
||||
)}
|
||||
</Route>
|
||||
|
||||
<Route path="/games/:game/:playtype">
|
||||
<UGPTContextProvider>
|
||||
<TargetsContextProvider>
|
||||
<GamePlaytypeRoutes game={game} />
|
||||
</TargetsContextProvider>
|
||||
</UGPTContextProvider>
|
||||
</Route>
|
||||
|
||||
<Route path="*">
|
||||
<ErrorPage statusCode={404} />
|
||||
<Route path="/games/:game">
|
||||
<V3GameRoutes />
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function GamePlaytypeRoutes({ game }: { game: GameGroup }) {
|
||||
const { playtype } = useParams<{ playtype: string }>();
|
||||
function V3GameRoutes() {
|
||||
const { game: gameParam } = useParams<{ game: string }>();
|
||||
const { setBackground } = useContext(BackgroundContext);
|
||||
|
||||
if (!IsSupportedPlaytype(game, playtype)) {
|
||||
useEffect(() => {
|
||||
setBackground(ToCDNURL(`/game-banners/${gameParam}`));
|
||||
}, [gameParam, setBackground]);
|
||||
|
||||
if (!IsSupportedGame(gameParam)) {
|
||||
return (
|
||||
<ErrorPage
|
||||
customMessage={`The playtype ${playtype} is not supported.`}
|
||||
statusCode={400}
|
||||
/>
|
||||
<ErrorPage customMessage={`The game ${gameParam} is not supported.`} statusCode={404} />
|
||||
);
|
||||
}
|
||||
|
||||
const game = gameParam;
|
||||
|
||||
return (
|
||||
<UGPTContextProvider>
|
||||
<TargetsContextProvider>
|
||||
<GameV3Routes game={game} />
|
||||
</TargetsContextProvider>
|
||||
</UGPTContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function GameV3Routes({ game }: { game: V3Game }) {
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<GPTBottomNav baseUrl={`/games/${game}/${playtype}`} />
|
||||
<GPTBottomNav baseUrl={`/games/${game}`} />
|
||||
</div>
|
||||
<Divider />
|
||||
<Switch>
|
||||
<Route exact path="/games/:game/:playtype">
|
||||
<GPTMainPage game={game} playtype={playtype} />
|
||||
<Route exact path="/games/:game">
|
||||
<GPTMainPage game={game} />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/games/:game/:playtype/songs">
|
||||
<Redirect to={`/games/${game}/${playtype}/charts`} />
|
||||
<Route exact path="/games/:game/songs">
|
||||
<Redirect to={`/games/${game}/charts`} />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/games/:game/:playtype/charts">
|
||||
<GPTChartsPage game={game} playtype={playtype} />
|
||||
<Route exact path="/games/:game/charts">
|
||||
<GPTChartsPage game={game} />
|
||||
</Route>
|
||||
|
||||
<Route path="/games/:game/:playtype/charts/:chartID">
|
||||
<ChartPageRoutes game={game} playtype={playtype} />
|
||||
<Route path="/games/:game/charts/:chartID">
|
||||
<ChartPageRoutes game={game} />
|
||||
</Route>
|
||||
|
||||
<Route path="/games/:game/:playtype/songs/:songID">
|
||||
<SongChartRedirectRoutes game={game} playtype={playtype} />
|
||||
<Route path="/games/:game/songs/:songID">
|
||||
<SongChartRedirectRoutes game={game} />
|
||||
</Route>
|
||||
|
||||
<Route path="/games/:game/:playtype/(quests|questlines|goals)">
|
||||
<GPTQuestRoutes game={game} playtype={playtype} />
|
||||
<Route path="/games/:game/(quests|questlines|goals)">
|
||||
<GPTQuestRoutes game={game} />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/games/:game/:playtype/leaderboards">
|
||||
<GPTLeaderboardsPage game={game} playtype={playtype} />
|
||||
<Route exact path="/games/:game/leaderboards">
|
||||
<GPTLeaderboardsPage game={game} />
|
||||
</Route>
|
||||
<Route exact path="/games/:game/:playtype/dev-info">
|
||||
<GPTDevInfo game={game} playtype={playtype} />
|
||||
<Route exact path="/games/:game/dev-info">
|
||||
<GPTDevInfo game={game} />
|
||||
</Route>
|
||||
|
||||
<Route path="*">
|
||||
@@ -150,46 +137,46 @@ function GamePlaytypeRoutes({ game }: { game: GameGroup }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GPTQuestRoutes({ game, playtype }: GamePT) {
|
||||
function GPTQuestRoutes({ game }: GamePT) {
|
||||
return (
|
||||
<>
|
||||
<Switch>
|
||||
<Route exact path="/games/:game/:playtype/quests">
|
||||
<QuestsPage game={game} playtype={playtype} />
|
||||
<Route exact path="/games/:game/quests">
|
||||
<QuestsPage game={game} />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/games/:game/:playtype/questlines">
|
||||
<Redirect to={`/games/${game}/${playtype}/quests`} />
|
||||
<Route exact path="/games/:game/questlines">
|
||||
<Redirect to={`/games/${game}/quests`} />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/games/:game/:playtype/questlines/:questlineID">
|
||||
<QuestlinePage game={game} playtype={playtype} />
|
||||
<Route exact path="/games/:game/questlines/:questlineID">
|
||||
<QuestlinePage game={game} />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/games/:game/:playtype/quests/:questID">
|
||||
<QuestPage game={game} playtype={playtype} />
|
||||
<Route exact path="/games/:game/quests/:questID">
|
||||
<QuestPage game={game} />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/games/:game/:playtype/goals">
|
||||
<Redirect to={`/games/${game}/${playtype}/quests`} />
|
||||
<Route exact path="/games/:game/goals">
|
||||
<Redirect to={`/games/${game}/quests`} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartPageRoutes({ game, playtype }: GamePT) {
|
||||
function ChartPageRoutes({ game }: GamePT) {
|
||||
const { chartID } = useParams<{ chartID: string }>();
|
||||
|
||||
const { data: singleData, error: chartErr } = useApiQuery<{
|
||||
chart: MONGO_ChartDocument;
|
||||
song: MONGO_SongDocument;
|
||||
}>(`/games/${game}/${playtype}/charts/${chartID}`);
|
||||
chart: ChartDocument;
|
||||
song: SongDocument;
|
||||
}>(`/games/${game}/charts/${chartID}`);
|
||||
|
||||
const songLegacyId = singleData?.song.id;
|
||||
|
||||
const { data: songsData, error: songsErr } = useApiQuery<SongsReturn>(
|
||||
songLegacyId !== undefined ? `/games/${game}/${playtype}/songs/${songLegacyId}` : "",
|
||||
songLegacyId !== undefined ? `/games/${game}/songs/${songLegacyId}` : "",
|
||||
undefined,
|
||||
undefined,
|
||||
songLegacyId === undefined,
|
||||
@@ -197,7 +184,7 @@ function ChartPageRoutes({ game, playtype }: GamePT) {
|
||||
|
||||
const { settings } = useContext(UserSettingsContext);
|
||||
|
||||
const [activeChart, setActiveChart] = useState<MONGO_ChartDocument | null>(null);
|
||||
const [activeChart, setActiveChart] = useState<ChartDocument | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const c = songsData?.charts.find((x) => x.chartID === chartID);
|
||||
@@ -216,10 +203,7 @@ function ChartPageRoutes({ game, playtype }: GamePT) {
|
||||
|
||||
if (songsData.charts.length === 0) {
|
||||
return (
|
||||
<ErrorPage
|
||||
customMessage={"This song has no charts for this playtype."}
|
||||
statusCode={404}
|
||||
/>
|
||||
<ErrorPage customMessage={"This song has no charts for this game."} statusCode={404} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,18 +211,12 @@ function ChartPageRoutes({ game, playtype }: GamePT) {
|
||||
<>
|
||||
<SongInfoHeader
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
{...songsData}
|
||||
activeChart={activeChart}
|
||||
setActiveChart={setActiveChart}
|
||||
/>
|
||||
<Divider />
|
||||
<GPTChartPage
|
||||
chart={activeChart}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
song={songsData.song}
|
||||
/>
|
||||
<GPTChartPage chart={activeChart} game={game} song={songsData.song} />
|
||||
{settings?.preferences.developerMode && (
|
||||
<>
|
||||
<Divider />
|
||||
@@ -251,10 +229,10 @@ function ChartPageRoutes({ game, playtype }: GamePT) {
|
||||
);
|
||||
}
|
||||
|
||||
function SongChartRedirectRoutes({ game, playtype }: GamePT) {
|
||||
function SongChartRedirectRoutes({ game }: GamePT) {
|
||||
const { songID } = useParams<{ songID: string }>();
|
||||
|
||||
const { data, error } = useApiQuery<SongsReturn>(`/games/${game}/${playtype}/songs/${songID}`);
|
||||
const { data, error } = useApiQuery<SongsReturn>(`/games/${game}/songs/${songID}`);
|
||||
|
||||
if (error) {
|
||||
return <ErrorPage customMessage={error.description} statusCode={error.statusCode} />;
|
||||
@@ -266,14 +244,11 @@ function SongChartRedirectRoutes({ game, playtype }: GamePT) {
|
||||
|
||||
if (data.charts.length === 0) {
|
||||
return (
|
||||
<ErrorPage
|
||||
customMessage={"This song has no charts for this playtype."}
|
||||
statusCode={404}
|
||||
/>
|
||||
<ErrorPage customMessage={"This song has no charts for this game."} statusCode={404} />
|
||||
);
|
||||
}
|
||||
|
||||
if (data.charts.every((c) => c.playtype !== playtype)) {
|
||||
if (data.charts.every((c) => c.game !== game)) {
|
||||
const c = data.charts[0];
|
||||
|
||||
if (!c.chartID) {
|
||||
@@ -285,17 +260,17 @@ function SongChartRedirectRoutes({ game, playtype }: GamePT) {
|
||||
);
|
||||
}
|
||||
|
||||
return <Redirect to={`/games/${game}/${c.playtype}/charts/${c.chartID}`} />;
|
||||
return <Redirect to={`/games/${c.game}/charts/${c.chartID}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/games/:game/:playtype/songs/:songID">
|
||||
<SongSongIdOnlyRedirect charts={data.charts} game={game} playtype={playtype} />
|
||||
<Route exact path="/games/:game/songs/:songID">
|
||||
<SongSongIdOnlyRedirect charts={data.charts} game={game} />
|
||||
</Route>
|
||||
|
||||
<Route path="/games/:game/:playtype/songs/:songID/:difficulty">
|
||||
<SongDifficultyRedirect data={data} game={game} playtype={playtype} />
|
||||
<Route path="/games/:game/songs/:songID/:difficulty">
|
||||
<SongDifficultyRedirect data={data} game={game} />
|
||||
</Route>
|
||||
|
||||
<Route path="*">
|
||||
@@ -305,11 +280,7 @@ function SongChartRedirectRoutes({ game, playtype }: GamePT) {
|
||||
);
|
||||
}
|
||||
|
||||
function SongSongIdOnlyRedirect({
|
||||
charts,
|
||||
game,
|
||||
playtype,
|
||||
}: { charts: MONGO_ChartDocument[] } & GamePT) {
|
||||
function SongSongIdOnlyRedirect({ charts, game }: { charts: ChartDocument[] } & GamePT) {
|
||||
const hardest = charts.slice(0).sort(NumericSOV((x) => x.levelNum, true))[0];
|
||||
|
||||
if (!hardest.chartID) {
|
||||
@@ -321,52 +292,53 @@ function SongSongIdOnlyRedirect({
|
||||
);
|
||||
}
|
||||
|
||||
return <Redirect to={`/games/${game}/${playtype}/charts/${hardest.chartID}`} />;
|
||||
return <Redirect to={`/games/${game}/charts/${hardest.chartID}`} />;
|
||||
}
|
||||
|
||||
function SongDifficultyRedirect({ data, game, playtype }: { data: SongsReturn } & GamePT) {
|
||||
function SongDifficultyRedirect({ data, game }: { data: SongsReturn } & GamePT) {
|
||||
const { difficulty: d } = useParams<{ difficulty: string }>();
|
||||
const difficulty = decodeURIComponent(d);
|
||||
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
const chart = SelectRightChart(gptConfig, difficulty, data.charts);
|
||||
const gameConfig = GetGameConfig(game);
|
||||
const chart = SelectRightChart(gameConfig, difficulty, data.charts);
|
||||
|
||||
if (!chart?.chartID) {
|
||||
return <ErrorPage customMessage={"Could not resolve this chart."} statusCode={404} />;
|
||||
}
|
||||
|
||||
return <Redirect to={`/games/${game}/${chart.playtype}/charts/${chart.chartID}`} />;
|
||||
return <Redirect to={`/games/${chart.game}/charts/${chart.chartID}`} />;
|
||||
}
|
||||
|
||||
function SongInfoHeader({
|
||||
game,
|
||||
playtype,
|
||||
song,
|
||||
charts,
|
||||
activeChart,
|
||||
setActiveChart,
|
||||
}: {
|
||||
activeChart: MONGO_ChartDocument | null;
|
||||
setActiveChart: SetState<MONGO_ChartDocument | null>;
|
||||
activeChart: ChartDocument | null;
|
||||
setActiveChart: SetState<ChartDocument | null>;
|
||||
} & GamePT &
|
||||
SongsReturn) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
const sortedCharts = charts.slice(0).sort(
|
||||
gptConfig.difficulties.type === "DYNAMIC"
|
||||
gameConfig.difficulties.type === "DYNAMIC"
|
||||
? StrSOV((x) => x.difficulty)
|
||||
: NumericSOV((x) =>
|
||||
// can TS *really* not infer this?
|
||||
(gptConfig.difficulties as FixedDifficulties<string>).order.indexOf(
|
||||
(gameConfig.difficulties as FixedDifficulties<string>).order.indexOf(
|
||||
x.difficulty,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const gameGroup = GameToGameGroup(game);
|
||||
|
||||
return (
|
||||
<Card header="Song Info">
|
||||
<Row className="align-items-center justify-content-evenly">
|
||||
{game !== "bms" && game !== "pms" && (
|
||||
{gameGroup !== "bms" && gameGroup !== "pms" && (
|
||||
<Col className="text-center" lg={3} xs={12}>
|
||||
{/* empty padding :) */}
|
||||
</Col>
|
||||
@@ -374,7 +346,7 @@ function SongInfoHeader({
|
||||
<Col className="text-center" lg={4} xs={12}>
|
||||
<SongChartInfoFormat {...{ game, song, chart: activeChart }} />
|
||||
</Col>
|
||||
{game !== "bms" && game !== "pms" && (
|
||||
{gameGroup !== "bms" && gameGroup !== "pms" && (
|
||||
<Col className="text-center" lg={3} xs={12}>
|
||||
<h5>Charts</h5>
|
||||
<hr />
|
||||
@@ -382,13 +354,12 @@ function SongInfoHeader({
|
||||
className="btn-group-vertical d-flex justify-content-center"
|
||||
role="group"
|
||||
>
|
||||
{game === "iidx" ? (
|
||||
{gameGroup === "iidx" ? (
|
||||
<IIDXDifficultyList
|
||||
{...{
|
||||
activeChart,
|
||||
charts: sortedCharts,
|
||||
game,
|
||||
playtype,
|
||||
setActiveChart,
|
||||
song,
|
||||
}}
|
||||
@@ -399,7 +370,6 @@ function SongInfoHeader({
|
||||
activeChart,
|
||||
charts: sortedCharts,
|
||||
game,
|
||||
playtype,
|
||||
setActiveChart,
|
||||
song,
|
||||
}}
|
||||
@@ -411,12 +381,7 @@ function SongInfoHeader({
|
||||
{activeChart && (
|
||||
<Col xs={12}>
|
||||
<hr />
|
||||
<ChartInfoFormat
|
||||
chart={activeChart}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
song={song}
|
||||
/>
|
||||
<ChartInfoFormat chart={activeChart} game={game} song={song} />
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
@@ -425,9 +390,9 @@ function SongInfoHeader({
|
||||
}
|
||||
|
||||
type Props = {
|
||||
activeChart: MONGO_ChartDocument | null;
|
||||
setActiveChart: SetState<MONGO_ChartDocument | null>;
|
||||
} & { song: MONGO_SongDocument } & GamePT;
|
||||
activeChart: ChartDocument | null;
|
||||
setActiveChart: SetState<ChartDocument | null>;
|
||||
} & { song: SongDocument } & GamePT;
|
||||
|
||||
const ITG_COLOUR_LOOKUP = {
|
||||
Beginner: COLOUR_SET.paleBlue,
|
||||
@@ -441,13 +406,13 @@ const ITG_COLOUR_LOOKUP = {
|
||||
function DifficultyButton({
|
||||
chart,
|
||||
game,
|
||||
playtype,
|
||||
setActiveChart,
|
||||
activeChart,
|
||||
}: { chart: MONGO_ChartDocument } & Props) {
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)];
|
||||
}: { chart: ChartDocument } & Props) {
|
||||
const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game];
|
||||
|
||||
const diffTag = chart.difficulty;
|
||||
const gameGroup = GameToGameGroup(game);
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
@@ -462,7 +427,7 @@ function DifficultyButton({
|
||||
gptImpl.difficultyColours[diffTag],
|
||||
activeChart?.chartID === chart.chartID ? 0.4 : 0.2,
|
||||
)
|
||||
: game === "itg"
|
||||
: gameGroup === "itg"
|
||||
? ChangeOpacity(
|
||||
// @ts-expect-error hack!
|
||||
ITG_COLOUR_LOOKUP[chart.data.difficultyTag],
|
||||
@@ -470,19 +435,19 @@ function DifficultyButton({
|
||||
)
|
||||
: undefined,
|
||||
}}
|
||||
to={CreateChartLink(chart, game)}
|
||||
to={CreateChartLink(chart)}
|
||||
variant="secondary"
|
||||
>
|
||||
<div
|
||||
className={activeChart?.chartID === chart.chartID ? "fw-bolder" : ""}
|
||||
style={{
|
||||
color:
|
||||
game === "ongeki" && diffTag === "LUNATIC"
|
||||
gameGroup === "ongeki" && diffTag === "LUNATIC"
|
||||
? "light-dark(rgba(140, 30, 40, 1), rgba(255, 180, 180, 1))"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{FormatDifficulty(chart, game)}
|
||||
{FormatDifficulty(chart)}
|
||||
{chart.isPrimary ? (
|
||||
""
|
||||
) : (
|
||||
@@ -502,9 +467,8 @@ function DifficultyList({
|
||||
activeChart,
|
||||
setActiveChart,
|
||||
game,
|
||||
playtype,
|
||||
}: {
|
||||
charts: MONGO_ChartDocument[];
|
||||
charts: ChartDocument[];
|
||||
} & Props) {
|
||||
return (
|
||||
<>
|
||||
@@ -514,7 +478,6 @@ function DifficultyList({
|
||||
chart={e}
|
||||
game={game}
|
||||
key={e.chartID}
|
||||
playtype={playtype}
|
||||
setActiveChart={setActiveChart}
|
||||
song={song}
|
||||
/>
|
||||
@@ -533,16 +496,15 @@ function IIDXDifficultyList({
|
||||
activeChart,
|
||||
setActiveChart,
|
||||
game,
|
||||
playtype,
|
||||
}: {
|
||||
charts: MONGO_ChartDocument[];
|
||||
charts: ChartDocument[];
|
||||
} & Props) {
|
||||
const { settings } = useLUGPTSettings<"iidx:DP" | "iidx:SP">();
|
||||
const { settings } = useLUGPTSettings<GamesForGroup["iidx"]>();
|
||||
|
||||
const [set, setSet] = useState<"All Scratch" | "Kichiku" | "Kiraku" | null>(null);
|
||||
|
||||
if (
|
||||
!(activeChart as MONGO_ChartDocument<"iidx:DP" | "iidx:SP">)?.data["2dxtraSet"] &&
|
||||
!(activeChart as ChartDocument<GamesForGroup["iidx"]>)?.data["2dxtraSet"] &&
|
||||
!settings?.preferences.gameSpecific.display2DXTra
|
||||
) {
|
||||
return (
|
||||
@@ -550,14 +512,12 @@ function IIDXDifficultyList({
|
||||
{...{
|
||||
charts: charts.filter(
|
||||
// @ts-expect-error hack
|
||||
(e: MONGO_ChartDocument<"iidx:DP" | "iidx:SP">) =>
|
||||
e.data["2dxtraSet"] === null,
|
||||
(e: ChartDocument<GamesForGroup["iidx"]>) => e.data["2dxtraSet"] === null,
|
||||
),
|
||||
song,
|
||||
activeChart,
|
||||
setActiveChart,
|
||||
game,
|
||||
playtype,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -583,14 +543,13 @@ function IIDXDifficultyList({
|
||||
{...{
|
||||
charts: charts.filter(
|
||||
// @ts-expect-error hack
|
||||
(e: MONGO_ChartDocument<"iidx:DP" | "iidx:SP">) =>
|
||||
(e: ChartDocument<GamesForGroup["iidx"]>) =>
|
||||
set ? e.difficulty.startsWith(set) : e.data["2dxtraSet"] === null,
|
||||
),
|
||||
song,
|
||||
activeChart,
|
||||
setActiveChart,
|
||||
game,
|
||||
playtype,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import PlaytypeSelect from "#app/pages/dashboard/games/_game/PlaytypeSelect";
|
||||
import FoldersMainPage from "#app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage";
|
||||
import LeaderboardsPage from "#app/pages/dashboard/users/games/_game/_playtype/LeaderboardsPage";
|
||||
import OverviewPage from "#app/pages/dashboard/users/games/_game/_playtype/OverviewPage";
|
||||
@@ -27,18 +26,11 @@ import { UserContext } from "#context/UserContext";
|
||||
import { UserSettingsContext } from "#context/UserSettingsContext";
|
||||
import { type UGPTStatsReturn } from "#types/api-returns";
|
||||
import { APIFetchV1, type APIFetchV1Return, ToAPIURL } from "#util/api";
|
||||
import { IsSupportedGame, IsSupportedPlaytype } from "#util/asserts";
|
||||
import { IsSupportedGame } from "#util/asserts";
|
||||
import React, { useContext, useEffect } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Redirect, Route, Switch, useHistory, useParams } from "react-router-dom";
|
||||
import {
|
||||
FormatGameGroup,
|
||||
type GameGroup,
|
||||
GetGameGroupConfig,
|
||||
type MONGO_UserDocument,
|
||||
type MONGO_UserGameStats,
|
||||
type Playtype,
|
||||
} from "tachi-common";
|
||||
import { FormatGame, type UserDocument, type UserGameStats, type V3Game } from "tachi-common";
|
||||
|
||||
import ScoresPage from "../pages/dashboard/users/games/_game/_playtype/ScoresPage";
|
||||
import UserPage from "../pages/dashboard/users/UserPage";
|
||||
@@ -48,7 +40,7 @@ export default function UserRoutes() {
|
||||
const { userID } = useParams<{ userID: string }>();
|
||||
const history = useHistory();
|
||||
|
||||
const { data: reqUser, error } = useApiQuery<MONGO_UserDocument>(`/users/${params.userID}`);
|
||||
const { data: reqUser, error } = useApiQuery<UserDocument>(`/users/${params.userID}`);
|
||||
|
||||
const { setBackground } = useContext(BackgroundContext);
|
||||
useEffect(() => {
|
||||
@@ -105,7 +97,7 @@ export default function UserRoutes() {
|
||||
);
|
||||
}
|
||||
|
||||
function UserProfileRoutes({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
function UserProfileRoutes({ reqUser }: { reqUser: UserDocument }) {
|
||||
const { settings } = useContext(UserSettingsContext);
|
||||
|
||||
return (
|
||||
@@ -150,81 +142,43 @@ function UserProfileRoutes({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
);
|
||||
}
|
||||
|
||||
function UserGameRoutes({ reqUser }: { reqUser: MONGO_UserDocument }) {
|
||||
const { game } = useParams<{ game: string }>();
|
||||
|
||||
if (!IsSupportedGame(game)) {
|
||||
return <ErrorPage customMessage={`The game ${game} is not supported.`} statusCode={400} />;
|
||||
}
|
||||
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
|
||||
function UserGameRoutes({ reqUser }: { reqUser: UserDocument }) {
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/u/:userID/games/:game">
|
||||
{gameConfig.playtypes.length === 1 ? (
|
||||
<Redirect
|
||||
to={`/u/${reqUser.username}/games/${game}/${gameConfig.playtypes[0]}`}
|
||||
/>
|
||||
) : (
|
||||
<PlaytypeSelect
|
||||
base={`/u/${reqUser.username}/games/${game}`}
|
||||
game={game}
|
||||
subheaderCrumbs={["Users", reqUser.username, "Games", gameConfig.name]}
|
||||
subheaderTitle={`${reqUser.username} ${gameConfig.name} Playtype Select`}
|
||||
/>
|
||||
)}
|
||||
</Route>
|
||||
|
||||
<Route path="/u/:userID/games/:game/:playtype">
|
||||
<UGPTContextProvider>
|
||||
<TargetsContextProvider>
|
||||
<UserGamePlaytypeRoutes game={game} reqUser={reqUser} />
|
||||
</TargetsContextProvider>
|
||||
</UGPTContextProvider>
|
||||
<Route path="/u/:userID/games/:game">
|
||||
<V3UserGameRoutes reqUser={reqUser} />
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function UserGamePlaytypeRoutes({
|
||||
reqUser,
|
||||
game,
|
||||
}: {
|
||||
game: GameGroup;
|
||||
reqUser: MONGO_UserDocument;
|
||||
}) {
|
||||
const { playtype } = useParams<{ playtype: string }>();
|
||||
function V3UserGameRoutes({ reqUser }: { reqUser: UserDocument }) {
|
||||
const { game: gameParam } = useParams<{ game: string }>();
|
||||
|
||||
if (!IsSupportedPlaytype(game, playtype)) {
|
||||
if (!IsSupportedGame(gameParam)) {
|
||||
return (
|
||||
<ErrorPage
|
||||
customMessage={`The playtype ${playtype} is not supported.`}
|
||||
statusCode={400}
|
||||
/>
|
||||
<ErrorPage customMessage={`The game ${gameParam} is not supported.`} statusCode={400} />
|
||||
);
|
||||
}
|
||||
|
||||
return <Inner game={game} playtype={playtype} reqUser={reqUser} />;
|
||||
const game = gameParam;
|
||||
|
||||
return (
|
||||
<UGPTContextProvider>
|
||||
<TargetsContextProvider>
|
||||
<Inner game={game} reqUser={reqUser} />
|
||||
</TargetsContextProvider>
|
||||
</UGPTContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Inner({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
}: {
|
||||
game: GameGroup;
|
||||
playtype: Playtype;
|
||||
reqUser: MONGO_UserDocument;
|
||||
}) {
|
||||
function Inner({ reqUser, game }: { game: V3Game; reqUser: UserDocument }) {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const { data, error } = useQuery<UGPTStatsReturn, APIFetchV1Return<MONGO_UserGameStats>>(
|
||||
[reqUser.id, game, playtype],
|
||||
const { data, error } = useQuery<UGPTStatsReturn, APIFetchV1Return<UserGameStats>>(
|
||||
[reqUser.id, game],
|
||||
async () => {
|
||||
const res = await APIFetchV1<UGPTStatsReturn>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}`,
|
||||
);
|
||||
const res = await APIFetchV1<UGPTStatsReturn>(`/users/${reqUser.id}/games/${game}`);
|
||||
|
||||
if (!res.success) {
|
||||
console.error(res);
|
||||
@@ -255,47 +209,46 @@ function Inner({
|
||||
<LayoutHeaderContainer
|
||||
footer={
|
||||
<UGPTBottomNav
|
||||
baseUrl={`/u/${reqUser.username}/games/${game}/${playtype}`}
|
||||
baseUrl={`/u/${reqUser.username}/games/${game}`}
|
||||
game={game}
|
||||
isRequestedUser={reqUser.id === user?.id}
|
||||
playtype={playtype}
|
||||
/>
|
||||
}
|
||||
header={`${reqUser.username}'s ${FormatGameGroup(game, playtype)} Profile`}
|
||||
header={`${reqUser.username}'s ${FormatGame(game)} Profile`}
|
||||
>
|
||||
<UGPTHeaderBody game={game} playtype={playtype} reqUser={reqUser} stats={stats} />
|
||||
<UGPTHeaderBody game={game} reqUser={reqUser} stats={stats} />
|
||||
</LayoutHeaderContainer>
|
||||
<Switch>
|
||||
<Route exact path="/u/:userID/games/:game/:playtype">
|
||||
<OverviewPage game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<Route exact path="/u/:userID/games/:game">
|
||||
<OverviewPage game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
<Route path="/u/:userID/games/:game/:playtype/scores">
|
||||
<ScoresPage game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<Route path="/u/:userID/games/:game/scores">
|
||||
<ScoresPage game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
<Route path="/u/:userID/games/:game/:playtype/folders">
|
||||
<FoldersMainPage game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<Route path="/u/:userID/games/:game/folders">
|
||||
<FoldersMainPage game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
<Route exact path="/u/:userID/games/:game/:playtype/sessions">
|
||||
<SessionsPage game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<Route exact path="/u/:userID/games/:game/sessions">
|
||||
<SessionsPage game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
<Route path="/u/:userID/games/:game/:playtype/sessions/:sessionID">
|
||||
<SpecificSessionPage game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<Route path="/u/:userID/games/:game/sessions/:sessionID">
|
||||
<SpecificSessionPage game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
<Route path="/u/:userID/games/:game/:playtype/rivals">
|
||||
<RivalsMainPage game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<Route path="/u/:userID/games/:game/rivals">
|
||||
<RivalsMainPage game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
<Route path="/u/:userID/games/:game/:playtype/targets">
|
||||
<TargetsPage game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<Route path="/u/:userID/games/:game/targets">
|
||||
<TargetsPage game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
<Route exact path="/u/:userID/games/:game/:playtype/leaderboard">
|
||||
<LeaderboardsPage game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<Route exact path="/u/:userID/games/:game/leaderboard">
|
||||
<LeaderboardsPage game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
<Route path="/u/:userID/games/:game/:playtype/utils">
|
||||
<UGPTUtilsPage game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<Route path="/u/:userID/games/:game/utils">
|
||||
<UGPTUtilsPage game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
<RequireAuthAsUserParam>
|
||||
<Route exact path="/u/:userID/games/:game/:playtype/settings">
|
||||
<UGPTSettingsPage game={game} playtype={playtype} reqUser={reqUser} />
|
||||
<Route exact path="/u/:userID/games/:game/settings">
|
||||
<UGPTSettingsPage game={game} reqUser={reqUser} />
|
||||
</Route>
|
||||
</RequireAuthAsUserParam>
|
||||
<Route path="*">
|
||||
|
||||
@@ -38,10 +38,10 @@ import { Button, Col, Row } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
FormatChart,
|
||||
FormatGameGroup,
|
||||
GetGamePTConfig,
|
||||
FormatGame,
|
||||
GetGameConfig,
|
||||
GetScoreEnumConfs,
|
||||
type MONGO_UserDocument,
|
||||
type UserDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
// Records activity for a group of users on a GPT. Also used for single users.
|
||||
@@ -57,7 +57,7 @@ export default function Activity({
|
||||
url: string;
|
||||
}) {
|
||||
const [clumped, setClumped] = useState<ClumpedActivity>([]);
|
||||
const [users, setUsers] = useState<Array<MONGO_UserDocument>>([]);
|
||||
const [users, setUsers] = useState<Array<UserDocument>>([]);
|
||||
const [shouldShowGame, setShouldShowGame] = useState(false);
|
||||
const [exhausted, setExhausted] = useState(false);
|
||||
|
||||
@@ -132,7 +132,7 @@ function ActivityInner({
|
||||
exhausted: boolean;
|
||||
fetchMoreFrom: (start: number) => void;
|
||||
shouldShowGame: boolean;
|
||||
users: Array<MONGO_UserDocument>;
|
||||
users: Array<UserDocument>;
|
||||
}) {
|
||||
const userMap = CreateUserMap(users);
|
||||
|
||||
@@ -250,11 +250,11 @@ function ScoresActivity({
|
||||
}: {
|
||||
data: ClumpedActivityScores;
|
||||
shouldShowGame: boolean;
|
||||
user: MONGO_UserDocument;
|
||||
user: UserDocument;
|
||||
}) {
|
||||
const { game, playtype } = data.scores[0];
|
||||
const game = data.scores[0].game;
|
||||
|
||||
const prettyGame = shouldShowGame ? `${FormatGameGroup(game, playtype)} ` : "";
|
||||
const prettyGame = shouldShowGame ? `${FormatGame(game)} ` : "";
|
||||
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
@@ -264,12 +264,7 @@ function ScoresActivity({
|
||||
if (data.scores.length === 1) {
|
||||
const score0 = data.scores[0];
|
||||
|
||||
subMessage = `a ${prettyGame}score on ${FormatChart(
|
||||
score0.game,
|
||||
score0.__related.song,
|
||||
score0.__related.chart,
|
||||
true,
|
||||
)}`;
|
||||
subMessage = `a ${prettyGame}score on ${FormatChart(score0.__related.chart)}`;
|
||||
|
||||
if (score0.comment) {
|
||||
mutedText = `"${score0.comment}"`;
|
||||
@@ -278,9 +273,7 @@ function ScoresActivity({
|
||||
subMessage = `${data.scores.length} ${prettyGame}scores`;
|
||||
|
||||
mutedText = TruncateString(
|
||||
data.scores
|
||||
.map((e) => FormatChart(e.game, e.__related.song, e.__related.chart, true))
|
||||
.join(", "),
|
||||
data.scores.map((e) => FormatChart(e.__related.chart)).join(", "),
|
||||
100,
|
||||
);
|
||||
}
|
||||
@@ -301,7 +294,7 @@ function ScoresActivity({
|
||||
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
|
||||
<div className="timeline-content-title">
|
||||
<span className="me-2">
|
||||
<ProfilePicture size="sm" toGPT={{ game, playtype }} user={user} />
|
||||
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
|
||||
</span>
|
||||
<Icon
|
||||
style={{
|
||||
@@ -310,8 +303,7 @@ function ScoresActivity({
|
||||
type={`chevron-${show ? "down" : "right"}`}
|
||||
/>
|
||||
<span className="ms-2" style={{ fontSize: "1.15rem" }}>
|
||||
<UGPTLink game={game} playtype={playtype} reqUser={user} /> highlighted{" "}
|
||||
{subMessage}!
|
||||
<UGPTLink game={game} reqUser={user} /> highlighted {subMessage}!
|
||||
</span>
|
||||
{mutedText && (
|
||||
<>
|
||||
@@ -333,12 +325,7 @@ function ScoresActivity({
|
||||
{show && (
|
||||
<>
|
||||
<Divider />
|
||||
<ScoreTable
|
||||
dataset={dataset}
|
||||
game={game}
|
||||
noTopDisplayStr
|
||||
playtype={playtype}
|
||||
/>
|
||||
<ScoreTable dataset={dataset} game={game} noTopDisplayStr />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -353,11 +340,11 @@ function GoalActivity({
|
||||
}: {
|
||||
data: ClumpedActivityGoalAchievement;
|
||||
shouldShowGame: boolean;
|
||||
user: MONGO_UserDocument;
|
||||
user: UserDocument;
|
||||
}) {
|
||||
const { game, playtype } = data.goals[0];
|
||||
const game = data.goals[0].game;
|
||||
|
||||
const prettyGame = shouldShowGame ? `${FormatGameGroup(game, playtype)} ` : "";
|
||||
const prettyGame = shouldShowGame ? `${FormatGame(game)} ` : "";
|
||||
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
@@ -368,7 +355,7 @@ function GoalActivity({
|
||||
const goal0 = data.goals[0];
|
||||
|
||||
subMessage = `${goal0.__related.goal.name}${
|
||||
shouldShowGame ? ` in ${FormatGameGroup(game, playtype)}` : ""
|
||||
shouldShowGame ? ` in ${FormatGame(game)}` : ""
|
||||
}!`;
|
||||
} else {
|
||||
subMessage = `${data.goals.length} ${prettyGame}goals`;
|
||||
@@ -383,7 +370,7 @@ function GoalActivity({
|
||||
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
|
||||
<div className="timeline-content-title">
|
||||
<span className="me-2">
|
||||
<ProfilePicture size="sm" toGPT={{ game, playtype }} user={user} />
|
||||
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
|
||||
</span>
|
||||
<Icon
|
||||
style={{
|
||||
@@ -392,8 +379,7 @@ function GoalActivity({
|
||||
type={`chevron-${show ? "down" : "right"}`}
|
||||
/>
|
||||
<span className="ms-2" style={{ fontSize: "1.15rem" }}>
|
||||
<UGPTLink game={game} playtype={playtype} reqUser={user} /> achieved{" "}
|
||||
{subMessage}!
|
||||
<UGPTLink game={game} reqUser={user} /> achieved {subMessage}!
|
||||
</span>
|
||||
{mutedText && (
|
||||
<>
|
||||
@@ -438,11 +424,11 @@ function QuestActivity({
|
||||
}: {
|
||||
data: ClumpedActivityQuestAchievement;
|
||||
shouldShowGame: boolean;
|
||||
user: MONGO_UserDocument;
|
||||
user: UserDocument;
|
||||
}) {
|
||||
const { game, playtype } = data.quest;
|
||||
const game = data.quest.game;
|
||||
|
||||
const prettyGame = shouldShowGame ? FormatGameGroup(game, playtype) : "";
|
||||
const prettyGame = shouldShowGame ? FormatGame(game) : "";
|
||||
|
||||
return (
|
||||
<div className="timeline-item timeline-hover my-4">
|
||||
@@ -452,13 +438,12 @@ function QuestActivity({
|
||||
<div className="timeline-content-title">
|
||||
<span style={{ fontSize: "1.15rem" }}>
|
||||
<span className="me-2">
|
||||
<ProfilePicture size="sm" toGPT={{ game, playtype }} user={user} />
|
||||
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
|
||||
</span>
|
||||
<UGPTLink game={game} playtype={playtype} reqUser={user} /> completed
|
||||
the{" "}
|
||||
<UGPTLink game={game} reqUser={user} /> completed the{" "}
|
||||
<Link
|
||||
className="text-decoration-none"
|
||||
to={`/games/${game}/${playtype}/quests/${data.quest.questID}`}
|
||||
to={`/games/${game}/quests/${data.quest.questID}`}
|
||||
>
|
||||
{data.quest.name}
|
||||
</Link>{" "}
|
||||
@@ -486,17 +471,16 @@ function SessionActivity({
|
||||
}: {
|
||||
data: ClumpedActivitySession;
|
||||
shouldShowGame: boolean;
|
||||
user: MONGO_UserDocument;
|
||||
user: UserDocument;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
const { user: loggedInUser } = useContext(UserContext);
|
||||
|
||||
const prettyGame = shouldShowGame ? `${FormatGameGroup(data.game, data.playtype)} ` : "";
|
||||
const game = data.game;
|
||||
const prettyGame = shouldShowGame ? `${FormatGame(game)} ` : "";
|
||||
|
||||
const isProbablyActive = Date.now() - data.timeEnded < ONE_HOUR;
|
||||
|
||||
const { game, playtype } = data;
|
||||
|
||||
return (
|
||||
<div className="timeline-item timeline-hover">
|
||||
<div className={`timeline-badge bg-${data.highlight ? "warning" : "secondary"}`}></div>
|
||||
@@ -504,7 +488,7 @@ function SessionActivity({
|
||||
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
|
||||
<div className="timeline-content-title">
|
||||
<span className="me-2">
|
||||
<ProfilePicture size="sm" toGPT={{ game, playtype }} user={user} />
|
||||
<ProfilePicture size="sm" toGPT={{ game }} user={user} />
|
||||
</span>
|
||||
<Icon
|
||||
style={{
|
||||
@@ -520,7 +504,7 @@ function SessionActivity({
|
||||
}}
|
||||
>
|
||||
{/* worst string formatting ever */}
|
||||
<UGPTLink game={data.game} playtype={data.playtype} reqUser={user} />{" "}
|
||||
<UGPTLink game={game} reqUser={user} />{" "}
|
||||
{isProbablyActive
|
||||
? user.id === loggedInUser?.id
|
||||
? "are having"
|
||||
@@ -564,7 +548,8 @@ function SessionShower({ sessionID }: { sessionID: string }) {
|
||||
|
||||
const scoreMap = CreateScoreIDMap(data.scores);
|
||||
|
||||
const gptConfig = GetGamePTConfig(data.session.game, data.session.playtype);
|
||||
const sessionGame = data.session.game;
|
||||
const gameConfig = GetGameConfig(sessionGame);
|
||||
|
||||
const raises = data.scoreInfo.filter((e) => {
|
||||
const score = scoreMap.get(e.scoreID);
|
||||
@@ -574,7 +559,7 @@ function SessionShower({ sessionID }: { sessionID: string }) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const enumMetrics = GetScoreEnumConfs(gptConfig);
|
||||
const enumMetrics = GetScoreEnumConfs(gameConfig);
|
||||
|
||||
// for all enum metrics, check if this score beats the minimum relevant enum
|
||||
// and is a raise.
|
||||
@@ -601,7 +586,7 @@ function SessionShower({ sessionID }: { sessionID: string }) {
|
||||
<div className="mb-4">This session had no raises.</div>
|
||||
<div>
|
||||
<LinkButton
|
||||
to={`/u/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
|
||||
to={`/u/${data.user.username}/games/${sessionGame}/sessions/${sessionID}`}
|
||||
variant="outline-primary"
|
||||
>
|
||||
View Full Session
|
||||
@@ -620,7 +605,7 @@ function SessionShower({ sessionID }: { sessionID: string }) {
|
||||
</Col>
|
||||
<div className="d-flex w-100 justify-content-center">
|
||||
<LinkButton
|
||||
to={`/u/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
|
||||
to={`/u/${data.user.username}/games/${sessionGame}/sessions/${sessionID}`}
|
||||
variant="outline-primary"
|
||||
>
|
||||
View Full Session
|
||||
@@ -637,7 +622,7 @@ function ClassAchievementActivity({
|
||||
}: {
|
||||
data: ClumpedActivityClassAchievement;
|
||||
shouldShowGame: boolean;
|
||||
user: MONGO_UserDocument;
|
||||
user: UserDocument;
|
||||
}) {
|
||||
return (
|
||||
<div className="timeline-item timeline-hover">
|
||||
@@ -645,35 +630,39 @@ function ClassAchievementActivity({
|
||||
<div className="timeline-content">
|
||||
<div className="timeline-content-inner">
|
||||
<div className="timeline-content-title">
|
||||
<span className="me-2">
|
||||
<ProfilePicture
|
||||
size="sm"
|
||||
toGPT={{ game: data.game, playtype: data.playtype }}
|
||||
user={user}
|
||||
/>
|
||||
</span>
|
||||
<UGPTLink game={data.game} playtype={data.playtype} reqUser={user} />{" "}
|
||||
achieved{" "}
|
||||
<ClassBadge
|
||||
classSet={data.classSet}
|
||||
classValue={data.classValue}
|
||||
game={data.game}
|
||||
playtype={data.playtype}
|
||||
/>
|
||||
{shouldShowGame && ` in ${FormatGameGroup(data.game, data.playtype)}`}!
|
||||
{data.classOldValue !== null && (
|
||||
<>
|
||||
{" "}
|
||||
(Raised from{" "}
|
||||
<ClassBadge
|
||||
classSet={data.classSet}
|
||||
classValue={data.classOldValue}
|
||||
game={data.game}
|
||||
playtype={data.playtype}
|
||||
/>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
{(() => {
|
||||
const classGame = data.game;
|
||||
return (
|
||||
<>
|
||||
<span className="me-2">
|
||||
<ProfilePicture
|
||||
size="sm"
|
||||
toGPT={{ game: classGame }}
|
||||
user={user}
|
||||
/>
|
||||
</span>
|
||||
<UGPTLink game={classGame} reqUser={user} /> achieved{" "}
|
||||
<ClassBadge
|
||||
classSet={data.classSet}
|
||||
classValue={data.classValue}
|
||||
game={classGame}
|
||||
/>
|
||||
{shouldShowGame && ` in ${FormatGame(classGame)}`}!
|
||||
{data.classOldValue !== null && (
|
||||
<>
|
||||
{" "}
|
||||
(Raised from{" "}
|
||||
<ClassBadge
|
||||
classSet={data.classSet}
|
||||
classValue={data.classOldValue}
|
||||
game={classGame}
|
||||
/>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="timeline-content-timestamp">
|
||||
@@ -689,15 +678,11 @@ function ClassAchievementActivity({
|
||||
);
|
||||
}
|
||||
|
||||
function UGPTLink({ reqUser, game, playtype }: UGPT) {
|
||||
// currently
|
||||
function UGPTLink({ reqUser, game }: UGPT) {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<Link
|
||||
className="text-decoration-none fw-bold"
|
||||
to={`/u/${reqUser.username}/games/${game}/${playtype}`}
|
||||
>
|
||||
<Link className="text-decoration-none fw-bold" to={`/u/${reqUser.username}/games/${game}`}>
|
||||
{user?.id === reqUser.id ? "You" : reqUser.username}
|
||||
{reqUser?.isSupporter ? (
|
||||
<>
|
||||
|
||||
@@ -73,11 +73,7 @@ const getScoreYAxisNotch = (game: GameGroup) => (s: number) => {
|
||||
};
|
||||
|
||||
const strokeColor = (
|
||||
type:
|
||||
| "BELLS"
|
||||
| Difficulties["chunithm:Single"]
|
||||
| Difficulties["maimaidx:Single"]
|
||||
| Difficulties["ongeki:Single"],
|
||||
type: "BELLS" | Difficulties["chunithm"] | Difficulties["maimaidx"] | Difficulties["ongeki"],
|
||||
) => {
|
||||
const isLight = getTheme() === "light";
|
||||
|
||||
@@ -170,10 +166,7 @@ export default function GekichumaiScoreChart({
|
||||
duration,
|
||||
}: {
|
||||
data: Serie[];
|
||||
difficulty:
|
||||
| Difficulties["chunithm:Single"]
|
||||
| Difficulties["maimaidx:Single"]
|
||||
| Difficulties["ongeki:Single"];
|
||||
difficulty: Difficulties["chunithm"] | Difficulties["maimaidx"] | Difficulties["ongeki"];
|
||||
duration: number;
|
||||
game: "chunithm" | "maimaidx" | "ongeki";
|
||||
height?: number | string;
|
||||
@@ -188,18 +181,18 @@ export default function GekichumaiScoreChart({
|
||||
if (type === "Score") {
|
||||
if (game === "chunithm") {
|
||||
color =
|
||||
GPT_CLIENT_IMPLEMENTATIONS["chunithm:Single"].difficultyColours[
|
||||
difficulty as Difficulties["chunithm:Single"]
|
||||
GPT_CLIENT_IMPLEMENTATIONS.chunithm.difficultyColours[
|
||||
difficulty as Difficulties["chunithm"]
|
||||
];
|
||||
} else if (game === "ongeki") {
|
||||
color =
|
||||
GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].difficultyColours[
|
||||
difficulty as Difficulties["ongeki:Single"]
|
||||
GPT_CLIENT_IMPLEMENTATIONS.ongeki.difficultyColours[
|
||||
difficulty as Difficulties["ongeki"]
|
||||
];
|
||||
} else if (game === "maimaidx") {
|
||||
color =
|
||||
GPT_CLIENT_IMPLEMENTATIONS["maimaidx:Single"].difficultyColours[
|
||||
difficulty as Difficulties["maimaidx:Single"]
|
||||
GPT_CLIENT_IMPLEMENTATIONS.maimaidx.difficultyColours[
|
||||
difficulty as Difficulties["maimaidx"]
|
||||
];
|
||||
}
|
||||
} else if (type === "Bells") {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Activity from "#components/activity/Activity";
|
||||
import { AllLUGPTStatsContext } from "#context/AllLUGPTStatsContext";
|
||||
import React, { useContext } from "react";
|
||||
import { type MONGO_UserDocument } from "tachi-common";
|
||||
import { type UserDocument } from "tachi-common";
|
||||
|
||||
import { DashboardLoggedInNoScores } from "./DashboardLoggedInNoScores";
|
||||
|
||||
export default function DashboardActivity({ user }: { user: MONGO_UserDocument }) {
|
||||
export default function DashboardActivity({ user }: { user: UserDocument }) {
|
||||
const { ugs } = useContext(AllLUGPTStatsContext);
|
||||
|
||||
if (ugs?.length === 0) {
|
||||
|
||||
@@ -2,9 +2,9 @@ import Divider from "#components/util/Divider";
|
||||
import LinkButton from "#components/util/LinkButton";
|
||||
import { TachiConfig } from "#lib/config";
|
||||
import React from "react";
|
||||
import { type MONGO_UserDocument } from "tachi-common";
|
||||
import { type UserDocument } from "tachi-common";
|
||||
|
||||
export function DashboardLoggedInNoScores({ user }: { user: MONGO_UserDocument }) {
|
||||
export function DashboardLoggedInNoScores({ user }: { user: UserDocument }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="display-4">
|
||||
|
||||
@@ -4,11 +4,10 @@ import { type GamePT } from "#types/react";
|
||||
import { UppercaseFirst } from "#util/misc";
|
||||
import React from "react";
|
||||
import { Badge } from "react-bootstrap";
|
||||
import { type Classes, GetGamePTConfig, GetGPTString, type GPTString } from "tachi-common";
|
||||
import { type Classes, GetGameConfig, type V3Game } from "tachi-common";
|
||||
|
||||
export default function ClassBadge<GPT extends GPTString = GPTString>({
|
||||
export default function ClassBadge<GPT extends V3Game = V3Game>({
|
||||
game,
|
||||
playtype,
|
||||
classSet,
|
||||
classValue,
|
||||
showSetOnHover = true,
|
||||
@@ -18,12 +17,10 @@ export default function ClassBadge<GPT extends GPTString = GPTString>({
|
||||
showSetOnHover?: boolean;
|
||||
} & GamePT) {
|
||||
const classStyle =
|
||||
// @ts-expect-error hepl i'm trapped in a type factory
|
||||
GPT_CLIENT_IMPLEMENTATIONS[GetGPTString(game, playtype)].classColours[classSet][classValue];
|
||||
// @ts-expect-error complex indexed types
|
||||
GPT_CLIENT_IMPLEMENTATIONS[game].classColours[classSet][classValue];
|
||||
|
||||
const data = GetGamePTConfig(game, playtype).classes[classSet].values.find(
|
||||
(e) => e.id === classValue,
|
||||
);
|
||||
const data = GetGameConfig(game).classes[classSet].values.find((e) => e.id === classValue);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
@@ -64,7 +61,9 @@ export default function ClassBadge<GPT extends GPTString = GPTString>({
|
||||
} else if (data.hoverText) {
|
||||
return <QuickTooltip tooltipContent={data.hoverText}>{badgeComponent}</QuickTooltip>;
|
||||
} else if (showSetOnHover) {
|
||||
<QuickTooltip tooltipContent={UppercaseFirst(classSet)}>{badgeComponent}</QuickTooltip>;
|
||||
return (
|
||||
<QuickTooltip tooltipContent={UppercaseFirst(classSet)}>{badgeComponent}</QuickTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return badgeComponent;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user