mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Import march-2020-03-10 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.03.10 client (also serves 2020.03.06).
Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces dorknet-clientpatch-{key}.zip
for v1-march-* Release uploads.
- .github/workflows/release.yml — on tag push publishes both
dorknet-server-march_2020_03_10-win-x64.zip and
dorknet-clientpatch-march_2020_03_10.zip as Release assets.
Same scrub rules as december; tools/build-patcher-zip.ps1 added to
the maintainer-side allow-list so it survives.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>DorkNet Admin</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="bg-ink-950 text-ink-100 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2788
@@ -0,0 +1,2788 @@
|
||||
{
|
||||
"name": "dorknet-admin-ui",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dorknet-admin-ui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "^2.7.52",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
|
||||
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
"json5": "^2.2.3",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/babel"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-plugin-utils": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
||||
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
||||
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
|
||||
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
|
||||
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
||||
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
||||
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
||||
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
||||
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
"run-parallel": "^1.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.stat": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.walk": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
"fastq": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
|
||||
"integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
|
||||
"integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
|
||||
"integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
|
||||
"integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
|
||||
"integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
|
||||
"integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
|
||||
"integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
|
||||
"integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
|
||||
"integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
|
||||
"integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
|
||||
"integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
|
||||
"integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
|
||||
"integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
|
||||
"integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.20.7",
|
||||
"@babel/types": "^7.20.7",
|
||||
"@types/babel__generator": "*",
|
||||
"@types/babel__template": "*",
|
||||
"@types/babel__traverse": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__generator": {
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
|
||||
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__template": {
|
||||
"version": "7.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
||||
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.1.0",
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__traverse": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
||||
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.27",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zip.js/zip.js": {
|
||||
"version": "2.8.26",
|
||||
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz",
|
||||
"integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"bun": ">=0.7.0",
|
||||
"deno": ">=1.0.0",
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
|
||||
"integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.2",
|
||||
"caniuse-lite": "^1.0.30001787",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"autoprefixer": "bin/autoprefixer"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.29",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
|
||||
"integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
"electron-to-chromium": "^1.5.328",
|
||||
"node-releases": "^2.0.36",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001792",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
|
||||
"integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar/node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.356",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz",
|
||||
"integrity": "sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.21.5",
|
||||
"@esbuild/android-arm": "0.21.5",
|
||||
"@esbuild/android-arm64": "0.21.5",
|
||||
"@esbuild/android-x64": "0.21.5",
|
||||
"@esbuild/darwin-arm64": "0.21.5",
|
||||
"@esbuild/darwin-x64": "0.21.5",
|
||||
"@esbuild/freebsd-arm64": "0.21.5",
|
||||
"@esbuild/freebsd-x64": "0.21.5",
|
||||
"@esbuild/linux-arm": "0.21.5",
|
||||
"@esbuild/linux-arm64": "0.21.5",
|
||||
"@esbuild/linux-ia32": "0.21.5",
|
||||
"@esbuild/linux-loong64": "0.21.5",
|
||||
"@esbuild/linux-mips64el": "0.21.5",
|
||||
"@esbuild/linux-ppc64": "0.21.5",
|
||||
"@esbuild/linux-riscv64": "0.21.5",
|
||||
"@esbuild/linux-s390x": "0.21.5",
|
||||
"@esbuild/linux-x64": "0.21.5",
|
||||
"@esbuild/netbsd-x64": "0.21.5",
|
||||
"@esbuild/openbsd-x64": "0.21.5",
|
||||
"@esbuild/sunos-x64": "0.21.5",
|
||||
"@esbuild/win32-arm64": "0.21.5",
|
||||
"@esbuild/win32-ia32": "0.21.5",
|
||||
"@esbuild/win32-x64": "0.21.5"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
"@nodelib/fs.walk": "^1.2.3",
|
||||
"glob-parent": "^5.1.2",
|
||||
"merge2": "^1.3.0",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob/node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.2",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
|
||||
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jsesc": "bin/jsesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antonk52"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0",
|
||||
"object-assign": "^4.0.1",
|
||||
"thenify-all": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.44",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
|
||||
"integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-hash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pify": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
||||
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pirates": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-import": {
|
||||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
|
||||
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-value-parser": "^4.0.0",
|
||||
"read-cache": "^1.0.0",
|
||||
"resolve": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-js": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
|
||||
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelcase-css": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12 || ^14 || >= 16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.4.21"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-load-config": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
|
||||
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lilconfig": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jiti": ">=1.21.0",
|
||||
"postcss": ">=8.0.9",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"postcss": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-nested": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
|
||||
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "^6.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.2.14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pify": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"is-core-module": "^2.16.1",
|
||||
"path-parse": "^1.0.7",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"resolve": "bin/resolve"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||
"integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.4",
|
||||
"@rollup/rollup-android-arm64": "4.60.4",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.4",
|
||||
"@rollup/rollup-darwin-x64": "4.60.4",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.4",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.4",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.4",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.4",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.4",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.4",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.4",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.4",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.4",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.4",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
"commander": "^4.0.0",
|
||||
"lines-and-columns": "^1.1.6",
|
||||
"mz": "^2.7.0",
|
||||
"pirates": "^4.0.1",
|
||||
"tinyglobby": "^0.2.11",
|
||||
"ts-interface-checker": "^0.1.9"
|
||||
},
|
||||
"bin": {
|
||||
"sucrase": "bin/sucrase",
|
||||
"sucrase-node": "bin/sucrase-node"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
"chokidar": "^3.6.0",
|
||||
"didyoumean": "^1.2.2",
|
||||
"dlv": "^1.1.3",
|
||||
"fast-glob": "^3.3.2",
|
||||
"glob-parent": "^6.0.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"jiti": "^1.21.7",
|
||||
"lilconfig": "^3.1.3",
|
||||
"micromatch": "^4.0.8",
|
||||
"normalize-path": "^3.0.0",
|
||||
"object-hash": "^3.0.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-js": "^4.0.1",
|
||||
"postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
|
||||
"postcss-nested": "^6.2.0",
|
||||
"postcss-selector-parser": "^6.1.2",
|
||||
"resolve": "^1.22.8",
|
||||
"sucrase": "^3.35.0"
|
||||
},
|
||||
"bin": {
|
||||
"tailwind": "lib/cli.js",
|
||||
"tailwindcss": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"any-promise": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify-all": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
|
||||
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"thenify": ">= 3.1.0 < 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-interface-checker": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escalade": "^3.2.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"update-browserslist-db": "cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
"rollup": "^4.20.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"less": "*",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"lightningcss": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "dorknet-admin-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "^2.7.52",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Layout } from './components/Layout';
|
||||
import { RequireAuth } from './components/RequireAuth';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { Login } from './pages/Login';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Players } from './pages/Players';
|
||||
import { Bans } from './pages/Bans';
|
||||
import { Reports } from './pages/Reports';
|
||||
import { Audit } from './pages/Audit';
|
||||
import { PlayerLogs } from './pages/PlayerLogs';
|
||||
import { Rooms } from './pages/Rooms';
|
||||
import { Room } from './pages/Room';
|
||||
import { ImportRoom } from './pages/ImportRoom';
|
||||
import { LegacyImportRoom } from './pages/LegacyImportRoom';
|
||||
import { Store } from './pages/Store';
|
||||
import { Broadcast } from './pages/Broadcast';
|
||||
import { CommunityBoard } from './pages/CommunityBoard';
|
||||
import { LoadingTips } from './pages/LoadingTips';
|
||||
import { Gift } from './pages/Gift';
|
||||
import { Passwords } from './pages/Passwords';
|
||||
import { Grants } from './pages/Grants';
|
||||
import { Storage } from './pages/Storage';
|
||||
import { Settings } from './pages/Settings';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<RequireAuth><Layout /></RequireAuth>}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="players" element={<Players />} />
|
||||
<Route path="bans" element={<Bans />} />
|
||||
<Route path="reports" element={<Reports />} />
|
||||
<Route path="audit" element={<Audit />} />
|
||||
<Route path="logs" element={<PlayerLogs />} />
|
||||
<Route path="rooms" element={<Rooms />} />
|
||||
<Route path="rooms/:id" element={<Room />} />
|
||||
{/* Back-compat: the merge left RR-Originals / Instances /
|
||||
Leaderboards / old per-room pages folded into /rooms and
|
||||
/rooms/:id (leaderboards tab inside the per-room view).
|
||||
Anyone with a stale bookmark hitting those old paths
|
||||
lands back on the unified rooms list. */}
|
||||
<Route path="rr-originals" element={<Navigate to="/rooms" replace />} />
|
||||
<Route path="instances" element={<Navigate to="/rooms" replace />} />
|
||||
<Route path="leaderboards" element={<Navigate to="/rooms" replace />} />
|
||||
<Route path="import-room" element={<ImportRoom />} />
|
||||
<Route path="import-room-legacy" element={<LegacyImportRoom />} />
|
||||
<Route path="store" element={<Store />} />
|
||||
<Route path="community" element={<CommunityBoard />} />
|
||||
<Route path="loading-tips" element={<LoadingTips />} />
|
||||
<Route path="broadcast" element={<Broadcast />} />
|
||||
<Route path="gift" element={<Gift />} />
|
||||
<Route path="passwords" element={<Passwords />} />
|
||||
<Route path="grants" element={<Grants />} />
|
||||
<Route path="storage" element={<Storage />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
body: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
destructive?: boolean;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function Confirm({ title, body, confirmLabel = 'Confirm', destructive, open, onClose, onConfirm }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
try { await onConfirm(); onClose(); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
footer={<>
|
||||
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
|
||||
<button onClick={run} className={(destructive ? 'btn-danger' : 'btn-primary') + ' text-xs'} disabled={busy}>
|
||||
{busy ? 'Working…' : confirmLabel}
|
||||
</button>
|
||||
</>}
|
||||
>
|
||||
<div className="text-sm text-ink-200">{body}</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Tiny stand-in for "nothing matches your filter / nothing exists yet"
|
||||
// table-empty states. Centered, muted, optional CTA.
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
blurb?: string;
|
||||
cta?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Empty({ title = 'Nothing here', blurb, cta, className }: Props) {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center gap-1 py-10 text-center ${className ?? ''}`}>
|
||||
<div className="text-sm font-medium text-ink-200">{title}</div>
|
||||
{blurb && <div className="text-xs text-ink-400 max-w-xs">{blurb}</div>}
|
||||
{cta && <div className="mt-3">{cta}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Inline SVG icons. Keeping them inline avoids a lucide-react / heroicons
|
||||
// dependency for the dozen glyphs the sidebar needs. All icons take a
|
||||
// className override so the parent's text color cascades via currentColor.
|
||||
|
||||
type IconProps = { className?: string };
|
||||
const base = 'size-4 shrink-0 stroke-current';
|
||||
|
||||
const Svg = ({ children, className }: IconProps & { children: React.ReactNode }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className={[base, className].filter(Boolean).join(' ')}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LayoutDashboard = (p: IconProps) => (
|
||||
<Svg {...p}><rect x="3" y="3" width="7" height="9" rx="1.5" /><rect x="14" y="3" width="7" height="5" rx="1.5" /><rect x="14" y="12" width="7" height="9" rx="1.5" /><rect x="3" y="16" width="7" height="5" rx="1.5" /></Svg>
|
||||
);
|
||||
export const Users = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></Svg>
|
||||
);
|
||||
export const Building2 = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z" /><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" /><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2" /><path d="M10 6h4" /><path d="M10 10h4" /><path d="M10 14h4" /><path d="M10 18h4" /></Svg>
|
||||
);
|
||||
export const Ban = (p: IconProps) => (
|
||||
<Svg {...p}><circle cx="12" cy="12" r="10" /><path d="m4.93 4.93 14.14 14.14" /></Svg>
|
||||
);
|
||||
export const Flag = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M4 22V4" /><path d="M4 4h13l-2 5 2 5H4" /></Svg>
|
||||
);
|
||||
export const ScrollText = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M15 12h-5" /><path d="M15 8h-5" /><path d="M19 17V5a2 2 0 0 0-2-2H4" /><path d="M8 21H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h3" /><path d="M8 21V7" /><path d="M14 21h6a2 2 0 0 0 2-2v-2H8" /></Svg>
|
||||
);
|
||||
export const Activity = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M22 12h-4l-3 9L9 3l-3 9H2" /></Svg>
|
||||
);
|
||||
export const Megaphone = (p: IconProps) => (
|
||||
<Svg {...p}><path d="m3 11 18-5v12L3 14v-3Z" /><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" /></Svg>
|
||||
);
|
||||
export const Upload = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" x2="12" y1="3" y2="15" /></Svg>
|
||||
);
|
||||
export const ShoppingBag = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z" /><line x1="3" x2="21" y1="6" y2="6" /><path d="M16 10a4 4 0 0 1-8 0" /></Svg>
|
||||
);
|
||||
export const Server = (p: IconProps) => (
|
||||
<Svg {...p}><rect x="2" y="2" width="20" height="8" rx="2" /><rect x="2" y="14" width="20" height="8" rx="2" /><line x1="6" x2="6.01" y1="6" y2="6" /><line x1="6" x2="6.01" y1="18" y2="18" /></Svg>
|
||||
);
|
||||
export const Gift = (p: IconProps) => (
|
||||
<Svg {...p}><polyline points="20 12 20 22 4 22 4 12" /><rect x="2" y="7" width="20" height="5" /><line x1="12" x2="12" y1="22" y2="7" /><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7Z" /><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7Z" /></Svg>
|
||||
);
|
||||
export const KeyRound = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M2 18a4 4 0 0 1 4-4h2a4 4 0 0 1 4 4v3H2v-3Z" /><circle cx="16" cy="9" r="6" /></Svg>
|
||||
);
|
||||
export const Wrench = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76Z" /></Svg>
|
||||
);
|
||||
export const MessageSquare = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /></Svg>
|
||||
);
|
||||
export const LogOut = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" x2="9" y1="12" y2="12" /></Svg>
|
||||
);
|
||||
export const Menu = (p: IconProps) => (
|
||||
<Svg {...p}><line x1="3" x2="21" y1="6" y2="6" /><line x1="3" x2="21" y1="12" y2="12" /><line x1="3" x2="21" y1="18" y2="18" /></Svg>
|
||||
);
|
||||
export const X = (p: IconProps) => (
|
||||
<Svg {...p}><line x1="18" x2="6" y1="6" y2="18" /><line x1="6" x2="18" y1="6" y2="18" /></Svg>
|
||||
);
|
||||
export const Search = (p: IconProps) => (
|
||||
<Svg {...p}><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></Svg>
|
||||
);
|
||||
export const Plus = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M5 12h14" /><path d="M12 5v14" /></Svg>
|
||||
);
|
||||
export const Trash = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></Svg>
|
||||
);
|
||||
export const RefreshCw = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M3 12a9 9 0 0 1 15-6.7L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-15 6.7L3 16" /><path d="M3 21v-5h5" /></Svg>
|
||||
);
|
||||
export const HardDrive = (p: IconProps) => (
|
||||
<Svg {...p}><line x1="22" x2="2" y1="12" y2="12" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11Z" /><line x1="6" x2="6.01" y1="16" y2="16" /><line x1="10" x2="10.01" y1="16" y2="16" /></Svg>
|
||||
);
|
||||
export const Settings = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" /></Svg>
|
||||
);
|
||||
export const ArrowLeft = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M19 12H5" /><path d="m12 19-7-7 7-7" /></Svg>
|
||||
);
|
||||
export const Trophy = (p: IconProps) => (
|
||||
<Svg {...p}><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" /><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" /><path d="M4 22h16" /><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" /><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" /><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" /></Svg>
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { LogOut, Menu } from './Icons';
|
||||
import { clearSession, getMe, notifyAuthChange, subscribeAuth } from '../lib/auth';
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const me = useSyncExternalStore(subscribeAuth, getMe, () => null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// Defensive: close the drawer on route change in case the user opens
|
||||
// it then navigates via something other than the sidebar links
|
||||
// (e.g. the legacy importer link in PageHeader actions). The drawer
|
||||
// also closes via NavLink onClick → onNavigate, so this is a belt.
|
||||
useEffect(() => { setDrawerOpen(false); }, [location.pathname]);
|
||||
|
||||
const logout = () => {
|
||||
clearSession();
|
||||
notifyAuthChange();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<Sidebar
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onNavigate={() => setDrawerOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<header className="flex h-14 items-center gap-2 border-b border-ink-800 bg-ink-900/60 px-3 sm:px-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="md:hidden rounded-md p-1.5 text-ink-200 hover:bg-ink-800"
|
||||
aria-label="Open menu"
|
||||
aria-expanded={drawerOpen}
|
||||
>
|
||||
<Menu />
|
||||
</button>
|
||||
<div className="flex-1 text-sm text-ink-300 truncate">
|
||||
{/* Tighter copy on small screens — the "Signed in as" prefix
|
||||
eats too much width on a phone. Tablet+ shows the full
|
||||
phrase. */}
|
||||
<span className="hidden sm:inline">Signed in as </span>
|
||||
<span className="font-medium text-ink-50">
|
||||
{me?.displayName ?? me?.username ?? `#${me?.id ?? '?'}`}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={logout} className="btn-ghost text-xs shrink-0" aria-label="Sign out">
|
||||
<LogOut />
|
||||
<span className="hidden sm:inline">Sign out</span>
|
||||
</button>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto px-3 sm:px-5 py-4 sm:py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
// Wider for dense edit forms (e.g. player detail with multiple sections).
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
// Sticky action bar at the bottom — pass buttons here so the form
|
||||
// body can scroll but the actions stay visible on small screens.
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ title, open, onClose, children, size = 'md', footer }: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
const sizeClass = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-ink-950/70 backdrop-blur-sm px-4 py-6"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<div
|
||||
className={`card flex max-h-full w-full flex-col ${sizeClass}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-ink-800 px-5 py-3">
|
||||
<h2 className="text-sm font-semibold tracking-tight text-ink-50">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-ink-400 hover:bg-ink-800 hover:text-ink-200"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-5 py-4">
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-2 border-t border-ink-800 px-5 py-3 bg-ink-900/40">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
interface Props {
|
||||
title: string;
|
||||
blurb?: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Consistent page header: title + optional one-line blurb + a slot
|
||||
// for primary actions (refresh, new, etc.) aligned to the right.
|
||||
export function PageHeader({ title, blurb, actions }: Props) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 sm:gap-4 mb-4 sm:mb-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold tracking-tight text-ink-50">{title}</h1>
|
||||
{blurb && <p className="text-xs sm:text-sm text-ink-400 mt-0.5">{blurb}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2 shrink-0">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import { profileImageUrl } from '../lib/types';
|
||||
|
||||
// Round avatar tile that falls back to the player's display-name
|
||||
// initial if there's no ProfileImageName or the request 404s. The
|
||||
// img.* CDN signs every response, so we don't have to do anything
|
||||
// special on this side — just point at the URL and let the browser
|
||||
// render or fail to render. Size in pixels controls both the rendered
|
||||
// box and the `?width=N` hint we send so the CDN can resize.
|
||||
export function PlayerAvatar({
|
||||
name,
|
||||
displayName,
|
||||
size = 28,
|
||||
className = '',
|
||||
}: {
|
||||
name?: string | null;
|
||||
displayName?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const [errored, setErrored] = useState(false);
|
||||
const url = errored ? null : profileImageUrl(name, Math.max(size * 2, 64));
|
||||
const initial = (displayName ?? '?').charAt(0).toUpperCase();
|
||||
const dim = { width: size, height: size };
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div
|
||||
style={dim}
|
||||
className={`shrink-0 rounded-full bg-gradient-to-br from-ink-700 to-ink-800 flex items-center justify-center font-semibold text-ink-200 ${className}`}
|
||||
>
|
||||
<span style={{ fontSize: size * 0.42 }}>{initial}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
style={dim}
|
||||
onError={() => setErrored(true)}
|
||||
alt={displayName ? `${displayName}'s avatar` : 'player avatar'}
|
||||
className={`shrink-0 rounded-full bg-ink-800 object-cover ${className}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { getToken, subscribeAuth } from '../lib/auth';
|
||||
|
||||
// Gate every authenticated route. We re-render on any auth state
|
||||
// change so a 401 from `api()` (which clears the token and pings
|
||||
// listeners) bounces the user out of the dashboard mid-session.
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = useSyncExternalStore(subscribeAuth, getToken, () => null);
|
||||
const location = useLocation();
|
||||
if (!token) return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Users, Building2, Ban, Flag, ScrollText, Activity,
|
||||
Megaphone, Upload, ShoppingBag, Gift, KeyRound, Wrench, MessageSquare, HardDrive, Settings, X,
|
||||
} from './Icons';
|
||||
|
||||
const groups: Array<{ title: string; items: Array<{ to: string; label: string; icon: React.ReactNode; new?: boolean }>}> = [
|
||||
{
|
||||
title: 'Overview',
|
||||
items: [
|
||||
{ to: '/', label: 'Dashboard', icon: <LayoutDashboard /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'People',
|
||||
items: [
|
||||
{ to: '/players', label: 'Players', icon: <Users /> },
|
||||
{ to: '/bans', label: 'Bans', icon: <Ban /> },
|
||||
{ to: '/reports', label: 'Reports', icon: <Flag /> },
|
||||
{ to: '/audit', label: 'Audit log', icon: <ScrollText /> },
|
||||
{ to: '/logs', label: 'Player logs', icon: <Activity /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Content',
|
||||
items: [
|
||||
// Rooms is the unified view — clicking a row opens /rooms/:id
|
||||
// which carries the old RR-Originals edit modal, live Photon
|
||||
// instance list, recent visitors, ownership management, and
|
||||
// per-room leaderboard channels in one tabbed page.
|
||||
{ to: '/rooms', label: 'Rooms', icon: <Building2 /> },
|
||||
{ to: '/import-room', label: 'Import room', icon: <Upload /> },
|
||||
{ to: '/store', label: 'Store catalog', icon: <ShoppingBag /> },
|
||||
{ to: '/community', label: 'Community board', icon: <MessageSquare /> },
|
||||
{ to: '/loading-tips', label: 'Loading screen tips', icon: <MessageSquare /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tools',
|
||||
items: [
|
||||
{ to: '/broadcast', label: 'Broadcast', icon: <Megaphone /> },
|
||||
{ to: '/gift', label: 'Gift items', icon: <Gift />, new: true },
|
||||
{ to: '/passwords', label: 'Reset passwords', icon: <KeyRound />, new: true },
|
||||
{ to: '/grants', label: 'Grant maker pen', icon: <Wrench />, new: true },
|
||||
{ to: '/storage', label: 'Storage backfill', icon: <HardDrive />, new: true },
|
||||
{ to: '/settings', label: 'Server settings', icon: <Settings />, new: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Two-mode rendering:
|
||||
// * Desktop (md+): always-visible static rail to the left of <main>.
|
||||
// * Mobile: hidden by default; opens as a slide-in drawer with a
|
||||
// backdrop scrim, controlled by `open` from <Layout>. Tapping any
|
||||
// nav link calls `onNavigate()` so the drawer closes automatically
|
||||
// after the user picks a destination. Escape also closes.
|
||||
export function Sidebar({ open, onClose, onNavigate }: { open: boolean; onClose: () => void; onNavigate: () => void }) {
|
||||
// Lock body scroll while the mobile drawer is up so the page behind
|
||||
// doesn't scroll through the backdrop. Skip on desktop where `open`
|
||||
// is irrelevant (the sidebar is always there).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<div className="px-2 pb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 rounded-md bg-gradient-to-br from-brand-400 to-brand-600 flex items-center justify-center font-bold text-white">D</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold tracking-tight text-ink-50">DorkNet</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-ink-400">admin</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="md:hidden rounded-md p-1 text-ink-400 hover:bg-ink-800 hover:text-ink-200"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-3 overflow-y-auto">
|
||||
{groups.map(group => (
|
||||
<div key={group.title}>
|
||||
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-widest text-ink-400">{group.title}</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) =>
|
||||
`group flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-brand-500/15 text-brand-100 ring-1 ring-inset ring-brand-500/30'
|
||||
: 'text-ink-200 hover:bg-ink-800 hover:text-ink-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-ink-300 group-[.active]:text-brand-200">{item.icon}</span>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.new && <span className="badge badge-neutral !bg-brand-500/20 !text-brand-200">new</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile drawer + backdrop */}
|
||||
<div
|
||||
className={`md:hidden fixed inset-0 z-40 transition-opacity ${open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
|
||||
onClick={onClose}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className="absolute inset-0 bg-ink-950/70 backdrop-blur-sm" />
|
||||
</div>
|
||||
<aside
|
||||
className={`md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] flex flex-col gap-1 border-r border-ink-800 bg-ink-900 px-3 py-4 transition-transform duration-200 ${open ? 'translate-x-0' : '-translate-x-full'}`}
|
||||
aria-hidden={!open}
|
||||
role="dialog"
|
||||
aria-label="Navigation"
|
||||
>
|
||||
{inner}
|
||||
</aside>
|
||||
{/* Desktop static rail */}
|
||||
<aside className="hidden md:flex md:w-60 lg:w-64 flex-col gap-1 border-r border-ink-800 bg-ink-900/60 px-3 py-4">
|
||||
{inner}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Kind = 'info' | 'success' | 'error';
|
||||
interface ToastMsg { id: number; kind: Kind; text: string }
|
||||
|
||||
interface Ctx { push: (text: string, kind?: Kind) => void }
|
||||
const ToastCtx = createContext<Ctx | null>(null);
|
||||
|
||||
export function useToast(): Ctx {
|
||||
const ctx = useContext(ToastCtx);
|
||||
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [items, setItems] = useState<ToastMsg[]>([]);
|
||||
const push = useCallback((text: string, kind: Kind = 'info') => {
|
||||
const id = Date.now() + Math.random();
|
||||
setItems(prev => [...prev, { id, kind, text }]);
|
||||
setTimeout(() => setItems(prev => prev.filter(t => t.id !== id)), 4000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastCtx.Provider value={{ push }}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2">
|
||||
{items.map(t => (
|
||||
<ToastItem key={t.id} msg={t} />
|
||||
))}
|
||||
</div>
|
||||
</ToastCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ msg }: { msg: ToastMsg }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => { requestAnimationFrame(() => setOpen(true)); }, []);
|
||||
const colors =
|
||||
msg.kind === 'success' ? 'bg-success/15 text-success border-success/30'
|
||||
: msg.kind === 'error' ? 'bg-danger/15 text-danger border-danger/30'
|
||||
: 'bg-ink-800 text-ink-100 border-ink-700';
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-auto min-w-[240px] max-w-sm rounded-lg border px-3.5 py-2 text-sm shadow-card transition-all duration-200 ${colors} ${open ? 'translate-x-0 opacity-100' : 'translate-x-2 opacity-0'}`}
|
||||
role="status"
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html, body, #root { height: 100%; }
|
||||
body {
|
||||
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
::selection { background-color: theme('colors.brand.500'); color: white; }
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply rounded-xl border border-ink-800 bg-ink-900 shadow-card;
|
||||
}
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-3.5 py-2 text-sm font-medium
|
||||
transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500/40 focus:ring-offset-2 focus:ring-offset-ink-950
|
||||
disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
.btn-primary { @apply btn bg-brand-500 text-white hover:bg-brand-400 active:bg-brand-600; }
|
||||
.btn-secondary{ @apply btn bg-ink-800 text-ink-100 hover:bg-ink-700 border border-ink-700; }
|
||||
.btn-danger { @apply btn bg-danger text-white hover:bg-danger/90; }
|
||||
.btn-ghost { @apply btn bg-transparent text-ink-200 hover:bg-ink-800; }
|
||||
.input {
|
||||
@apply w-full rounded-lg border border-ink-700 bg-ink-900 px-3 py-2 text-sm text-ink-100
|
||||
placeholder:text-ink-400 focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/30;
|
||||
}
|
||||
.label {
|
||||
@apply text-xs font-medium uppercase tracking-wide text-ink-300;
|
||||
}
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider;
|
||||
}
|
||||
.badge-online { @apply badge bg-success/15 text-success; }
|
||||
.badge-banned { @apply badge bg-danger/15 text-danger; }
|
||||
.badge-admin { @apply badge bg-brand-500/15 text-brand-200; }
|
||||
.badge-junior { @apply badge bg-warn/15 text-warn; }
|
||||
.badge-neutral { @apply badge bg-ink-800 text-ink-200; }
|
||||
|
||||
.table-row-hover { @apply hover:bg-ink-800/60 transition-colors; }
|
||||
|
||||
/* Wrap any wide data table in <div class="table-scroll"> so it
|
||||
scrolls horizontally on narrow viewports instead of forcing the
|
||||
whole layout wider than the screen. Pair with `whitespace-nowrap`
|
||||
on cells that should never wrap (usernames, timestamps). */
|
||||
.table-scroll { @apply overflow-x-auto -mx-px; }
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { clearSession, getToken, notifyAuthChange } from './auth';
|
||||
|
||||
const API_BASE = '/api/admin/v1';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string, public body?: unknown) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
// For multipart/form-data uploads — pass the FormData and skip JSON encoding.
|
||||
formData?: FormData;
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(path: string, opts: ApiOptions = {}): Promise<T> {
|
||||
const headers = new Headers(opts.headers ?? {});
|
||||
const token = getToken();
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (opts.formData) {
|
||||
body = opts.formData;
|
||||
} else if (opts.body !== undefined && opts.body !== null) {
|
||||
if (typeof opts.body === 'string') {
|
||||
body = opts.body;
|
||||
} else {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
body = JSON.stringify(opts.body);
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 15000);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(API_BASE + path, {
|
||||
...opts,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
throw new ApiError(0, `request timed out (${opts.timeoutMs ?? 15000}ms)`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
clearSession();
|
||||
notifyAuthChange();
|
||||
throw new ApiError(401, 'Unauthorized — sign in again');
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = `${res.status} ${res.statusText}`;
|
||||
let bodyJson: unknown;
|
||||
try {
|
||||
bodyJson = await res.json();
|
||||
const err = bodyJson as { error?: string; error_description?: string; message?: string };
|
||||
if (err?.error) message = err.error + (err.error_description ? ` (${err.error_description})` : '');
|
||||
else if (err?.message) message = err.message;
|
||||
} catch { /* leave message as status */ }
|
||||
throw new ApiError(res.status, message, bodyJson);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
const ct = res.headers.get('content-type') ?? '';
|
||||
if (ct.includes('application/json')) {
|
||||
const raw = await res.json();
|
||||
return camelizeKeys(raw) as T;
|
||||
}
|
||||
return (await res.text()) as T;
|
||||
}
|
||||
|
||||
// Server responses are PascalCase because Program.cs sets
|
||||
// PropertyNamingPolicy=null project-wide (the old Rec Room watch
|
||||
// depends on exact wire keys). The admin SPA is the only place we
|
||||
// control both ends, so we camelCase on ingress so TS code stays
|
||||
// idiomatic. Outbound POST bodies don't need the inverse — ASP.NET
|
||||
// Core's JSON binder has PropertyNameCaseInsensitive=true by default,
|
||||
// so camelCase request fields bind to PascalCase record properties
|
||||
// fine.
|
||||
function camelizeKey(s: string): string {
|
||||
if (s.length === 0) return s;
|
||||
// Leave already-lower (OAuth-style snake_case keys like access_token)
|
||||
// and single-character keys alone — only flip the leading capital.
|
||||
return s[0] === s[0].toLowerCase() ? s : s[0].toLowerCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function camelizeKeys(input: unknown): unknown {
|
||||
if (Array.isArray(input)) return input.map(camelizeKeys);
|
||||
if (input !== null && typeof input === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
|
||||
out[camelizeKey(k)] = camelizeKeys(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
// Convenience shorthands so callers don't have to spell out the options object.
|
||||
export const get = <T = unknown>(path: string) => api<T>(path);
|
||||
export const post = <T = unknown>(path: string, body?: unknown) => api<T>(path, { method: 'POST', body });
|
||||
export const put = <T = unknown>(path: string, body?: unknown) => api<T>(path, { method: 'PUT', body });
|
||||
export const del = <T = unknown>(path: string, body?: unknown) => api<T>(path, { method: 'DELETE', body });
|
||||
|
||||
// Upload progress callback fires repeatedly during the request body
|
||||
// send (request bytes), then once with {total, loaded: total} just
|
||||
// before the server starts processing. fetch() doesn't expose request
|
||||
// progress at all, so we fall back to XHR for this one helper.
|
||||
export interface UploadProgress { loaded: number; total: number; percent: number }
|
||||
|
||||
// Chunk size for chunked uploads. Cloudflare's free/pro plans cap
|
||||
// request bodies at 100 MB end-to-end (Tunnel inherits the same
|
||||
// limit), so any chunk we send has to stay below that. 50 MB leaves
|
||||
// generous headroom for the multipart envelope, TLS framing, and
|
||||
// HTTP/2 stream buffers.
|
||||
export const ZIP_CHUNK_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export async function uploadWithProgress<T = unknown>(
|
||||
path: string,
|
||||
formData: FormData,
|
||||
onProgress?: (p: UploadProgress) => void,
|
||||
timeoutMs = 30 * 60_000,
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/admin/v1' + path, true);
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.timeout = timeoutMs;
|
||||
xhr.responseType = 'text';
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (!onProgress) return;
|
||||
const total = e.lengthComputable ? e.total : 0;
|
||||
const percent = total > 0 ? Math.round((e.loaded / total) * 100) : 0;
|
||||
onProgress({ loaded: e.loaded, total, percent });
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 401) {
|
||||
clearSession();
|
||||
notifyAuthChange();
|
||||
reject(new ApiError(401, 'Unauthorized — sign in again'));
|
||||
return;
|
||||
}
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
let msg = `${xhr.status} ${xhr.statusText}`;
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText) as { error?: string; message?: string };
|
||||
if (body?.error) msg = body.error;
|
||||
else if (body?.message) msg = body.message;
|
||||
} catch { /* keep status text */ }
|
||||
reject(new ApiError(xhr.status, msg));
|
||||
return;
|
||||
}
|
||||
// Parse response with the same camelize behavior as fetch path.
|
||||
try {
|
||||
const ct = xhr.getResponseHeader('content-type') ?? '';
|
||||
if (ct.includes('application/json') && xhr.responseText) {
|
||||
const raw = JSON.parse(xhr.responseText);
|
||||
resolve(camelizeKeys(raw) as T);
|
||||
} else {
|
||||
resolve(xhr.responseText as T);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new ApiError(0, 'network error'));
|
||||
xhr.ontimeout = () => reject(new ApiError(0, `request timed out (${timeoutMs}ms)`));
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
// Chunked upload for the zip importer. Splits a File into
|
||||
// ZIP_CHUNK_BYTES-sized pieces with Blob.slice(), POSTs each piece's
|
||||
// raw bytes to /zip-upload-chunk?offset=N (no multipart wrapper —
|
||||
// keeps the bytes-on-wire identical to the in-memory slice), then
|
||||
// calls /zip-upload-finalize once everything's uploaded. Progress
|
||||
// callback fires per chunk-completion as well as during each chunk's
|
||||
// upload phase, so the UI bar advances smoothly through a multi-GB
|
||||
// archive instead of stepping in 50 MB jumps.
|
||||
export interface ZipImportOptions {
|
||||
/// Top-level archive folders (e.g. "Rooms/Room_3163127_YarrHarrHeist_…"
|
||||
/// or "Inventions/Invention_…") to actually import. Leave undefined
|
||||
/// or empty to import everything in the archive — the SPA's preview
|
||||
/// builds these lists from the room/invention checkboxes.
|
||||
selectedRoomFolders?: string[];
|
||||
selectedInventionFolders?: string[];
|
||||
/// Round-trip the imported .room blobs through the 2020 protobuf
|
||||
/// schema before persisting. Defaults off because the re-encode
|
||||
/// currently crashes the watch on load; the SPA exposes the toggle
|
||||
/// in the importer's options panel for cases where the admin wants
|
||||
/// to opt back in.
|
||||
normalizeBlobs?: boolean;
|
||||
}
|
||||
|
||||
export async function uploadZipInChunks<T = unknown>(
|
||||
file: File,
|
||||
creatorPlayerId: number,
|
||||
onProgress: (p: UploadProgress) => void,
|
||||
timeoutMs = 30 * 60_000,
|
||||
options: ZipImportOptions = {},
|
||||
): Promise<T> {
|
||||
// 1. Init — server allocates a temp file and hands back an uploadId.
|
||||
const init = await api<{ uploadId: string }>('/rooms/zip-upload-init', {
|
||||
method: 'POST',
|
||||
body: { FileName: file.name, TotalBytes: file.size },
|
||||
});
|
||||
const uploadId = init.uploadId;
|
||||
|
||||
try {
|
||||
let uploadedSoFar = 0;
|
||||
for (let offset = 0; offset < file.size; offset += ZIP_CHUNK_BYTES) {
|
||||
const end = Math.min(offset + ZIP_CHUNK_BYTES, file.size);
|
||||
const chunk = file.slice(offset, end);
|
||||
const chunkStart = offset;
|
||||
const chunkSize = end - offset;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const token = getToken();
|
||||
xhr.open('POST', `/api/admin/v1/rooms/zip-upload-chunk/${uploadId}?offset=${chunkStart}`, true);
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
xhr.timeout = timeoutMs;
|
||||
xhr.responseType = 'text';
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
const loaded = uploadedSoFar + (e.lengthComputable ? e.loaded : 0);
|
||||
const percent = file.size > 0 ? Math.round((loaded / file.size) * 100) : 0;
|
||||
onProgress({ loaded, total: file.size, percent });
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 401) {
|
||||
clearSession(); notifyAuthChange();
|
||||
reject(new ApiError(401, 'Unauthorized — sign in again'));
|
||||
return;
|
||||
}
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
let msg = `${xhr.status} ${xhr.statusText}`;
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText) as { error?: string; message?: string };
|
||||
if (body?.error) msg = body.error;
|
||||
else if (body?.message) msg = body.message;
|
||||
} catch { /* keep status */ }
|
||||
reject(new ApiError(xhr.status, msg));
|
||||
return;
|
||||
}
|
||||
uploadedSoFar += chunkSize;
|
||||
onProgress({ loaded: uploadedSoFar, total: file.size, percent: Math.round((uploadedSoFar / file.size) * 100) });
|
||||
resolve();
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiError(0, `chunk @${chunkStart} network error`));
|
||||
xhr.ontimeout = () => reject(new ApiError(0, `chunk @${chunkStart} timed out`));
|
||||
xhr.send(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Finalize — server runs the existing import on the assembled file.
|
||||
return await api<T>(`/rooms/zip-upload-finalize/${uploadId}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
CreatorPlayerId: creatorPlayerId,
|
||||
SelectedRoomFolders: options.selectedRoomFolders ?? null,
|
||||
SelectedInventionFolders: options.selectedInventionFolders ?? null,
|
||||
NormalizeBlobs: options.normalizeBlobs ?? false,
|
||||
},
|
||||
timeoutMs,
|
||||
});
|
||||
} catch (err) {
|
||||
// Best-effort cleanup of the temp file on the server. The session
|
||||
// also auto-expires after 2 hours, so a missed abort isn't fatal.
|
||||
try { await api(`/rooms/zip-upload-abort/${uploadId}`, { method: 'DELETE' }); } catch { /* swallow */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Token + identity storage. The server's same-origin AdminLoginController
|
||||
// hands back an access_token plus a small identity blob; we stash both
|
||||
// in localStorage and re-hydrate on page load so a refresh doesn't kick
|
||||
// the admin back to the login screen.
|
||||
|
||||
const TOKEN_KEY = 'dorknet.admin.token';
|
||||
const ME_KEY = 'dorknet.admin.me';
|
||||
|
||||
export interface AdminMe {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
// getMe is read on every render by useSyncExternalStore. Re-parsing
|
||||
// localStorage each call would return a fresh object reference even
|
||||
// when the underlying data hasn't changed, which React detects as a
|
||||
// state change and re-renders forever (React error #185). Cache by
|
||||
// the raw string so consecutive identical reads return the same
|
||||
// AdminMe instance and the snapshot stays stable.
|
||||
let cachedMeRaw: string | null | undefined;
|
||||
let cachedMe: AdminMe | null = null;
|
||||
export function getMe(): AdminMe | null {
|
||||
const raw = localStorage.getItem(ME_KEY);
|
||||
if (raw === cachedMeRaw) return cachedMe;
|
||||
cachedMeRaw = raw;
|
||||
if (!raw) { cachedMe = null; return null; }
|
||||
try { cachedMe = JSON.parse(raw) as AdminMe; }
|
||||
catch { cachedMe = null; }
|
||||
return cachedMe;
|
||||
}
|
||||
|
||||
export function setSession(token: string, me: AdminMe): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(ME_KEY, JSON.stringify(me));
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(ME_KEY);
|
||||
}
|
||||
|
||||
// Simple subscription so components can react to login/logout without
|
||||
// pulling in a state lib. Anything that calls getToken() in render and
|
||||
// wants live updates can useSyncExternalStore against this.
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
export function subscribeAuth(fn: Listener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
export function notifyAuthChange(): void {
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Chromium gate. The zip importer uploads multi-hundred-megabyte
|
||||
// FormData bodies and uses File / Blob slicing patterns that are
|
||||
// effectively only stable on Chromium right now:
|
||||
// * Firefox's xhr.upload.progress + multipart streaming has buffer
|
||||
// issues over ~500 MB.
|
||||
// * Safari has a long-standing bug where the FormData clone for
|
||||
// large bodies hits a memory ceiling and aborts mid-upload.
|
||||
//
|
||||
// Easier to gate the importer than to chase per-browser workarounds.
|
||||
// Other admin pages don't have this constraint, so the warning only
|
||||
// shows on the import flow.
|
||||
|
||||
interface NavigatorWithUaData extends Navigator {
|
||||
userAgentData?: { brands: Array<{ brand: string; version: string }> };
|
||||
}
|
||||
|
||||
export function isChromium(): boolean {
|
||||
if (typeof navigator === 'undefined') return true; // SSR / tests
|
||||
// userAgentData is currently only implemented in Chromium-based
|
||||
// browsers — Firefox/Safari haven't shipped it. If it's present,
|
||||
// we're on Chromium.
|
||||
if ((navigator as NavigatorWithUaData).userAgentData) return true;
|
||||
// Fallback UA sniff for Chromium-derived browsers that haven't
|
||||
// exposed userAgentData yet (e.g. older Edge/Brave builds).
|
||||
const ua = navigator.userAgent;
|
||||
return /Chrome\/|Chromium\/|Edg\//.test(ua) && !/Firefox\/|FxiOS\//.test(ua);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Display helpers used across pages. Kept here so we render dates,
|
||||
// durations, and numeric quantities the same way everywhere — a player
|
||||
// table and a player detail page should not disagree about how to
|
||||
// format "5 days ago".
|
||||
|
||||
export function relativeTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const t = new Date(iso).getTime();
|
||||
if (!Number.isFinite(t)) return '—';
|
||||
const diffSec = Math.round((Date.now() - t) / 1000);
|
||||
const abs = Math.abs(diffSec);
|
||||
if (abs < 45) return diffSec >= 0 ? 'just now' : 'in a moment';
|
||||
const units: Array<[number, string]> = [
|
||||
[60, 'sec'],
|
||||
[60, 'min'],
|
||||
[24, 'hr'],
|
||||
[7, 'day'],
|
||||
[4.345, 'wk'],
|
||||
[12, 'mo'],
|
||||
[Infinity, 'yr'],
|
||||
];
|
||||
let v = abs;
|
||||
let label = 'sec';
|
||||
for (let i = 0; i < units.length; i++) {
|
||||
const [step, name] = units[i];
|
||||
if (v < step) { label = name; break; }
|
||||
v = v / step;
|
||||
label = units[i + 1]?.[1] ?? 'yr';
|
||||
}
|
||||
const rounded = v >= 10 ? Math.round(v) : Math.round(v * 10) / 10;
|
||||
return diffSec >= 0 ? `${rounded} ${label} ago` : `in ${rounded} ${label}`;
|
||||
}
|
||||
|
||||
export function absoluteTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (!Number.isFinite(d.getTime())) return '—';
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export function num(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined || !Number.isFinite(n)) return '—';
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function clip(s: string | null | undefined, max = 80): string {
|
||||
if (!s) return '';
|
||||
return s.length <= max ? s : s.slice(0, max - 1) + '…';
|
||||
}
|
||||
|
||||
// Currency type IDs follow the canonical Rec Room enum:
|
||||
// 1 = Tokens, 2 = Coins (RecRoom currency).
|
||||
export function currencyName(type: number): string {
|
||||
switch (type) {
|
||||
case 1: return 'Tokens';
|
||||
case 2: return 'Coins';
|
||||
default: return `Currency #${type}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Wire shapes returned by AdminController. Mirrors the anonymous-object
|
||||
// projections in DorkNet.Server/Controllers/Admin/AdminController.cs.
|
||||
// Loosely typed where the server returns dynamic structures; tightened
|
||||
// to specific fields where we render columns.
|
||||
|
||||
export interface Player {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
isDeveloper: boolean;
|
||||
isCommunityTeam: boolean;
|
||||
isVerified: boolean;
|
||||
isJunior: boolean;
|
||||
bannedUntil: string | null;
|
||||
lastIp: string | null;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
level: number;
|
||||
xp: number;
|
||||
profileImageName: string | null;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export interface PlayerDetail extends Player {
|
||||
bio: string | null;
|
||||
balances: Array<{ currencyType: number; balance: number }>;
|
||||
avatar: {
|
||||
outfitSelections: string | null;
|
||||
hairColor: string | null;
|
||||
skinColor: string | null;
|
||||
faceFeatures: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// img.* CDN URL for a player's stored profile image — same path the
|
||||
// 2020 watch uses (cropSquare=1 produces a face-zoomed square, sig
|
||||
// matches the host's signing key id).
|
||||
export function profileImageUrl(name: string | null | undefined, width = 96): string | null {
|
||||
if (!name) return null;
|
||||
const apex = typeof window !== 'undefined' && window.location.host.endsWith('localhost') ? 'localhost' : 'rec.net';
|
||||
return `https://img.${apex}/${encodeURIComponent(name)}?width=${width}&cropSquare=1&sig=p1`;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: number;
|
||||
name: string;
|
||||
isAGRoom: boolean;
|
||||
isDormRoom: boolean;
|
||||
creatorPlayerId: number;
|
||||
blobCount: number;
|
||||
}
|
||||
|
||||
// RoomRole.Role enum (server-side RoomRoleEntity.Role):
|
||||
// 0 = CoOwner, 1 = Moderator, 2 = Host.
|
||||
// Accepted=true ⇒ surfaces in CoOwners / Moderators / Hosts arrays.
|
||||
// Accepted=false ⇒ surfaces in the Invited* counterparts (pending grant).
|
||||
export interface RoomRoleGrant {
|
||||
id: number;
|
||||
playerId: number;
|
||||
role: 0 | 1 | 2;
|
||||
accepted: boolean;
|
||||
grantedByPlayerId: number | null;
|
||||
grantedAt: string;
|
||||
player: {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
profileImageName: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
// Per-room live Photon instance — GET /api/admin/v1/rooms/{id}/instances.
|
||||
// masterPlayerId is a best-effort proxy for the Photon master client
|
||||
// (server doesn't see ActorNumbers; we use dorm-creator-if-present
|
||||
// else lowest-pid). The matching participant has isMaster=true so the
|
||||
// UI can tag a row without recomputing.
|
||||
export interface RoomInstance {
|
||||
roomInstanceId: number;
|
||||
roomId: number;
|
||||
subRoomId: number;
|
||||
roomName: string;
|
||||
photonRoomId: string;
|
||||
photonRegionId: string;
|
||||
location: string;
|
||||
maxCapacity: number;
|
||||
isPrivate: boolean;
|
||||
masterPlayerId: number;
|
||||
participants: Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
isMaster: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Full per-room detail returned by GET /api/admin/v1/rooms/{id}. The
|
||||
// admin SPA's per-room detail page consumes this in one round trip.
|
||||
export interface RoomDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
imageName: string;
|
||||
state: number;
|
||||
accessibility: number;
|
||||
isAGRoom: boolean;
|
||||
isDormRoom: boolean;
|
||||
cloningAllowed: boolean;
|
||||
supportsLevelVoting: boolean;
|
||||
supportsVRLow: boolean;
|
||||
supportsMobile: boolean;
|
||||
supportsScreens: boolean;
|
||||
supportsWalkVR: boolean;
|
||||
supportsTeleportVR: boolean;
|
||||
allowsJuniors: boolean;
|
||||
disableMicAutoMute: boolean;
|
||||
roomWarningMask: number;
|
||||
customRoomWarning: string;
|
||||
tagsCsv: string;
|
||||
cheerCount: number;
|
||||
favoriteCount: number;
|
||||
visitCount: number;
|
||||
visitorCount: number;
|
||||
hotScore: number;
|
||||
locationReplicationId: string;
|
||||
currentDataBlobName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
sceneCount: number;
|
||||
blobCount: number;
|
||||
owner: {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
profileImageName: string | null;
|
||||
};
|
||||
roles: RoomRoleGrant[];
|
||||
}
|
||||
|
||||
export interface IpBan {
|
||||
id: number;
|
||||
cidr: string;
|
||||
reason: string;
|
||||
bannedByAdminId: number;
|
||||
bannedAt: string;
|
||||
until: string | null;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
adminPlayerId: number;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: number;
|
||||
reason: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
id: number;
|
||||
reporterPlayerId: number;
|
||||
targetPlayerId: number;
|
||||
reason: string;
|
||||
context: string;
|
||||
createdAt: string;
|
||||
resolvedAt: string | null;
|
||||
resolverAdminId: number | null;
|
||||
resolutionNote: string | null;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
players: { total: number; onlineNow: number; bannedNow: number };
|
||||
rooms: {
|
||||
total: number;
|
||||
topByVisits: Array<{ id: number; name: string; visitCount: number; visitorCount: number; cheerCount: number }>;
|
||||
};
|
||||
inventions: number;
|
||||
moderation: { openReports: number; activeIpBans: number };
|
||||
recentJoins: Array<{ id: number; username: string; createdAt: string }>;
|
||||
serverTime: string;
|
||||
}
|
||||
|
||||
export interface StoreItem {
|
||||
id: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
imageName: string;
|
||||
currencyType: number;
|
||||
price: number;
|
||||
isActive: boolean;
|
||||
isLimitedTime: boolean;
|
||||
availableUntil: string | null;
|
||||
storefront: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface StorefrontDefinition {
|
||||
key: string;
|
||||
storefrontType: number | null;
|
||||
displayName: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface Instance {
|
||||
roomInstanceId: number;
|
||||
roomId: number;
|
||||
subRoomId: number;
|
||||
roomName: string;
|
||||
photonRoomId: string;
|
||||
photonRegionId: string;
|
||||
location: string;
|
||||
maxCapacity: number;
|
||||
isPrivate: boolean;
|
||||
participants: Array<{ id: number; username: string; displayName: string }>;
|
||||
}
|
||||
|
||||
export interface PlayerLogEntry {
|
||||
timestamp: string;
|
||||
method: string;
|
||||
host: string;
|
||||
path: string;
|
||||
query: string;
|
||||
status: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
// Mirrors DorkNet.Server.Services.CommunityBoardState (and the
|
||||
// nested DTOs in CommunityBoardService.cs). The 2020 watch decodes
|
||||
// the same shape from /api/communityboard/v1/current — admins
|
||||
// edit it here via /api/admin/v1/communityboard.
|
||||
export interface CommunityBoardState {
|
||||
currentAnnouncement: { message: string; moreInfoUrl: string } | null;
|
||||
featuredPlayer: { id: number; titleOverride: string; urlOverride: string } | null;
|
||||
featuredRoomGroup: { name: string; featuredRooms: number[] } | null;
|
||||
instagramImages: Array<{ imageName: string; imageUrl: string }>;
|
||||
videos: Array<{ blobName: string; title: string; description: string; thumbnailBlobName: string; sourceUrl: string }>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, ApiError, type ApiOptions } from './api';
|
||||
|
||||
interface State<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Read-side hook for "fetch on mount, expose refresh + error state".
|
||||
// Not a replacement for TanStack Query — we don't need its cache /
|
||||
// dedupe / focus-refetch behavior for an admin tool used by 1-2 people.
|
||||
// But every page does the same useState/useEffect/abort dance, so this
|
||||
// collapses it to one line.
|
||||
export function useApi<T = unknown>(path: string, opts: ApiOptions & { manual?: boolean } = {}) {
|
||||
const [state, setState] = useState<State<T>>({ data: null, loading: !opts.manual, error: null });
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const { manual, ...rest } = opts;
|
||||
// Stabilise opts across renders by capturing in a ref the caller can ignore.
|
||||
// Anything that varies between renders (e.g. inline objects) is fine because
|
||||
// we re-run on `path` change only.
|
||||
const optsRef = useRef(rest);
|
||||
optsRef.current = rest;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setState(s => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const data = await api<T>(path, { ...optsRef.current, signal: ctrl.signal });
|
||||
if (!ctrl.signal.aborted) setState({ data, loading: false, error: null });
|
||||
} catch (e) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const msg = e instanceof ApiError ? e.message : (e as Error).message;
|
||||
setState({ data: null, loading: false, error: msg });
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manual) return;
|
||||
refresh();
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, [refresh, manual]);
|
||||
|
||||
return { ...state, refresh };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename="/">
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import type { AuditEntry } from '../lib/types';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { Empty } from '../components/Empty';
|
||||
import { absoluteTime, clip, relativeTime } from '../lib/format';
|
||||
import { RefreshCw } from '../components/Icons';
|
||||
|
||||
export function Audit() {
|
||||
const [take, setTake] = useState(200);
|
||||
const { data, loading, error, refresh } = useApi<AuditEntry[]>(`/audit?take=${take}`);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Audit log"
|
||||
blurb="Every admin action with actor, target, and reason. Newest first."
|
||||
actions={<>
|
||||
<select value={take} onChange={e => setTake(parseInt(e.target.value))} className="input w-24 text-xs !py-1.5">
|
||||
<option value={100}>last 100</option>
|
||||
<option value={200}>last 200</option>
|
||||
<option value={500}>last 500</option>
|
||||
</select>
|
||||
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
</>}
|
||||
/>
|
||||
|
||||
{error && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{error}</div>}
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{data && data.length === 0 && <Empty title="No audit entries yet" />}
|
||||
{data && data.length > 0 && (
|
||||
<div className="table-scroll"><table className="w-full text-sm min-w-[640px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-2.5">When</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Admin</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Action</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Target</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{data.map(e => (
|
||||
<tr key={e.id} className="table-row-hover">
|
||||
<td className="px-4 py-2 text-ink-300 text-xs whitespace-nowrap" title={absoluteTime(e.timestamp)}>
|
||||
{relativeTime(e.timestamp)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-ink-200">#{e.adminPlayerId}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="font-mono text-xs text-brand-200 bg-brand-500/10 px-1.5 py-0.5 rounded">{e.action}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-ink-200">
|
||||
<span className="text-ink-400">{e.targetType}</span>
|
||||
{e.targetId > 0 && <> · #{e.targetId}</>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-ink-300 text-xs" title={e.reason}>{clip(e.reason, 100)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import type { IpBan, Player } from '../lib/types';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { Empty } from '../components/Empty';
|
||||
import { absoluteTime, relativeTime } from '../lib/format';
|
||||
import { RefreshCw, Trash } from '../components/Icons';
|
||||
|
||||
export function Bans() {
|
||||
const [tab, setTab] = useState<'players' | 'ip'>('players');
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Bans"
|
||||
blurb="Player bans and IP-level bans, all in one place."
|
||||
/>
|
||||
<div className="flex border-b border-ink-800 text-sm mb-4">
|
||||
{(['players', 'ip'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-4 py-2 -mb-px border-b-2 ${tab === t ? 'border-brand-500 text-ink-50' : 'border-transparent text-ink-300 hover:text-ink-100'}`}
|
||||
>
|
||||
{t === 'players' ? 'Player bans' : 'IP bans'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'players' && <PlayerBans />}
|
||||
{tab === 'ip' && <IpBans />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerBans() {
|
||||
// Pull a generous page of players and filter client-side for currently-banned.
|
||||
// For a small private server this is fine; if we ever scale we can add a
|
||||
// dedicated /players/banned endpoint.
|
||||
const { data, loading, error, refresh } = useApi<Player[]>('/players?take=200');
|
||||
const now = Date.now();
|
||||
const banned = (data ?? []).filter(p => p.bannedUntil && new Date(p.bannedUntil).getTime() > now);
|
||||
const toast = useToast();
|
||||
|
||||
const unban = async (id: number) => {
|
||||
try {
|
||||
await api(`/players/${id}/unban`, { method: 'POST', body: { Reason: 'admin-unban' } });
|
||||
toast.push('Unbanned', 'success');
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-ink-800 px-4 py-2.5">
|
||||
<div className="text-xs text-ink-400">{banned.length} active player ban{banned.length === 1 ? '' : 's'}</div>
|
||||
<button onClick={refresh} className="btn-ghost text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
|
||||
{!error && banned.length === 0 && <Empty title="No active player bans" blurb="Issued bans appear here until they expire or are lifted." />}
|
||||
{banned.length > 0 && (
|
||||
<div className="table-scroll"><table className="w-full text-sm min-w-[640px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-2.5">Player</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Expires</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Last IP</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{banned.map(p => (
|
||||
<tr key={p.id} className="table-row-hover">
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="font-medium text-ink-50">{p.displayName || p.username}</div>
|
||||
<div className="text-xs text-ink-400">@{p.username} · #{p.id}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-200 text-xs" title={absoluteTime(p.bannedUntil)}>
|
||||
{relativeTime(p.bannedUntil)} <span className="text-ink-500">({absoluteTime(p.bannedUntil)})</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-300 font-mono text-xs">{p.lastIp ?? '—'}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<button onClick={() => unban(p.id)} className="btn-secondary text-xs">Unban</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IpBans() {
|
||||
const { data, loading, error, refresh } = useApi<IpBan[]>('/ipbans');
|
||||
const toast = useToast();
|
||||
const [cidr, setCidr] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [days, setDays] = useState<number | ''>('');
|
||||
|
||||
const add = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!cidr.trim()) return;
|
||||
try {
|
||||
await api('/ipbans', { method: 'POST', body: { Cidr: cidr.trim(), Reason: reason, DurationDays: days || null } });
|
||||
setCidr(''); setReason(''); setDays('');
|
||||
toast.push('IP ban added', 'success');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.push((err as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
if (!confirm('Remove this IP ban?')) return;
|
||||
try {
|
||||
await api(`/ipbans/${id}`, { method: 'DELETE' });
|
||||
toast.push('IP ban removed', 'success');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.push((err as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const active = (b: IpBan) => !b.until || new Date(b.until).getTime() > Date.now();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={add} className="card !p-4 grid grid-cols-1 md:grid-cols-[1fr,1fr,140px,auto] gap-2">
|
||||
<input value={cidr} onChange={e => setCidr(e.target.value)} placeholder="1.2.3.4 or 10.0.0.0/24" required className="input" />
|
||||
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason (logged)" className="input" />
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={days}
|
||||
onChange={e => setDays(e.target.value === '' ? '' : parseInt(e.target.value))}
|
||||
placeholder="days (∞)"
|
||||
className="input"
|
||||
/>
|
||||
<button className="btn-primary text-xs">Add ban</button>
|
||||
</form>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-ink-800 px-4 py-2.5">
|
||||
<div className="text-xs text-ink-400">{data ? `${data.length} total · ${data.filter(active).length} active` : ''}</div>
|
||||
<button onClick={refresh} className="btn-ghost text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
|
||||
{data && data.length === 0 && <Empty title="No IP bans" blurb="Add a CIDR above to start blocking ranges." />}
|
||||
{data && data.length > 0 && (
|
||||
<div className="table-scroll"><table className="w-full text-sm min-w-[640px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-2.5">CIDR</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Reason</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Issued</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Expires</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{data.map(b => (
|
||||
<tr key={b.id} className="table-row-hover">
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-ink-100">{b.cidr}</td>
|
||||
<td className="px-4 py-2.5 text-ink-300">{b.reason || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-ink-300 text-xs" title={absoluteTime(b.bannedAt)}>{relativeTime(b.bannedAt)}</td>
|
||||
<td className="px-4 py-2.5 text-ink-300 text-xs">
|
||||
{b.until ? <span title={absoluteTime(b.until)}>{relativeTime(b.until)}</span> : <span className="text-ink-500">∞</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<button onClick={() => remove(b.id)} className="btn-ghost text-xs text-danger">
|
||||
<Trash /> Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { useToast } from '../components/Toast';
|
||||
|
||||
export function Broadcast() {
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const send = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!message.trim()) return;
|
||||
if (!confirm(`Broadcast this to every connected player?\n\n"${message}"`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api('/broadcast', { method: 'POST', body: { Message: message } });
|
||||
toast.push('Broadcast sent', 'success');
|
||||
setMessage('');
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Broadcast"
|
||||
blurb="Push a server-maintenance notification to every connected player. The watch shows it as a system toast."
|
||||
/>
|
||||
|
||||
<form onSubmit={send} className="card !p-5 max-w-2xl">
|
||||
<label className="label">Message</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
rows={5}
|
||||
className="input mt-1.5 font-mono text-sm"
|
||||
placeholder="Server going down for maintenance in 5 minutes — log out cleanly to avoid losing your dorm save."
|
||||
required
|
||||
/>
|
||||
<div className="mt-2 text-xs text-ink-400">
|
||||
{message.length} characters
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setMessage('')} className="btn-ghost text-xs" disabled={busy || !message}>Clear</button>
|
||||
<button className="btn-primary text-xs" disabled={busy || !message.trim()}>
|
||||
{busy ? 'Sending…' : 'Send broadcast'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api, get } from '../lib/api';
|
||||
import type { CommunityBoardState, Player, Room } from '../lib/types';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { Plus, Trash } from '../components/Icons';
|
||||
|
||||
export function CommunityBoard() {
|
||||
const { data: roomList } = useApi<Room[]>('/rooms');
|
||||
const { data: players } = useApi<Player[]>('/players?take=500');
|
||||
const [state, setState] = useState<CommunityBoardState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
get<CommunityBoardState>('/communityboard').then(setState).catch(e => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
if (err) return <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>;
|
||||
if (!state) return <div className="py-10 text-center text-xs text-ink-400">Loading…</div>;
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
// Server-side `CommunityBoardService.UpdateAsync` replaces every
|
||||
// field — null nested objects clear that section. We send the
|
||||
// whole state on every save, no PATCH semantics.
|
||||
const saved = await api<CommunityBoardState>('/communityboard', { method: 'POST', body: state });
|
||||
setState(saved);
|
||||
toast.push('Community board saved', 'success');
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Community board"
|
||||
blurb="The dorm community panel the watch fetches from /api/communityboard/v1/current. Edits go live instantly."
|
||||
actions={<button onClick={save} disabled={busy} className="btn-primary text-xs">{busy ? 'Saving…' : 'Save all'}</button>}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Announcement state={state} setState={setState} />
|
||||
<FeaturedPlayer state={state} setState={setState} players={players ?? []} />
|
||||
<FeaturedRoomGroup state={state} setState={setState} rooms={roomList ?? []} />
|
||||
<InstagramImages state={state} setState={setState} />
|
||||
<Videos state={state} setState={setState} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section panels ───────────────────────────────────────────────────
|
||||
|
||||
interface SectionProps {
|
||||
state: CommunityBoardState;
|
||||
setState: (s: CommunityBoardState) => void;
|
||||
}
|
||||
|
||||
function Announcement({ state, setState }: SectionProps) {
|
||||
const a = state.currentAnnouncement;
|
||||
return (
|
||||
<Panel title="Announcement" blurb="Top-of-board banner. Clear to hide.">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-200 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!a}
|
||||
onChange={e => setState({ ...state, currentAnnouncement: e.target.checked ? { message: '', moreInfoUrl: '' } : null })}
|
||||
className="size-4 accent-brand-500"
|
||||
/>
|
||||
Show announcement
|
||||
</label>
|
||||
{a && (
|
||||
<div className="space-y-2">
|
||||
<Field label="Message">
|
||||
<textarea
|
||||
value={a.message}
|
||||
onChange={e => setState({ ...state, currentAnnouncement: { ...a, message: e.target.value } })}
|
||||
rows={2}
|
||||
className="input"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="More-info URL (optional)">
|
||||
<input
|
||||
value={a.moreInfoUrl}
|
||||
onChange={e => setState({ ...state, currentAnnouncement: { ...a, moreInfoUrl: e.target.value } })}
|
||||
className="input"
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedPlayer({ state, setState, players }: SectionProps & { players: Player[] }) {
|
||||
const fp = state.featuredPlayer;
|
||||
return (
|
||||
<Panel title="Featured player" blurb="One spotlit account on the board.">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-200 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!fp}
|
||||
onChange={e => setState({ ...state, featuredPlayer: e.target.checked ? { id: 0, titleOverride: '', urlOverride: '' } : null })}
|
||||
className="size-4 accent-brand-500"
|
||||
/>
|
||||
Feature a player
|
||||
</label>
|
||||
{fp && (
|
||||
<div className="space-y-2">
|
||||
<Field label="Player">
|
||||
<select
|
||||
value={fp.id || ''}
|
||||
onChange={e => setState({ ...state, featuredPlayer: { ...fp, id: parseInt(e.target.value || '0') } })}
|
||||
className="input"
|
||||
>
|
||||
<option value="">— pick a player —</option>
|
||||
{players.map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Title override (optional)">
|
||||
<input
|
||||
value={fp.titleOverride}
|
||||
onChange={e => setState({ ...state, featuredPlayer: { ...fp, titleOverride: e.target.value } })}
|
||||
className="input"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="URL override (optional)">
|
||||
<input
|
||||
value={fp.urlOverride}
|
||||
onChange={e => setState({ ...state, featuredPlayer: { ...fp, urlOverride: e.target.value } })}
|
||||
className="input"
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedRoomGroup({ state, setState, rooms }: SectionProps & { rooms: Room[] }) {
|
||||
const g = state.featuredRoomGroup;
|
||||
return (
|
||||
<Panel title="Featured rooms" blurb="Card carousel labeled with the group name.">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-200 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!g}
|
||||
onChange={e => setState({ ...state, featuredRoomGroup: e.target.checked ? { name: '', featuredRooms: [] } : null })}
|
||||
className="size-4 accent-brand-500"
|
||||
/>
|
||||
Feature a room group
|
||||
</label>
|
||||
{g && (
|
||||
<div className="space-y-2">
|
||||
<Field label="Group name">
|
||||
<input value={g.name} onChange={e => setState({ ...state, featuredRoomGroup: { ...g, name: e.target.value } })} className="input" placeholder="Staff picks" />
|
||||
</Field>
|
||||
<div>
|
||||
<div className="label mb-1.5">Rooms ({g.featuredRooms.length})</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{g.featuredRooms.map((id, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<select
|
||||
value={id || ''}
|
||||
onChange={e => {
|
||||
const next = [...g.featuredRooms];
|
||||
next[i] = parseInt(e.target.value || '0');
|
||||
setState({ ...state, featuredRoomGroup: { ...g, featuredRooms: next } });
|
||||
}}
|
||||
className="input flex-1"
|
||||
>
|
||||
<option value="">— pick a room —</option>
|
||||
{rooms.map(r => <option key={r.id} value={r.id}>{r.name} · #{r.id}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setState({ ...state, featuredRoomGroup: { ...g, featuredRooms: g.featuredRooms.filter((_, j) => j !== i) } })}
|
||||
className="btn-ghost text-xs text-danger"
|
||||
>
|
||||
<Trash />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setState({ ...state, featuredRoomGroup: { ...g, featuredRooms: [...g.featuredRooms, 0] } })}
|
||||
className="btn-secondary text-xs self-start"
|
||||
>
|
||||
<Plus /> Add room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function InstagramImages({ state, setState }: SectionProps) {
|
||||
return (
|
||||
<Panel title="Instagram images" blurb="Pick an image to upload — the file gets stored as a RoomDataBlob and served via img.localhost. The optional URL field is the click-through target on the board.">
|
||||
<div className="flex flex-col gap-2">
|
||||
{state.instagramImages.map((img, i) => (
|
||||
<InstagramImageRow
|
||||
key={i}
|
||||
img={img}
|
||||
onChange={next => {
|
||||
const arr = [...state.instagramImages];
|
||||
arr[i] = next;
|
||||
setState({ ...state, instagramImages: arr });
|
||||
}}
|
||||
onRemove={() => setState({ ...state, instagramImages: state.instagramImages.filter((_, j) => j !== i) })}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setState({ ...state, instagramImages: [...state.instagramImages, { imageName: '', imageUrl: '' }] })}
|
||||
className="btn-secondary text-xs self-start"
|
||||
>
|
||||
<Plus /> Add image
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function InstagramImageRow({
|
||||
img,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
img: { imageName: string; imageUrl: string };
|
||||
onChange: (next: { imageName: string; imageUrl: string }) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.push('Please pick an image file', 'error');
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await api<{ imageName: string; imageUrl: string; bytes: number; reused: boolean }>(
|
||||
'/communityboard/instagram/upload',
|
||||
{ method: 'POST', formData: fd, timeoutMs: 60_000 },
|
||||
);
|
||||
onChange({ imageName: res.imageName, imageUrl: img.imageUrl });
|
||||
toast.push(res.reused ? 'Image already on file — reused' : `Uploaded (${(res.bytes / 1024).toFixed(0)} KB)`, 'success');
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInput.current) fileInput.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const apex = typeof window !== 'undefined' && window.location.host.endsWith('localhost') ? 'localhost' : 'rec.net';
|
||||
const preview = img.imageName ? `https://img.${apex}/${encodeURIComponent(img.imageName)}?width=128&sig=p1` : null;
|
||||
|
||||
return (
|
||||
<div className="rounded border border-ink-800 p-2 flex gap-2 items-start">
|
||||
<div
|
||||
className="shrink-0 size-16 rounded bg-ink-900 border border-ink-800 overflow-hidden flex items-center justify-center cursor-pointer hover:border-brand-500"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
title="Click to upload"
|
||||
>
|
||||
{preview ? (
|
||||
<img src={preview} className="size-full object-cover" alt="" />
|
||||
) : (
|
||||
<span className="text-[10px] text-ink-500 px-1 text-center">{uploading ? 'Uploading…' : 'Pick image'}</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={uploading}
|
||||
className="btn-secondary text-xs"
|
||||
>
|
||||
{uploading ? 'Uploading…' : preview ? 'Replace' : 'Upload'}
|
||||
</button>
|
||||
<span className="font-mono text-[11px] text-ink-400 truncate flex-1" title={img.imageName}>
|
||||
{img.imageName || <span className="italic text-ink-600">no image yet</span>}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
value={img.imageUrl}
|
||||
onChange={e => onChange({ ...img, imageUrl: e.target.value })}
|
||||
placeholder="https://instagram.com/p/… (click-through URL)"
|
||||
className="input text-xs"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="btn-ghost text-xs text-danger self-start"
|
||||
>
|
||||
<Trash />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Videos({ state, setState }: SectionProps) {
|
||||
return (
|
||||
<Panel
|
||||
title="Videos"
|
||||
blurb="Upload an .mp4/.webm/.mov (≤100 MB) plus a thumbnail image — the files land in RoomDataBlobs and are served from cdn / img. Or skip the upload and paste a SourceUrl pointing at YouTube / etc. for an externally-hosted video."
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{state.videos.map((v, i) => (
|
||||
<VideoRow
|
||||
key={i}
|
||||
video={v}
|
||||
onChange={next => {
|
||||
const arr = [...state.videos];
|
||||
arr[i] = next;
|
||||
setState({ ...state, videos: arr });
|
||||
}}
|
||||
onRemove={() => setState({ ...state, videos: state.videos.filter((_, j) => j !== i) })}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setState({ ...state, videos: [...state.videos, { blobName: '', title: '', description: '', thumbnailBlobName: '', sourceUrl: '' }] })}
|
||||
className="btn-secondary text-xs self-start"
|
||||
>
|
||||
<Plus /> Add video
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// Capture a still frame from a video File and return it as a JPEG Blob.
|
||||
// Strategy: spin up an off-DOM <video> element, point it at a blob URL
|
||||
// so the bytes never leave the page, wait for the first decoded frame
|
||||
// (`loadeddata`), nudge currentTime so most encoders give us a real
|
||||
// I-frame (some MP4s render black at t=0 because the first sample is
|
||||
// only a P-frame referencing nothing), draw to a canvas, then export.
|
||||
//
|
||||
// Width is capped at 1280 so we don't end up with a 30-MB poster image
|
||||
// from a 4K source — the watch renders thumbnails at ~256 px anyway.
|
||||
// Returns null if the browser refuses to decode (Chrome on Linux occas-
|
||||
// ionally chokes on H.265/HEVC; H.264 + VP9 cover the rest).
|
||||
async function captureFirstFrame(videoFile: File): Promise<Blob | null> {
|
||||
const url = URL.createObjectURL(videoFile);
|
||||
try {
|
||||
const video = document.createElement('video');
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.preload = 'auto';
|
||||
video.src = url;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onReady = () => { cleanup(); resolve(); };
|
||||
const onError = () => { cleanup(); reject(new Error('video decode failed')); };
|
||||
const cleanup = () => {
|
||||
video.removeEventListener('loadeddata', onReady);
|
||||
video.removeEventListener('error', onError);
|
||||
};
|
||||
video.addEventListener('loadeddata', onReady);
|
||||
video.addEventListener('error', onError);
|
||||
});
|
||||
// Seek a tenth of a second in — t=0 frames are often black with
|
||||
// certain encoders. Wait for `seeked` so we draw real bytes.
|
||||
if (video.duration && Number.isFinite(video.duration) && video.duration > 0.2) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const onSeeked = () => { video.removeEventListener('seeked', onSeeked); resolve(); };
|
||||
video.addEventListener('seeked', onSeeked);
|
||||
video.currentTime = Math.min(0.1, video.duration / 2);
|
||||
});
|
||||
}
|
||||
const maxWidth = 1280;
|
||||
const ratio = Math.min(1, maxWidth / video.videoWidth);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(2, Math.round(video.videoWidth * ratio));
|
||||
canvas.height = Math.max(2, Math.round(video.videoHeight * ratio));
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
return await new Promise<Blob | null>(resolve =>
|
||||
canvas.toBlob(b => resolve(b), 'image/jpeg', 0.88));
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Single editable row for a community-board video. Mirrors
|
||||
// InstagramImageRow's click-to-upload pattern with two pickers (the
|
||||
// video itself + a thumbnail). The video upload caps at 100 MB to dodge
|
||||
// Cloudflare's edge limit on non-chunked POSTs — anything larger should
|
||||
// be hosted externally and pasted into SourceUrl.
|
||||
function VideoRow({
|
||||
video, onChange, onRemove,
|
||||
}: {
|
||||
video: { blobName: string; title: string; description: string; thumbnailBlobName: string; sourceUrl: string };
|
||||
onChange: (next: typeof video) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const videoInput = useRef<HTMLInputElement>(null);
|
||||
const thumbInput = useRef<HTMLInputElement>(null);
|
||||
const [uploadingVideo, setUploadingVideo] = useState(false);
|
||||
const [uploadingThumb, setUploadingThumb] = useState(false);
|
||||
// Auto-grab the first frame of the uploaded video and use it as the
|
||||
// thumbnail. On by default — admins almost always want this and can
|
||||
// still upload a custom thumbnail afterwards (the custom one wins
|
||||
// because it overwrites thumbnailBlobName).
|
||||
const [autoThumb, setAutoThumb] = useState(true);
|
||||
|
||||
const apex = typeof window !== 'undefined' && window.location.host.endsWith('localhost') ? 'localhost' : 'rec.net';
|
||||
const thumbPreview = video.thumbnailBlobName ? `https://img.${apex}/${encodeURIComponent(video.thumbnailBlobName)}?width=160&sig=p1` : null;
|
||||
// The watch fetches videos under cdn.{apex}/video/<BlobName>
|
||||
// (RecNet/CommunityBoard.txt:1068 — `String.Concat("/video/", BlobName)`).
|
||||
// Match that path here so the inline admin preview hits the same
|
||||
// route the watch does; previously we built a bare `cdn.{apex}/<name>`
|
||||
// URL which 404'd because CdnController's catch-all regex didn't
|
||||
// include video extensions.
|
||||
const videoUrl = video.blobName ? `https://cdn.${apex}/video/${encodeURIComponent(video.blobName)}` : null;
|
||||
|
||||
// Video file picker → POST /communityboard/video/upload (timeout
|
||||
// generous: 100 MB at 5 MB/s is 20s of body time alone, plus S3
|
||||
// PutObject latency on cold cache). If the auto-thumbnail toggle is
|
||||
// on we extract the first frame BEFORE the upload kicks off — quick
|
||||
// (sub-second for most files) and a failure there doesn't block the
|
||||
// video upload itself; the admin can always pick a thumbnail by hand.
|
||||
const handleVideoFile = async (file: File) => {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('video/') && !/\.(mp4|webm|mov|m4v)$/i.test(file.name)) {
|
||||
toast.push('Pick a video file (.mp4 / .webm / .mov / .m4v)', 'error');
|
||||
return;
|
||||
}
|
||||
if (file.size > 100_000_000) {
|
||||
toast.push('Max 100 MB — host larger files externally and use the SourceUrl field', 'error');
|
||||
return;
|
||||
}
|
||||
setUploadingVideo(true);
|
||||
try {
|
||||
// Snapshot the first frame BEFORE we lose the local File reference.
|
||||
// Store the resulting Blob — we'll upload it as a thumbnail after
|
||||
// the video upload succeeds. Captured up front so a single video
|
||||
// upload doesn't hold the spinner open while we extract a frame
|
||||
// sequentially.
|
||||
let framePromise: Promise<Blob | null> | null = null;
|
||||
if (autoThumb) framePromise = captureFirstFrame(file).catch(() => null);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await api<{ blobName: string; videoUrl: string; bytes: number; reused: boolean }>(
|
||||
'/communityboard/video/upload',
|
||||
{ method: 'POST', formData: fd, timeoutMs: 300_000 },
|
||||
);
|
||||
let next = { ...video, blobName: res.blobName };
|
||||
toast.push(res.reused ? 'Video already on file — reused' : `Uploaded video (${(res.bytes / 1024 / 1024).toFixed(1)} MB)`, 'success');
|
||||
|
||||
// Upload the captured frame as the thumbnail. Skipped silently if
|
||||
// the canvas grab failed (e.g. CORS-tainted decode on some
|
||||
// browsers) so the video itself isn't blocked on an optional
|
||||
// niceity.
|
||||
if (framePromise) {
|
||||
const frame = await framePromise;
|
||||
if (frame) {
|
||||
setUploadingThumb(true);
|
||||
try {
|
||||
const tfd = new FormData();
|
||||
tfd.append('file', new File([frame], `${res.blobName.replace(/\.[a-z0-9]+$/i, '')}.jpg`, { type: 'image/jpeg' }));
|
||||
const thumbRes = await api<{ blobName: string; imageUrl: string; bytes: number; reused: boolean }>(
|
||||
'/communityboard/videothumb/upload',
|
||||
{ method: 'POST', formData: tfd, timeoutMs: 60_000 },
|
||||
);
|
||||
next = { ...next, thumbnailBlobName: thumbRes.blobName };
|
||||
toast.push(thumbRes.reused ? 'Frame thumbnail reused' : 'Captured first-frame thumbnail', 'success');
|
||||
} catch (e) {
|
||||
toast.push(`Auto-thumbnail failed: ${(e as Error).message}`, 'error');
|
||||
} finally {
|
||||
setUploadingThumb(false);
|
||||
}
|
||||
} else {
|
||||
toast.push('Could not decode a frame for the thumbnail — pick one manually', 'error');
|
||||
}
|
||||
}
|
||||
onChange(next);
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setUploadingVideo(false);
|
||||
if (videoInput.current) videoInput.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Thumbnail file picker → POST /communityboard/videothumb/upload.
|
||||
// Separate endpoint from videos so admins don't accidentally upload a
|
||||
// 50 MB poster image (which the watch only renders ~256px wide).
|
||||
const handleThumbFile = async (file: File) => {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.push('Pick an image file for the thumbnail', 'error');
|
||||
return;
|
||||
}
|
||||
setUploadingThumb(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await api<{ blobName: string; imageUrl: string; bytes: number; reused: boolean }>(
|
||||
'/communityboard/videothumb/upload',
|
||||
{ method: 'POST', formData: fd, timeoutMs: 60_000 },
|
||||
);
|
||||
onChange({ ...video, thumbnailBlobName: res.blobName });
|
||||
toast.push(res.reused ? 'Thumbnail already on file — reused' : `Uploaded thumbnail (${(res.bytes / 1024).toFixed(0)} KB)`, 'success');
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setUploadingThumb(false);
|
||||
if (thumbInput.current) thumbInput.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded border border-ink-800 p-3 space-y-2">
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Thumbnail tile — click-to-pick like InstagramImageRow */}
|
||||
<div
|
||||
className="shrink-0 size-20 rounded bg-ink-900 border border-ink-800 overflow-hidden flex items-center justify-center cursor-pointer hover:border-brand-500"
|
||||
onClick={() => thumbInput.current?.click()}
|
||||
title="Click to upload thumbnail"
|
||||
>
|
||||
{thumbPreview ? (
|
||||
<img src={thumbPreview} className="size-full object-cover" alt="" />
|
||||
) : (
|
||||
<span className="text-[10px] text-ink-500 px-1 text-center">{uploadingThumb ? 'Uploading…' : 'Pick thumb'}</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={thumbInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) handleThumbFile(f); }}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<input
|
||||
value={video.title}
|
||||
onChange={e => onChange({ ...video, title: e.target.value })}
|
||||
placeholder="Title"
|
||||
className="input text-xs"
|
||||
/>
|
||||
<textarea
|
||||
value={video.description}
|
||||
onChange={e => onChange({ ...video, description: e.target.value })}
|
||||
placeholder="Description"
|
||||
rows={2}
|
||||
className="input text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="btn-ghost text-xs text-danger self-start"
|
||||
title="Remove video"
|
||||
>
|
||||
<Trash />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Video file row + native preview if we have a blob to play */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5 items-center flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => videoInput.current?.click()}
|
||||
disabled={uploadingVideo}
|
||||
className="btn-secondary text-xs"
|
||||
>
|
||||
{uploadingVideo ? 'Uploading…' : video.blobName ? 'Replace video' : 'Upload video'}
|
||||
</button>
|
||||
<input
|
||||
ref={videoInput}
|
||||
type="file"
|
||||
accept="video/mp4,video/webm,video/quicktime,.mp4,.webm,.mov,.m4v"
|
||||
className="hidden"
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) handleVideoFile(f); }}
|
||||
/>
|
||||
{/* Auto-thumbnail toggle. Lives next to the upload button so
|
||||
the admin sets it BEFORE clicking — checking it after the
|
||||
upload is too late, the File reference is gone. */}
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-ink-300 cursor-pointer" title="Capture the first decoded frame from the uploaded video and store it as the thumbnail.">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoThumb}
|
||||
onChange={e => setAutoThumb(e.target.checked)}
|
||||
className="size-3.5 accent-brand-500"
|
||||
/>
|
||||
Auto-thumb from first frame
|
||||
</label>
|
||||
<span className="font-mono text-[11px] text-ink-400 truncate flex-1" title={video.blobName}>
|
||||
{video.blobName || <span className="italic text-ink-600">no video uploaded (use SourceUrl for external hosts)</span>}
|
||||
</span>
|
||||
{video.blobName && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...video, blobName: '' })}
|
||||
className="btn-ghost text-[11px] py-0.5 px-1.5 text-danger"
|
||||
title="Clear uploaded video"
|
||||
>Clear</button>
|
||||
)}
|
||||
</div>
|
||||
{videoUrl && (
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
className="w-full max-h-48 rounded bg-black"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={video.sourceUrl}
|
||||
onChange={e => onChange({ ...video, sourceUrl: e.target.value })}
|
||||
placeholder="SourceUrl — external video URL (YouTube etc.). Leave blank if you uploaded above."
|
||||
className="input text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, blurb, children }: { title: string; blurb?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="card !p-4">
|
||||
<h2 className="text-sm font-semibold text-ink-50">{title}</h2>
|
||||
{blurb && <p className="text-xs text-ink-400 mb-3">{blurb}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { get } from '../lib/api';
|
||||
import type { Stats } from '../lib/types';
|
||||
import { RefreshCw } from '../components/Icons';
|
||||
|
||||
export function Dashboard() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const data = await get<Stats>('/stats');
|
||||
setStats(data);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 10_000);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-ink-50">Dashboard</h1>
|
||||
<p className="text-sm text-ink-400">Server snapshot, refreshed every 10s.</p>
|
||||
</div>
|
||||
<button onClick={load} className="btn-secondary text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Stat label="Players online" value={stats?.players.onlineNow} accent="success" sub={stats && `${stats.players.total} total`} />
|
||||
<Stat label="Banned now" value={stats?.players.bannedNow} accent="danger" sub={stats && `${stats.moderation.activeIpBans} IP bans`} />
|
||||
<Stat label="Open reports" value={stats?.moderation.openReports} accent="warn" />
|
||||
<Stat label="Total rooms" value={stats?.rooms.total} accent="neutral" sub={stats && `${stats.inventions} inventions`} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-ink-100">Top rooms by visits</h2>
|
||||
<p className="text-xs text-ink-400 mb-3">All-time visit counts.</p>
|
||||
<div className="table-scroll"><table className="w-full text-sm min-w-[480px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium pb-2">Room</th>
|
||||
<th className="text-right font-medium pb-2">Visits</th>
|
||||
<th className="text-right font-medium pb-2">Visitors</th>
|
||||
<th className="text-right font-medium pb-2">Cheers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{stats?.rooms.topByVisits.map(r => (
|
||||
<tr key={r.id} className="table-row-hover">
|
||||
<td className="py-2 text-ink-100">{r.name}</td>
|
||||
<td className="py-2 text-right text-ink-200 tabular-nums">{r.visitCount.toLocaleString()}</td>
|
||||
<td className="py-2 text-right text-ink-200 tabular-nums">{r.visitorCount.toLocaleString()}</td>
|
||||
<td className="py-2 text-right text-ink-200 tabular-nums">{r.cheerCount.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!stats && <tr><td colSpan={4} className="py-6 text-center text-ink-400 text-xs">loading…</td></tr>}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-ink-100">Recent signups</h2>
|
||||
<p className="text-xs text-ink-400 mb-3">Latest 10 accounts.</p>
|
||||
<div className="table-scroll"><table className="w-full text-sm min-w-[480px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium pb-2">Username</th>
|
||||
<th className="text-left font-medium pb-2">Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{stats?.recentJoins.map(j => (
|
||||
<tr key={j.id} className="table-row-hover">
|
||||
<td className="py-2 text-ink-100">{j.username} <span className="text-ink-500 text-xs">#{j.id}</span></td>
|
||||
<td className="py-2 text-ink-300 text-xs">{new Date(j.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!stats && <tr><td colSpan={2} className="py-6 text-center text-ink-400 text-xs">loading…</td></tr>}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-ink-500">Server time: {stats ? new Date(stats.serverTime).toLocaleString() : '—'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub, accent }: { label: string; value: number | undefined; sub?: string | null; accent: 'success' | 'danger' | 'warn' | 'neutral' }) {
|
||||
const accentRing = {
|
||||
success: 'before:bg-success',
|
||||
danger: 'before:bg-danger',
|
||||
warn: 'before:bg-warn',
|
||||
neutral: 'before:bg-brand-500',
|
||||
}[accent];
|
||||
return (
|
||||
<div className={`card p-4 relative overflow-hidden before:absolute before:left-0 before:top-0 before:h-full before:w-0.5 ${accentRing}`}>
|
||||
<div className="text-[11px] uppercase tracking-widest text-ink-400">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums text-ink-50">{value?.toLocaleString() ?? '—'}</div>
|
||||
{sub && <div className="mt-0.5 text-xs text-ink-400">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api, get } from '../lib/api';
|
||||
import type { Player } from '../lib/types';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { Confirm } from '../components/Confirm';
|
||||
import { RefreshCw, Search, Trash } from '../components/Icons';
|
||||
import { relativeTime } from '../lib/format';
|
||||
|
||||
// Avatar-item slot ids from the watch enum (RecRoom.Avatar.Data.OutfitType
|
||||
// in the decompiled client).
|
||||
const SLOT_NAMES: Record<number, string> = {
|
||||
0: 'Head', 1: 'Face', 2: 'Hair', 3: 'Torso',
|
||||
4: 'Legs', 5: 'Feet', 6: 'Accessory', 7: 'Hand item',
|
||||
};
|
||||
|
||||
// GiftRarity: RecNet.Avatars+GiftRarity. The 2020 watch tints the
|
||||
// gift-box particle effects + post-open VFX from this value, so it's
|
||||
// worth letting admins pick.
|
||||
const RARITY_OPTIONS: Array<{ value: number; label: string }> = [
|
||||
{ value: 0, label: 'Common' },
|
||||
{ value: 10, label: 'Uncommon' },
|
||||
{ value: 20, label: 'Rare' },
|
||||
{ value: 30, label: 'Epic' },
|
||||
{ value: 50, label: 'Legendary' },
|
||||
];
|
||||
|
||||
interface AvatarItem {
|
||||
guid: string;
|
||||
slot: number;
|
||||
friendlyName: string;
|
||||
tooltip: string;
|
||||
rarity: number;
|
||||
safe: boolean;
|
||||
}
|
||||
|
||||
// Gifting fires the in-game gift-box popup by inserting a GiftPackageEntity
|
||||
// row + pushing GiftPackageReceived. The watch's DowloadGiftPackages loop
|
||||
// picks it up on the next poll, the player sees the popup, taps "open",
|
||||
// and the consume endpoint writes the avatar item into their inventory.
|
||||
// This is the only flow that triggers the popup — directly poking
|
||||
// InventoryJson skips it entirely.
|
||||
export function Gift() {
|
||||
const { data: players } = useApi<Player[]>('/players?take=500');
|
||||
const [playerId, setPlayerId] = useState<number | null>(null);
|
||||
|
||||
const [includeItem, setIncludeItem] = useState(true);
|
||||
const [includeCurrency, setIncludeCurrency] = useState(false);
|
||||
const [includeXp, setIncludeXp] = useState(false);
|
||||
|
||||
const [item, setItem] = useState<AvatarItem | null>(null);
|
||||
const [manualGuid, setManualGuid] = useState('');
|
||||
const [avatarItemType, setAvatarItemType] = useState(0); // 0 Outfit, 1 HairDye
|
||||
|
||||
const [currencyType, setCurrencyType] = useState(2);
|
||||
const [currencyAmount, setCurrencyAmount] = useState(1000);
|
||||
|
||||
const [xp, setXp] = useState(500);
|
||||
|
||||
const [rarity, setRarity] = useState(0);
|
||||
const [message, setMessage] = useState('A gift from the admins.');
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pendingRefreshKey, setPendingRefreshKey] = useState(0);
|
||||
const toast = useToast();
|
||||
|
||||
const send = async () => {
|
||||
if (!playerId) return toast.push('Pick a recipient first', 'error');
|
||||
if (!includeItem && !includeCurrency && !includeXp) {
|
||||
return toast.push('Pick at least one reward (item / currency / xp)', 'error');
|
||||
}
|
||||
const guid = includeItem ? (manualGuid.trim() || item?.guid) : null;
|
||||
if (includeItem && !guid) {
|
||||
return toast.push('Pick an avatar item from the list, or paste a GUID', 'error');
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api<{ id: number }>(`/players/${playerId}/gift`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
AvatarItemGuid: includeItem ? guid : null,
|
||||
AvatarItemType: includeItem ? avatarItemType : null,
|
||||
CurrencyType: includeCurrency ? currencyType : null,
|
||||
Currency: includeCurrency ? currencyAmount : null,
|
||||
Xp: includeXp ? xp : null,
|
||||
Message: message.trim() || null,
|
||||
Rarity: rarity,
|
||||
},
|
||||
});
|
||||
toast.push(`Gift #${res.id} sent — the player will see the popup in-game`, 'success');
|
||||
setManualGuid('');
|
||||
setItem(null);
|
||||
setPendingRefreshKey(k => k + 1);
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Send a gift"
|
||||
blurb="Drops a wrapped gift box on the recipient's HUD — they tap to open it and the rewards land in their inventory / wallet."
|
||||
/>
|
||||
|
||||
{playerId !== null && <PendingGifts playerId={playerId} refreshKey={pendingRefreshKey} />}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr,360px] gap-4 max-w-5xl">
|
||||
<div className="card !p-5 space-y-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Recipient</span>
|
||||
<select value={playerId ?? ''} onChange={e => setPlayerId(e.target.value ? parseInt(e.target.value) : null)} className="input">
|
||||
<option value="">— pick a player —</option>
|
||||
{(players ?? []).map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100 font-medium">
|
||||
<input type="checkbox" checked={includeItem} onChange={e => setIncludeItem(e.target.checked)} className="size-4 accent-brand-500" />
|
||||
Include avatar item
|
||||
</label>
|
||||
{includeItem && (
|
||||
<div className="pl-6 space-y-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="label">Type</span>
|
||||
<select value={avatarItemType} onChange={e => setAvatarItemType(parseInt(e.target.value))} className="input max-w-[160px]">
|
||||
<option value={0}>Outfit item</option>
|
||||
<option value={1}>Hair dye</option>
|
||||
</select>
|
||||
</div>
|
||||
<AvatarItemPicker selected={item} onSelect={(i) => { setItem(i); setManualGuid(''); }} />
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Or paste a raw GUID</span>
|
||||
<input
|
||||
value={manualGuid}
|
||||
onChange={e => { setManualGuid(e.target.value); setItem(null); }}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
className="input font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100 font-medium">
|
||||
<input type="checkbox" checked={includeCurrency} onChange={e => setIncludeCurrency(e.target.checked)} className="size-4 accent-brand-500" />
|
||||
Include currency
|
||||
</label>
|
||||
{includeCurrency && (
|
||||
<div className="pl-6 grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Currency</span>
|
||||
<select value={currencyType} onChange={e => setCurrencyType(parseInt(e.target.value))} className="input">
|
||||
<option value={1}>Tokens</option>
|
||||
<option value={2}>Coins</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Amount</span>
|
||||
<input type="number" min={1} value={currencyAmount} onChange={e => setCurrencyAmount(parseInt(e.target.value || '0'))} className="input" />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100 font-medium">
|
||||
<input type="checkbox" checked={includeXp} onChange={e => setIncludeXp(e.target.checked)} className="size-4 accent-brand-500" />
|
||||
Include XP
|
||||
</label>
|
||||
{includeXp && (
|
||||
<div className="pl-6">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">XP amount</span>
|
||||
<input type="number" min={1} value={xp} onChange={e => setXp(parseInt(e.target.value || '0'))} className="input w-32" />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar: presentation + send button. The watch tints the gift box
|
||||
and applies VFX based on rarity, so it's a real cosmetic choice. */}
|
||||
<div className="card !p-5 space-y-4 h-fit">
|
||||
<h2 className="text-sm font-semibold text-ink-50">Presentation</h2>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Rarity (gift-box tint)</span>
|
||||
<select value={rarity} onChange={e => setRarity(parseInt(e.target.value))} className="input">
|
||||
{RARITY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Message (shown on the open card)</span>
|
||||
<textarea value={message} onChange={e => setMessage(e.target.value)} rows={3} className="input" />
|
||||
</label>
|
||||
<button onClick={send} disabled={busy || !playerId} className="btn-primary w-full text-xs">
|
||||
{busy ? 'Sending…' : 'Send gift'}
|
||||
</button>
|
||||
<div className="text-[11px] text-ink-500">
|
||||
The recipient sees the gift box in their next HUD update (SignalR push) or when they next poll <code className="font-mono">/api/avatar/v2/gifts</code>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarItemPicker({ selected, onSelect }: { selected: AvatarItem | null; onSelect: (i: AvatarItem) => void }) {
|
||||
const { data: catalog, loading } = useApi<AvatarItem[]>('/avatar-items');
|
||||
const [search, setSearch] = useState('');
|
||||
const [slotFilter, setSlotFilter] = useState<number | 'all'>('all');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return (catalog ?? []).filter(it => {
|
||||
if (slotFilter !== 'all' && it.slot !== slotFilter) return false;
|
||||
if (term && !it.friendlyName.toLowerCase().includes(term) && !it.guid.toLowerCase().includes(term)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [catalog, search, slotFilter]);
|
||||
|
||||
const slots = useMemo(() => Array.from(new Set((catalog ?? []).map(c => c.slot))).sort((a, b) => a - b), [catalog]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2 items-end mb-2">
|
||||
<div className="relative flex-1 min-w-[180px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-400" />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search by name or GUID…" className="input pl-8" />
|
||||
</div>
|
||||
<select value={slotFilter} onChange={e => setSlotFilter(e.target.value === 'all' ? 'all' : parseInt(e.target.value))} className="input max-w-[140px]">
|
||||
<option value="all">All slots</option>
|
||||
{slots.map(s => <option key={s} value={s}>{SLOT_NAMES[s] ?? `Slot #${s}`}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="rounded-lg border border-ink-800 max-h-64 overflow-y-auto">
|
||||
{loading && <div className="p-3 text-xs text-ink-400">Loading catalog…</div>}
|
||||
{catalog && filtered.length === 0 && <div className="p-3 text-xs text-ink-400">No items match.</div>}
|
||||
{filtered.length > 0 && (
|
||||
<div className="table-scroll"><table className="w-full text-sm min-w-[560px]">
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{filtered.slice(0, 200).map(it => (
|
||||
<tr
|
||||
key={it.guid}
|
||||
onClick={() => onSelect(it)}
|
||||
className={`cursor-pointer table-row-hover ${selected?.guid === it.guid ? 'bg-brand-500/10' : ''}`}
|
||||
>
|
||||
<td className="px-3 py-1.5">
|
||||
<div className="text-ink-100 text-sm">{it.friendlyName || <span className="text-ink-500">unnamed</span>}</div>
|
||||
<div className="font-mono text-[10px] text-ink-500">{it.guid}</div>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-xs text-ink-300 whitespace-nowrap">{SLOT_NAMES[it.slot] ?? `slot ${it.slot}`}</td>
|
||||
</tr>
|
||||
))}
|
||||
{filtered.length > 200 && (
|
||||
<tr><td colSpan={2} className="p-3 text-center text-xs text-ink-500">+ {filtered.length - 200} more — refine the search.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pending gift inbox for the selected recipient ───────────────────
|
||||
// Surfaces any unconsumed gifts on the recipient's account so we can
|
||||
// see what's actually queued and delete broken / abandoned entries
|
||||
// from earlier test sends. IsGifted-flagged or otherwise malformed
|
||||
// rows might still be in the inbox refusing to render — clear them
|
||||
// here.
|
||||
|
||||
interface PendingGift {
|
||||
id: number;
|
||||
fromPlayerId: number | null;
|
||||
avatarItemType: number;
|
||||
avatarItemDescOrHairDyeDesc: string;
|
||||
currencyType: number;
|
||||
currency: number;
|
||||
xp: number;
|
||||
level: number;
|
||||
giftContext: number;
|
||||
giftRarity: number;
|
||||
message: string;
|
||||
consumed: boolean;
|
||||
isValid: boolean;
|
||||
isGifted: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function PendingGifts({ playerId, refreshKey }: { playerId: number; refreshKey: number }) {
|
||||
const [rows, setRows] = useState<PendingGift[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const [pendingDelete, setPendingDelete] = useState<PendingGift | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const data = await get<PendingGift[]>(`/players/${playerId}/gifts`);
|
||||
setRows(data);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [playerId, refreshKey]);
|
||||
|
||||
const delOne = async () => {
|
||||
if (!pendingDelete) return;
|
||||
try {
|
||||
await api(`/gifts/${pendingDelete.id}`, { method: 'DELETE' });
|
||||
toast.push(`Deleted gift #${pendingDelete.id}`, 'success');
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const clearAll = async () => {
|
||||
try {
|
||||
const res = await api<{ removed: number }>(`/players/${playerId}/gifts/clear`, { method: 'POST' });
|
||||
toast.push(`Removed ${res.removed} pending gift${res.removed === 1 ? '' : 's'}`, 'success');
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Most of the broken test-gifts will have IsGifted=true or empty
|
||||
// payloads — flag those so the user can spot stuck rows at a glance.
|
||||
const looksBroken = (g: PendingGift) =>
|
||||
g.isGifted
|
||||
|| !g.isValid
|
||||
|| (g.avatarItemType === 0
|
||||
&& !g.avatarItemDescOrHairDyeDesc
|
||||
&& g.currency === 0
|
||||
&& g.xp === 0
|
||||
&& g.level === 0);
|
||||
|
||||
return (
|
||||
<div className="card !p-4 mb-4 max-w-5xl">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-ink-50">Pending gifts on this player</h2>
|
||||
<p className="text-xs text-ink-400">Unconsumed gift packages currently in the recipient's inbox. Delete anything stuck.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={load} className="btn-ghost text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
{rows && rows.length > 0 && (
|
||||
<button onClick={() => setConfirmClear(true)} className="btn-danger text-xs">
|
||||
Clear all ({rows.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="text-xs text-danger">{err}</div>}
|
||||
{!err && rows && rows.length === 0 && (
|
||||
<div className="text-xs text-ink-500 py-2">Inbox is empty.</div>
|
||||
)}
|
||||
{rows && rows.length > 0 && (
|
||||
<div className="table-scroll"><table className="w-full text-sm min-w-[560px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium pb-2">#</th>
|
||||
<th className="text-left font-medium pb-2">Contents</th>
|
||||
<th className="text-left font-medium pb-2">From</th>
|
||||
<th className="text-left font-medium pb-2">Created</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{rows.map(g => (
|
||||
<tr key={g.id} className={`table-row-hover ${looksBroken(g) ? 'bg-danger/5' : ''}`}>
|
||||
<td className="py-2 text-ink-400 tabular-nums">{g.id}</td>
|
||||
<td className="py-2 text-ink-100">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{g.avatarItemDescOrHairDyeDesc && (
|
||||
<span className="badge-neutral font-mono">item</span>
|
||||
)}
|
||||
{g.currency > 0 && (
|
||||
<span className="badge-neutral">{g.currency.toLocaleString()} {g.currencyType === 1 ? 'tokens' : 'coins'}</span>
|
||||
)}
|
||||
{g.xp > 0 && <span className="badge-neutral">{g.xp} xp</span>}
|
||||
{g.isGifted && <span className="badge-banned">IsGifted=true (broken)</span>}
|
||||
{!g.isValid && <span className="badge-banned">IsValid=false</span>}
|
||||
</div>
|
||||
{g.message && <div className="text-xs text-ink-400 mt-0.5">{g.message}</div>}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-ink-300">{g.fromPlayerId ? `#${g.fromPlayerId}` : <span className="text-ink-500">system</span>}</td>
|
||||
<td className="py-2 text-xs text-ink-300">{relativeTime(g.createdAt)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<button onClick={() => setPendingDelete(g)} className="btn-ghost text-xs text-danger">
|
||||
<Trash />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
|
||||
<Confirm
|
||||
open={confirmClear}
|
||||
onClose={() => setConfirmClear(false)}
|
||||
title="Clear all pending gifts"
|
||||
body={<>Remove every unconsumed gift currently in this player's inbox? Consumed gifts (already opened) are left untouched.</>}
|
||||
confirmLabel="Clear all"
|
||||
destructive
|
||||
onConfirm={clearAll}
|
||||
/>
|
||||
<Confirm
|
||||
open={pendingDelete !== null}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
title={`Delete gift #${pendingDelete?.id}`}
|
||||
body={<>This removes the gift permanently. If the player had already opened it, the inventory grant from that consume is unaffected.</>}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
onConfirm={delOne}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
|
||||
// Maker-pen-anywhere needs server-side integration with CdnController
|
||||
// (substitute the room blob for flagged players so they get the
|
||||
// all-permissions PersistedRoomData) AND a PlayerEntity.HasGlobalMakerPen
|
||||
// column with an EF migration. Wiring is non-trivial enough to keep this
|
||||
// page as a clearly-labeled placeholder until that lands — saves shipping
|
||||
// a button that does nothing.
|
||||
export function Grants() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Grant maker pen"
|
||||
blurb="Flag players who should receive maker-pen permissions in every room they load."
|
||||
/>
|
||||
<div className="card !p-6 max-w-2xl space-y-3 text-sm text-ink-300">
|
||||
<div className="rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-xs text-warn">
|
||||
<strong className="text-warn">Not yet wired up.</strong> The endpoint and CDN integration land in a follow-up commit.
|
||||
</div>
|
||||
<p>
|
||||
The plan: add <span className="font-mono text-xs text-ink-100">HasGlobalMakerPen</span> to
|
||||
<span className="font-mono text-xs text-ink-100"> PlayerEntity</span>, intercept room blob
|
||||
fetches in <span className="font-mono text-xs text-ink-100">CdnController</span> for flagged players,
|
||||
and substitute the all-permissions default blob that
|
||||
<span className="font-mono text-xs text-ink-100"> RoomDataBlobService</span> already produces.
|
||||
Result: the player loads any room with full Maker Pen / invention spawn permissions, regardless of the
|
||||
room's owner-defined roles.
|
||||
</p>
|
||||
<p className="text-xs text-ink-400">
|
||||
Until then, use the Player → Grants tab to bestow per-item inventory grants (Maker Pen is granted via
|
||||
inventory in some flows).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,1039 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BlobReader, TextWriter, ZipReader, type Entry } from '@zip.js/zip.js';
|
||||
import { ApiError, uploadZipInChunks, type UploadProgress } from '../lib/api';
|
||||
import { isChromium } from '../lib/browser';
|
||||
import type { Player } from '../lib/types';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { Upload, Trash, RefreshCw } from '../components/Icons';
|
||||
|
||||
// Matches RecNet's real export layout (see comments in
|
||||
// Controllers/Admin/RoomZipImportController.cs for the schema):
|
||||
//
|
||||
// Rooms/Room_<id>_<name>_<ts>/
|
||||
// RoomDetails.json
|
||||
// RoomImage.jpg
|
||||
// Promo/Image/PromoImage_*.jpg
|
||||
// SubRooms/<SceneName>/
|
||||
// Subroom.json
|
||||
// <hash>.room (referenced by Subroom.CurrentSave.DataBlob)
|
||||
// AudioSampler/<hash>.htr
|
||||
// Holotar/<hash>.htr
|
||||
// Image/PVImage_*.jpg
|
||||
// Inventions/Invention_<id>_<name>_<ts>/
|
||||
// Invention.json
|
||||
// InventionDetails.json
|
||||
// InventionVersion.json
|
||||
// InventionImage.jpg
|
||||
// <hash>.inv
|
||||
//
|
||||
// We parse the archive client-side purely for a preview/validation
|
||||
// pass — the same blob is then POSTed to the server, which re-parses
|
||||
// it authoritatively.
|
||||
|
||||
interface RoomDetailsJson {
|
||||
RoomId?: number;
|
||||
Name?: string;
|
||||
FriendlyName?: string;
|
||||
Description?: string;
|
||||
ImageName?: string;
|
||||
CreatedAt?: string;
|
||||
PublishedAt?: string;
|
||||
ModifiedAt?: string;
|
||||
Stats?: { CheerCount?: number; VisitCount?: number; VisitorCount?: number; FavoriteCount?: number };
|
||||
Tags?: Array<{ Tag?: string; Type?: number }>;
|
||||
SubRooms?: Array<{ Name?: string; CurrentSave?: { DataBlob?: string }; IsSandbox?: boolean; MaxPlayers?: number; ModifiedAt?: string }>;
|
||||
}
|
||||
|
||||
/// The 2020 Rec Room watch was finalized around September 2020; rooms
|
||||
/// authored after end-of-2020 commonly reference circuits, props, and
|
||||
/// UI components added in 2021+ builds that the 2020 binary can't
|
||||
/// resolve. Importing them still produces a row in the DB but the
|
||||
/// scene either loads with missing chunks or crashes the watch at
|
||||
/// load time. We don't refuse the import — just flag in the preview
|
||||
/// so admins know to expect breakage and can untick those rooms.
|
||||
const CLIENT_BUILD_CUTOFF = new Date('2020-12-31T00:00:00Z');
|
||||
|
||||
interface SubroomJson {
|
||||
Name?: string;
|
||||
CurrentSave?: { DataBlob?: string };
|
||||
IsSandbox?: boolean;
|
||||
MaxPlayers?: number;
|
||||
UnitySceneId?: string;
|
||||
ModifiedAt?: string;
|
||||
}
|
||||
|
||||
interface InventionJson {
|
||||
Name?: string;
|
||||
Description?: string;
|
||||
ImageName?: string;
|
||||
CurrentVersionNumber?: number;
|
||||
}
|
||||
|
||||
interface PreviewRoom {
|
||||
folder: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
stats: { visits: number; visitors: number; cheers: number; favorites: number };
|
||||
subrooms: PreviewSubroom[];
|
||||
hasImage: boolean;
|
||||
promoImageCount: number;
|
||||
/// Latest of CreatedAt / PublishedAt / ModifiedAt / per-subroom
|
||||
/// ModifiedAt. Used to flag rooms newer than the 2020 client's
|
||||
/// release window — see CLIENT_BUILD_CUTOFF.
|
||||
latestModifiedAt: string | null;
|
||||
postBuildCutoff: boolean;
|
||||
}
|
||||
|
||||
interface PreviewSubroom {
|
||||
scene: string;
|
||||
blobFilename: string | null;
|
||||
blobFound: boolean;
|
||||
htrAssetCount: number;
|
||||
pvImageCount: number;
|
||||
polaroidCount: number;
|
||||
hasSubroomJson: boolean;
|
||||
}
|
||||
|
||||
interface PreviewInvention {
|
||||
folder: string;
|
||||
name: string;
|
||||
description: string;
|
||||
hasBlob: boolean;
|
||||
hasImage: boolean;
|
||||
versionNumber: number;
|
||||
}
|
||||
|
||||
interface ZipPreview {
|
||||
rooms: PreviewRoom[];
|
||||
inventions: PreviewInvention[];
|
||||
unknownTopLevel: string[];
|
||||
}
|
||||
|
||||
interface AssetBreakdown { referenced: number; newlyImported: number; alreadyInDb: number }
|
||||
|
||||
interface ImportResultRoom {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
roomId?: number;
|
||||
folder?: string;
|
||||
sceneCount?: number;
|
||||
entryScene?: string;
|
||||
entryDetectionSource?: string;
|
||||
htrAssets?: AssetBreakdown;
|
||||
pvImages?: AssetBreakdown;
|
||||
polaroids?: AssetBreakdown;
|
||||
// Legacy flat counters — keep reading them as a fallback in case
|
||||
// we point this UI at an older server build.
|
||||
htrAssetsImported?: number;
|
||||
pvImagesImported?: number;
|
||||
missingScenes?: string[] | null;
|
||||
tags?: string[];
|
||||
stats?: { cheers: number; favorites: number; visitors: number; visits: number };
|
||||
scenes?: Array<{ name: string; blobName: string; bytes: number; htrAssets: number; pvImages: number; isSandbox?: boolean; maxPlayers?: number }>;
|
||||
}
|
||||
|
||||
interface ImportResultInvention {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
inventionId?: number;
|
||||
folder?: string;
|
||||
blobName?: string;
|
||||
bytes?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
archiveBytes: number;
|
||||
roomCount: number;
|
||||
inventionCount: number;
|
||||
rooms: ImportResultRoom[];
|
||||
inventions: ImportResultInvention[];
|
||||
}
|
||||
|
||||
// Stages for the visible upload progress strip. "parsing" lasts until
|
||||
// the client-side zip preview lands; "uploading" tracks bytes-on-wire;
|
||||
// "server" is the indeterminate window between upload completion and
|
||||
// the server response (during which the server is walking the archive,
|
||||
// normalising blobs, inserting rows). "done" stays terminal until the
|
||||
// user clears the file.
|
||||
type ImportStage = 'idle' | 'parsing' | 'uploading' | 'server' | 'done';
|
||||
|
||||
export function ImportRoom() {
|
||||
const chromium = useMemo(() => isChromium(), []);
|
||||
const { data: players } = useApi<Player[]>('/players?take=500');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<ZipPreview | null>(null);
|
||||
const [previewErr, setPreviewErr] = useState<string | null>(null);
|
||||
const [parseProgress, setParseProgress] = useState(0); // 0..100 — number of entries we've inspected
|
||||
const [ownerId, setOwnerId] = useState<number | null>(null);
|
||||
// Per-folder selection. Initialised to "everything checked except
|
||||
// post-2020-cutoff rooms" once the preview lands — admins typically
|
||||
// want to import everything compatible by default but skip the
|
||||
// flagged ones until they've vetted them. The Set holds folder paths
|
||||
// (Rooms/Room_*… or Inventions/Invention_*…).
|
||||
const [selectedFolders, setSelectedFolders] = useState<Set<string>>(new Set());
|
||||
// Per-import "round-trip the .room bytes through the 2020 protobuf
|
||||
// normaliser" toggle. Defaults OFF because the re-encode crashes the
|
||||
// watch on load; flip on to opt back into the normaliser for a
|
||||
// specific zip once we're confident its bytes survive the round-trip.
|
||||
const [normalizeBlobs, setNormalizeBlobs] = useState(false);
|
||||
const [stage, setStage] = useState<ImportStage>('idle');
|
||||
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({ loaded: 0, total: 0, percent: 0 });
|
||||
const [serverStartedAt, setServerStartedAt] = useState<number | null>(null);
|
||||
const [serverElapsed, setServerElapsed] = useState(0);
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
const [drag, setDrag] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
|
||||
// Tick a clock while the server is processing so the user knows
|
||||
// something is happening — large archives can spend 10s+ in
|
||||
// System.IO.Compression + EF inserts with no wire traffic.
|
||||
useEffect(() => {
|
||||
if (stage !== 'server' || serverStartedAt === null) return;
|
||||
setServerElapsed(0);
|
||||
const id = window.setInterval(() => setServerElapsed(Math.round((Date.now() - serverStartedAt) / 1000)), 250);
|
||||
return () => window.clearInterval(id);
|
||||
}, [stage, serverStartedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) { setPreview(null); setPreviewErr(null); setStage('idle'); setSelectedFolders(new Set()); return; }
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setStage('parsing');
|
||||
setPreviewErr(null);
|
||||
setParseProgress(0);
|
||||
try {
|
||||
const p = await parseZipPreview(file, pct => { if (!cancelled) setParseProgress(pct); });
|
||||
if (!cancelled) {
|
||||
setPreview(p);
|
||||
setStage('idle');
|
||||
// Default selection: every importable room + invention,
|
||||
// EXCEPT rooms flagged as post-2020-cutoff (admin can tick
|
||||
// them back on explicitly after acknowledging the warning).
|
||||
const next = new Set<string>();
|
||||
for (const r of p.rooms) if (!r.postBuildCutoff) next.add(r.folder);
|
||||
for (const inv of p.inventions) next.add(inv.folder);
|
||||
setSelectedFolders(next);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) { setPreview(null); setPreviewErr((e as Error).message); setStage('idle'); }
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [file]);
|
||||
|
||||
const toggleFolder = (folder: string) => {
|
||||
setSelectedFolders(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folder)) next.delete(folder); else next.add(folder);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const upload = async () => {
|
||||
if (!file || !ownerId) return;
|
||||
setResult(null);
|
||||
setStage('uploading');
|
||||
setUploadProgress({ loaded: 0, total: file.size, percent: 0 });
|
||||
try {
|
||||
// Chunked upload — Cloudflare's edge caps request bodies at
|
||||
// 100 MB on free/pro plans, so even our 165 MB sample zip needs
|
||||
// splitting. The helper slices into ZIP_CHUNK_BYTES (50 MB) pieces
|
||||
// sent to /zip-upload-chunk, then calls /zip-upload-finalize which
|
||||
// runs the same import logic the single-shot endpoint does.
|
||||
// Split the selection into the two server-side filters. Empty
|
||||
// arrays mean "don't filter" on the server, so if the admin
|
||||
// unticked everything in a category we send a sentinel ([""])
|
||||
// that matches no real folder and effectively imports nothing
|
||||
// from that category.
|
||||
const selectedRoomFolders = (preview?.rooms ?? [])
|
||||
.filter(r => selectedFolders.has(r.folder))
|
||||
.map(r => r.folder);
|
||||
const selectedInventionFolders = (preview?.inventions ?? [])
|
||||
.filter(inv => selectedFolders.has(inv.folder))
|
||||
.map(inv => inv.folder);
|
||||
const anyRoomSelected = (preview?.rooms.length ?? 0) > 0 && selectedRoomFolders.length === 0;
|
||||
const anyInventionSelected = (preview?.inventions.length ?? 0) > 0 && selectedInventionFolders.length === 0;
|
||||
const res = await uploadZipInChunks<ImportResult>(
|
||||
file,
|
||||
ownerId,
|
||||
p => {
|
||||
setUploadProgress(p);
|
||||
if (p.total > 0 && p.loaded >= p.total) {
|
||||
setStage(prev => (prev === 'uploading' ? 'server' : prev));
|
||||
setServerStartedAt(prev => prev ?? Date.now());
|
||||
}
|
||||
},
|
||||
30 * 60_000,
|
||||
{
|
||||
selectedRoomFolders: anyRoomSelected ? ['__NONE__'] : selectedRoomFolders,
|
||||
selectedInventionFolders: anyInventionSelected ? ['__NONE__'] : selectedInventionFolders,
|
||||
normalizeBlobs,
|
||||
},
|
||||
);
|
||||
setStage('done');
|
||||
setResult(res);
|
||||
const ok = res.rooms.filter(r => r.ok).length;
|
||||
toast.push(
|
||||
`Imported ${ok}/${res.rooms.length} rooms · ${res.inventions.filter(i => i.ok).length}/${res.inventions.length} inventions`,
|
||||
ok === res.rooms.length ? 'success' : 'info',
|
||||
);
|
||||
} catch (e) {
|
||||
setStage('idle');
|
||||
const msg = e instanceof ApiError ? e.message : (e as Error).message;
|
||||
toast.push(msg, 'error');
|
||||
} finally {
|
||||
setServerStartedAt(null);
|
||||
}
|
||||
};
|
||||
|
||||
const busy = stage === 'uploading' || stage === 'server';
|
||||
const previewBusy = stage === 'parsing';
|
||||
|
||||
const validRooms = preview?.rooms.filter(r => r.subrooms.some(s => s.blobFound)) ?? [];
|
||||
const totalScenes = validRooms.reduce((n, r) => n + r.subrooms.length, 0);
|
||||
const totalHtr = validRooms.reduce((n, r) => n + r.subrooms.reduce((m, s) => m + s.htrAssetCount, 0), 0);
|
||||
const totalPolaroids = validRooms.reduce((n, r) => n + r.subrooms.reduce((m, s) => m + s.polaroidCount, 0), 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Import room (zip)"
|
||||
blurb="Drop a RecNet-format export archive. Each Rooms/Room_*/ folder is one room; SubRooms/<Scene>/ holds the scene blob + bundled .htr assets + preview images. The original RoomDetails.json + Subroom.json drive everything — no rec.net round-trip."
|
||||
actions={<a href="/import-room-legacy" className="btn-ghost text-xs">Use legacy importer →</a>}
|
||||
/>
|
||||
|
||||
{!chromium && (
|
||||
<div className="card !p-4 mb-4 border-warn/40 bg-warn/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-warn shrink-0 mt-0.5">⚠</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-warn">Chromium-based browser required</div>
|
||||
<p className="text-xs text-ink-300 mt-1 max-w-prose">
|
||||
The zip importer uploads multi-hundred-megabyte archives via <code className="font-mono text-[11px]">FormData</code> and uses streaming Blob slicing. Firefox and Safari both choke on this — Firefox's upload progress buffers
|
||||
stall over ~500 MB, and Safari hits a memory ceiling cloning the form body and aborts mid-upload. Use Chrome, Edge, Brave, Vivaldi, or any other Chromium-based browser for this page. (The rest of the admin works fine everywhere.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDrag(true); }}
|
||||
onDragLeave={() => setDrag(false)}
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
setDrag(false);
|
||||
if (!chromium) { toast.push('Switch to a Chromium-based browser first', 'error'); return; }
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f && f.name.toLowerCase().endsWith('.zip')) {
|
||||
setFile(f); setResult(null);
|
||||
} else if (f) {
|
||||
toast.push('Drop a .zip file', 'error');
|
||||
}
|
||||
}}
|
||||
className={`card !p-8 mb-4 border-2 border-dashed transition-colors ${
|
||||
drag ? 'border-brand-500 bg-brand-500/5' : 'border-ink-700 bg-ink-900/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-center">
|
||||
<Upload className="size-8 text-ink-400" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-ink-100">Drop your archive here, or pick a file</div>
|
||||
<div className="text-xs text-ink-400 mt-0.5">.zip up to ~2 GB</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
disabled={!chromium}
|
||||
onChange={e => { setFile(e.target.files?.[0] ?? null); setResult(null); }}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => fileInputRef.current?.click()} disabled={!chromium} className="btn-secondary text-xs">Browse…</button>
|
||||
{file && (
|
||||
<button
|
||||
onClick={() => { setFile(null); setPreview(null); setResult(null); if (fileInputRef.current) fileInputRef.current.value = ''; }}
|
||||
className="btn-ghost text-xs text-danger"
|
||||
>
|
||||
<Trash /> Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{file && (
|
||||
<div className="text-xs text-ink-300 font-mono">
|
||||
{file.name} · {(file.size / 1024 / 1024).toFixed(1)} MB
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewBusy && (
|
||||
<div className="card !p-4 mb-4">
|
||||
<div className="flex items-center gap-3 text-xs text-ink-300 mb-2">
|
||||
<RefreshCw className="animate-spin" />
|
||||
<span>Parsing archive — reading the central directory and walking room manifests…</span>
|
||||
<span className="ml-auto tabular-nums">{Math.min(100, parseProgress)}%</span>
|
||||
</div>
|
||||
<ProgressBar percent={parseProgress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewErr && (
|
||||
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">
|
||||
Failed to parse archive: {previewErr}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live import status strip — appears while we're uploading the
|
||||
archive and then while the server processes it. */}
|
||||
{busy && (
|
||||
<div className="card !p-4 mb-4">
|
||||
{stage === 'uploading' && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 text-xs text-ink-200 mb-2">
|
||||
<RefreshCw className="animate-spin text-brand-300" />
|
||||
<span className="font-medium">Uploading archive…</span>
|
||||
<span className="ml-auto tabular-nums text-ink-300">
|
||||
{(uploadProgress.loaded / 1024 / 1024).toFixed(1)} / {(uploadProgress.total / 1024 / 1024).toFixed(1)} MB
|
||||
{' · '}{uploadProgress.percent}%
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={uploadProgress.percent} />
|
||||
</>
|
||||
)}
|
||||
{stage === 'server' && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 text-xs text-ink-200 mb-2">
|
||||
<RefreshCw className="animate-spin text-brand-300" />
|
||||
<span className="font-medium">Server is processing the archive…</span>
|
||||
<span className="ml-auto tabular-nums text-ink-300">{serverElapsed}s elapsed</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-ink-400 mb-2">
|
||||
Unpacking, normalising every <code className="font-mono">.room</code> blob, inserting room + scene rows, persisting bundled <code className="font-mono">.htr</code> assets and preview images.
|
||||
Large archives can take 30s+; we'll show the per-room result table when it finishes.
|
||||
</p>
|
||||
<ProgressBar indeterminate />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="card !p-5 mb-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-4">
|
||||
<Stat label="Rooms" value={preview.rooms.length} />
|
||||
<Stat label="Scenes" value={totalScenes} />
|
||||
<Stat label=".htr assets" value={totalHtr} />
|
||||
<Stat label="Polaroids" value={totalPolaroids} />
|
||||
<Stat label="Inventions" value={preview.inventions.length} />
|
||||
</div>
|
||||
|
||||
{preview.rooms.length > 0 && (
|
||||
<details open className="mb-3">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-ink-100 mb-2 flex items-center gap-3">
|
||||
<span>Rooms ({preview.rooms.filter(r => selectedFolders.has(r.folder)).length}/{preview.rooms.length} selected)</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const allOn = preview.rooms.every(r => selectedFolders.has(r.folder));
|
||||
setSelectedFolders(prev => {
|
||||
const next = new Set(prev);
|
||||
for (const r of preview.rooms) {
|
||||
if (allOn) next.delete(r.folder); else next.add(r.folder);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="btn-ghost text-[10px] uppercase tracking-widest"
|
||||
>
|
||||
{preview.rooms.every(r => selectedFolders.has(r.folder)) ? 'Deselect all' : 'Select all'}
|
||||
</button>
|
||||
</summary>
|
||||
<div className="space-y-2">
|
||||
{preview.rooms.map(r => (
|
||||
<div
|
||||
key={r.folder}
|
||||
className={`rounded-lg border p-3 transition-colors ${
|
||||
selectedFolders.has(r.folder)
|
||||
? 'border-ink-800'
|
||||
: 'border-ink-800/50 bg-ink-900/30 opacity-70'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFolders.has(r.folder)}
|
||||
onChange={() => toggleFolder(r.folder)}
|
||||
className="size-4 accent-brand-500"
|
||||
/>
|
||||
<span className="font-medium text-ink-50">{r.name}</span>
|
||||
</label>
|
||||
<span className="text-xs text-ink-500 font-mono">{r.folder.split('/').pop()}</span>
|
||||
{!r.hasImage && <span className="badge-junior">no image</span>}
|
||||
{r.postBuildCutoff && (
|
||||
<span
|
||||
className="badge-banned"
|
||||
title={`Latest timestamp ${r.latestModifiedAt} is after the 2020 client cutoff (${CLIENT_BUILD_CUTOFF.toISOString().slice(0, 10)}). Scene may use circuits/props the watch doesn't ship.`}
|
||||
>
|
||||
may be incompatible
|
||||
</span>
|
||||
)}
|
||||
{r.latestModifiedAt && (
|
||||
<span className="text-[10px] text-ink-500 font-mono" title={r.latestModifiedAt}>
|
||||
{r.latestModifiedAt.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.description && <p className="text-xs text-ink-400 mt-1 line-clamp-2">{r.description}</p>}
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-xs">
|
||||
{r.tags.map(t => <span key={t} className="badge-neutral">{t}</span>)}
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 md:grid-cols-5 gap-2 text-xs text-ink-300">
|
||||
<Mini label="Scenes" value={r.subrooms.length} />
|
||||
<Mini label="HTR" value={r.subrooms.reduce((n, s) => n + s.htrAssetCount, 0)} />
|
||||
<Mini label="PV imgs" value={r.subrooms.reduce((n, s) => n + s.pvImageCount, 0)} />
|
||||
<Mini label="Polaroids" value={r.subrooms.reduce((n, s) => n + s.polaroidCount, 0)} />
|
||||
<Mini label="Promo" value={r.promoImageCount} />
|
||||
</div>
|
||||
{r.subrooms.length > 0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-ink-400">Scene breakdown</summary>
|
||||
<table className="w-full text-xs mt-1.5">
|
||||
<thead className="text-[10px] uppercase tracking-wider text-ink-500">
|
||||
<tr>
|
||||
<th className="text-left font-medium pb-1">Scene</th>
|
||||
<th className="text-left font-medium pb-1">Blob</th>
|
||||
<th className="text-right font-medium pb-1">HTR</th>
|
||||
<th className="text-right font-medium pb-1">PV</th>
|
||||
<th className="text-right font-medium pb-1">Pol</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{r.subrooms.map(s => (
|
||||
<tr key={s.scene}>
|
||||
<td className="py-1 text-ink-100">{s.scene}</td>
|
||||
<td className="py-1 font-mono text-[11px] text-ink-300">
|
||||
{s.blobFilename ?? <span className="text-danger">no DataBlob in Subroom.json</span>}
|
||||
{s.blobFilename && !s.blobFound && <span className="text-danger ml-1">(missing!)</span>}
|
||||
{!s.hasSubroomJson && <span className="text-danger ml-1">(no Subroom.json)</span>}
|
||||
</td>
|
||||
<td className="py-1 text-right text-ink-300 tabular-nums">{s.htrAssetCount}</td>
|
||||
<td className="py-1 text-right text-ink-300 tabular-nums">{s.pvImageCount}</td>
|
||||
<td className="py-1 text-right text-ink-300 tabular-nums">{s.polaroidCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{preview.inventions.length > 0 && (
|
||||
<details className="mb-3">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-ink-100 flex items-center gap-3">
|
||||
<span>Inventions ({preview.inventions.filter(inv => selectedFolders.has(inv.folder)).length}/{preview.inventions.length} selected)</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
// Mirror the Rooms section toggle — "select all"
|
||||
// when at least one is unchecked, "deselect all"
|
||||
// once every invention is on. Stops the <details>
|
||||
// collapsing on click via preventDefault.
|
||||
e.preventDefault();
|
||||
const allOn = preview.inventions.every(inv => selectedFolders.has(inv.folder));
|
||||
setSelectedFolders(prev => {
|
||||
const next = new Set(prev);
|
||||
for (const inv of preview.inventions) {
|
||||
if (allOn) next.delete(inv.folder); else next.add(inv.folder);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="btn-ghost text-[10px] uppercase tracking-widest"
|
||||
>
|
||||
{preview.inventions.every(inv => selectedFolders.has(inv.folder)) ? 'Deselect all' : 'Select all'}
|
||||
</button>
|
||||
</summary>
|
||||
<table className="w-full text-sm mt-2">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="w-8 pb-1" />
|
||||
<th className="text-left font-medium pb-1">Name</th>
|
||||
<th className="text-left font-medium pb-1">Folder</th>
|
||||
<th className="text-right font-medium pb-1">Version</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{preview.inventions.map(inv => (
|
||||
<tr key={inv.folder} className={selectedFolders.has(inv.folder) ? '' : 'opacity-60'}>
|
||||
<td className="py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFolders.has(inv.folder)}
|
||||
onChange={() => toggleFolder(inv.folder)}
|
||||
className="size-4 accent-brand-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5 text-ink-100">{inv.name}</td>
|
||||
<td className="py-1.5 font-mono text-xs text-ink-400">{inv.folder.split('/').pop()}</td>
|
||||
<td className="py-1.5 text-right text-ink-200 tabular-nums">{inv.versionNumber}</td>
|
||||
<td className="py-1.5">
|
||||
<div className="flex gap-1">
|
||||
{!inv.hasBlob && <span className="badge-banned">no blob</span>}
|
||||
{!inv.hasImage && <span className="badge-junior">no image</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{preview.unknownTopLevel.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs text-ink-500">{preview.unknownTopLevel.length} file(s) outside the schema</summary>
|
||||
<ul className="text-[11px] text-ink-500 font-mono pl-3 mt-1 max-h-32 overflow-y-auto">
|
||||
{preview.unknownTopLevel.map(f => <li key={f}>{f}</li>)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (preview.rooms.length > 0 || preview.inventions.length > 0) && (
|
||||
<div className="card !p-4 mb-4 space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="flex flex-col gap-1 flex-1 min-w-[260px]">
|
||||
<span className="label">Owner (becomes the creator of every imported room + invention)</span>
|
||||
<select value={ownerId ?? ''} onChange={e => setOwnerId(e.target.value ? parseInt(e.target.value) : null)} disabled={!chromium} className="input">
|
||||
<option value="">— pick an owner —</option>
|
||||
{(players ?? []).map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button onClick={upload} disabled={busy || !ownerId || !chromium} className="btn-primary text-xs">
|
||||
{stage === 'uploading' ? `Uploading… ${uploadProgress.percent}%`
|
||||
: stage === 'server' ? `Server processing… ${serverElapsed}s`
|
||||
: (() => {
|
||||
const selectedRoomsCount = preview.rooms.filter(r => selectedFolders.has(r.folder)).length;
|
||||
const selectedInvCount = preview.inventions.filter(inv => selectedFolders.has(inv.folder)).length;
|
||||
return `Import ${selectedRoomsCount} room${selectedRoomsCount === 1 ? '' : 's'} · ${selectedInvCount} invention${selectedInvCount === 1 ? '' : 's'}`;
|
||||
})()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Per-import blob-normaliser toggle. Visible as a checkbox so
|
||||
the admin makes the call PER ZIP — defaults off because the
|
||||
2020 protobuf re-encode currently crashes the watch on load.
|
||||
The diagnostic Normalize() call still runs server-side either
|
||||
way and its parse-OK / parse-FAIL log line stays in import
|
||||
telemetry. */}
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={normalizeBlobs}
|
||||
onChange={e => setNormalizeBlobs(e.target.checked)}
|
||||
disabled={busy}
|
||||
className="size-4 mt-0.5 accent-brand-500"
|
||||
/>
|
||||
<span className="text-xs leading-snug">
|
||||
<span className="text-ink-100">Re-encode .room blobs through the 2020 protobuf schema</span>
|
||||
<span className="block text-[11px] text-ink-500 mt-0.5">
|
||||
Strips non-canonical encodings from modern Rec Room exports so the 2020 watch's
|
||||
stricter parser accepts them. Off by default — the current 2020 schema bindings
|
||||
are incomplete and the re-encode tends to crash the watch on dorm/room load.
|
||||
Leave OFF unless you've verified the round-trip works for this specific zip.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="card !p-5">
|
||||
<h2 className="text-sm font-semibold text-ink-50 mb-3">Import result</h2>
|
||||
<div className="grid grid-cols-3 gap-3 mb-4 text-sm">
|
||||
<Stat label="Archive size" value={`${(result.archiveBytes / 1024 / 1024).toFixed(1)} MB`} />
|
||||
<Stat label="Rooms" value={result.roomCount} />
|
||||
<Stat label="Inventions" value={result.inventions.length} />
|
||||
</div>
|
||||
{result.rooms.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-widest text-ink-300 mb-1.5">Rooms</h3>
|
||||
<table className="w-full text-sm mb-4">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium pb-1">Room</th>
|
||||
<th className="text-left font-medium pb-1">Result</th>
|
||||
<th className="text-left font-medium pb-1">Entry</th>
|
||||
<th className="text-right font-medium pb-1">Scenes</th>
|
||||
<th className="text-left font-medium pb-1">HTR</th>
|
||||
<th className="text-left font-medium pb-1">PV</th>
|
||||
<th className="text-left font-medium pb-1">Polaroids</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{result.rooms.map(r => (
|
||||
<tr key={r.folder ?? r.name}>
|
||||
<td className="py-1.5 text-ink-100">{r.name}{r.roomId !== undefined && <span className="text-ink-500 text-xs"> · #{r.roomId}</span>}</td>
|
||||
<td className="py-1.5">
|
||||
{r.ok
|
||||
? <span className="badge-online">imported</span>
|
||||
: <span className="badge-banned" title={r.error}>failed{r.error ? ` — ${r.error}` : ''}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 text-xs text-ink-300">
|
||||
{r.entryScene ?? '—'}
|
||||
{r.entryDetectionSource && <span className="text-ink-500"> ({r.entryDetectionSource})</span>}
|
||||
</td>
|
||||
<td className="py-1.5 text-right text-ink-200 tabular-nums">{r.sceneCount ?? '—'}</td>
|
||||
<td className="py-1.5"><AssetCell breakdown={r.htrAssets} legacy={r.htrAssetsImported} /></td>
|
||||
<td className="py-1.5"><AssetCell breakdown={r.pvImages} legacy={r.pvImagesImported} /></td>
|
||||
<td className="py-1.5"><AssetCell breakdown={r.polaroids} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
{result.inventions.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-widest text-ink-300 mb-1.5">Inventions</h3>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{result.inventions.map(i => (
|
||||
<tr key={i.folder ?? i.name}>
|
||||
<td className="py-1.5 text-ink-100">{i.name}</td>
|
||||
<td className="py-1.5">
|
||||
{i.ok
|
||||
? <span className="badge-online">invention #{i.inventionId}</span>
|
||||
: <span className="badge-banned">{i.error ?? 'failed'}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 text-xs text-ink-400">{i.blobName}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<div className="card !p-3">
|
||||
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
|
||||
<div className="mt-0.5 text-lg font-semibold tabular-nums text-ink-50">
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// "16 new · 44 already · 60 total" so admins can see at a glance
|
||||
// whether HTR/PV blobs are present even after a re-import where most
|
||||
// were already in the DB from a prior run (purge-custom-rooms keeps
|
||||
// shared assets by design).
|
||||
function AssetCell({ breakdown, legacy }: { breakdown?: AssetBreakdown; legacy?: number }) {
|
||||
if (!breakdown) {
|
||||
return <span className="text-ink-300 tabular-nums">{legacy ?? 0}</span>;
|
||||
}
|
||||
const present = breakdown.newlyImported + breakdown.alreadyInDb;
|
||||
if (breakdown.referenced === 0) return <span className="text-ink-500">—</span>;
|
||||
const allPresent = present >= breakdown.referenced;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className={`tabular-nums font-medium ${allPresent ? 'text-success' : 'text-warn'}`}>
|
||||
{present}/{breakdown.referenced}
|
||||
</span>
|
||||
<span className="text-ink-500">
|
||||
({breakdown.newlyImported} new
|
||||
{breakdown.alreadyInDb > 0 ? ` · ${breakdown.alreadyInDb} kept` : ''})
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Mini({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded border border-ink-800 px-2 py-1">
|
||||
<div className="text-[10px] uppercase tracking-widest text-ink-500">{label}</div>
|
||||
<div className="text-sm text-ink-100 tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determinate bar when percent is given (0-100); indeterminate uses a
|
||||
// keyframed sliding strip so the user can see something's happening
|
||||
// even though we don't know how long the server will take.
|
||||
function ProgressBar({ percent, indeterminate }: { percent?: number; indeterminate?: boolean }) {
|
||||
if (indeterminate) {
|
||||
return (
|
||||
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-ink-800">
|
||||
<div className="absolute inset-y-0 left-0 w-1/3 rounded-full bg-brand-500 animate-[progress-slide_1.4s_ease-in-out_infinite]" />
|
||||
<style>{`@keyframes progress-slide { 0% { transform: translateX(-100%); } 50% { transform: translateX(150%); } 100% { transform: translateX(350%); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const clamped = Math.max(0, Math.min(100, percent ?? 0));
|
||||
return (
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-800">
|
||||
<div className="h-full rounded-full bg-brand-500 transition-[width] duration-200" style={{ width: `${clamped}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Zip parsing ───────────────────────────────────────────────────────
|
||||
|
||||
async function parseZipPreview(file: File, onProgress?: (percent: number) => void): Promise<ZipPreview> {
|
||||
const reader = new ZipReader(new BlobReader(file));
|
||||
let entries: Entry[];
|
||||
try {
|
||||
entries = await reader.getEntries();
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
// ZipReader.getEntries reads the central directory — that's the
|
||||
// slow bit. Once we have the entries array, the rest is in-memory
|
||||
// map building + small JSON reads for the manifest files. We tick
|
||||
// progress as we walk; manifest JSONs read after that bump us to
|
||||
// 100%.
|
||||
onProgress?.(20);
|
||||
|
||||
// Build a path-indexed view first; the rest of the parse runs against
|
||||
// this map, never touching the zip stream again.
|
||||
const allPaths = new Set<string>();
|
||||
const entryByPath = new Map<string, Entry>();
|
||||
for (const e of entries) {
|
||||
if (e.directory) continue;
|
||||
const p = e.filename.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
allPaths.add(p);
|
||||
entryByPath.set(p, e);
|
||||
}
|
||||
|
||||
// Count manifests up front so we can advance progress smoothly
|
||||
// as we read each one — the central-directory parse and the
|
||||
// manifest reads dominate; the rest is O(entries) string work.
|
||||
//
|
||||
// CRITICAL: the counter has to match what the iteration loops below
|
||||
// actually call `bump()` on, or progress overflows past 100%. Invention
|
||||
// manifests are accepted at TWO locations (top-level `Inventions/...`
|
||||
// AND per-room `Rooms/Room_X/Inventions/...`); originally we only
|
||||
// counted the top-level ones, so a zip with many per-room inventions
|
||||
// hit 374% on the parse bar. Match the same predicates here.
|
||||
const roomManifestPaths: string[] = [];
|
||||
const inventionManifestPaths: string[] = [];
|
||||
for (const p of allPaths) {
|
||||
if (p.startsWith('Rooms/') && p.endsWith('/RoomDetails.json')) {
|
||||
roomManifestPaths.push(p);
|
||||
} else if (p.endsWith('/Invention.json')
|
||||
&& (p.startsWith('Inventions/') || p.includes('/Inventions/'))) {
|
||||
inventionManifestPaths.push(p);
|
||||
}
|
||||
}
|
||||
const totalManifests = roomManifestPaths.length + inventionManifestPaths.length;
|
||||
let processed = 0;
|
||||
const bump = () => {
|
||||
processed++;
|
||||
// Reserve 20% for the central-dir parse already done, leave 10%
|
||||
// for the final cleanup, so manifest reads occupy 20..90%. Clamp
|
||||
// defensively in case the counters drift again — better to peg the
|
||||
// bar at 90% than show "374%" to the user.
|
||||
const raw = totalManifests > 0 ? 20 + (processed / totalManifests) * 70 : 90;
|
||||
onProgress?.(Math.min(90, Math.round(raw)));
|
||||
};
|
||||
|
||||
// Per-room walk. Anchor on Rooms/<folder>/RoomDetails.json.
|
||||
const rooms: PreviewRoom[] = [];
|
||||
for (const path of allPaths) {
|
||||
if (!path.startsWith('Rooms/') || !path.endsWith('/RoomDetails.json')) continue;
|
||||
const roomFolder = path.slice(0, -'/RoomDetails.json'.length);
|
||||
|
||||
const detailsEntry = entryByPath.get(path);
|
||||
let details: RoomDetailsJson = {};
|
||||
if (detailsEntry) {
|
||||
try { details = JSON.parse(await readText(detailsEntry)); } catch { /* leave as default */ }
|
||||
}
|
||||
|
||||
// Scene folders under SubRooms/
|
||||
const sceneRoot = `${roomFolder}/SubRooms/`;
|
||||
const sceneNames = new Set<string>();
|
||||
for (const p of allPaths) {
|
||||
if (!p.startsWith(sceneRoot)) continue;
|
||||
const rest = p.slice(sceneRoot.length);
|
||||
const slash = rest.indexOf('/');
|
||||
if (slash <= 0) continue;
|
||||
sceneNames.add(rest.slice(0, slash));
|
||||
}
|
||||
|
||||
const subrooms: PreviewSubroom[] = [];
|
||||
for (const scene of sceneNames) {
|
||||
const sceneFolder = `${sceneRoot}${scene}`;
|
||||
const subroomJsonPath = `${sceneFolder}/Subroom.json`;
|
||||
let subroomJson: SubroomJson = {};
|
||||
const hasSubroomJson = entryByPath.has(subroomJsonPath);
|
||||
if (hasSubroomJson) {
|
||||
try { subroomJson = JSON.parse(await readText(entryByPath.get(subroomJsonPath)!)); } catch { /* default */ }
|
||||
}
|
||||
const blobFilename = subroomJson.CurrentSave?.DataBlob ?? null;
|
||||
const blobPath = blobFilename ? `${sceneFolder}/${blobFilename}` : null;
|
||||
const blobFound = blobPath ? entryByPath.has(blobPath) : false;
|
||||
|
||||
let htrAssetCount = 0;
|
||||
let pvImageCount = 0;
|
||||
let polaroidCount = 0;
|
||||
const imgPrefix = `${sceneFolder}/Image/`;
|
||||
const polPrefix = `${sceneFolder}/Polaroids/`;
|
||||
// Server-side mirrors this: a `PVImage_<hash>.ext` file in
|
||||
// Image/ counts as BOTH a PV image (its own filename) and a
|
||||
// polaroid (with the prefix stripped). The watch fetches both
|
||||
// URLs, so the importer writes the bytes twice.
|
||||
for (const p of allPaths) {
|
||||
if (p.startsWith(`${sceneFolder}/AudioSampler/`) && p.endsWith('.htr')) htrAssetCount++;
|
||||
else if (p.startsWith(`${sceneFolder}/Holotar/`) && p.endsWith('.htr')) htrAssetCount++;
|
||||
else if (p.startsWith(imgPrefix)) {
|
||||
pvImageCount++;
|
||||
const file = p.slice(imgPrefix.length);
|
||||
if (file.toLowerCase().startsWith('pvimage_')) polaroidCount++;
|
||||
}
|
||||
else if (p.startsWith(polPrefix)) polaroidCount++;
|
||||
}
|
||||
subrooms.push({ scene, blobFilename, blobFound, htrAssetCount, pvImageCount, polaroidCount, hasSubroomJson });
|
||||
}
|
||||
|
||||
// Sort by RoomDetails.SubRooms[] order if present so the preview
|
||||
// shows the entry-scene first.
|
||||
if (details.SubRooms && details.SubRooms.length > 0) {
|
||||
const manifestOrder = details.SubRooms.map(s => s.Name ?? '').filter(Boolean);
|
||||
subrooms.sort((a, b) => {
|
||||
const ai = manifestOrder.indexOf(a.scene);
|
||||
const bi = manifestOrder.indexOf(b.scene);
|
||||
if (ai === -1 && bi === -1) return a.scene.localeCompare(b.scene);
|
||||
if (ai === -1) return 1;
|
||||
if (bi === -1) return -1;
|
||||
return ai - bi;
|
||||
});
|
||||
} else {
|
||||
subrooms.sort((a, b) => a.scene.localeCompare(b.scene));
|
||||
}
|
||||
|
||||
// Image / promo discovery
|
||||
const hasImage = !!details.ImageName && entryByPath.has(`${roomFolder}/${details.ImageName}`)
|
||||
|| Array.from(allPaths).some(p => p.startsWith(`${roomFolder}/RoomImage.`));
|
||||
let promoImageCount = 0;
|
||||
for (const p of allPaths) if (p.startsWith(`${roomFolder}/Promo/Image/`)) promoImageCount++;
|
||||
|
||||
// Pick the most-recent timestamp across the room's own CreatedAt /
|
||||
// PublishedAt / ModifiedAt and each subroom's ModifiedAt — the
|
||||
// most aggressive signal of "made on a build newer than 2020".
|
||||
const candidateDates: number[] = [];
|
||||
for (const raw of [details.CreatedAt, details.PublishedAt, details.ModifiedAt]) {
|
||||
if (raw) {
|
||||
const t = Date.parse(raw);
|
||||
if (!Number.isNaN(t)) candidateDates.push(t);
|
||||
}
|
||||
}
|
||||
// We need to re-read the subroom jsons we already parsed above
|
||||
// for their ModifiedAt fields. Cheap second pass — preview only
|
||||
// parses small JSONs.
|
||||
for (const scene of sceneNames) {
|
||||
const subroomJsonPath = `${sceneRoot}${scene}/Subroom.json`;
|
||||
if (!entryByPath.has(subroomJsonPath)) continue;
|
||||
try {
|
||||
const sr = JSON.parse(await readText(entryByPath.get(subroomJsonPath)!)) as SubroomJson;
|
||||
if (sr.ModifiedAt) {
|
||||
const t = Date.parse(sr.ModifiedAt);
|
||||
if (!Number.isNaN(t)) candidateDates.push(t);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
const latestMs = candidateDates.length > 0 ? Math.max(...candidateDates) : null;
|
||||
const latestModifiedAt = latestMs !== null ? new Date(latestMs).toISOString() : null;
|
||||
const postBuildCutoff = latestMs !== null && latestMs > CLIENT_BUILD_CUTOFF.getTime();
|
||||
|
||||
rooms.push({
|
||||
folder: roomFolder,
|
||||
name: details.Name ?? details.FriendlyName ?? roomFolder.split('/').pop()!,
|
||||
description: details.Description ?? '',
|
||||
tags: (details.Tags ?? []).map(t => t.Tag).filter((t): t is string => !!t),
|
||||
stats: {
|
||||
visits: details.Stats?.VisitCount ?? 0,
|
||||
visitors: details.Stats?.VisitorCount ?? 0,
|
||||
cheers: details.Stats?.CheerCount ?? 0,
|
||||
favorites: details.Stats?.FavoriteCount ?? 0,
|
||||
},
|
||||
subrooms,
|
||||
hasImage,
|
||||
promoImageCount,
|
||||
latestModifiedAt,
|
||||
postBuildCutoff,
|
||||
});
|
||||
bump();
|
||||
}
|
||||
|
||||
// Inventions — top-level `Inventions/` OR per-room `Rooms/<r>/Inventions/`.
|
||||
// The exporter uses either layout depending on the room (rooms with
|
||||
// their own attached inventions ship them inside the room folder).
|
||||
const inventions: PreviewInvention[] = [];
|
||||
for (const path of allPaths) {
|
||||
if (!path.endsWith('/Invention.json')) continue;
|
||||
if (!path.startsWith('Inventions/') && !path.includes('/Inventions/')) continue;
|
||||
const folder = path.slice(0, -'/Invention.json'.length);
|
||||
let meta: InventionJson = {};
|
||||
try { meta = JSON.parse(await readText(entryByPath.get(path)!)); } catch { /* default */ }
|
||||
|
||||
const hasBlob = Array.from(allPaths).some(p => p.startsWith(`${folder}/`) && p.endsWith('.inv'));
|
||||
const hasImage = (!!meta.ImageName && entryByPath.has(`${folder}/${meta.ImageName}`))
|
||||
|| Array.from(allPaths).some(p => p.startsWith(`${folder}/InventionImage.`));
|
||||
inventions.push({
|
||||
folder,
|
||||
name: meta.Name ?? folder.split('/').pop()!,
|
||||
description: meta.Description ?? '',
|
||||
hasBlob,
|
||||
hasImage,
|
||||
versionNumber: meta.CurrentVersionNumber ?? 1,
|
||||
});
|
||||
bump();
|
||||
}
|
||||
|
||||
// Anything not under Rooms/ or top-level Inventions/ is flagged.
|
||||
// Per-room Inventions/ paths are valid (live under Rooms/) so they
|
||||
// don't trigger here.
|
||||
const unknownTopLevel: string[] = [];
|
||||
for (const p of allPaths) {
|
||||
if (!p.startsWith('Rooms/') && !p.startsWith('Inventions/')) unknownTopLevel.push(p);
|
||||
}
|
||||
unknownTopLevel.sort();
|
||||
|
||||
rooms.sort((a, b) => a.name.localeCompare(b.name));
|
||||
inventions.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
onProgress?.(100);
|
||||
return { rooms, inventions, unknownTopLevel };
|
||||
}
|
||||
|
||||
// Entry is a union of FileEntry | DirectoryEntry; only FileEntry has
|
||||
// .getData. We already filter out directories before reaching this,
|
||||
// but TS can't carry that info through the Map, so narrow explicitly.
|
||||
async function readText(entry: Entry): Promise<string> {
|
||||
const file = entry as Entry & { getData?: (writer: TextWriter) => Promise<string> };
|
||||
if (!file.getData) throw new Error(`zip entry "${entry.filename}" has no data (directory entry)`);
|
||||
return file.getData(new TextWriter());
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import type { Player, Room } from '../lib/types';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { useToast } from '../components/Toast';
|
||||
|
||||
// Extracts the file's parent folder name from a webkitRelativePath
|
||||
// like "YarrHarrHeist/Lobby/abc.room" → "Lobby". Mirrors the C# helper
|
||||
// in AdminController.ImportRoom so the preview matches what the server
|
||||
// will actually use.
|
||||
function extractScene(relPath: string): string {
|
||||
const parts = relPath.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
return parts.length >= 2 ? parts[parts.length - 2] : '';
|
||||
}
|
||||
|
||||
interface FilePick {
|
||||
file: File;
|
||||
scene: string;
|
||||
}
|
||||
|
||||
export function LegacyImportRoom() {
|
||||
const { data: players } = useApi<Player[]>('/players?take=500');
|
||||
const { data: rooms } = useApi<Room[]>('/rooms');
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [entryScene, setEntryScene] = useState('');
|
||||
const [ownerId, setOwnerId] = useState<number | null>(null);
|
||||
const [picks, setPicks] = useState<FilePick[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const folderRef = useRef<HTMLInputElement>(null);
|
||||
const filesRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const distinctScenes = useMemo(() => Array.from(new Set(picks.map(p => p.scene))).filter(Boolean), [picks]);
|
||||
|
||||
const onFolder = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
setPicks(files
|
||||
.filter(f => f.name.endsWith('.room'))
|
||||
.map(f => {
|
||||
// webkitRelativePath is in lib.dom.d.ts as a non-empty string when
|
||||
// the file was picked via <input webkitdirectory>; otherwise "".
|
||||
const rel = f.webkitRelativePath ?? '';
|
||||
return { file: f, scene: extractScene(rel) || extractScene(f.name) };
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
setPicks(prev => [
|
||||
...prev,
|
||||
...files.filter(f => f.name.endsWith('.room'))
|
||||
.map(f => ({ file: f, scene: '' })),
|
||||
]);
|
||||
};
|
||||
|
||||
const updateScene = (idx: number, scene: string) => {
|
||||
setPicks(prev => prev.map((p, i) => i === idx ? { ...p, scene } : p));
|
||||
};
|
||||
|
||||
const removeFile = (idx: number) => {
|
||||
setPicks(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !ownerId || picks.length === 0) return;
|
||||
if (picks.some(p => !p.scene.trim())) {
|
||||
toast.push('Every file needs a scene folder name', 'error');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
const fd = new FormData();
|
||||
fd.append('name', name.trim());
|
||||
fd.append('description', description.trim());
|
||||
fd.append('entryScene', entryScene.trim());
|
||||
fd.append('creatorPlayerId', String(ownerId));
|
||||
for (const p of picks) {
|
||||
fd.append('files', p.file, p.file.name);
|
||||
fd.append('scenePaths', p.scene);
|
||||
}
|
||||
try {
|
||||
const res = await api<{
|
||||
roomId: number; roomName: string; sceneCount: number; entryScene: string;
|
||||
normalizedSceneCount: number; htrMirrorStarted: boolean;
|
||||
}>('/rooms/import', { method: 'POST', formData: fd, timeoutMs: 600_000 });
|
||||
setResult(`Imported room #${res.roomId} "${res.roomName}" — ${res.sceneCount} scene(s), entry: ${res.entryScene}. .htr mirror running in background.`);
|
||||
toast.push('Room imported', 'success');
|
||||
setName(''); setDescription(''); setEntryScene(''); setPicks([]);
|
||||
if (folderRef.current) folderRef.current.value = '';
|
||||
if (filesRef.current) filesRef.current.value = '';
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : (e as Error).message;
|
||||
setResult(`Failed: ${msg}`);
|
||||
toast.push(msg, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── .htr re-mirror ────────────────────────────────────────────────
|
||||
const [mirrorRoomId, setMirrorRoomId] = useState<number | ''>('');
|
||||
const [mirrorResult, setMirrorResult] = useState<string | null>(null);
|
||||
const [mirrorBusy, setMirrorBusy] = useState(false);
|
||||
|
||||
const runMirror = async () => {
|
||||
if (!mirrorRoomId) return;
|
||||
setMirrorBusy(true);
|
||||
setMirrorResult(null);
|
||||
try {
|
||||
const res = await api<{ uniqueRefs: number; downloaded: number; alreadyMirrored: number }>(
|
||||
`/rooms/${mirrorRoomId}/mirror-htr`, { method: 'POST', timeoutMs: 600_000 }
|
||||
);
|
||||
setMirrorResult(`Scanned ${res.uniqueRefs} refs · ${res.downloaded} downloaded · ${res.alreadyMirrored} already mirrored.`);
|
||||
toast.push('Mirror complete', 'success');
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
setMirrorResult(msg);
|
||||
toast.push(msg, 'error');
|
||||
} finally {
|
||||
setMirrorBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Import room (legacy)"
|
||||
blurb="Original folder-based importer. The new zip-based importer at /import-room handles the same workflow with less manual fiddling; this page stays around for tooling that already targets the legacy multi-file endpoint."
|
||||
/>
|
||||
|
||||
<form onSubmit={submit} className="card !p-5 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="Room name">
|
||||
<input value={name} onChange={e => setName(e.target.value)} required className="input" placeholder="YarrHarrHeist" />
|
||||
</Field>
|
||||
<Field label="Entry scene (optional, defaults to Lobby or alphabetical first)">
|
||||
<input value={entryScene} onChange={e => setEntryScene(e.target.value)} className="input" placeholder="Lobby" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Description">
|
||||
<input value={description} onChange={e => setDescription(e.target.value)} className="input" placeholder="Imported from archive" />
|
||||
</Field>
|
||||
<Field label="Owner (account that becomes the room's creator)">
|
||||
<select value={ownerId ?? ''} onChange={e => setOwnerId(e.target.value ? parseInt(e.target.value) : null)} required className="input">
|
||||
<option value="">— pick an owner —</option>
|
||||
{(players ?? []).map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="Pick a folder (preserves scene structure)">
|
||||
<input
|
||||
ref={folderRef}
|
||||
type="file"
|
||||
multiple
|
||||
// @ts-expect-error — webkitdirectory is non-standard but supported by Chromium + Firefox
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
onChange={onFolder}
|
||||
className="input file:mr-2 file:rounded-md file:border-0 file:bg-ink-700 file:px-2.5 file:py-1 file:text-xs file:text-ink-100 file:cursor-pointer"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="…or pick individual .room files (set scene manually)">
|
||||
<input
|
||||
ref={filesRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".room"
|
||||
onChange={onFiles}
|
||||
className="input file:mr-2 file:rounded-md file:border-0 file:bg-ink-700 file:px-2.5 file:py-1 file:text-xs file:text-ink-100 file:cursor-pointer"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{picks.length > 0 && (
|
||||
<div className="card !p-3">
|
||||
<div className="text-xs text-ink-400 mb-2">
|
||||
{picks.length} file{picks.length === 1 ? '' : 's'} · {distinctScenes.length} scene{distinctScenes.length === 1 ? '' : 's'} ({distinctScenes.join(', ') || 'none'})
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-ink-400">
|
||||
<tr><th className="text-left pb-1">Scene</th><th className="text-left pb-1">File</th><th className="text-right pb-1">Bytes</th><th /></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{picks.map((p, i) => (
|
||||
<tr key={i}>
|
||||
<td className="py-1.5"><input value={p.scene} onChange={e => updateScene(i, e.target.value)} className="input !py-1 text-xs" /></td>
|
||||
<td className="py-1.5 font-mono text-ink-300">{p.file.name}</td>
|
||||
<td className="py-1.5 text-right text-ink-400 tabular-nums">{p.file.size.toLocaleString()}</td>
|
||||
<td className="py-1.5 text-right"><button type="button" onClick={() => removeFile(i)} className="btn-ghost text-xs">×</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button disabled={busy || !name.trim() || !ownerId || picks.length === 0} className="btn-primary text-xs">
|
||||
{busy ? 'Uploading…' : `Import (${picks.length} files)`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result && <div className="card !p-3 text-xs text-ink-200 whitespace-pre-wrap">{result}</div>}
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<h2 className="text-lg font-semibold text-ink-50 mb-1">Re-mirror .htr assets</h2>
|
||||
<p className="text-sm text-ink-400 mb-3">
|
||||
Synchronously rescan an existing room's blobs and download any Holotar / AudioSampler <code className="font-mono text-xs">.htr</code> refs not yet in <code className="font-mono text-xs">RoomDataBlobs</code>.
|
||||
</p>
|
||||
<div className="card !p-4 flex flex-wrap items-end gap-2">
|
||||
<Field label="Room">
|
||||
<select value={mirrorRoomId} onChange={e => setMirrorRoomId(e.target.value ? parseInt(e.target.value) : '')} className="input min-w-[280px]">
|
||||
<option value="">— pick a room —</option>
|
||||
{(rooms ?? []).map(r => <option key={r.id} value={r.id}>{r.name} · #{r.id} · {r.blobCount} blobs</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<button onClick={runMirror} disabled={mirrorBusy || !mirrorRoomId} className="btn-secondary text-xs">
|
||||
{mirrorBusy ? 'Mirroring…' : 'Run mirror'}
|
||||
</button>
|
||||
{mirrorResult && <div className="text-xs text-ink-300 ml-2">{mirrorResult}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api, get } from '../lib/api';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { Plus, Trash } from '../components/Icons';
|
||||
|
||||
interface LoadingScreenTip {
|
||||
id?: number;
|
||||
title: string;
|
||||
message: string;
|
||||
imageName: string;
|
||||
context: number;
|
||||
platformMask: number;
|
||||
roomNamesCsv: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function blankTip(): LoadingScreenTip {
|
||||
return {
|
||||
title: '',
|
||||
message: '',
|
||||
imageName: '',
|
||||
context: 0,
|
||||
platformMask: -1,
|
||||
roomNamesCsv: '',
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function LoadingTips() {
|
||||
const [tips, setTips] = useState<LoadingScreenTip[] | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
get<LoadingScreenTip[]>('/loadingscreentips')
|
||||
.then(setTips)
|
||||
.catch(e => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
const update = (idx: number, next: LoadingScreenTip) => {
|
||||
if (!tips) return;
|
||||
const copy = tips.slice();
|
||||
copy[idx] = next;
|
||||
setTips(copy);
|
||||
};
|
||||
|
||||
const removeLocal = (idx: number) => {
|
||||
if (!tips) return;
|
||||
setTips(tips.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const addNew = async () => {
|
||||
try {
|
||||
const created = await api<LoadingScreenTip>('/loadingscreentips', {
|
||||
method: 'POST',
|
||||
body: blankTip(),
|
||||
});
|
||||
setTips([...(tips ?? []), created]);
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async (idx: number) => {
|
||||
if (!tips) return;
|
||||
const t = tips[idx];
|
||||
try {
|
||||
if (t.id) {
|
||||
const saved = await api<LoadingScreenTip>(`/loadingscreentips/${t.id}`, {
|
||||
method: 'PUT',
|
||||
body: t,
|
||||
});
|
||||
update(idx, saved);
|
||||
} else {
|
||||
const saved = await api<LoadingScreenTip>('/loadingscreentips', {
|
||||
method: 'POST',
|
||||
body: t,
|
||||
});
|
||||
update(idx, saved);
|
||||
}
|
||||
toast.push('Saved', 'success');
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (idx: number) => {
|
||||
if (!tips) return;
|
||||
const t = tips[idx];
|
||||
if (!confirm(`Delete tip "${t.title || '(untitled)'}"?`)) return;
|
||||
try {
|
||||
if (t.id) await api(`/loadingscreentips/${t.id}`, { method: 'DELETE' });
|
||||
removeLocal(idx);
|
||||
toast.push('Deleted', 'success');
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Loading screen tips"
|
||||
blurb="The list the watch fetches from cdn.localhost/config/LoadingScreenTipData on every dorm load. Edit, upload an image, or scope a tip to specific rooms — saves are live immediately."
|
||||
actions={<button onClick={addNew} className="btn-primary text-xs"><Plus /> Add tip</button>}
|
||||
/>
|
||||
|
||||
{err && (
|
||||
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tips === null && !err && (
|
||||
<div className="card !p-6 text-center text-xs text-ink-400">Loading tips…</div>
|
||||
)}
|
||||
|
||||
{tips && tips.length === 0 && (
|
||||
<div className="card !p-6 text-center text-xs text-ink-400">
|
||||
No tips yet — add one with the button above.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tips && (
|
||||
<div className="space-y-3">
|
||||
{tips.map((t, i) => (
|
||||
<TipEditor
|
||||
key={t.id ?? `new-${i}`}
|
||||
tip={t}
|
||||
onChange={next => update(i, next)}
|
||||
onSave={() => save(i)}
|
||||
onRemove={() => remove(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TipEditor({
|
||||
tip,
|
||||
onChange,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: {
|
||||
tip: LoadingScreenTip;
|
||||
onChange: (next: LoadingScreenTip) => void;
|
||||
onSave: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.push('Please pick an image file', 'error');
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await api<{ imageName: string; imageUrl: string; bytes: number; reused: boolean }>(
|
||||
'/loadingscreentips/upload',
|
||||
{ method: 'POST', formData: fd, timeoutMs: 60_000 },
|
||||
);
|
||||
onChange({ ...tip, imageName: res.imageName });
|
||||
toast.push(res.reused ? 'Image already on file — reused' : `Uploaded (${(res.bytes / 1024).toFixed(0)} KB)`, 'success');
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInput.current) fileInput.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const apex = typeof window !== 'undefined' && window.location.host.endsWith('localhost') ? 'localhost' : 'rec.net';
|
||||
const preview = tip.imageName ? `https://img.${apex}/${encodeURIComponent(tip.imageName)}?width=160&sig=p1` : null;
|
||||
|
||||
return (
|
||||
<div className="card !p-4 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
className="shrink-0 size-24 rounded bg-ink-900 border border-ink-800 overflow-hidden flex items-center justify-center cursor-pointer hover:border-brand-500"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
title="Click to upload"
|
||||
>
|
||||
{preview ? (
|
||||
<img src={preview} className="size-full object-cover" alt="" />
|
||||
) : (
|
||||
<span className="text-[10px] text-ink-500 px-1 text-center">{uploading ? 'Uploading…' : 'Pick image'}</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<input
|
||||
value={tip.title}
|
||||
onChange={e => onChange({ ...tip, title: e.target.value })}
|
||||
placeholder="Tip title (shown bold on the splash)"
|
||||
className="input text-sm w-full"
|
||||
maxLength={128}
|
||||
/>
|
||||
<textarea
|
||||
value={tip.message}
|
||||
onChange={e => onChange({ ...tip, message: e.target.value })}
|
||||
placeholder="Tip body (the actual text the player reads)"
|
||||
className="input text-xs w-full"
|
||||
rows={2}
|
||||
maxLength={512}
|
||||
/>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={uploading}
|
||||
className="btn-secondary text-xs"
|
||||
>
|
||||
{uploading ? 'Uploading…' : preview ? 'Replace image' : 'Upload image'}
|
||||
</button>
|
||||
{tip.imageName && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...tip, imageName: '' })}
|
||||
className="btn-ghost text-xs text-danger"
|
||||
>
|
||||
Clear image
|
||||
</button>
|
||||
)}
|
||||
<span className="font-mono text-[11px] text-ink-400 truncate flex-1" title={tip.imageName}>
|
||||
{tip.imageName || <span className="italic text-ink-600">no image</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2 text-xs">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-ink-400">Room scope (csv, optional)</span>
|
||||
<input
|
||||
value={tip.roomNamesCsv}
|
||||
onChange={e => onChange({ ...tip, roomNamesCsv: e.target.value })}
|
||||
placeholder="DormRoom,MakerRoom"
|
||||
className="input text-xs"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-ink-400">Context (int)</span>
|
||||
<input
|
||||
type="number"
|
||||
value={tip.context}
|
||||
onChange={e => onChange({ ...tip, context: parseInt(e.target.value) || 0 })}
|
||||
className="input text-xs"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-ink-400">Platform mask</span>
|
||||
<input
|
||||
type="number"
|
||||
value={tip.platformMask}
|
||||
onChange={e => onChange({ ...tip, platformMask: parseInt(e.target.value) || -1 })}
|
||||
className="input text-xs"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-ink-400">Sort order</span>
|
||||
<input
|
||||
type="number"
|
||||
value={tip.sortOrder}
|
||||
onChange={e => onChange({ ...tip, sortOrder: parseInt(e.target.value) || 0 })}
|
||||
className="input text-xs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-ink-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tip.isActive}
|
||||
onChange={e => onChange({ ...tip, isActive: e.target.checked })}
|
||||
/>
|
||||
Active (served to watch)
|
||||
</label>
|
||||
<div className="flex-1" />
|
||||
<button onClick={onSave} className="btn-primary text-xs">Save</button>
|
||||
<button onClick={onRemove} className="btn-ghost text-xs text-danger" title="Delete tip">
|
||||
<Trash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import { notifyAuthChange, setSession } from '../lib/auth';
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
account_id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api<LoginResponse>('/login', {
|
||||
method: 'POST',
|
||||
body: { username, password },
|
||||
});
|
||||
setSession(res.access_token, {
|
||||
id: res.account_id,
|
||||
username: res.username,
|
||||
displayName: res.display_name,
|
||||
});
|
||||
notifyAuthChange();
|
||||
navigate(from, { replace: true });
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.body && typeof e.body === 'object' && 'error' in e.body) {
|
||||
const code = (e.body as { error: string }).error;
|
||||
if (code === 'not_admin') setErr('That account is not an admin.');
|
||||
else if (code === 'invalid_credentials') setErr('Invalid username or password.');
|
||||
else setErr(e.message);
|
||||
} else setErr(e.message);
|
||||
} else setErr(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center px-4 py-12 bg-gradient-to-br from-ink-950 via-ink-950 to-ink-900">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 flex flex-col items-center gap-2">
|
||||
<div className="size-12 rounded-xl bg-gradient-to-br from-brand-400 to-brand-600 flex items-center justify-center text-xl font-bold text-white shadow-lg shadow-brand-900/40">D</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-ink-50">DorkNet Admin</h1>
|
||||
<p className="text-xs text-ink-400">Sign in with an admin account.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="card p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="label" htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="input"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="label" htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{err && (
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger">{err}</div>
|
||||
)}
|
||||
<button type="submit" disabled={loading} className="btn-primary mt-1">
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-[11px] text-ink-500">DorkNet private server · admin console</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import type { Player } from '../lib/types';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { useToast } from '../components/Toast';
|
||||
|
||||
export function Passwords() {
|
||||
const { data: players } = useApi<Player[]>('/players?take=500');
|
||||
const [playerId, setPlayerId] = useState<number | null>(null);
|
||||
const [pw, setPw] = useState('');
|
||||
const [pw2, setPw2] = useState('');
|
||||
const [show, setShow] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const random = () => {
|
||||
// Simple human-friendly random password — letters, digits, and a
|
||||
// touch of punctuation. 16 chars is comfortably above the BCrypt
|
||||
// 72-byte cap and the server's 8-char minimum.
|
||||
const alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!?#@*';
|
||||
const buf = new Uint32Array(16);
|
||||
crypto.getRandomValues(buf);
|
||||
const next = Array.from(buf, n => alphabet[n % alphabet.length]).join('');
|
||||
setPw(next); setPw2(next); setShow(true);
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!playerId) return;
|
||||
if (pw !== pw2) return toast.push("Passwords don't match", 'error');
|
||||
if (pw.length < 8) return toast.push('Password must be at least 8 characters', 'error');
|
||||
const player = players?.find(p => p.id === playerId);
|
||||
if (!confirm(`Reset password for @${player?.username ?? `#${playerId}`}? This forces a logout on every active session.`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/players/${playerId}/password`, {
|
||||
method: 'POST',
|
||||
body: { NewPassword: pw },
|
||||
});
|
||||
toast.push('Password updated', 'success');
|
||||
setPw(''); setPw2(''); setShow(false);
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Reset passwords"
|
||||
blurb="Admin-set a new password for any account. Issues a Logout push so any active sessions on the old password get dropped."
|
||||
/>
|
||||
|
||||
<form onSubmit={submit} className="card !p-5 max-w-xl space-y-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Account</span>
|
||||
<select value={playerId ?? ''} onChange={e => setPlayerId(e.target.value ? parseInt(e.target.value) : null)} required className="input">
|
||||
<option value="">— pick an account —</option>
|
||||
{(players ?? []).map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">New password</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={show ? 'text' : 'password'}
|
||||
value={pw}
|
||||
onChange={e => setPw(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="input flex-1 font-mono"
|
||||
/>
|
||||
<button type="button" onClick={() => setShow(s => !s)} className="btn-ghost text-xs">{show ? 'Hide' : 'Show'}</button>
|
||||
<button type="button" onClick={random} className="btn-secondary text-xs">Random</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Confirm new password</span>
|
||||
<input
|
||||
type={show ? 'text' : 'password'}
|
||||
value={pw2}
|
||||
onChange={e => setPw2(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="input font-mono"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-xs text-warn">
|
||||
The plaintext is hashed with BCrypt before write — we never store it. The audit log records the action but not the password.
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={() => { setPw(''); setPw2(''); setShow(false); }} className="btn-ghost text-xs" disabled={busy}>Clear</button>
|
||||
<button className="btn-primary text-xs" disabled={busy || !playerId || pw.length < 8 || pw !== pw2}>
|
||||
{busy ? 'Updating…' : 'Update password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { get } from '../lib/api';
|
||||
import type { Player, PlayerLogEntry } from '../lib/types';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { Empty } from '../components/Empty';
|
||||
import { absoluteTime, clip, relativeTime } from '../lib/format';
|
||||
import { RefreshCw } from '../components/Icons';
|
||||
|
||||
export function PlayerLogs() {
|
||||
const { data: players } = useApi<Player[]>('/players?take=500');
|
||||
const [pid, setPid] = useState<number | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [take, setTake] = useState(200);
|
||||
const [auto, setAuto] = useState(false);
|
||||
const [rows, setRows] = useState<PlayerLogEntry[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const load = useMemo(() => async () => {
|
||||
if (pid === null) return;
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const data = await get<PlayerLogEntry[]>(`/players/${pid}/logs?take=${take}`);
|
||||
setRows(data);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pid, take]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => {
|
||||
if (!auto) return;
|
||||
const id = setInterval(load, 5000);
|
||||
return () => clearInterval(id);
|
||||
}, [auto, load]);
|
||||
|
||||
const filtered = (rows ?? []).filter(r => {
|
||||
if (!filter.trim()) return true;
|
||||
const f = filter.toLowerCase();
|
||||
return r.path.toLowerCase().includes(f)
|
||||
|| String(r.status).includes(f)
|
||||
|| r.method.toLowerCase().includes(f)
|
||||
|| r.host.toLowerCase().includes(f);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Player request logs"
|
||||
blurb="Last few hundred HTTP calls per player, pulled from the Redis ring buffer. Useful when a player reports something weird."
|
||||
/>
|
||||
|
||||
<div className="card !p-4 mb-4 grid grid-cols-1 md:grid-cols-[2fr,1fr,120px,auto,auto] gap-2 items-end">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Player</span>
|
||||
<select value={pid ?? ''} onChange={e => setPid(e.target.value ? parseInt(e.target.value) : null)} className="input">
|
||||
<option value="">— pick a player —</option>
|
||||
{(players ?? []).map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.displayName || p.username} · @{p.username} · #{p.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Filter</span>
|
||||
<input value={filter} onChange={e => setFilter(e.target.value)} placeholder="path, host, status…" className="input" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Take</span>
|
||||
<input type="number" min={10} max={500} value={take} onChange={e => setTake(parseInt(e.target.value || '200'))} className="input" />
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-ink-300 pb-1">
|
||||
<input type="checkbox" checked={auto} onChange={e => setAuto(e.target.checked)} className="size-4 accent-brand-500" />
|
||||
auto 5s
|
||||
</label>
|
||||
<button onClick={load} className="btn-secondary text-xs h-9" disabled={loading || pid === null}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>}
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{pid === null && <Empty title="Pick a player" blurb="Logs load once you select someone above." />}
|
||||
{pid !== null && rows && filtered.length === 0 && (
|
||||
<Empty title={filter ? 'No log lines match the filter' : 'No log entries for this player yet'} />
|
||||
)}
|
||||
{filtered.length > 0 && (
|
||||
<div className="table-scroll"><table className="w-full text-sm min-w-[720px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-3 py-2">When</th>
|
||||
<th className="text-left font-medium px-3 py-2">Status</th>
|
||||
<th className="text-left font-medium px-3 py-2">Method</th>
|
||||
<th className="text-left font-medium px-3 py-2">Host</th>
|
||||
<th className="text-left font-medium px-3 py-2">Path</th>
|
||||
<th className="text-right font-medium px-3 py-2">ms</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800 font-mono text-xs">
|
||||
{filtered.map((r, i) => (
|
||||
<tr key={i} className="table-row-hover">
|
||||
<td className="px-3 py-1.5 text-ink-400 whitespace-nowrap" title={absoluteTime(r.timestamp)}>{relativeTime(r.timestamp)}</td>
|
||||
<td className={`px-3 py-1.5 tabular-nums ${r.status >= 500 ? 'text-danger' : r.status >= 400 ? 'text-warn' : 'text-success'}`}>{r.status}</td>
|
||||
<td className="px-3 py-1.5 text-ink-200">{r.method}</td>
|
||||
<td className="px-3 py-1.5 text-ink-300">{r.host}</td>
|
||||
<td className="px-3 py-1.5 text-ink-100" title={r.path + (r.query ? '?' + r.query : '')}>
|
||||
{clip(r.path + (r.query ? '?' + r.query : ''), 100)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right text-ink-300 tabular-nums">{r.elapsedMs}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api, get } from '../lib/api';
|
||||
import type { Player, PlayerDetail } from '../lib/types';
|
||||
import { absoluteTime, currencyName, num, relativeTime } from '../lib/format';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { Modal } from '../components/Modal';
|
||||
import { Confirm } from '../components/Confirm';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { Empty } from '../components/Empty';
|
||||
import { PlayerAvatar } from '../components/PlayerAvatar';
|
||||
import { RefreshCw, Search } from '../components/Icons';
|
||||
|
||||
export function Players() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [rows, setRows] = useState<Player[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
// Debounced search — the watch admin sat on /api/admin/v1/players for
|
||||
// every keypress, blowing through the SQLite write lock pool. 200ms
|
||||
// is enough to feel responsive without thrashing the DB.
|
||||
const search = useMemo(() => {
|
||||
const handler = (q: string) => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
get<Player[]>(`/players?take=200${q ? `&query=${encodeURIComponent(q)}` : ''}`)
|
||||
.then(setRows)
|
||||
.catch(e => setErr((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
let t: number | undefined;
|
||||
return (q: string) => {
|
||||
window.clearTimeout(t);
|
||||
t = window.setTimeout(() => handler(q), 200);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => { search(query); }, [query, search]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Players"
|
||||
blurb="Search and moderate every account in the database."
|
||||
actions={
|
||||
<button onClick={() => search(query)} className="btn-secondary text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="border-b border-ink-800 p-3 flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search username or display name…"
|
||||
className="input pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-ink-400 ml-auto">
|
||||
{rows ? `${rows.length} match${rows.length === 1 ? '' : 'es'}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="px-4 py-3 text-sm text-danger border-b border-danger/30 bg-danger/5">{err}</div>}
|
||||
|
||||
<div className="table-scroll">
|
||||
<table className="w-full text-sm min-w-[640px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-2.5">Account</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Status</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Level</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Last seen</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Joined</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{rows?.map(p => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className="table-row-hover cursor-pointer"
|
||||
onClick={() => setSelectedId(p.id)}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<PlayerAvatar name={p.profileImageName} displayName={p.displayName || p.username} size={32} />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-ink-50 truncate">{p.displayName || p.username}</div>
|
||||
<div className="text-xs text-ink-400 truncate">@{p.username} <span className="text-ink-600">·</span> #{p.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.online && <span className="badge-online">Online</span>}
|
||||
{p.bannedUntil && new Date(p.bannedUntil) > new Date() && <span className="badge-banned">Banned</span>}
|
||||
{p.isAdmin && <span className="badge-admin">Admin</span>}
|
||||
{p.isDeveloper && <span className="badge-neutral">Dev</span>}
|
||||
{p.isCommunityTeam && <span className="badge-neutral">Community</span>}
|
||||
{p.isJunior && <span className="badge-junior">Junior</span>}
|
||||
{p.isVerified && <span className="badge-neutral">Verified</span>}
|
||||
{!p.online && !p.bannedUntil && !p.isAdmin && !p.isDeveloper && !p.isCommunityTeam && !p.isJunior && !p.isVerified && (
|
||||
<span className="text-xs text-ink-500">—</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-200">{p.level}</td>
|
||||
<td className="px-4 py-2.5 text-ink-300 text-xs" title={absoluteTime(p.lastSeenAt)}>{relativeTime(p.lastSeenAt)}</td>
|
||||
<td className="px-4 py-2.5 text-ink-300 text-xs" title={absoluteTime(p.createdAt)}>{relativeTime(p.createdAt)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<button className="btn-ghost text-xs">Open →</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows && rows.length === 0 && (
|
||||
<tr><td colSpan={6}><Empty title="No matches" blurb="Try a different name or clear the filter." /></td></tr>
|
||||
)}
|
||||
{!rows && !err && (
|
||||
<tr><td colSpan={6} className="py-10 text-center text-xs text-ink-400">Loading players…</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedId !== null && (
|
||||
<PlayerDetail
|
||||
id={selectedId}
|
||||
onClose={() => setSelectedId(null)}
|
||||
onChanged={() => { setSelectedId(null); search(query); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Detail / edit modal ───────────────────────────────────────────────
|
||||
|
||||
function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => void; onChanged: () => void }) {
|
||||
const [data, setData] = useState<PlayerDetail | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<'overview' | 'mod' | 'grants' | 'profile'>('overview');
|
||||
|
||||
const reload = () => {
|
||||
setErr(null);
|
||||
get<PlayerDetail>(`/players/${id}`)
|
||||
.then(setData)
|
||||
.catch(e => setErr((e as Error).message));
|
||||
};
|
||||
useEffect(reload, [id]);
|
||||
|
||||
return (
|
||||
<Modal title={`Player #${id}`} open onClose={onClose} size="xl">
|
||||
{err && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger mb-3">{err}</div>}
|
||||
{!data && !err && <div className="py-10 text-center text-xs text-ink-400">Loading…</div>}
|
||||
{data && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<PlayerAvatar name={data.profileImageName} displayName={data.displayName || data.username} size={56} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-lg font-semibold text-ink-50 truncate">{data.displayName || data.username}</div>
|
||||
<div className="text-xs text-ink-400 truncate">@{data.username} · #{data.id} · {data.email ?? 'no email'}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{data.online && <span className="badge-online">Online</span>}
|
||||
{data.bannedUntil && new Date(data.bannedUntil) > new Date() && (
|
||||
<span className="badge-banned" title={absoluteTime(data.bannedUntil)}>Banned · expires {relativeTime(data.bannedUntil)}</span>
|
||||
)}
|
||||
{data.isAdmin && <span className="badge-admin">Admin</span>}
|
||||
{data.isDeveloper && <span className="badge-neutral">Developer</span>}
|
||||
{data.isCommunityTeam && <span className="badge-neutral">Community Team</span>}
|
||||
{data.isVerified && <span className="badge-neutral">Verified</span>}
|
||||
{data.isJunior && <span className="badge-junior">Junior</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<Stat label="Level" value={num(data.level)} />
|
||||
<Stat label="XP" value={num(data.xp)} />
|
||||
<Stat label="Last IP" value={data.lastIp ?? '—'} mono />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-ink-800 text-sm">
|
||||
{(['overview', 'mod', 'grants', 'profile'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-3 py-2 -mb-px border-b-2 capitalize ${tab === t ? 'border-brand-500 text-ink-50' : 'border-transparent text-ink-300 hover:text-ink-100'}`}
|
||||
>
|
||||
{t === 'mod' ? 'Moderation' : t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'overview' && <OverviewTab data={data} />}
|
||||
{tab === 'mod' && <ModerationTab data={data} onChanged={onChanged} />}
|
||||
{tab === 'grants' && <GrantsTab data={data} onChanged={reload} />}
|
||||
{tab === 'profile' && <ProfileTab data={data} onChanged={reload} />}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<div className="card !p-2 text-center">
|
||||
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
|
||||
<div className={`text-sm font-medium text-ink-50 ${mono ? 'font-mono text-xs' : ''}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({ data }: { data: PlayerDetail }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<Field label="Display name" value={data.displayName} />
|
||||
<Field label="Username" value={`@${data.username}`} />
|
||||
<Field label="Email" value={data.email ?? '—'} />
|
||||
<Field label="Joined" value={absoluteTime(data.createdAt)} />
|
||||
<Field label="Last seen" value={absoluteTime(data.lastSeenAt)} />
|
||||
<Field label="Last IP" value={data.lastIp ?? '—'} mono />
|
||||
<Field label="Bio" full value={data.bio || <span className="text-ink-500">no bio</span>} />
|
||||
<div className="card !p-3 md:col-span-2">
|
||||
<div className="text-[11px] uppercase tracking-widest text-ink-400 mb-2">Wallet</div>
|
||||
{data.balances.length === 0 ? (
|
||||
<div className="text-xs text-ink-500">No currency balances on file.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{data.balances.map(b => (
|
||||
<div key={b.currencyType} className="rounded border border-ink-800 px-3 py-2">
|
||||
<div className="text-[10px] uppercase tracking-widest text-ink-400">{currencyName(b.currencyType)}</div>
|
||||
<div className="text-sm font-medium text-ink-50 tabular-nums">{num(b.balance)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, full, mono }: { label: string; value: React.ReactNode; full?: boolean; mono?: boolean }) {
|
||||
return (
|
||||
<div className={`card !p-3 ${full ? 'md:col-span-2' : ''}`}>
|
||||
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
|
||||
<div className={`mt-0.5 text-sm text-ink-100 ${mono ? 'font-mono text-xs' : ''}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Moderation actions: ban, unban, kick, promote, demote ────────────
|
||||
|
||||
function ModerationTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) {
|
||||
const toast = useToast();
|
||||
const [banDays, setBanDays] = useState(7);
|
||||
const [reason, setReason] = useState('');
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [confirmKey, setConfirmKey] = useState<null | 'ban' | 'unban' | 'kick' | 'promote' | 'demote'>(null);
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setBusy(label);
|
||||
try {
|
||||
await fn();
|
||||
toast.push(`${label} succeeded`, 'success');
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const banned = data.bannedUntil && new Date(data.bannedUntil) > new Date();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="card !p-4">
|
||||
<h3 className="text-sm font-semibold text-ink-50 mb-1">Ban / unban</h3>
|
||||
<p className="text-xs text-ink-400 mb-3">
|
||||
{banned ? `Currently banned until ${absoluteTime(data.bannedUntil)}.` : 'Temporary ban with optional reason.'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input type="number" min={1} max={3650} value={banDays} onChange={e => setBanDays(parseInt(e.target.value || '1'))} className="input w-24" />
|
||||
<span className="text-xs text-ink-400">days</span>
|
||||
</div>
|
||||
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason (optional, logged)" className="input mb-2 text-xs" />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setConfirmKey('ban')} className="btn-danger text-xs" disabled={!!busy || data.isAdmin}>
|
||||
{busy === 'Ban' ? 'Banning…' : 'Ban player'}
|
||||
</button>
|
||||
{banned && (
|
||||
<button onClick={() => setConfirmKey('unban')} className="btn-secondary text-xs" disabled={!!busy}>
|
||||
Unban
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{data.isAdmin && <p className="text-[11px] text-warn mt-2">Demote this admin first.</p>}
|
||||
</div>
|
||||
|
||||
<div className="card !p-4">
|
||||
<h3 className="text-sm font-semibold text-ink-50 mb-1">Kick</h3>
|
||||
<p className="text-xs text-ink-400 mb-3">Sends a ModerationKick push so the watch boots out of its current session. The player can reconnect immediately.</p>
|
||||
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason (optional)" className="input mb-2 text-xs" />
|
||||
<button onClick={() => setConfirmKey('kick')} className="btn-secondary text-xs" disabled={!!busy}>Kick player</button>
|
||||
</div>
|
||||
|
||||
<div className="card !p-4 md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-ink-50 mb-1">Admin role</h3>
|
||||
<p className="text-xs text-ink-400 mb-3">
|
||||
Admins bypass the ban check and can use this console. Don't demote yourself — there's a server-side guard but it's safer to have another admin do it.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{!data.isAdmin && (
|
||||
<button onClick={() => setConfirmKey('promote')} className="btn-primary text-xs" disabled={!!busy}>Promote to admin</button>
|
||||
)}
|
||||
{data.isAdmin && (
|
||||
<button onClick={() => setConfirmKey('demote')} className="btn-danger text-xs" disabled={!!busy}>Revoke admin</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Confirm
|
||||
open={confirmKey === 'ban'}
|
||||
onClose={() => setConfirmKey(null)}
|
||||
title="Ban player"
|
||||
body={`Ban @${data.username} for ${banDays} day${banDays === 1 ? '' : 's'}?`}
|
||||
confirmLabel="Ban"
|
||||
destructive
|
||||
onConfirm={() => run('Ban', () => api(`/players/${data.id}/ban`, { method: 'POST', body: { DurationDays: banDays, Reason: reason } }))}
|
||||
/>
|
||||
<Confirm
|
||||
open={confirmKey === 'unban'}
|
||||
onClose={() => setConfirmKey(null)}
|
||||
title="Unban player"
|
||||
body={`Lift the ban on @${data.username}?`}
|
||||
confirmLabel="Unban"
|
||||
onConfirm={() => run('Unban', () => api(`/players/${data.id}/unban`, { method: 'POST', body: { Reason: reason } }))}
|
||||
/>
|
||||
<Confirm
|
||||
open={confirmKey === 'kick'}
|
||||
onClose={() => setConfirmKey(null)}
|
||||
title="Kick player"
|
||||
body={`Boot @${data.username} from their current session?`}
|
||||
confirmLabel="Kick"
|
||||
onConfirm={() => run('Kick', () => api(`/players/${data.id}/kick`, { method: 'POST', body: { Reason: reason } }))}
|
||||
/>
|
||||
<Confirm
|
||||
open={confirmKey === 'promote'}
|
||||
onClose={() => setConfirmKey(null)}
|
||||
title="Promote to admin"
|
||||
body={`Grant admin privileges to @${data.username}? They'll be able to use this console.`}
|
||||
confirmLabel="Promote"
|
||||
onConfirm={() => run('Promote', () => api(`/players/${data.id}/promote`, { method: 'POST' }))}
|
||||
/>
|
||||
<Confirm
|
||||
open={confirmKey === 'demote'}
|
||||
onClose={() => setConfirmKey(null)}
|
||||
title="Revoke admin"
|
||||
body={`Strip admin privileges from @${data.username}?`}
|
||||
confirmLabel="Demote"
|
||||
destructive
|
||||
onConfirm={() => run('Demote', () => api(`/players/${data.id}/demote`, { method: 'POST' }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Grants: currency, XP, item, displayName, flags ───────────────────
|
||||
|
||||
function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) {
|
||||
const toast = useToast();
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
try {
|
||||
await fn();
|
||||
toast.push(`${label} applied`, 'success');
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const [currType, setCurrType] = useState(2);
|
||||
const [currAmount, setCurrAmount] = useState(1000);
|
||||
const [xp, setXp] = useState(1000);
|
||||
const [itemId, setItemId] = useState('');
|
||||
const [itemQty, setItemQty] = useState(1);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="card !p-4">
|
||||
<h3 className="text-sm font-semibold text-ink-50 mb-1">Adjust currency</h3>
|
||||
<p className="text-xs text-ink-400 mb-3">Positive grants, negative deducts (clamped to zero).</p>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<select value={currType} onChange={e => setCurrType(parseInt(e.target.value))} className="input w-32">
|
||||
<option value={1}>Tokens</option>
|
||||
<option value={2}>Coins</option>
|
||||
</select>
|
||||
<input type="number" value={currAmount} onChange={e => setCurrAmount(parseInt(e.target.value || '0'))} className="input flex-1" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => run('Currency', () => api(`/players/${data.id}/currency`, { method: 'POST', body: { CurrencyType: currType, Amount: currAmount, Reason: 'admin_grant' } }))}
|
||||
className="btn-primary text-xs"
|
||||
>Apply</button>
|
||||
</div>
|
||||
|
||||
<div className="card !p-4">
|
||||
<h3 className="text-sm font-semibold text-ink-50 mb-1">Grant XP</h3>
|
||||
<p className="text-xs text-ink-400 mb-3">May trigger level-up rewards.</p>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input type="number" value={xp} onChange={e => setXp(parseInt(e.target.value || '0'))} className="input" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => run('XP', () => api(`/players/${data.id}/xp`, { method: 'POST', body: { Amount: xp, Reason: 'admin_grant' } }))}
|
||||
className="btn-primary text-xs"
|
||||
>Grant XP</button>
|
||||
</div>
|
||||
|
||||
<div className="card !p-4 md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-ink-50 mb-1">Grant inventory item</h3>
|
||||
<p className="text-xs text-ink-400 mb-3">Appends an item GUID to the player's inventory JSON. Use the Store catalog to find slugs / GUIDs.</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr,120px,auto] gap-2">
|
||||
<input value={itemId} onChange={e => setItemId(e.target.value)} placeholder="item GUID" className="input font-mono text-xs" />
|
||||
<input type="number" min={1} max={1000} value={itemQty} onChange={e => setItemQty(parseInt(e.target.value || '1'))} className="input" />
|
||||
<button
|
||||
onClick={() => run('Item', () => api(`/players/${data.id}/inventory/grant`, { method: 'POST', body: { ItemId: itemId.trim(), Quantity: itemQty } }))}
|
||||
disabled={!itemId.trim()}
|
||||
className="btn-primary text-xs"
|
||||
>Grant</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Profile: display name + role flags ───────────────────────────────
|
||||
|
||||
function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) {
|
||||
const toast = useToast();
|
||||
const [displayName, setDisplayName] = useState(data.displayName);
|
||||
const [flags, setFlags] = useState({
|
||||
isVerified: data.isVerified,
|
||||
isDeveloper: data.isDeveloper,
|
||||
isCommunityTeam: data.isCommunityTeam,
|
||||
isJunior: data.isJunior,
|
||||
});
|
||||
|
||||
const save = async (label: string, fn: () => Promise<unknown>) => {
|
||||
try {
|
||||
await fn();
|
||||
toast.push(`${label} saved`, 'success');
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="card !p-4">
|
||||
<h3 className="text-sm font-semibold text-ink-50 mb-1">Display name</h3>
|
||||
<p className="text-xs text-ink-400 mb-3">Force-override the player's display name.</p>
|
||||
<div className="flex gap-2">
|
||||
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="input flex-1" />
|
||||
<button
|
||||
onClick={() => save('Display name', () => api(`/players/${data.id}/displayName`, { method: 'POST', body: { DisplayName: displayName } }))}
|
||||
disabled={!displayName.trim() || displayName === data.displayName}
|
||||
className="btn-primary text-xs"
|
||||
>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card !p-4">
|
||||
<h3 className="text-sm font-semibold text-ink-50 mb-1">Profile flags</h3>
|
||||
<p className="text-xs text-ink-400 mb-3">
|
||||
Verified = blue check on profile. Developer or Community Team unlocks the watch's overhead-badge slider — once unlocked, the player picks "Developer" or "Community Team" from their in-watch settings and the label renders above their head for everyone in the room.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 mb-3">
|
||||
{([
|
||||
['isVerified', 'Verified'],
|
||||
['isDeveloper', 'Developer'],
|
||||
['isCommunityTeam', 'Community Team'],
|
||||
['isJunior', 'Junior'],
|
||||
] as const).map(([k, label]) => (
|
||||
<label key={k} className="flex items-center gap-2 text-sm text-ink-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={flags[k]}
|
||||
onChange={e => setFlags(f => ({ ...f, [k]: e.target.checked }))}
|
||||
className="size-4 accent-brand-500"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => save('Flags', () => api(`/players/${data.id}/flags`, { method: 'POST', body: { IsVerified: flags.isVerified, IsDeveloper: flags.isDeveloper, IsCommunityTeam: flags.isCommunityTeam, IsJunior: flags.isJunior } }))}
|
||||
className="btn-primary text-xs"
|
||||
>Save flags</button>
|
||||
</div>
|
||||
|
||||
<div className="card !p-4 md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-ink-50 mb-1">Reset avatar</h3>
|
||||
<p className="text-xs text-ink-400 mb-3">
|
||||
Wipes the player's equipped outfit and inventory back to the 2020 starter set.
|
||||
Use this when the watch crashes loading the avatar — usually means an item
|
||||
GUID in their inventory has no matching <code className="text-[11px]">outfits_assets_*.bundle</code> on disk
|
||||
(the build can't load it and Unity's Addressables tears down the local player).
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!confirm(`Reset @${data.username}'s avatar to the starter outfit and inventory? Existing wardrobe items will be removed.`)) return;
|
||||
save('Avatar reset', () => api(`/players/${data.id}/avatar/reset`, { method: 'POST' }));
|
||||
}}
|
||||
className="btn-danger text-xs"
|
||||
>Reset to starter</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import type { Report } from '../lib/types';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { Empty } from '../components/Empty';
|
||||
import { Modal } from '../components/Modal';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { absoluteTime, relativeTime } from '../lib/format';
|
||||
import { RefreshCw } from '../components/Icons';
|
||||
|
||||
export function Reports() {
|
||||
const { data, loading, error, refresh } = useApi<Report[]>('/reports?take=100');
|
||||
const [resolving, setResolving] = useState<Report | null>(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Open reports"
|
||||
blurb="Oldest first. Resolving a report only closes the ticket — issue the actual moderation action separately."
|
||||
actions={
|
||||
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{error}</div>}
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{data && data.length === 0 && <Empty title="No open reports" blurb="When a player reports another, it lands here." />}
|
||||
{data && data.length > 0 && (
|
||||
<div className="table-scroll"><table className="w-full text-sm min-w-[640px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-2.5">#</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Reporter</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Target</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Reason</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Filed</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{data.map(r => (
|
||||
<tr key={r.id} className="table-row-hover">
|
||||
<td className="px-4 py-2.5 text-ink-400 tabular-nums">{r.id}</td>
|
||||
<td className="px-4 py-2.5 text-ink-200">#{r.reporterPlayerId}</td>
|
||||
<td className="px-4 py-2.5 text-ink-200">#{r.targetPlayerId}</td>
|
||||
<td className="px-4 py-2.5 text-ink-100 max-w-md">
|
||||
<div className="font-medium">{r.reason || '—'}</div>
|
||||
{r.context && <div className="text-xs text-ink-400 mt-0.5 line-clamp-2">{r.context}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-300 text-xs" title={absoluteTime(r.createdAt)}>{relativeTime(r.createdAt)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<button onClick={() => setResolving(r)} className="btn-primary text-xs">Resolve</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{resolving && <ResolveModal report={resolving} onClose={() => setResolving(null)} onResolved={() => { setResolving(null); refresh(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResolveModal({ report, onClose, onResolved }: { report: Report; onClose: () => void; onResolved: () => void }) {
|
||||
const [note, setNote] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/reports/${report.id}/resolve`, { method: 'POST', body: { Note: note } });
|
||||
toast.push('Report resolved', 'success');
|
||||
onResolved();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Resolve report #${report.id}`}
|
||||
open
|
||||
onClose={onClose}
|
||||
footer={<>
|
||||
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
|
||||
<button onClick={submit} className="btn-primary text-xs" disabled={busy}>{busy ? 'Resolving…' : 'Resolve'}</button>
|
||||
</>}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div className="card !p-2"><div className="text-ink-400">Reporter</div><div className="text-ink-100">#{report.reporterPlayerId}</div></div>
|
||||
<div className="card !p-2"><div className="text-ink-400">Target</div><div className="text-ink-100">#{report.targetPlayerId}</div></div>
|
||||
</div>
|
||||
<div className="card !p-3">
|
||||
<div className="text-[11px] uppercase tracking-widest text-ink-400">Reason</div>
|
||||
<div className="text-ink-100 mt-1">{report.reason || '—'}</div>
|
||||
</div>
|
||||
{report.context && (
|
||||
<div className="card !p-3">
|
||||
<div className="text-[11px] uppercase tracking-widest text-ink-400">Context</div>
|
||||
<div className="text-ink-200 text-xs mt-1 whitespace-pre-wrap">{report.context}</div>
|
||||
</div>
|
||||
)}
|
||||
<label className="block">
|
||||
<div className="label mb-1">Resolution note (logged to audit)</div>
|
||||
<textarea value={note} onChange={e => setNote(e.target.value)} rows={3} className="input" placeholder="warned · dismissed · banned 7d · etc." />
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,1025 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { api } from '../lib/api';
|
||||
import type { RoomDetail, RoomInstance, RoomRoleGrant } from '../lib/types';
|
||||
import { profileImageUrl } from '../lib/types';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { Empty } from '../components/Empty';
|
||||
import { Modal } from '../components/Modal';
|
||||
import { Confirm } from '../components/Confirm';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { ArrowLeft, RefreshCw, Trash } from '../components/Icons';
|
||||
|
||||
// Unified per-room admin view. One screen replaces what used to be
|
||||
// scattered across Rooms / RR Originals / Instances / individual edit
|
||||
// modals: general info edit, owner / co-owner / mod / host
|
||||
// management, live photon instances, recent visitors, danger zone.
|
||||
|
||||
type Tab = 'general' | 'roles' | 'instances' | 'leaderboards' | 'visitors' | 'danger';
|
||||
|
||||
const ROLE_LABEL: Record<0 | 1 | 2, string> = {
|
||||
0: 'Co-owner',
|
||||
1: 'Moderator',
|
||||
2: 'Host',
|
||||
};
|
||||
|
||||
const ACCESSIBILITY: Record<number, string> = {
|
||||
0: 'Private',
|
||||
1: 'Public',
|
||||
2: 'Friends only',
|
||||
};
|
||||
|
||||
export function Room() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const roomId = Number(id);
|
||||
const { data: room, loading, error, refresh } = useApi<RoomDetail>(`/rooms/${roomId}`);
|
||||
const [tab, setTab] = useState<Tab>('general');
|
||||
|
||||
if (Number.isNaN(roomId) || roomId <= 0) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Room" />
|
||||
<Empty title="Invalid room id" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={room?.name ?? `Room #${roomId}`}
|
||||
blurb={room?.description || undefined}
|
||||
actions={<>
|
||||
<Link to="/rooms" className="btn-ghost text-xs"><ArrowLeft /> Back to rooms</Link>
|
||||
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
</>}
|
||||
/>
|
||||
|
||||
{error && <div className="card px-4 py-3 text-sm text-danger mb-4">{error}</div>}
|
||||
{loading && !room && <div className="text-sm text-ink-400">Loading…</div>}
|
||||
|
||||
{room && (
|
||||
<>
|
||||
<RoomHeaderCard room={room} />
|
||||
<div className="flex gap-1 mt-4 mb-3 overflow-x-auto border-b border-ink-800">
|
||||
{(['general', 'roles', 'instances', 'leaderboards', 'visitors', 'danger'] as Tab[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-3 py-2 text-sm whitespace-nowrap border-b-2 -mb-px ${
|
||||
tab === t
|
||||
? 'border-brand-500 text-brand-100'
|
||||
: 'border-transparent text-ink-300 hover:text-ink-50'
|
||||
}`}
|
||||
>
|
||||
{t === 'general' ? 'General' :
|
||||
t === 'roles' ? 'Roles & ownership' :
|
||||
t === 'instances' ? 'Live instances' :
|
||||
t === 'leaderboards' ? 'Leaderboards' :
|
||||
t === 'visitors' ? 'Recent visitors' :
|
||||
'Danger zone'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'general' && <GeneralTab room={room} onSaved={refresh} />}
|
||||
{tab === 'roles' && <RolesTab room={room} onChanged={refresh} />}
|
||||
{tab === 'instances' && <InstancesTab roomId={room.id} />}
|
||||
{tab === 'leaderboards' && <LeaderboardsTab roomId={room.id} />}
|
||||
{tab === 'visitors' && <VisitorsTab roomId={room.id} />}
|
||||
{tab === 'danger' && <DangerTab room={room} onAfter={() => navigate('/rooms')} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Header card ──────────────────────────────────────────────────────
|
||||
|
||||
function RoomHeaderCard({ room }: { room: RoomDetail }) {
|
||||
const img = profileImageUrl(room.imageName || null, 192);
|
||||
const tags = room.tagsCsv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
return (
|
||||
<div className="card p-4 flex flex-col sm:flex-row gap-4 items-start">
|
||||
<div className="size-24 rounded-lg overflow-hidden bg-ink-800 flex-shrink-0 flex items-center justify-center">
|
||||
{img
|
||||
? <img src={img} alt={room.name} className="size-full object-cover" />
|
||||
: <div className="text-ink-500 text-xs">no image</div>}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-ink-50">{room.name}</h2>
|
||||
{room.isDormRoom && <span className="badge-neutral">Dorm</span>}
|
||||
{!room.isDormRoom && room.isAGRoom && room.owner.id === 1 && <span className="badge-admin">RR Original</span>}
|
||||
{!room.isDormRoom && room.owner.id !== 1 && <span className="badge-neutral">Custom</span>}
|
||||
{room.state === 1 && <span className="badge-banned">Archived</span>}
|
||||
</div>
|
||||
<div className="text-xs text-ink-400 mt-1">
|
||||
#{room.id} · {ACCESSIBILITY[room.accessibility] ?? `Access ${room.accessibility}`} · {room.cloningAllowed ? 'Cloning allowed' : 'No cloning'}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs">
|
||||
<StatPill label="Cheers" value={room.cheerCount} />
|
||||
<StatPill label="Visits" value={room.visitCount} />
|
||||
<StatPill label="Visitors" value={room.visitorCount} />
|
||||
<StatPill label="Scenes" value={room.sceneCount} />
|
||||
<StatPill label="Blobs" value={room.blobCount} />
|
||||
<StatPill label="Hot" value={room.hotScore.toFixed(1)} />
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tags.map(t => <span key={t} className="badge-neutral text-[10px]">#{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-ink-400 sm:text-right space-y-1">
|
||||
<div>
|
||||
Owner: <Link to={`/players?focus=${room.owner.id}`} className="text-ink-200 hover:text-brand-200">@{room.owner.username || `#${room.owner.id}`}</Link>
|
||||
</div>
|
||||
<div>Created {new Date(room.createdAt).toLocaleDateString()}</div>
|
||||
<div>Updated {new Date(room.updatedAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-ink-900 px-2 py-1 ring-1 ring-inset ring-ink-800">
|
||||
<span className="text-ink-400">{label}</span>
|
||||
<span className="text-ink-100 font-medium tabular-nums">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── General tab — name/desc/image/access/cloning/tags/hot ──────────────
|
||||
|
||||
function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }) {
|
||||
const toast = useToast();
|
||||
const [description, setDescription] = useState(room.description);
|
||||
const [accessibility, setAccessibility] = useState(room.accessibility);
|
||||
const [cloningAllowed, setCloningAllowed] = useState(room.cloningAllowed);
|
||||
const [tagsCsv, setTagsCsv] = useState(room.tagsCsv);
|
||||
const [hotScore, setHotScore] = useState(String(room.hotScore));
|
||||
const [imageName, setImageName] = useState(room.imageName);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Re-sync local state if a refresh brings new server values in.
|
||||
useEffect(() => {
|
||||
setDescription(room.description);
|
||||
setAccessibility(room.accessibility);
|
||||
setCloningAllowed(room.cloningAllowed);
|
||||
setTagsCsv(room.tagsCsv);
|
||||
setHotScore(String(room.hotScore));
|
||||
setImageName(room.imageName);
|
||||
}, [room.id, room.updatedAt]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const hot = Number(hotScore);
|
||||
await api(`/rooms/${room.id}/props`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
Description: description,
|
||||
Accessibility: accessibility,
|
||||
CloningAllowed: cloningAllowed,
|
||||
TagsCsv: tagsCsv,
|
||||
HotScore: Number.isFinite(hot) ? hot : undefined,
|
||||
ImageName: imageName,
|
||||
},
|
||||
});
|
||||
toast.push('Saved', 'success');
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-4 space-y-3 max-w-2xl">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Description</span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="input min-h-[80px]"
|
||||
maxLength={2000}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Accessibility</span>
|
||||
<select
|
||||
value={accessibility}
|
||||
onChange={e => setAccessibility(Number(e.target.value))}
|
||||
className="input"
|
||||
>
|
||||
<option value={0}>Private</option>
|
||||
<option value={1}>Public</option>
|
||||
<option value={2}>Friends only</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Hot score</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={hotScore}
|
||||
onChange={e => setHotScore(e.target.value)}
|
||||
className="input tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Tags (comma-separated)</span>
|
||||
<input
|
||||
value={tagsCsv}
|
||||
onChange={e => setTagsCsv(e.target.value)}
|
||||
placeholder="featured, recroomoriginal"
|
||||
className="input"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Image blob name</span>
|
||||
<input
|
||||
value={imageName}
|
||||
onChange={e => setImageName(e.target.value)}
|
||||
placeholder="img_p1_xxxxxxxxxxxx.jpg"
|
||||
className="input font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cloningAllowed}
|
||||
onChange={e => setCloningAllowed(e.target.checked)}
|
||||
/>
|
||||
<span>Cloning allowed</span>
|
||||
</label>
|
||||
<div className="flex gap-2 pt-2 border-t border-ink-800">
|
||||
<button onClick={save} disabled={busy} className="btn-primary text-xs">
|
||||
{busy ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Roles tab — owner + co-owners + mods + hosts ────────────────────────
|
||||
|
||||
function RolesTab({ room, onChanged }: { room: RoomDetail; onChanged: () => void }) {
|
||||
const toast = useToast();
|
||||
const [addingRole, setAddingRole] = useState<0 | 1 | 2 | null>(null);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
|
||||
const byRole = useMemo(() => {
|
||||
const out: Record<0 | 1 | 2, RoomRoleGrant[]> = { 0: [], 1: [], 2: [] };
|
||||
for (const r of room.roles) out[r.role].push(r);
|
||||
return out;
|
||||
}, [room.roles]);
|
||||
|
||||
const remove = async (g: RoomRoleGrant) => {
|
||||
try {
|
||||
await api(`/rooms/${room.id}/roles/${g.playerId}/${g.role}`, { method: 'DELETE' });
|
||||
toast.push(`Removed ${ROLE_LABEL[g.role]} grant for @${g.player.username}`, 'success');
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="label">Owner</div>
|
||||
<PlayerRow player={room.owner} />
|
||||
</div>
|
||||
<button onClick={() => setTransferOpen(true)} className="btn-secondary text-xs">Transfer ownership</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{([0, 1, 2] as const).map(role => (
|
||||
<div key={role} className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-2.5 border-b border-ink-800">
|
||||
<h3 className="text-sm font-semibold text-ink-50">{ROLE_LABEL[role]}s</h3>
|
||||
<button onClick={() => setAddingRole(role)} className="btn-secondary text-xs">Add</button>
|
||||
</div>
|
||||
{byRole[role].length === 0
|
||||
? <div className="px-4 py-4 text-xs text-ink-400">No {ROLE_LABEL[role].toLowerCase()} grants.</div>
|
||||
: <ul className="divide-y divide-ink-800">
|
||||
{byRole[role].map(g => (
|
||||
<li key={g.id} className="flex items-center justify-between gap-2 px-4 py-2.5">
|
||||
<PlayerRow player={g.player} />
|
||||
<div className="flex items-center gap-2">
|
||||
{!g.accepted && <span className="badge-neutral">Pending</span>}
|
||||
<span className="text-xs text-ink-500">{new Date(g.grantedAt).toLocaleDateString()}</span>
|
||||
<button onClick={() => remove(g)} className="btn-ghost text-xs text-danger"><Trash /> Revoke</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{addingRole !== null && (
|
||||
<AddRoleModal
|
||||
roomId={room.id}
|
||||
role={addingRole}
|
||||
onClose={() => setAddingRole(null)}
|
||||
onAdded={() => { setAddingRole(null); onChanged(); }}
|
||||
/>
|
||||
)}
|
||||
{transferOpen && (
|
||||
<TransferOwnerModal
|
||||
room={room}
|
||||
onClose={() => setTransferOpen(false)}
|
||||
onTransferred={() => { setTransferOpen(false); onChanged(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerRow({ player }: { player: { id: number; username: string; displayName: string; profileImageName: string | null } }) {
|
||||
const img = profileImageUrl(player.profileImageName, 64);
|
||||
return (
|
||||
<Link to={`/players?focus=${player.id}`} className="flex items-center gap-2 min-w-0 hover:text-brand-100">
|
||||
<div className="size-7 rounded-md overflow-hidden bg-ink-800 flex-shrink-0">
|
||||
{img && <img src={img} alt="" className="size-full object-cover" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-ink-50 truncate">@{player.username || `#${player.id}`}</div>
|
||||
{player.displayName && player.displayName !== player.username &&
|
||||
<div className="text-xs text-ink-400 truncate">{player.displayName}</div>}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function AddRoleModal({ roomId, role, onClose, onAdded }: {
|
||||
roomId: number; role: 0 | 1 | 2; onClose: () => void; onAdded: () => void;
|
||||
}) {
|
||||
const [playerIdStr, setPlayerIdStr] = useState('');
|
||||
const [accepted, setAccepted] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const submit = async () => {
|
||||
const pid = Number(playerIdStr);
|
||||
if (!Number.isFinite(pid) || pid <= 0) {
|
||||
toast.push('Enter a numeric player id', 'error');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/rooms/${roomId}/roles`, {
|
||||
method: 'POST',
|
||||
body: { PlayerId: pid, Role: role, Accepted: accepted },
|
||||
});
|
||||
toast.push(`Granted ${ROLE_LABEL[role]} to #${pid}`, 'success');
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Add ${ROLE_LABEL[role].toLowerCase()}`}
|
||||
footer={<>
|
||||
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
|
||||
<button onClick={submit} className="btn-primary text-xs" disabled={busy}>
|
||||
{busy ? 'Adding…' : 'Add'}
|
||||
</button>
|
||||
</>}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Player id</span>
|
||||
<input
|
||||
value={playerIdStr}
|
||||
onChange={e => setPlayerIdStr(e.target.value)}
|
||||
className="input font-mono"
|
||||
placeholder="1362428"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" checked={accepted} onChange={e => setAccepted(e.target.checked)} />
|
||||
<span>Mark as accepted (otherwise shows as pending invite)</span>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferOwnerModal({ room, onClose, onTransferred }: {
|
||||
room: RoomDetail; onClose: () => void; onTransferred: () => void;
|
||||
}) {
|
||||
const [pid, setPid] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const ready = pid && confirm === room.name && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/rooms/${room.id}/owner`, {
|
||||
method: 'POST',
|
||||
body: { NewCreatorPlayerId: Number(pid) },
|
||||
});
|
||||
toast.push(`Ownership transferred to #${pid}`, 'success');
|
||||
onTransferred();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title="Transfer ownership"
|
||||
footer={<>
|
||||
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
|
||||
<button onClick={submit} className="btn-danger text-xs" disabled={!ready}>
|
||||
{busy ? 'Transferring…' : 'Transfer'}
|
||||
</button>
|
||||
</>}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>Reassign the room's creator. The previous owner (@{room.owner.username}) keeps a Co-owner grant automatically so they don't lose access.</p>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">New owner player id</span>
|
||||
<input value={pid} onChange={e => setPid(e.target.value)} className="input font-mono" placeholder="1362428" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Type the room name to confirm</span>
|
||||
<input value={confirm} onChange={e => setConfirm(e.target.value)} className="input font-mono" placeholder={room.name} />
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Instances tab — live photon matches in this room ────────────────────
|
||||
|
||||
function InstancesTab({ roomId }: { roomId: number }) {
|
||||
const toast = useToast();
|
||||
const { data, loading, error, refresh } = useApi<RoomInstance[]>(`/rooms/${roomId}/instances`);
|
||||
const [pullInto, setPullInto] = useState<RoomInstance | null>(null);
|
||||
const [closing, setClosing] = useState<RoomInstance | null>(null);
|
||||
|
||||
const close = async (inst: RoomInstance) => {
|
||||
try {
|
||||
const res = await api<{ kicked: number[] }>(
|
||||
`/rooms/${roomId}/instances/${inst.roomInstanceId}/close`,
|
||||
{ method: 'POST', body: { Reason: 'Instance closed by admin' } },
|
||||
);
|
||||
toast.push(`Closed instance — kicked ${res.kicked.length}`, 'success');
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-ink-800">
|
||||
<div className="text-xs text-ink-400">{data ? `${data.length} active` : ''}</div>
|
||||
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
|
||||
{data && data.length === 0 && <Empty title="No active instances" />}
|
||||
{data && data.length > 0 && (
|
||||
<ul className="divide-y divide-ink-800">
|
||||
{data.map(inst => (
|
||||
<li key={inst.roomInstanceId} className="px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium text-ink-50">Instance #{inst.roomInstanceId}</span>
|
||||
<span className="badge-neutral">SubRoom {inst.subRoomId}</span>
|
||||
{inst.isPrivate && <span className="badge-banned">Private</span>}
|
||||
<span className="text-xs text-ink-500 font-mono truncate">{inst.photonRegionId}/{inst.photonRoomId}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setPullInto(inst)} className="btn-secondary text-xs">Pull player in</button>
|
||||
<button onClick={() => setClosing(inst)} className="btn-ghost text-xs text-danger">
|
||||
<Trash /> Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{inst.participants.map(p => (
|
||||
<Link
|
||||
key={p.id}
|
||||
to={`/players?focus=${p.id}`}
|
||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs ring-1 ring-inset ${
|
||||
p.isMaster
|
||||
? 'bg-brand-500/15 text-brand-100 ring-brand-500/30'
|
||||
: 'bg-ink-900 text-ink-200 ring-ink-800 hover:bg-ink-800'
|
||||
}`}
|
||||
>
|
||||
{p.isMaster && <span className="text-[10px] uppercase font-semibold tracking-wider text-brand-300">Master</span>}
|
||||
<span>@{p.username}</span>
|
||||
</Link>
|
||||
))}
|
||||
{inst.participants.length === 0 && <span className="text-xs text-ink-500">empty</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{pullInto && (
|
||||
<PullPlayerModal
|
||||
roomId={roomId}
|
||||
instance={pullInto}
|
||||
onClose={() => setPullInto(null)}
|
||||
onPulled={() => { setPullInto(null); refresh(); }}
|
||||
/>
|
||||
)}
|
||||
<Confirm
|
||||
open={closing !== null}
|
||||
onClose={() => setClosing(null)}
|
||||
title="Close instance"
|
||||
body={<>Kick all {closing?.participants.length ?? 0} player(s) out of instance <span className="font-mono text-ink-100">#{closing?.roomInstanceId}</span>? The Photon room dies once it's empty.</>}
|
||||
confirmLabel="Close instance"
|
||||
destructive
|
||||
onConfirm={() => { if (closing) close(closing); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PullPlayerModal({ roomId, instance, onClose, onPulled }: {
|
||||
roomId: number; instance: RoomInstance; onClose: () => void; onPulled: () => void;
|
||||
}) {
|
||||
const [pidStr, setPidStr] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const submit = async () => {
|
||||
const pid = Number(pidStr);
|
||||
if (!Number.isFinite(pid) || pid <= 0) { toast.push('Enter a numeric player id', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/rooms/${roomId}/instances/${instance.roomInstanceId}/pull`, {
|
||||
method: 'POST',
|
||||
body: { PlayerId: pid },
|
||||
});
|
||||
toast.push(`Pulled #${pid} into instance`, 'success');
|
||||
onPulled();
|
||||
} catch (e) { toast.push((e as Error).message, 'error'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Pull a player into instance #${instance.roomInstanceId}`}
|
||||
footer={<>
|
||||
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
|
||||
<button onClick={submit} className="btn-primary text-xs" disabled={busy}>{busy ? 'Pulling…' : 'Pull in'}</button>
|
||||
</>}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-xs text-ink-400">
|
||||
Re-points the target player's presence at this exact Photon shard ({instance.photonRegionId}/{instance.photonRoomId})
|
||||
and pushes a SubscriptionUpdateGameSession so their watch joins the same instance the existing participants are in.
|
||||
</p>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Player id</span>
|
||||
<input value={pidStr} onChange={e => setPidStr(e.target.value)} className="input font-mono" placeholder="1362428" autoFocus />
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Leaderboards tab — channels mapped to this room + orphan registry ──
|
||||
|
||||
interface RoomLeaderboardRow {
|
||||
channel: number;
|
||||
name: string;
|
||||
lowerIsBetter: boolean;
|
||||
valueFormat: string;
|
||||
entryCount: number;
|
||||
bestValue: number | null;
|
||||
}
|
||||
|
||||
interface OrphanRow {
|
||||
channel: number;
|
||||
entryCount: number;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
interface ChannelEntries {
|
||||
channel: number;
|
||||
name: string;
|
||||
lowerIsBetter: boolean;
|
||||
valueFormat: string;
|
||||
entries: Array<{
|
||||
rank: number;
|
||||
id: number;
|
||||
playerId: number;
|
||||
username: string | null;
|
||||
displayName: string | null;
|
||||
value: number;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function formatChannelValue(v: number | null | undefined, fmt: string) {
|
||||
if (v === null || v === undefined) return '—';
|
||||
if (fmt === 'time-ms') {
|
||||
const total = v;
|
||||
const m = Math.floor(total / 60000);
|
||||
const s = Math.floor((total % 60000) / 1000);
|
||||
const ms = total % 1000;
|
||||
return `${m}:${s.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
|
||||
}
|
||||
return v.toLocaleString();
|
||||
}
|
||||
|
||||
function LeaderboardsTab({ roomId }: { roomId: number }) {
|
||||
const toast = useToast();
|
||||
const lb = useApi<RoomLeaderboardRow[]>(`/rooms/${roomId}/leaderboards`);
|
||||
const orphans = useApi<OrphanRow[]>(`/leaderboards/orphans`);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [adoptChannel, setAdoptChannel] = useState<number | null>(null);
|
||||
const [expanded, setExpanded] = useState<number | null>(null);
|
||||
|
||||
const refresh = () => { lb.refresh(); orphans.refresh(); };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-2.5 border-b border-ink-800">
|
||||
<h3 className="text-sm font-semibold text-ink-50">Channels mapped to this room</h3>
|
||||
<button onClick={() => setAddOpen(true)} className="btn-secondary text-xs">Register channel</button>
|
||||
</div>
|
||||
{lb.error && <div className="px-4 py-3 text-sm text-danger">{lb.error}</div>}
|
||||
{lb.data && lb.data.length === 0 && (
|
||||
<Empty title="No leaderboards yet — register a channel id, or adopt one from the orphan list below" />
|
||||
)}
|
||||
{lb.data && lb.data.length > 0 && (
|
||||
<ul className="divide-y divide-ink-800">
|
||||
{lb.data.map(c => (
|
||||
<li key={c.channel}>
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-2.5">
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === c.channel ? null : c.channel)}
|
||||
className="text-left min-w-0 flex-1 hover:text-brand-100"
|
||||
>
|
||||
<div className="text-sm text-ink-50 truncate">{c.name}</div>
|
||||
<div className="text-xs text-ink-400 tabular-nums">
|
||||
Channel #{c.channel} · {c.entryCount} {c.entryCount === 1 ? 'entry' : 'entries'} · {c.lowerIsBetter ? 'lower is better' : 'higher is better'}
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] uppercase text-ink-500">Best</div>
|
||||
<div className="text-sm tabular-nums text-ink-100">{formatChannelValue(c.bestValue, c.valueFormat)}</div>
|
||||
</div>
|
||||
<button onClick={() => setExpanded(expanded === c.channel ? null : c.channel)} className="btn-ghost text-xs">
|
||||
{expanded === c.channel ? 'Hide' : 'View'}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Unregister channel #${c.channel}? Scores stay, but it stops appearing under this room.`)) return;
|
||||
try {
|
||||
await api(`/leaderboards/meta/${c.channel}`, { method: 'DELETE' });
|
||||
toast.push('Unregistered', 'success');
|
||||
refresh();
|
||||
} catch (e) { toast.push((e as Error).message, 'error'); }
|
||||
}}
|
||||
className="btn-ghost text-xs text-danger"
|
||||
>
|
||||
<Trash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded === c.channel && (
|
||||
<ChannelEntriesPanel channel={c.channel} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-2.5 border-b border-ink-800">
|
||||
<h3 className="text-sm font-semibold text-ink-50">Unmapped channels with score data</h3>
|
||||
<div className="text-xs text-ink-400">
|
||||
{orphans.data ? `${orphans.data.length} orphan${orphans.data.length === 1 ? '' : 's'}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-2.5 text-xs text-ink-400 border-b border-ink-800">
|
||||
Play a stat-reporting game mode (Stunt Runner course, Paintball, etc.) and its channel id will appear here.
|
||||
Adopt the channel to attach it to this room and give it a name.
|
||||
</div>
|
||||
{orphans.data && orphans.data.length === 0 && <Empty title="No orphan channels — every channel with score rows is mapped" />}
|
||||
{orphans.data && orphans.data.length > 0 && (
|
||||
<ul className="divide-y divide-ink-800">
|
||||
{orphans.data.map(o => (
|
||||
<li key={o.channel} className="flex items-center justify-between gap-2 px-4 py-2.5">
|
||||
<div>
|
||||
<div className="text-sm text-ink-50">Channel #{o.channel}</div>
|
||||
<div className="text-xs text-ink-400 tabular-nums">{o.entryCount} entries · last update {new Date(o.lastSeen).toLocaleString()}</div>
|
||||
</div>
|
||||
<button onClick={() => setAdoptChannel(o.channel)} className="btn-secondary text-xs">Adopt for this room</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(addOpen || adoptChannel !== null) && (
|
||||
<RegisterChannelModal
|
||||
roomId={roomId}
|
||||
initialChannel={adoptChannel ?? undefined}
|
||||
onClose={() => { setAddOpen(false); setAdoptChannel(null); }}
|
||||
onSaved={() => { setAddOpen(false); setAdoptChannel(null); refresh(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelEntriesPanel({ channel }: { channel: number }) {
|
||||
const { data, loading, error } = useApi<ChannelEntries>(`/leaderboards/${channel}?take=25`);
|
||||
if (loading && !data) return <div className="px-6 py-3 text-xs text-ink-400 bg-ink-900/40">Loading…</div>;
|
||||
if (error) return <div className="px-6 py-3 text-xs text-danger bg-ink-900/40">{error}</div>;
|
||||
if (!data) return null;
|
||||
if (data.entries.length === 0) return <div className="px-6 py-3 text-xs text-ink-400 bg-ink-900/40">No entries yet.</div>;
|
||||
return (
|
||||
<div className="bg-ink-900/40 border-t border-ink-800">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] uppercase tracking-wider text-ink-400">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-1.5 w-10">#</th>
|
||||
<th className="text-left font-medium px-4 py-1.5">Player</th>
|
||||
<th className="text-right font-medium px-4 py-1.5">Value</th>
|
||||
<th className="text-right font-medium px-4 py-1.5">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800/60">
|
||||
{data.entries.map(e => (
|
||||
<tr key={e.id}>
|
||||
<td className="px-4 py-1.5 text-ink-300 tabular-nums">{e.rank}</td>
|
||||
<td className="px-4 py-1.5">
|
||||
<Link to={`/players?focus=${e.playerId}`} className="text-ink-100 hover:text-brand-200">@{e.username ?? `#${e.playerId}`}</Link>
|
||||
</td>
|
||||
<td className="px-4 py-1.5 text-right tabular-nums text-ink-100">{formatChannelValue(e.value, data.valueFormat)}</td>
|
||||
<td className="px-4 py-1.5 text-right text-ink-500">{new Date(e.updatedAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegisterChannelModal({ roomId, initialChannel, onClose, onSaved }: {
|
||||
roomId: number; initialChannel?: number; onClose: () => void; onSaved: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [channel, setChannel] = useState(initialChannel?.toString() ?? '');
|
||||
const [name, setName] = useState('');
|
||||
const [lowerIsBetter, setLowerIsBetter] = useState(false);
|
||||
const [valueFormat, setValueFormat] = useState<'count' | 'time-ms' | 'score'>('count');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
const ch = Number(channel);
|
||||
if (!Number.isInteger(ch) || ch <= 0) { toast.push('Channel must be a positive integer', 'error'); return; }
|
||||
if (!name.trim()) { toast.push('Name required', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/leaderboards/meta`, {
|
||||
method: 'POST',
|
||||
body: { Channel: ch, RoomId: roomId, Name: name.trim(), LowerIsBetter: lowerIsBetter, ValueFormat: valueFormat },
|
||||
});
|
||||
toast.push(`Channel #${ch} mapped to this room`, 'success');
|
||||
onSaved();
|
||||
} catch (e) { toast.push((e as Error).message, 'error'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={initialChannel !== undefined ? `Adopt channel #${initialChannel}` : 'Register leaderboard channel'}
|
||||
footer={<>
|
||||
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
|
||||
<button onClick={submit} className="btn-primary text-xs" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
|
||||
</>}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Channel id</span>
|
||||
<input
|
||||
value={channel}
|
||||
onChange={e => setChannel(e.target.value)}
|
||||
className="input font-mono"
|
||||
placeholder="7"
|
||||
disabled={initialChannel !== undefined}
|
||||
autoFocus={initialChannel === undefined}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Display name</span>
|
||||
<input value={name} onChange={e => setName(e.target.value)} className="input" placeholder="Stunt Runner — Forest Loop" autoFocus={initialChannel !== undefined} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Value format</span>
|
||||
<select value={valueFormat} onChange={e => setValueFormat(e.target.value as 'count' | 'time-ms' | 'score')} className="input">
|
||||
<option value="count">Count (e.g. wins)</option>
|
||||
<option value="time-ms">Time in milliseconds</option>
|
||||
<option value="score">Score (points)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" checked={lowerIsBetter} onChange={e => setLowerIsBetter(e.target.checked)} />
|
||||
<span>Lower value wins (race times, lap times, etc.)</span>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Visitors tab — recent unique visitors w/ visit counts ───────────────
|
||||
|
||||
interface VisitorRow {
|
||||
playerId: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
visitCount: number;
|
||||
firstVisitAt: string;
|
||||
lastVisitAt: string;
|
||||
}
|
||||
|
||||
function VisitorsTab({ roomId }: { roomId: number }) {
|
||||
const { data, loading, error, refresh } = useApi<VisitorRow[]>(`/rooms/${roomId}/visitors`);
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-ink-800">
|
||||
<div className="text-xs text-ink-400">{data ? `${data.length} recent visitors` : ''}</div>
|
||||
<button onClick={refresh} className="btn-ghost text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
|
||||
{data && data.length === 0 && <Empty title="No visitors yet" />}
|
||||
{data && data.length > 0 && (
|
||||
<div className="table-scroll"><table className="w-full text-sm min-w-[520px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-2.5">Player</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Visits</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">First</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Last</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{data.map(v => (
|
||||
<tr key={v.playerId} className="table-row-hover">
|
||||
<td className="px-4 py-2 text-ink-50">
|
||||
<Link to={`/players?focus=${v.playerId}`} className="hover:text-brand-200">@{v.username}</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">{v.visitCount}</td>
|
||||
<td className="px-4 py-2 text-right text-xs text-ink-400">{new Date(v.firstVisitAt).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-2 text-right text-xs text-ink-400">{new Date(v.lastVisitAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Danger zone — archive / purge ────────────────────────────────────────
|
||||
|
||||
function DangerTab({ room, onAfter }: { room: RoomDetail; onAfter: () => void }) {
|
||||
const toast = useToast();
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const [purgeOpen, setPurgeOpen] = useState(false);
|
||||
const [confirm1, setConfirm1] = useState('');
|
||||
const [confirm2, setConfirm2] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const isCustom = !room.isDormRoom && room.owner.id !== 1;
|
||||
const purgeReady = confirm1 === room.name && confirm2 === room.name && !busy;
|
||||
|
||||
const archive = async () => {
|
||||
try {
|
||||
await api(`/rooms/${room.id}`, { method: 'DELETE', body: { Reason: 'admin' } });
|
||||
toast.push(`Archived ${room.name}`, 'success');
|
||||
onAfter();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const purge = async () => {
|
||||
if (!purgeReady) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api<{ deleted: number; blobs: number; scenes: number }>(
|
||||
`/rooms/${room.id}/purge`,
|
||||
{ method: 'POST', body: { Reason: 'admin', ConfirmName1: confirm1, ConfirmName2: confirm2 } },
|
||||
);
|
||||
toast.push(`Purged ${room.name} (${res.blobs} blobs · ${res.scenes} scenes)`, 'success');
|
||||
onAfter();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-50">Archive</h3>
|
||||
<p className="text-xs text-ink-400 mt-1">
|
||||
Soft delete — sets State=Archived. The room stops appearing in browse/search but stays restorable.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setArchiveOpen(true)} className="btn-secondary text-xs text-danger">
|
||||
<Trash /> Archive
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isCustom && (
|
||||
<div className="card p-4 border-danger/40">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-danger">Purge permanently</h3>
|
||||
<p className="text-xs text-ink-400 mt-1">
|
||||
Hard delete — drops the row, every scene, every per-room
|
||||
<code className="font-mono mx-1">.room</code> blob, and the thumbnail.
|
||||
The name frees up so a re-import works. Irreversible.
|
||||
</p>
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger mt-3">
|
||||
Type the room name <span className="font-mono text-ink-100">{room.name}</span> in BOTH inputs. Case-sensitive.
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-3">
|
||||
<input value={confirm1} onChange={e => setConfirm1(e.target.value)} className="input font-mono" placeholder="confirm (1 of 2)" />
|
||||
<input value={confirm2} onChange={e => setConfirm2(e.target.value)} className="input font-mono" placeholder="confirm (2 of 2)" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setPurgeOpen(true)} disabled={!purgeReady} className="btn-danger text-xs">
|
||||
{busy ? 'Purging…' : 'Purge permanently'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Confirm
|
||||
open={archiveOpen}
|
||||
onClose={() => setArchiveOpen(false)}
|
||||
title="Archive room"
|
||||
body={<>Set <span className="font-medium text-ink-50">{room.name}</span> to State=Archived?</>}
|
||||
confirmLabel="Archive"
|
||||
destructive
|
||||
onConfirm={archive}
|
||||
/>
|
||||
<Confirm
|
||||
open={purgeOpen}
|
||||
onClose={() => setPurgeOpen(false)}
|
||||
title="Purge room permanently"
|
||||
body={<>This is irreversible. Continue?</>}
|
||||
confirmLabel="Yes, purge"
|
||||
destructive
|
||||
onConfirm={purge}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../lib/api';
|
||||
import type { Room } from '../lib/types';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { Empty } from '../components/Empty';
|
||||
import { Modal } from '../components/Modal';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { Confirm } from '../components/Confirm';
|
||||
import { RefreshCw, Trash } from '../components/Icons';
|
||||
|
||||
export function Rooms() {
|
||||
const { data, loading, error, refresh } = useApi<Room[]>('/rooms');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filterKind, setFilterKind] = useState<'all' | 'original' | 'custom' | 'dorm'>('all');
|
||||
const [pendingDelete, setPendingDelete] = useState<Room | null>(null);
|
||||
const [pendingPurge, setPendingPurge] = useState<Room | null>(null);
|
||||
const toast = useToast();
|
||||
// Truth table for what's actually canonical:
|
||||
// isSeededOriginal = IsAGRoom AND creator == system seed (id 1).
|
||||
// isUserOwnedCustom = NOT dorm AND creator != system seed.
|
||||
// (covers user-cloned rooms that incorrectly
|
||||
// inherited IsAGRoom=true from their source.)
|
||||
// isDorm = IsDormRoom.
|
||||
// IsAGRoom alone is not a reliable "is this canonical?" signal,
|
||||
// because user-cloned rooms inherited it from RR Originals for years
|
||||
// before we fixed the clone path.
|
||||
const SYSTEM_ACCOUNT_ID = 1;
|
||||
const isSeededOriginal = (r: Room) => r.isAGRoom && r.creatorPlayerId === SYSTEM_ACCOUNT_ID && !r.isDormRoom;
|
||||
const isUserCustom = (r: Room) => !r.isDormRoom && r.creatorPlayerId !== SYSTEM_ACCOUNT_ID;
|
||||
|
||||
const filtered = (data ?? []).filter(r => {
|
||||
if (filterKind === 'original' && !isSeededOriginal(r)) return false;
|
||||
if (filterKind === 'custom' && !isUserCustom(r)) return false;
|
||||
if (filterKind === 'dorm' && !r.isDormRoom) return false;
|
||||
if (filter.trim() && !r.name.toLowerCase().includes(filter.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const del = async () => {
|
||||
if (!pendingDelete) return;
|
||||
try {
|
||||
await api(`/rooms/${pendingDelete.id}`, { method: 'DELETE', body: { Reason: 'admin' } });
|
||||
toast.push(`Archived ${pendingDelete.name}`, 'success');
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Rooms"
|
||||
blurb="Every room in the DB. Archive (trash icon) is soft and reversible. Purge is a hard wipe — drops scene rows + per-room blobs + thumbnail and requires typing the room name twice."
|
||||
actions={<>
|
||||
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
</>}
|
||||
/>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-ink-800 px-3 py-2.5">
|
||||
<input value={filter} onChange={e => setFilter(e.target.value)} placeholder="Filter by name…" className="input max-w-xs" />
|
||||
<div className="flex gap-1">
|
||||
{(['all', 'original', 'custom', 'dorm'] as const).map(k => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setFilterKind(k)}
|
||||
className={`px-2.5 py-1 rounded-md text-xs ${filterKind === k ? 'bg-brand-500/15 text-brand-100 ring-1 ring-inset ring-brand-500/30' : 'text-ink-300 hover:bg-ink-800'}`}
|
||||
>
|
||||
{k === 'all' ? 'All' : k === 'original' ? 'RR Originals' : k === 'custom' ? 'Custom' : 'Dorms'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto text-xs text-ink-400">
|
||||
{data ? `${filtered.length} / ${data.length}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
|
||||
{data && filtered.length === 0 && <Empty title="No rooms match" />}
|
||||
{filtered.length > 0 && (
|
||||
<div className="table-scroll"><table className="w-full text-sm min-w-[640px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-2.5">#</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Name</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Type</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Creator</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Blobs</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{filtered.map(r => (
|
||||
<tr key={r.id} className="table-row-hover">
|
||||
<td className="px-4 py-2.5 text-ink-400 tabular-nums">{r.id}</td>
|
||||
<td className="px-4 py-2.5 font-medium">
|
||||
{/* Whole row's primary action is "open detail view"
|
||||
— the link makes the room name keyboard- and
|
||||
middle-click-friendly, and the click target
|
||||
large enough on mobile. Archive/Purge stay as
|
||||
action buttons in the rightmost cell. */}
|
||||
<Link to={`/rooms/${r.id}`} className="text-ink-50 hover:text-brand-200">{r.name}</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{r.isDormRoom && <span className="badge-neutral">Dorm</span>}
|
||||
{/* "RR Original" badge is reserved for rows the
|
||||
system seed account owns. User clones that
|
||||
inherited IsAGRoom=true from their source are
|
||||
rendered as Custom — that matches reality. */}
|
||||
{isSeededOriginal(r) && <span className="badge-admin">RR Original</span>}
|
||||
{isUserCustom(r) && <span className="badge-neutral">Custom</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-ink-300 text-xs">
|
||||
{r.creatorPlayerId ? `#${r.creatorPlayerId}` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-ink-200 tabular-nums">{r.blobCount}</td>
|
||||
<td className="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<Link to={`/rooms/${r.id}`} className="btn-ghost text-xs">Manage</Link>
|
||||
<button onClick={() => setPendingDelete(r)} className="btn-ghost text-xs text-danger ml-1" title="Soft archive">
|
||||
<Trash />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Confirm
|
||||
open={pendingDelete !== null}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
title="Archive room"
|
||||
body={<>Set <span className="font-medium text-ink-50">{pendingDelete?.name}</span> to State=1 (Archived)? The room will stop appearing in browse/search but stays restorable.</>}
|
||||
confirmLabel="Archive"
|
||||
destructive
|
||||
onConfirm={del}
|
||||
/>
|
||||
|
||||
{pendingPurge && (
|
||||
<PurgeRoomModal
|
||||
room={pendingPurge}
|
||||
onClose={() => setPendingPurge(null)}
|
||||
onPurged={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Per-room hard-delete modal with two typed-name confirmations ─────────
|
||||
// The previous bulk "purge all custom" button was removed — too easy to
|
||||
// click by accident. Each room now gets its own purge flow that requires
|
||||
// typing the room's exact name TWICE in separate inputs (case-sensitive
|
||||
// per the server's StringComparison.Ordinal check). Same pattern GitHub
|
||||
// uses for "delete repository" — slows a fat finger down enough to read
|
||||
// what they're about to wipe.
|
||||
|
||||
function PurgeRoomModal({
|
||||
room,
|
||||
onClose,
|
||||
onPurged,
|
||||
}: {
|
||||
room: Room;
|
||||
onClose: () => void;
|
||||
onPurged: () => void;
|
||||
}) {
|
||||
const [confirm1, setConfirm1] = useState('');
|
||||
const [confirm2, setConfirm2] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
// Both inputs MUST exactly equal the room name. The server validates
|
||||
// identically — keeping the same comparison here means we disable the
|
||||
// submit button rather than letting the user click through to a 400.
|
||||
const ready = confirm1 === room.name && confirm2 === room.name && !busy;
|
||||
|
||||
// Reset typed values whenever the modal switches to a different room.
|
||||
useEffect(() => {
|
||||
setConfirm1('');
|
||||
setConfirm2('');
|
||||
}, [room.id]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api<{ deleted: number; blobs: number; scenes: number }>(
|
||||
`/rooms/${room.id}/purge`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
Reason: 'admin',
|
||||
ConfirmName1: confirm1,
|
||||
ConfirmName2: confirm2,
|
||||
},
|
||||
},
|
||||
);
|
||||
toast.push(
|
||||
`Purged ${room.name} (${res.blobs} blobs · ${res.scenes} scenes)`,
|
||||
'success',
|
||||
);
|
||||
onPurged();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title="Purge room (hard delete)"
|
||||
footer={<>
|
||||
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!ready}
|
||||
className="btn-danger text-xs"
|
||||
title={!ready ? 'Type the room name in both inputs to enable purge' : undefined}
|
||||
>
|
||||
{busy ? 'Purging…' : 'Purge permanently'}
|
||||
</button>
|
||||
</>}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>
|
||||
Hard-delete <span className="font-medium text-ink-50">{room.name}</span> (room #{room.id})?
|
||||
This drops the room row, every scene, every per-room
|
||||
<code className="font-mono text-xs mx-1">.room</code>
|
||||
blob, and the thumbnail. Shared HTR / PV image assets stay. The
|
||||
Name frees up so a re-import works.
|
||||
</p>
|
||||
<p className="text-xs text-warn">
|
||||
Irreversible — no soft-archive fallback.
|
||||
</p>
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">
|
||||
To confirm, type the room name <span className="font-mono text-ink-100">{room.name}</span> in BOTH inputs below.
|
||||
Case-sensitive.
|
||||
</div>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Confirm name (1 of 2)</span>
|
||||
<input
|
||||
value={confirm1}
|
||||
onChange={e => setConfirm1(e.target.value)}
|
||||
className="input font-mono"
|
||||
placeholder={room.name}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">Confirm name (2 of 2)</span>
|
||||
<input
|
||||
value={confirm2}
|
||||
onChange={e => setConfirm2(e.target.value)}
|
||||
className="input font-mono"
|
||||
placeholder={room.name}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, get } from '../lib/api';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { RefreshCw } from '../components/Icons';
|
||||
|
||||
interface ServerSettings {
|
||||
signupsDisabled: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const [settings, setSettings] = useState<ServerSettings | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const s = await get<ServerSettings>('/settings');
|
||||
setSettings(s);
|
||||
setErr(null);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const toggleSignups = async () => {
|
||||
if (!settings) return;
|
||||
const next = !settings.signupsDisabled;
|
||||
const verb = next ? 'disable' : 'enable';
|
||||
if (!confirm(`Really ${verb} new account creation? Existing players keep their access either way.`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const updated = await api<ServerSettings>('/settings/signups', {
|
||||
method: 'POST',
|
||||
body: { Disabled: next },
|
||||
});
|
||||
setSettings(updated);
|
||||
toast.push(next ? 'New signups blocked.' : 'New signups allowed.', 'success');
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Server settings"
|
||||
blurb="Runtime toggles applied across every replica without a redeploy."
|
||||
actions={
|
||||
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
|
||||
<RefreshCw className={busy ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{err && (
|
||||
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
|
||||
)}
|
||||
|
||||
{!settings && !err && (
|
||||
<div className="card !p-6 text-center text-xs text-ink-400">Loading…</div>
|
||||
)}
|
||||
|
||||
{settings && (
|
||||
<div className="card !p-5 max-w-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold text-ink-50">In-game signups</h2>
|
||||
<p className="mt-1 text-xs text-ink-400">
|
||||
When disabled, the watch's account-creation flow returns an error to the player instead of minting a new account.
|
||||
Existing logins keep working — only brand-new signups are blocked.
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-ink-500">
|
||||
Last changed {new Date(settings.updatedAt).toLocaleString()}.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleSignups}
|
||||
disabled={busy}
|
||||
className={(settings.signupsDisabled ? 'btn-primary' : 'btn-danger') + ' text-xs shrink-0'}
|
||||
>
|
||||
{busy ? 'Working…' : settings.signupsDisabled ? 'Enable signups' : 'Disable signups'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 text-xs">
|
||||
{settings.signupsDisabled
|
||||
? <span className="badge-banned">Signups disabled</span>
|
||||
: <span className="badge-online">Signups allowed</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api, get } from '../lib/api';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { Confirm } from '../components/Confirm';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { HardDrive, RefreshCw } from '../components/Icons';
|
||||
import { num } from '../lib/format';
|
||||
|
||||
// Wire shape returned by StorageBackfillController.BackfillStatus.
|
||||
interface BackfillStatus {
|
||||
running: boolean;
|
||||
dryRun: boolean;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
total: number;
|
||||
uploaded: number;
|
||||
alreadyMirrored: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
lastBlobName: string | null;
|
||||
elapsedMs: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function Storage() {
|
||||
const [status, setStatus] = useState<BackfillStatus | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [pendingMode, setPendingMode] = useState<null | 'dry' | 'real'>(null);
|
||||
const pollRef = useRef<number | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
// Polling cadence:
|
||||
// * 1 s while a run is active so the progress bar feels live.
|
||||
// * 5 s when idle so the page doesn't hammer the server.
|
||||
// Single source of truth — useEffect re-schedules itself based on
|
||||
// the latest status.running.
|
||||
useEffect(() => {
|
||||
const fetchOnce = async () => {
|
||||
try {
|
||||
const s = await get<BackfillStatus>('/storage/backfill/status');
|
||||
setStatus(s);
|
||||
setErr(null);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
}
|
||||
};
|
||||
void fetchOnce();
|
||||
const interval = status?.running ? 1000 : 5000;
|
||||
pollRef.current = window.setInterval(fetchOnce, interval);
|
||||
return () => { if (pollRef.current) window.clearInterval(pollRef.current); };
|
||||
}, [status?.running]);
|
||||
|
||||
const start = async (dryRun: boolean) => {
|
||||
setPendingMode(null);
|
||||
try {
|
||||
await api(`/storage/backfill${dryRun ? '?dryRun=true' : ''}`, { method: 'POST' });
|
||||
toast.push(dryRun ? 'Dry-run started' : 'Backfill started', 'success');
|
||||
// Force a status refresh so the UI flips to running immediately.
|
||||
const s = await get<BackfillStatus>('/storage/backfill/status');
|
||||
setStatus(s);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
// 409 already-running is informational, not an error.
|
||||
if (msg.includes('already_running')) toast.push('A backfill is already in progress', 'error');
|
||||
else toast.push(msg, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const pct = status && status.total > 0
|
||||
? Math.round(((status.uploaded + status.alreadyMirrored + status.skipped + status.failed) / status.total) * 100)
|
||||
: 0;
|
||||
const done = status && !status.running && status.startedAt !== null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Storage backfill"
|
||||
blurb="Move the legacy RoomDataBlobs.Bytes column out of Postgres and into S3 at the canonical key. Safe to run anytime — each row is HEAD-verified in S3 before the DB copy is dropped, and re-runs are idempotent."
|
||||
actions={<>
|
||||
<button
|
||||
onClick={() => setPendingMode('dry')}
|
||||
disabled={status?.running}
|
||||
className="btn-secondary text-xs"
|
||||
>
|
||||
<HardDrive /> Dry-run
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPendingMode('real')}
|
||||
disabled={status?.running}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
<HardDrive /> Run backfill
|
||||
</button>
|
||||
</>}
|
||||
/>
|
||||
|
||||
{err && (
|
||||
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === null && !err && (
|
||||
<div className="card !p-6 text-center text-xs text-ink-400">Loading status…</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="space-y-4">
|
||||
<div className="card !p-5">
|
||||
<div className="flex flex-wrap items-center gap-3 justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{status.running
|
||||
? <RefreshCw className="animate-spin text-brand-300" />
|
||||
: status.failed > 0
|
||||
? <span className="badge-banned">Errors</span>
|
||||
: done
|
||||
? <span className="badge-online">Idle · last run OK</span>
|
||||
: <span className="badge-neutral">Idle</span>
|
||||
}
|
||||
<div className="text-sm font-medium text-ink-50">
|
||||
{status.running
|
||||
? (status.dryRun ? 'Dry-run in progress…' : 'Backfill in progress…')
|
||||
: done
|
||||
? (status.dryRun ? 'Last run: dry-run' : 'Last run: live')
|
||||
: 'No run yet'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-ink-400 tabular-nums">
|
||||
{status.running ? `${pct}%` : status.elapsedMs != null ? `${(status.elapsedMs / 1000).toFixed(1)}s` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-2 rounded-full bg-ink-800 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${status.failed > 0 ? 'bg-danger' : 'bg-brand-500'}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 sm:grid-cols-5 gap-2 text-xs">
|
||||
<Counter label="Total" value={num(status.total)} />
|
||||
<Counter label="Uploaded" value={num(status.uploaded)} tone="success" />
|
||||
<Counter label="Already" value={num(status.alreadyMirrored)} />
|
||||
<Counter label="Skipped" value={num(status.skipped)} />
|
||||
<Counter label="Failed" value={num(status.failed)} tone={status.failed > 0 ? 'danger' : undefined} />
|
||||
</div>
|
||||
|
||||
{status.lastBlobName && (
|
||||
<div className="mt-3 text-[11px] text-ink-400 font-mono truncate" title={status.lastBlobName}>
|
||||
last: {status.lastBlobName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.error && (
|
||||
<div className="mt-3 rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">
|
||||
{status.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card !p-4 text-xs text-ink-400 leading-relaxed space-y-2">
|
||||
<p className="font-medium text-ink-200">What this does</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-ink-400">
|
||||
<li>Walks every <code className="text-ink-200">RoomDataBlobs</code> row whose <code className="text-ink-200">Bytes</code> column still holds data.</li>
|
||||
<li>Routes each blob through <code className="text-ink-200">BlobRouter.Route(BlobName)</code> to its canonical S3 (bucket, key).</li>
|
||||
<li>Uploads to S3, HEAD-verifies the object exists, then clears the row's <code className="text-ink-200">Bytes</code>.</li>
|
||||
<li>A row's bytes are only dropped from the DB after S3 confirms the same key — no data loss path.</li>
|
||||
</ul>
|
||||
<p className="font-medium text-ink-200 pt-2">When to run</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-ink-400">
|
||||
<li>Once after the S3-only refactor deploy lands, to drain pre-cutover imports.</li>
|
||||
<li>Idempotent — re-running picks up only rows whose <code className="text-ink-200">Bytes</code> hasn't been cleared yet.</li>
|
||||
<li>Failed rows leave their DB bytes untouched and get retried on the next run.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Confirm
|
||||
open={pendingMode === 'dry'}
|
||||
onClose={() => setPendingMode(null)}
|
||||
title="Run dry-run backfill"
|
||||
body={<>Walks every row, logs the (bucket, key) it WOULD upload to, but doesn't write to S3 or clear the DB. Safe to run anytime — useful to verify the routing before a real run.</>}
|
||||
confirmLabel="Start dry-run"
|
||||
onConfirm={() => start(true)}
|
||||
/>
|
||||
<Confirm
|
||||
open={pendingMode === 'real'}
|
||||
onClose={() => setPendingMode(null)}
|
||||
title="Run live backfill"
|
||||
body={<>Uploads every row's bytes to S3 and clears the DB Bytes column for each row after HEAD-verifies the S3 object. Safe — re-runs are idempotent, and failures leave bytes intact in the DB. Continue?</>}
|
||||
confirmLabel="Start backfill"
|
||||
onConfirm={() => start(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Counter({ label, value, tone }: { label: string; value: string; tone?: 'success' | 'danger' }) {
|
||||
const color = tone === 'success' ? 'text-success'
|
||||
: tone === 'danger' ? 'text-danger'
|
||||
: 'text-ink-50';
|
||||
return (
|
||||
<div className="rounded border border-ink-800 bg-ink-900/60 px-3 py-2">
|
||||
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
|
||||
<div className={`text-sm font-semibold tabular-nums ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { api } from '../lib/api';
|
||||
import type { StoreItem, StorefrontDefinition } from '../lib/types';
|
||||
import { profileImageUrl } from '../lib/types';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { Empty } from '../components/Empty';
|
||||
import { Modal } from '../components/Modal';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { Confirm } from '../components/Confirm';
|
||||
import { currencyName, num, relativeTime } from '../lib/format';
|
||||
import { Plus, RefreshCw, Search, Trash } from '../components/Icons';
|
||||
|
||||
// Storefronts the SPA can target when /admin/v1/storefronts hasn't
|
||||
// responded yet. Mirrors StoreService.GetStorefrontDefinitions on the
|
||||
// server side — replaced by the live response as soon as it arrives.
|
||||
const FALLBACK_STOREFRONTS: StorefrontDefinition[] = [
|
||||
{ key: 'main', storefrontType: null, displayName: 'Main watch catalog', scope: 'watch' },
|
||||
{ key: 'watch', storefrontType: 3, displayName: 'Watch gift-drop shelf', scope: 'watch' },
|
||||
{ key: 'all', storefrontType: null, displayName: 'All storefront shelves', scope: 'shared' },
|
||||
{ key: 'rro', storefrontType: null, displayName: 'All RRO and Rec Center shelves', scope: 'shared' },
|
||||
{ key: 'giftdrop:1', storefrontType: 1, displayName: 'Laser Tag', scope: 'room' },
|
||||
{ key: 'giftdrop:2', storefrontType: 2, displayName: 'Rec Center', scope: 'room' },
|
||||
{ key: 'giftdrop:100', storefrontType: 100, displayName: 'Quest - Lost Skulls', scope: 'room' },
|
||||
{ key: 'giftdrop:101', storefrontType: 101, displayName: 'Quest - Dracula', scope: 'room' },
|
||||
{ key: 'giftdrop:102', storefrontType: 102, displayName: 'Quest - Golden Trophy', scope: 'room' },
|
||||
{ key: 'giftdrop:103', storefrontType: 103, displayName: 'Quest - Crimson Cauldron', scope: 'room' },
|
||||
{ key: 'giftdrop:200', storefrontType: 200, displayName: 'Rec Royale', scope: 'room' },
|
||||
{ key: 'giftdrop:300', storefrontType: 300, displayName: 'Cafe', scope: 'room' },
|
||||
{ key: 'giftdrop:400', storefrontType: 400, displayName: 'Paintball', scope: 'room' },
|
||||
{ key: 'giftdrop:401', storefrontType: 401, displayName: 'Paintball - River', scope: 'room' },
|
||||
{ key: 'giftdrop:402', storefrontType: 402, displayName: 'Paintball - Homestead', scope: 'room' },
|
||||
{ key: 'giftdrop:403', storefrontType: 403, displayName: 'Paintball - Quarry', scope: 'room' },
|
||||
{ key: 'giftdrop:404', storefrontType: 404, displayName: 'Paintball - Clear Cut', scope: 'room' },
|
||||
{ key: 'giftdrop:405', storefrontType: 405, displayName: 'Paintball - Spillway', scope: 'room' },
|
||||
{ key: 'giftdrop:406', storefrontType: 406, displayName: 'Paintball - Sunset Drive-In', scope: 'room' },
|
||||
{ key: 'giftdrop:500', storefrontType: 500, displayName: 'Bowling', scope: 'room' },
|
||||
{ key: 'giftdrop:600', storefrontType: 600, displayName: 'Stunt Runner', scope: 'room' },
|
||||
{ key: 'giftdrop:700', storefrontType: 700, displayName: 'Dorm Mirror', scope: 'room' },
|
||||
{ key: 'season:1', storefrontType: null, displayName: 'Season 1', scope: 'season' },
|
||||
];
|
||||
const CATEGORIES = ['head', 'torso', 'legs', 'feet', 'accessory', 'hair', 'face', 'consumable', 'roomtemplate'];
|
||||
|
||||
// Sort options for the catalog list. `dir` keeps the comparator direction
|
||||
// out of the sort dropdown labels so "Newest first" reads naturally.
|
||||
type SortKey = 'name-asc' | 'name-desc' | 'price-asc' | 'price-desc' | 'updated-desc' | 'created-desc';
|
||||
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
|
||||
{ key: 'name-asc', label: 'Name (A → Z)' },
|
||||
{ key: 'name-desc', label: 'Name (Z → A)' },
|
||||
{ key: 'price-asc', label: 'Price (low → high)' },
|
||||
{ key: 'price-desc', label: 'Price (high → low)' },
|
||||
{ key: 'updated-desc', label: 'Recently updated' },
|
||||
{ key: 'created-desc', label: 'Recently created' },
|
||||
];
|
||||
|
||||
type ViewMode = 'cards' | 'table';
|
||||
|
||||
export function Store() {
|
||||
const [storefront, setStorefront] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<SortKey>('updated-desc');
|
||||
const [view, setView] = useState<ViewMode>('cards');
|
||||
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive' | 'limited' | 'expired'>('all');
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [editing, setEditing] = useState<StoreItem | null | 'new'>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<StoreItem | null>(null);
|
||||
const [pendingBulk, setPendingBulk] = useState<null | { action: 'delete' | 'activate' | 'deactivate'; count: number }>(null);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
// Server-side pagination is overkill for this dataset (low hundreds
|
||||
// of rows typically); pull a generous slice and filter / sort in the
|
||||
// browser so the storefront/category dropdowns don't trigger network
|
||||
// round-trips on every change.
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('take', '500');
|
||||
const { data, loading, error, refresh } = useApi<StoreItem[]>(`/storeitems?${qs}`);
|
||||
const { data: storefrontData } = useApi<StorefrontDefinition[]>('/storefronts');
|
||||
const storefronts = storefrontData ?? FALLBACK_STOREFRONTS;
|
||||
|
||||
// Apply every filter (search / storefront / category / activeFilter)
|
||||
// then sort. Done in one memo so a single state change re-renders
|
||||
// once instead of cascading through multiple useMemos.
|
||||
const items = useMemo(() => {
|
||||
if (!data) return [] as StoreItem[];
|
||||
const now = Date.now();
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = data.filter(it => {
|
||||
if (storefront && it.storefront !== storefront) return false;
|
||||
if (category && it.category !== category) return false;
|
||||
if (q && !it.slug.toLowerCase().includes(q) && !it.displayName.toLowerCase().includes(q)) return false;
|
||||
if (activeFilter === 'active' && !it.isActive) return false;
|
||||
if (activeFilter === 'inactive' && it.isActive) return false;
|
||||
if (activeFilter === 'limited' && !it.isLimitedTime) return false;
|
||||
if (activeFilter === 'expired') {
|
||||
if (!it.availableUntil) return false;
|
||||
if (Date.parse(it.availableUntil) > now) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const cmp = (a: StoreItem, b: StoreItem) => {
|
||||
switch (sort) {
|
||||
case 'name-asc': return a.displayName.localeCompare(b.displayName);
|
||||
case 'name-desc': return b.displayName.localeCompare(a.displayName);
|
||||
case 'price-asc': return a.price - b.price;
|
||||
case 'price-desc': return b.price - a.price;
|
||||
case 'updated-desc': return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||||
case 'created-desc': return Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
||||
}
|
||||
};
|
||||
return [...filtered].sort(cmp);
|
||||
}, [data, storefront, category, search, sort, activeFilter]);
|
||||
|
||||
// Bulk-selection helpers — keep the Set immutable so React picks up
|
||||
// the change without us tracking version numbers.
|
||||
const toggleSelected = (id: number) => setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const allVisibleSelected = items.length > 0 && items.every(it => selected.has(it.id));
|
||||
const selectAllVisible = () => setSelected(prev => {
|
||||
if (allVisibleSelected) {
|
||||
const next = new Set(prev);
|
||||
for (const it of items) next.delete(it.id);
|
||||
return next;
|
||||
}
|
||||
const next = new Set(prev);
|
||||
for (const it of items) next.add(it.id);
|
||||
return next;
|
||||
});
|
||||
const clearSelection = () => setSelected(new Set());
|
||||
|
||||
const del = async () => {
|
||||
if (!pendingDelete) return;
|
||||
try {
|
||||
await api(`/storeitems/${pendingDelete.id}`, { method: 'DELETE' });
|
||||
toast.push('Item deleted', 'success');
|
||||
setPendingDelete(null);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Bulk action — sequential rather than Promise.all so the server
|
||||
// doesn't get hit with 50 simultaneous writes (and so a partial
|
||||
// failure tells us how far we got). Selected ids that aren't in the
|
||||
// current `data` array (e.g. removed while the user was selecting)
|
||||
// are skipped without error.
|
||||
const runBulk = async () => {
|
||||
if (!pendingBulk || !data) return;
|
||||
setBulkBusy(true);
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
try {
|
||||
const targets = data.filter(it => selected.has(it.id));
|
||||
for (const it of targets) {
|
||||
try {
|
||||
if (pendingBulk.action === 'delete') {
|
||||
await api(`/storeitems/${it.id}`, { method: 'DELETE' });
|
||||
} else {
|
||||
await api(`/storeitems/${it.id}`, {
|
||||
method: 'PUT',
|
||||
body: { IsActive: pendingBulk.action === 'activate' },
|
||||
});
|
||||
}
|
||||
ok++;
|
||||
} catch { failed++; }
|
||||
}
|
||||
const verb = pendingBulk.action === 'delete' ? 'deleted'
|
||||
: pendingBulk.action === 'activate' ? 'activated'
|
||||
: 'deactivated';
|
||||
toast.push(`${verb} ${ok}/${ok + failed}`, failed > 0 ? 'error' : 'success');
|
||||
clearSelection();
|
||||
setPendingBulk(null);
|
||||
refresh();
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Store catalog"
|
||||
blurb="Every row in StoreItemEntity across every storefront. Live items prefer IsActive=false (toggle from the card or bulk bar); hard delete is here for test cleanup."
|
||||
actions={<>
|
||||
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
<button onClick={() => setEditing('new')} className="btn-primary text-xs">
|
||||
<Plus /> New item
|
||||
</button>
|
||||
</>}
|
||||
/>
|
||||
|
||||
{/* Filter / sort bar — kept on a single card so the controls share
|
||||
visual weight and the count + view-toggle anchor the right edge. */}
|
||||
<div className="card !p-3 mb-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[180px] max-w-md">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search slug or display name…"
|
||||
className="input pl-8 w-full"
|
||||
/>
|
||||
</div>
|
||||
<select value={storefront} onChange={e => setStorefront(e.target.value)} className="input max-w-[200px]">
|
||||
<option value="">All storefronts</option>
|
||||
{storefronts.map(s => <option key={s.key} value={s.key}>{storefrontLabel(s)}</option>)}
|
||||
</select>
|
||||
<select value={category} onChange={e => setCategory(e.target.value)} className="input max-w-[160px]">
|
||||
<option value="">All categories</option>
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<select value={sort} onChange={e => setSort(e.target.value as SortKey)} className="input max-w-[180px]">
|
||||
{SORT_OPTIONS.map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
|
||||
</select>
|
||||
<div className="ml-auto inline-flex rounded-md border border-ink-800 bg-ink-900/40 p-0.5 text-[11px]">
|
||||
<button
|
||||
onClick={() => setView('cards')}
|
||||
className={`px-2 py-1 rounded ${view === 'cards' ? 'bg-ink-800 text-ink-50' : 'text-ink-400 hover:text-ink-200'}`}
|
||||
>Cards</button>
|
||||
<button
|
||||
onClick={() => setView('table')}
|
||||
className={`px-2 py-1 rounded ${view === 'table' ? 'bg-ink-800 text-ink-50' : 'text-ink-400 hover:text-ink-200'}`}
|
||||
>Table</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status chips — single-select. Stays on its own row so labels
|
||||
wrap predictably on narrow screens. */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
{(['all', 'active', 'inactive', 'limited', 'expired'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setActiveFilter(f)}
|
||||
className={`rounded-full border px-2.5 py-1 transition-colors ${
|
||||
activeFilter === f
|
||||
? 'border-brand-500/60 bg-brand-500/15 text-brand-100'
|
||||
: 'border-ink-800 bg-ink-900/40 text-ink-400 hover:border-ink-700 hover:text-ink-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'All' :
|
||||
f === 'active' ? 'Active only' :
|
||||
f === 'inactive' ? 'Inactive only' :
|
||||
f === 'limited' ? 'Limited time' :
|
||||
'Expired'}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto text-xs text-ink-400">
|
||||
{data ? `${items.length} of ${data.length} items` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar — only shows when there's a selection. Floats
|
||||
above the grid as a sticky-feeling banner. */}
|
||||
{selected.size > 0 && (
|
||||
<div className="card !p-2 mb-4 border-brand-500/40 bg-brand-500/5 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-ink-100">
|
||||
<span className="font-semibold text-brand-100">{selected.size}</span> selected
|
||||
</span>
|
||||
<button onClick={selectAllVisible} className="btn-ghost text-xs">
|
||||
{allVisibleSelected ? 'Deselect visible' : 'Select all visible'}
|
||||
</button>
|
||||
<div className="ml-auto flex gap-1">
|
||||
<button
|
||||
onClick={() => setPendingBulk({ action: 'activate', count: selected.size })}
|
||||
className="btn-secondary text-xs"
|
||||
disabled={bulkBusy}
|
||||
>Activate</button>
|
||||
<button
|
||||
onClick={() => setPendingBulk({ action: 'deactivate', count: selected.size })}
|
||||
className="btn-secondary text-xs"
|
||||
disabled={bulkBusy}
|
||||
>Deactivate</button>
|
||||
<button
|
||||
onClick={() => setPendingBulk({ action: 'delete', count: selected.size })}
|
||||
className="btn-danger text-xs"
|
||||
disabled={bulkBusy}
|
||||
><Trash /> Delete</button>
|
||||
<button onClick={clearSelection} className="btn-ghost text-xs">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{error}</div>}
|
||||
{!loading && items.length === 0 && (
|
||||
<Empty title={data && data.length === 0 ? 'No items in the catalog' : 'No items match the filters'} />
|
||||
)}
|
||||
|
||||
{items.length > 0 && view === 'cards' && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
{items.map(it => (
|
||||
<StoreCard
|
||||
key={it.id}
|
||||
item={it}
|
||||
selected={selected.has(it.id)}
|
||||
onToggleSelected={() => toggleSelected(it.id)}
|
||||
onEdit={() => setEditing(it)}
|
||||
onDelete={() => setPendingDelete(it)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && view === 'table' && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="table-scroll">
|
||||
<table className="w-full text-sm min-w-[820px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="w-10 px-3 py-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allVisibleSelected}
|
||||
onChange={selectAllVisible}
|
||||
className="size-4 accent-brand-500"
|
||||
aria-label="Select all visible"
|
||||
/>
|
||||
</th>
|
||||
<th className="w-14 px-2 py-2.5" />
|
||||
<th className="text-left font-medium px-2 py-2.5">Item</th>
|
||||
<th className="text-left font-medium px-3 py-2.5">Storefront</th>
|
||||
<th className="text-left font-medium px-3 py-2.5">Category</th>
|
||||
<th className="text-right font-medium px-3 py-2.5">Price</th>
|
||||
<th className="text-left font-medium px-3 py-2.5">State</th>
|
||||
<th className="w-32 px-3 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{items.map(it => (
|
||||
<tr key={it.id} className={`table-row-hover ${selected.has(it.id) ? 'bg-brand-500/5' : ''}`}>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(it.id)}
|
||||
onChange={() => toggleSelected(it.id)}
|
||||
className="size-4 accent-brand-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2"><Thumb imageName={it.imageName} size={36} /></td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="text-ink-50">{it.displayName}</div>
|
||||
<div className="text-[11px] font-mono text-ink-500">{it.slug}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-ink-200 text-xs">{it.storefront}</td>
|
||||
<td className="px-3 py-2 text-ink-200 text-xs">{it.category}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-ink-100">
|
||||
{num(it.price)} <span className="text-ink-400 text-xs">{currencyName(it.currencyType)}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<ItemBadges item={it} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
<button onClick={() => setEditing(it)} className="btn-ghost text-xs">Edit</button>
|
||||
<button onClick={() => setPendingDelete(it)} className="btn-ghost text-xs text-danger">
|
||||
<Trash />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<StoreItemForm
|
||||
item={editing === 'new' ? null : editing}
|
||||
storefronts={storefronts}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => { setEditing(null); refresh(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Confirm
|
||||
open={pendingDelete !== null}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
title="Delete store item"
|
||||
body={<>Hard-delete <span className="font-medium text-ink-50">{pendingDelete?.displayName}</span>? Prefer toggling IsActive=false for live items.</>}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
onConfirm={del}
|
||||
/>
|
||||
|
||||
<Confirm
|
||||
open={pendingBulk !== null}
|
||||
onClose={() => pendingBulk && !bulkBusy && setPendingBulk(null)}
|
||||
title={
|
||||
pendingBulk?.action === 'delete' ? `Delete ${pendingBulk.count} items` :
|
||||
pendingBulk?.action === 'activate' ? `Activate ${pendingBulk?.count} items` :
|
||||
`Deactivate ${pendingBulk?.count} items`
|
||||
}
|
||||
body={pendingBulk?.action === 'delete'
|
||||
? <>Hard-delete <span className="font-medium text-ink-50">{pendingBulk.count}</span> selected items? This cannot be undone.</>
|
||||
: <>Toggle <span className="font-medium text-ink-50">{pendingBulk?.count}</span> items to {pendingBulk?.action === 'activate' ? 'active' : 'inactive'}?</>}
|
||||
confirmLabel={bulkBusy ? 'Working…' : pendingBulk?.action === 'delete' ? 'Delete all' : 'Apply'}
|
||||
destructive={pendingBulk?.action === 'delete'}
|
||||
onConfirm={runBulk}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cards / table cells ──────────────────────────────────────────────
|
||||
|
||||
function StoreCard({
|
||||
item, selected, onToggleSelected, onEdit, onDelete,
|
||||
}: {
|
||||
item: StoreItem;
|
||||
selected: boolean;
|
||||
onToggleSelected: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`card !p-0 group relative flex flex-col overflow-hidden ${
|
||||
selected ? 'ring-2 ring-brand-500/60 border-brand-500/30' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Selection checkbox — sits over the thumbnail. Only fully opaque
|
||||
when the item is selected OR the user is hovering the card. */}
|
||||
<label className={`absolute left-2 top-2 z-10 flex items-center justify-center rounded bg-ink-950/70 backdrop-blur-sm size-6 cursor-pointer transition-opacity ${selected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={onToggleSelected}
|
||||
className="size-4 accent-brand-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Active/limited/expired badges — top-right of the thumbnail. */}
|
||||
<div className="absolute right-2 top-2 z-10 flex flex-col gap-1 items-end">
|
||||
<ItemBadges item={item} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="block w-full aspect-square bg-ink-950/40 overflow-hidden focus:outline-none"
|
||||
title="Edit"
|
||||
>
|
||||
<Thumb imageName={item.imageName} size={256} fill />
|
||||
</button>
|
||||
|
||||
<div className="px-2.5 py-2 flex flex-col gap-1">
|
||||
<div className="text-sm text-ink-50 truncate" title={item.displayName}>{item.displayName}</div>
|
||||
<div className="text-[10px] font-mono text-ink-500 truncate" title={item.slug}>{item.slug}</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs tabular-nums text-ink-100">
|
||||
{num(item.price)} <span className="text-ink-400">{currencyName(item.currencyType)}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={onEdit} className="btn-ghost text-[11px] py-0.5 px-1.5">Edit</button>
|
||||
<button onClick={onDelete} className="btn-ghost text-[11px] py-0.5 px-1.5 text-danger" title="Delete">
|
||||
<Trash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-ink-500 truncate">
|
||||
{item.storefront} · {item.category}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemBadges({ item }: { item: StoreItem }) {
|
||||
const expired = item.availableUntil && Date.parse(item.availableUntil) < Date.now();
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.isActive ? <span className="badge-online">Active</span> : <span className="badge-neutral">Inactive</span>}
|
||||
{item.isLimitedTime && <span className="badge-junior">Limited</span>}
|
||||
{expired && <span className="badge-banned">Expired</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Thumbnail ────────────────────────────────────────────────────────
|
||||
// Pulls the image from img.* with the same signed URL the watch uses
|
||||
// (sig=p1, optional ?width hint). Falls back to a placeholder tile
|
||||
// showing the filename when the image is missing or 404s — that's
|
||||
// surprisingly common with imported rooms whose CDN bytes never made
|
||||
// the trip.
|
||||
|
||||
function Thumb({ imageName, size, fill }: { imageName: string | null; size: number; fill?: boolean }) {
|
||||
const [errored, setErrored] = useState(false);
|
||||
const url = imageName && !errored ? profileImageUrl(imageName, Math.max(size, 128)) : null;
|
||||
if (!url) {
|
||||
return (
|
||||
<div
|
||||
className={`${fill ? 'w-full h-full' : ''} flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-950 text-[10px] text-ink-500 px-2 text-center`}
|
||||
style={fill ? undefined : { width: size, height: size }}
|
||||
title={imageName ?? 'no image'}
|
||||
>
|
||||
<span className="font-mono truncate">{imageName || 'no image'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
onError={() => setErrored(true)}
|
||||
alt={imageName ?? ''}
|
||||
loading="lazy"
|
||||
className={`${fill ? 'w-full h-full object-cover' : ''} bg-ink-950`}
|
||||
style={fill ? undefined : { width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Edit / create modal ──────────────────────────────────────────────
|
||||
|
||||
function StoreItemForm({
|
||||
item, storefronts, onClose, onSaved,
|
||||
}: {
|
||||
item: StoreItem | null;
|
||||
storefronts: StorefrontDefinition[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const isNew = item === null;
|
||||
const [form, setForm] = useState({
|
||||
slug: item?.slug ?? '',
|
||||
displayName: item?.displayName ?? '',
|
||||
description: item?.description ?? '',
|
||||
storefront: item?.storefront ?? 'main',
|
||||
category: item?.category ?? 'accessory',
|
||||
imageName: item?.imageName ?? '',
|
||||
currencyType: item?.currencyType ?? 2,
|
||||
price: item?.price ?? 100,
|
||||
isActive: item?.isActive ?? true,
|
||||
isLimitedTime: item?.isLimitedTime ?? false,
|
||||
availableUntil: item?.availableUntil ?? '',
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const body = {
|
||||
Slug: form.slug.trim(),
|
||||
DisplayName: form.displayName.trim(),
|
||||
Description: form.description.trim(),
|
||||
Storefront: form.storefront,
|
||||
Category: form.category,
|
||||
ImageName: form.imageName.trim(),
|
||||
CurrencyType: form.currencyType,
|
||||
Price: form.price,
|
||||
IsActive: form.isActive,
|
||||
IsLimitedTime: form.isLimitedTime,
|
||||
AvailableUntil: form.availableUntil || null,
|
||||
};
|
||||
if (isNew) {
|
||||
await api('/storeitems', { method: 'POST', body });
|
||||
} else {
|
||||
await api(`/storeitems/${item!.id}`, { method: 'PUT', body });
|
||||
}
|
||||
toast.push(isNew ? 'Item created' : 'Item updated', 'success');
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isNew ? 'New store item' : `Edit ${item!.slug}`}
|
||||
open
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
footer={<>
|
||||
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
|
||||
<button onClick={save} disabled={busy || !form.slug.trim() || !form.displayName.trim()} className="btn-primary text-xs">
|
||||
{busy ? 'Saving…' : isNew ? 'Create item' : 'Save changes'}
|
||||
</button>
|
||||
</>}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-[160px_1fr] gap-4">
|
||||
{/* Live preview anchored next to the form — pulls the image
|
||||
using the same code path the catalog grid uses, so any
|
||||
"no image" or 404 the admin sees in the cards is reproduced
|
||||
here while editing. */}
|
||||
<div className="space-y-2">
|
||||
<div className="label">Preview</div>
|
||||
<div className="aspect-square rounded-lg overflow-hidden bg-ink-950/40 border border-ink-800">
|
||||
<Thumb imageName={form.imageName.trim() || null} size={256} fill />
|
||||
</div>
|
||||
<div className="text-[10px] text-ink-500 font-mono break-all">{form.imageName || 'no imageName set'}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="Slug (unique)">
|
||||
<input value={form.slug} onChange={e => setForm({ ...form, slug: e.target.value })} disabled={!isNew} className="input font-mono text-xs" />
|
||||
</Field>
|
||||
<Field label="Display name">
|
||||
<input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} className="input" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Description">
|
||||
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={2} className="input" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Field label="Storefront">
|
||||
<select value={form.storefront} onChange={e => setForm({ ...form, storefront: e.target.value })} className="input">
|
||||
{!storefronts.some(s => s.key === form.storefront) && (
|
||||
<option value={form.storefront}>{form.storefront}</option>
|
||||
)}
|
||||
{storefronts.map(s => <option key={s.key} value={s.key}>{storefrontLabel(s)}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Category">
|
||||
<select value={form.category} onChange={e => setForm({ ...form, category: e.target.value })} className="input">
|
||||
{CATEGORIES.map(c => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Image name (filename only)">
|
||||
<input value={form.imageName} onChange={e => setForm({ ...form, imageName: e.target.value })} className="input font-mono text-xs" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Field label="Currency">
|
||||
<select value={form.currencyType} onChange={e => setForm({ ...form, currencyType: parseInt(e.target.value) })} className="input">
|
||||
<option value={1}>Tokens</option>
|
||||
<option value={2}>Coins</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Price">
|
||||
<input type="number" min={0} value={form.price} onChange={e => setForm({ ...form, price: parseInt(e.target.value || '0') })} className="input" />
|
||||
</Field>
|
||||
<Field label="Available until (ISO)">
|
||||
<input type="datetime-local" value={form.availableUntil ? form.availableUntil.slice(0, 16) : ''} onChange={e => setForm({ ...form, availableUntil: e.target.value })} className="input" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-200">
|
||||
<input type="checkbox" checked={form.isActive} onChange={e => setForm({ ...form, isActive: e.target.checked })} className="size-4 accent-brand-500" />
|
||||
Active
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-ink-200">
|
||||
<input type="checkbox" checked={form.isLimitedTime} onChange={e => setForm({ ...form, isLimitedTime: e.target.checked })} className="size-4 accent-brand-500" />
|
||||
Limited time
|
||||
</label>
|
||||
</div>
|
||||
{!isNew && (
|
||||
<div className="text-xs text-ink-500">
|
||||
Created {relativeTime(item!.createdAt)} · Updated {relativeTime(item!.updatedAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function storefrontLabel(s: StorefrontDefinition) {
|
||||
return `${s.displayName} (${s.key})`;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="label">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Placeholder used by routes whose page hasn't been built yet so the
|
||||
// router/nav is wireable today and pages can land incrementally without
|
||||
// breaking links.
|
||||
|
||||
export function Stub({ title, blurb }: { title: string; blurb?: string }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-ink-50">{title}</h1>
|
||||
<div className="card p-6 text-sm text-ink-300">
|
||||
<div className="text-ink-200 mb-1">Coming soon.</div>
|
||||
{blurb && <p className="text-ink-400">{blurb}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: {
|
||||
50: '#f7f7f8',
|
||||
100: '#ececef',
|
||||
200: '#d3d4d9',
|
||||
300: '#a9abb4',
|
||||
400: '#7e818d',
|
||||
500: '#5d606b',
|
||||
600: '#43464f',
|
||||
700: '#34363d',
|
||||
800: '#23252b',
|
||||
850: '#1b1d22',
|
||||
900: '#131418',
|
||||
950: '#0a0b0e',
|
||||
},
|
||||
brand: {
|
||||
DEFAULT: '#7c5cff',
|
||||
50: '#f1edff',
|
||||
100: '#e1d8ff',
|
||||
200: '#c5b6ff',
|
||||
300: '#a48dff',
|
||||
400: '#8d72ff',
|
||||
500: '#7c5cff',
|
||||
600: '#6948e3',
|
||||
700: '#5236b8',
|
||||
800: '#3e2989',
|
||||
900: '#2a1c5f',
|
||||
},
|
||||
success: '#3ecf8e',
|
||||
warn: '#f4b740',
|
||||
danger: '#ef5b6b',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'SFMono-Regular', 'Menlo', 'monospace'],
|
||||
},
|
||||
boxShadow: {
|
||||
card: '0 1px 0 rgba(0,0,0,0.05), 0 1px 3px rgba(0,0,0,0.10)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"useDefineForClassFields": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
|
||||
// Source lives at DorkNet.Server/admin-ui; build emits straight into
|
||||
// DorkNet.Server/wwwroot/admin so the static file middleware picks it
|
||||
// up at admin.localhost without any extra publish wiring.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, '../wwwroot/admin'),
|
||||
emptyOutDir: true,
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Dev convenience: `npm run dev` proxies API calls to the
|
||||
// running ASP.NET server so we can iterate on the SPA with HMR
|
||||
// without dealing with CORS. Adjust target if the server runs
|
||||
// on a non-default port locally.
|
||||
'/api': {
|
||||
target: 'http://localhost:80',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user