@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.github
|
||||
.agents
|
||||
.codex
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.steam-auth.json
|
||||
.steam-credentials.json
|
||||
*.log
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ultradesu/doka2-lobby-list
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
.steam-auth.json
|
||||
.steam-credentials.json
|
||||
*.log
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
STEAM_AUTH_FILE=/data/.steam-auth.json \
|
||||
STEAM_CREDENTIALS_FILE=/data/.steam-credentials.json
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /data && chown -R node:node /app /data
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
VOLUME ["/data"]
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
@@ -0,0 +1,83 @@
|
||||
# doka-lobby
|
||||
|
||||
Web UI for Dota 2 joinable custom lobbies from the game coordinator.
|
||||
|
||||
The app keeps the latest lobby snapshot in memory, refreshes it every 3 minutes, and streams updates to the browser. There is no database. Steam account credentials and the refresh token can be persisted on a PVC.
|
||||
|
||||
## Local run
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
$env:AUTH_ADMIN_PASSWORD = "admin-password"
|
||||
npm start
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
Use `/admin` to enter the admin password, save Steam credentials, and submit a Steam Guard code when Steam asks for it.
|
||||
|
||||
## Kubernetes configuration
|
||||
|
||||
Mount a PVC to `/data` and set:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: AUTH_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: doka-lobby-admin
|
||||
key: password
|
||||
- name: STEAM_AUTH_FILE
|
||||
value: /data/.steam-auth.json
|
||||
- name: STEAM_CREDENTIALS_FILE
|
||||
value: /data/.steam-credentials.json
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
```
|
||||
|
||||
Run one replica only. A single Steam account should not be used by multiple pods at once.
|
||||
|
||||
## Auth files
|
||||
|
||||
- `.steam-credentials.json` stores the Steam account name and password saved from `/admin`.
|
||||
- `.steam-auth.json` stores the Steam refresh token emitted by `steam-user`.
|
||||
- Both files are ignored by git and Docker build context.
|
||||
|
||||
If the refresh token is still valid, restarts use it. If it is missing or expired, the app falls back to the saved Steam account/password and may ask for Steam Guard again.
|
||||
|
||||
## Docker image CI
|
||||
|
||||
GitHub Actions builds and pushes:
|
||||
|
||||
```text
|
||||
ultradesu/doka2-lobby-list
|
||||
```
|
||||
|
||||
Configure repository secrets:
|
||||
|
||||
- `DOCKERHUB_USERNAME`
|
||||
- `DOCKERHUB_TOKEN`
|
||||
|
||||
The workflow publishes `latest` on the default branch, branch/tag refs, and `sha-*` tags.
|
||||
|
||||
## Options
|
||||
|
||||
```powershell
|
||||
node index.js --port 3001
|
||||
node index.js --region 3
|
||||
node index.js --game-id 123456789
|
||||
node index.js --refresh-ms 180000
|
||||
```
|
||||
|
||||
One-shot console mode:
|
||||
|
||||
```powershell
|
||||
node index.js --once --map "ffa"
|
||||
```
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
syntax = "proto2";
|
||||
|
||||
// Minimal subset of Dota 2 GC protobufs (from SteamDatabase/GameTracking-Dota2)
|
||||
|
||||
message CMsgClientHello {
|
||||
optional uint32 version = 1;
|
||||
optional uint32 client_session_need = 3;
|
||||
optional int32 engine = 5; // 1 = Source 2
|
||||
}
|
||||
|
||||
message CMsgJoinableCustomLobbiesRequest {
|
||||
optional uint32 server_region = 1;
|
||||
optional uint64 custom_game_id = 2;
|
||||
}
|
||||
|
||||
message CMsgJoinableCustomLobbiesResponseEntry {
|
||||
optional fixed64 lobby_id = 1;
|
||||
optional uint64 custom_game_id = 2;
|
||||
optional string lobby_name = 3;
|
||||
optional uint32 member_count = 4;
|
||||
optional uint32 leader_account_id = 5;
|
||||
optional string leader_name = 6;
|
||||
optional string custom_map_name = 7;
|
||||
optional uint32 max_player_count = 8;
|
||||
optional uint32 server_region = 9;
|
||||
optional bool has_pass_key = 11;
|
||||
}
|
||||
|
||||
message CMsgJoinableCustomLobbiesResponse {
|
||||
repeated CMsgJoinableCustomLobbiesResponseEntry lobbies = 1;
|
||||
}
|
||||
Generated
+579
@@ -0,0 +1,579 @@
|
||||
{
|
||||
"name": "doka-lobby",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "doka-lobby",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"protobufjs": "^7.4.0",
|
||||
"steam-user": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@bbob/parser": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@bbob/parser/-/parser-2.9.0.tgz",
|
||||
"integrity": "sha512-tldSYsMoEclke/B1nqL7+HbYMWZHTKvpbEHRSHuY+sZvS1o7Jpdfjb+KPpwP9wLI3p3r7GPv69/wGy+Xibs9yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bbob/plugin-helper": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bbob/plugin-helper": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@bbob/plugin-helper/-/plugin-helper-2.9.0.tgz",
|
||||
"integrity": "sha512-idpUcNQ2co6T1oU/7/DG/ZRfipSSkTn9Ozw9f5vaXH7nzV3qhqZnhFVlHTzGGnRlzKlBwWOBzOdWi4Zeqg1c5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@doctormckay/stdlib": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@doctormckay/stdlib/-/stdlib-2.10.0.tgz",
|
||||
"integrity": "sha512-bwy+gPn6oa2KTpfxJKX3leZoV/wHDVtO0/gq3usPvqPswG//dcf3jVB8LcbRRsKO3BXCt5DqctOQ+Xb07ivxnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"psl": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@doctormckay/steam-crypto": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@doctormckay/steam-crypto/-/steam-crypto-1.2.0.tgz",
|
||||
"integrity": "sha512-lsxgLw640gEdZBOXpVIcYWcYD+V+QbtEsMPzRvjmjz2XXKc7QeEMyHL07yOFRmay+cUwO4ObKTJO0dSInEuq5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@doctormckay/user-agents": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@doctormckay/user-agents/-/user-agents-1.0.0.tgz",
|
||||
"integrity": "sha512-F+sL1YmebZTY2CnjoR9BXFEULpq7y8dxyLx48LZVa0BSDseXdLG/DtPISfM1iNv1XKCeiBzVNfAT/MOQ69v1Zw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/inquire": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
|
||||
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
|
||||
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@types/long": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
|
||||
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.18",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz",
|
||||
"integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/binarykvparser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binarykvparser/-/binarykvparser-2.3.0.tgz",
|
||||
"integrity": "sha512-B1N5ZxC8I9oSLis7Rg36DxsZJoIikUGU2XwpI0FKFCaPIJIEYi0B9UeIk3QU006axzq0TI9KC3iXelfGGgnWew==",
|
||||
"bundleDependencies": [
|
||||
"long"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"long": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytebuffer": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/bytebuffer/-/bytebuffer-5.0.1.tgz",
|
||||
"integrity": "sha512-IuzSdmADppkZ6DlpycMkm8l9zeEq16fWtLvunEwFiYciR/BHo4E8/xs5piFquG+Za8OWmMqHF8zuRviz2LHvRQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"long": "~3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/bytebuffer/node_modules/long": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz",
|
||||
"integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cuint": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz",
|
||||
"integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/file-manager": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-manager/-/file-manager-2.0.1.tgz",
|
||||
"integrity": "sha512-y/K/1OCha04OXOxzo3cXJYtIzEk/CUMBb7Okipxueu0u+xCiuoocbwPyh1smUBasOobo4GAYmjgjD9Vh5zI51w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@doctormckay/stdlib": "^1.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-manager/node_modules/@doctormckay/stdlib": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@doctormckay/stdlib/-/stdlib-1.16.1.tgz",
|
||||
"integrity": "sha512-XhuUOzElz6fnNdt70IYNKqhPAEpGaL4JHOhAvklRh0hAhVPW+/wLxaWT3DWUbaG5Dta5YvIp7+cZK3GhIpAuug==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/kvparser": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/kvparser/-/kvparser-1.0.2.tgz",
|
||||
"integrity": "sha512-5P/5qpTAHjVYWqcI55B3yQwSY2FUrYYrJj5i65V1Wmg7/4W4OnBcaodaEvLyVuugeOnS+BAaKm9LbPazGJcRyA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lzma": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/lzma/-/lzma-2.3.2.tgz",
|
||||
"integrity": "sha512-DcfiawQ1avYbW+hsILhF38IKAlnguc/fjHrychs9hdxe4qLykvhT5VTGNs5YRWgaNePh7NTxGD4uv4gKsRomCQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lzma.js": "bin/lzma.js"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-bignumber": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-bignumber/-/node-bignumber-1.2.2.tgz",
|
||||
"integrity": "sha512-VoTZHmdFQpZH1+q1dz2qcHNCwTWsJg2T3PYwlAyDNFOfVhSYUKQBLFcCpCud+wJBGgCttGavZILaIggDIKqEQQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/permessage-deflate": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/permessage-deflate/-/permessage-deflate-0.1.7.tgz",
|
||||
"integrity": "sha512-EUNi/RIsyJ1P1u9QHFwMOUWMYetqlE22ZgGbad7YP856WF4BFF0B7DuNy6vEGsgNNud6c/SkdWzkne71hH8MjA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.6.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.1",
|
||||
"@protobufjs/fetch": "^1.1.1",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/lupomontero"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"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/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.1.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz",
|
||||
"integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^6.0.2",
|
||||
"debug": "^4.3.3",
|
||||
"socks": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/steam-appticket": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/steam-appticket/-/steam-appticket-1.0.2.tgz",
|
||||
"integrity": "sha512-zwDwZALGv3RanE8RHNYcQU3u4Ez23EzMuQ4Lh15uIHddpDh6TI6uFGbC0HNyt6y+UJYSILe77A33VhFZKQiaqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@doctormckay/stdlib": "^1.6.0",
|
||||
"@doctormckay/steam-crypto": "^1.2.0",
|
||||
"bytebuffer": "^5.0.1",
|
||||
"protobufjs": "^6.8.8",
|
||||
"steamid": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/steam-appticket/node_modules/@doctormckay/stdlib": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@doctormckay/stdlib/-/stdlib-1.16.1.tgz",
|
||||
"integrity": "sha512-XhuUOzElz6fnNdt70IYNKqhPAEpGaL4JHOhAvklRh0hAhVPW+/wLxaWT3DWUbaG5Dta5YvIp7+cZK3GhIpAuug==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/steam-appticket/node_modules/long": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
|
||||
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/steam-appticket/node_modules/protobufjs": {
|
||||
"version": "6.11.6",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz",
|
||||
"integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.4",
|
||||
"@protobufjs/eventemitter": "^1.1.0",
|
||||
"@protobufjs/fetch": "^1.1.0",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.0",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.0",
|
||||
"@types/long": "^4.0.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbjs": "bin/pbjs",
|
||||
"pbts": "bin/pbts"
|
||||
}
|
||||
},
|
||||
"node_modules/steam-appticket/node_modules/steamid": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/steamid/-/steamid-1.1.3.tgz",
|
||||
"integrity": "sha512-t86YjtP1LtPt8D+TaIARm6PtC9tBnF1FhxQeLFs6ohG7vDUfQuy/M8II14rx1TTUkVuYoWHP/7DlvTtoCGULcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cuint": "^0.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/steam-session": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/steam-session/-/steam-session-1.9.4.tgz",
|
||||
"integrity": "sha512-MLvg1uMLEOIRHZS5LKruy1w5OqHb8EL7TeMxIY2a4mUcaVOgszz060+jo4c7s3gFeedyuoqGcfwJrR0pLKYzLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@doctormckay/stdlib": "^2.9.0",
|
||||
"@doctormckay/user-agents": "^1.0.0",
|
||||
"debug": "^4.3.4",
|
||||
"kvparser": "^1.0.1",
|
||||
"node-bignumber": "^1.2.2",
|
||||
"protobufjs": "^7.1.0",
|
||||
"socks-proxy-agent": "^7.0.0",
|
||||
"steamid": "^2.0.0",
|
||||
"tiny-typed-emitter": "^2.1.0",
|
||||
"websocket13": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/steam-totp": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/steam-totp/-/steam-totp-2.1.2.tgz",
|
||||
"integrity": "sha512-bTKlc/NoIUQId+my+O556s55DDsNNXfVIPWFDNVu68beql7AJhV0c+GTjFxfwCDYfdc4NkAme+0WrDdnY2D2VA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/steam-user": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/steam-user/-/steam-user-5.3.0.tgz",
|
||||
"integrity": "sha512-/92MOZGIocixlgzjloXrDffbAL5sF9rz4sbsafZ73samhkv2ITQNJrKaKGCJbAq5Xz8CbtCzstdjH8qXawLJVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bbob/parser": "^2.2.0",
|
||||
"@doctormckay/stdlib": "^2.9.1",
|
||||
"@doctormckay/steam-crypto": "^1.2.0",
|
||||
"adm-zip": "^0.5.10",
|
||||
"binarykvparser": "^2.2.0",
|
||||
"bytebuffer": "^5.0.0",
|
||||
"file-manager": "^2.0.0",
|
||||
"kvparser": "^1.0.1",
|
||||
"lzma": "^2.3.2",
|
||||
"protobufjs": "^7.2.4",
|
||||
"socks-proxy-agent": "^7.0.0",
|
||||
"steam-appticket": "^1.0.1",
|
||||
"steam-session": "^1.8.0",
|
||||
"steam-totp": "^2.0.1",
|
||||
"steamid": "^2.0.0",
|
||||
"websocket13": "^4.0.0",
|
||||
"zstddec": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lzma-native": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"lzma-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/steamid": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/steamid/-/steamid-2.1.0.tgz",
|
||||
"integrity": "sha512-ndt1cvuuSC+i8fcxVsmeyRlgGsR1QsoAuIXz+eabj8/Y4GIWE2+mgHA7Hys61JDHOxttfWtXHtN2m5TNYTlORg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/websocket-extensions": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz",
|
||||
"integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/websocket13": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/websocket13/-/websocket13-4.1.0.tgz",
|
||||
"integrity": "sha512-7+hxkUVTKQlUDTzN2rJI7fJRBXCT6dvRXr1aZflxUZlpNJutHBkKiEIbZOCGs0A1s7vxAmcAXngsNUQMSUTiVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@doctormckay/stdlib": "^2.7.1",
|
||||
"bytebuffer": "^5.0.1",
|
||||
"permessage-deflate": "^0.1.7",
|
||||
"tiny-typed-emitter": "^2.1.0",
|
||||
"websocket-extensions": "^0.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zstddec": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz",
|
||||
"integrity": "sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==",
|
||||
"license": "MIT AND BSD-3-Clause"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "doka-lobby",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "List joinable Dota 2 custom game lobbies, filtered by game name / map",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"once": "node index.js --once"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"protobufjs": "^7.4.0",
|
||||
"steam-user": "^5.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Steam Auth Admin</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<h1>Steam auth admin</h1>
|
||||
<div class="subline">
|
||||
<span id="adminStatus" class="badge badge-muted">locked</span>
|
||||
<a class="admin-link" href="/">Lobby list</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metrics" aria-label="Auth status">
|
||||
<div class="metric wide">
|
||||
<span class="metric-label">credentials</span>
|
||||
<strong id="credentialsSource">-</strong>
|
||||
</div>
|
||||
<div class="metric wide">
|
||||
<span class="metric-label">refresh token</span>
|
||||
<strong id="tokenStatus">-</strong>
|
||||
</div>
|
||||
<div class="metric wide">
|
||||
<span class="metric-label">steam</span>
|
||||
<strong id="steamStatus">-</strong>
|
||||
</div>
|
||||
<div class="metric wide">
|
||||
<span class="metric-label">game coordinator</span>
|
||||
<strong id="gcStatus">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="admin-main">
|
||||
<section id="loginPanel" class="auth-panel">
|
||||
<h2>Admin password</h2>
|
||||
<div class="admin-grid">
|
||||
<label>
|
||||
<span>password</span>
|
||||
<input id="adminPassword" type="password" autocomplete="current-password">
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="unlockBtn" class="primary" type="button">Unlock</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p id="adminError" class="error" hidden></p>
|
||||
<p id="adminMessage" class="notice" hidden></p>
|
||||
|
||||
<section id="credentialsPanel" class="auth-panel" hidden>
|
||||
<h2>Steam credentials</h2>
|
||||
<div class="admin-grid">
|
||||
<label>
|
||||
<span>account</span>
|
||||
<input id="steamAccount" type="text" autocomplete="username">
|
||||
</label>
|
||||
<label>
|
||||
<span>password</span>
|
||||
<input id="steamPassword" type="text" autocomplete="current-password">
|
||||
</label>
|
||||
<label class="check">
|
||||
<input id="clearToken" type="checkbox" checked>
|
||||
<span>reset saved refresh token</span>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="reloadBtn" type="button">Reload</button>
|
||||
<button id="saveBtn" class="primary" type="button">Save and reconnect</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="guardPanel" class="auth-panel" hidden>
|
||||
<h2>Steam Guard</h2>
|
||||
<div class="admin-grid">
|
||||
<label>
|
||||
<span>code</span>
|
||||
<input id="steamGuardCode" type="text" autocomplete="one-time-code" inputmode="numeric">
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="submitGuardBtn" class="primary" type="button">Submit code</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="guardHint" class="hint"></p>
|
||||
</section>
|
||||
|
||||
<section id="tokenPanel" class="auth-panel" hidden>
|
||||
<h2>Saved token</h2>
|
||||
<dl class="details">
|
||||
<div>
|
||||
<dt>SteamID</dt>
|
||||
<dd id="tokenSteamId">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Account</dt>
|
||||
<dd id="tokenAccount">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd id="tokenUpdated">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expires</dt>
|
||||
<dd id="tokenExpires">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="actions left">
|
||||
<button id="clearTokenBtn" type="button">Clear refresh token</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
const els = {
|
||||
adminStatus: document.querySelector('#adminStatus'),
|
||||
credentialsSource: document.querySelector('#credentialsSource'),
|
||||
tokenStatus: document.querySelector('#tokenStatus'),
|
||||
steamStatus: document.querySelector('#steamStatus'),
|
||||
gcStatus: document.querySelector('#gcStatus'),
|
||||
loginPanel: document.querySelector('#loginPanel'),
|
||||
credentialsPanel: document.querySelector('#credentialsPanel'),
|
||||
guardPanel: document.querySelector('#guardPanel'),
|
||||
tokenPanel: document.querySelector('#tokenPanel'),
|
||||
adminPassword: document.querySelector('#adminPassword'),
|
||||
unlockBtn: document.querySelector('#unlockBtn'),
|
||||
adminError: document.querySelector('#adminError'),
|
||||
adminMessage: document.querySelector('#adminMessage'),
|
||||
steamAccount: document.querySelector('#steamAccount'),
|
||||
steamPassword: document.querySelector('#steamPassword'),
|
||||
clearToken: document.querySelector('#clearToken'),
|
||||
reloadBtn: document.querySelector('#reloadBtn'),
|
||||
saveBtn: document.querySelector('#saveBtn'),
|
||||
steamGuardCode: document.querySelector('#steamGuardCode'),
|
||||
submitGuardBtn: document.querySelector('#submitGuardBtn'),
|
||||
guardHint: document.querySelector('#guardHint'),
|
||||
tokenSteamId: document.querySelector('#tokenSteamId'),
|
||||
tokenAccount: document.querySelector('#tokenAccount'),
|
||||
tokenUpdated: document.querySelector('#tokenUpdated'),
|
||||
tokenExpires: document.querySelector('#tokenExpires'),
|
||||
clearTokenBtn: document.querySelector('#clearTokenBtn'),
|
||||
};
|
||||
|
||||
let adminPassword = sessionStorage.getItem('dokaAdminPassword') || '';
|
||||
let credentialsDirty = false;
|
||||
|
||||
const dateTimeFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '-';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return dateTimeFormat.format(date);
|
||||
}
|
||||
|
||||
function setBadge(label, klass) {
|
||||
els.adminStatus.textContent = label;
|
||||
els.adminStatus.className = `badge ${klass}`;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
els.adminError.hidden = !message;
|
||||
els.adminError.textContent = message || '';
|
||||
}
|
||||
|
||||
function showMessage(message) {
|
||||
els.adminMessage.hidden = !message;
|
||||
els.adminMessage.textContent = message || '';
|
||||
}
|
||||
|
||||
async function adminFetch(url, options = {}) {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Password': adminPassword,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function unlocked() {
|
||||
return Boolean(adminPassword);
|
||||
}
|
||||
|
||||
function render(data, options = {}) {
|
||||
const preserveEditedInputs = options.preserveEditedInputs !== false;
|
||||
const connection = data.connection || {};
|
||||
const credentials = data.credentials || {};
|
||||
const token = data.refreshToken || {};
|
||||
const steamGuard = connection.steamGuard || {};
|
||||
|
||||
els.loginPanel.hidden = unlocked();
|
||||
els.credentialsPanel.hidden = !unlocked();
|
||||
els.tokenPanel.hidden = !unlocked();
|
||||
els.guardPanel.hidden = !unlocked() || !steamGuard.required;
|
||||
|
||||
setBadge(unlocked() ? 'unlocked' : 'locked', unlocked() ? 'badge-ok' : 'badge-muted');
|
||||
els.credentialsSource.textContent = credentials.source || '-';
|
||||
els.tokenStatus.textContent = token.exists ? 'saved' : 'missing';
|
||||
els.steamStatus.textContent = connection.steam || '-';
|
||||
els.gcStatus.textContent = connection.gc || '-';
|
||||
|
||||
const editingCredentials = document.activeElement === els.steamAccount
|
||||
|| document.activeElement === els.steamPassword
|
||||
|| credentialsDirty;
|
||||
|
||||
if (!preserveEditedInputs || !editingCredentials) {
|
||||
els.steamAccount.value = credentials.accountName || '';
|
||||
els.steamPassword.value = credentials.password || '';
|
||||
credentialsDirty = false;
|
||||
}
|
||||
|
||||
els.tokenSteamId.textContent = token.steamID || '-';
|
||||
els.tokenAccount.textContent = token.accountName || '-';
|
||||
els.tokenUpdated.textContent = formatDate(token.updatedAt);
|
||||
els.tokenExpires.textContent = formatDate(token.expiresAt);
|
||||
|
||||
if (steamGuard.required) {
|
||||
const where = steamGuard.domain ? `Email domain: ${steamGuard.domain}` : 'Mobile authenticator code required';
|
||||
els.guardHint.textContent = steamGuard.lastCodeWrong ? `Last code was rejected. ${where}.` : where;
|
||||
} else {
|
||||
els.guardHint.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuth(options = {}) {
|
||||
if (!unlocked()) {
|
||||
render({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showError('');
|
||||
const data = await adminFetch('/api/admin/auth');
|
||||
render(data, options);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
if (err.message.includes('Invalid admin password')) {
|
||||
adminPassword = '';
|
||||
sessionStorage.removeItem('dokaAdminPassword');
|
||||
}
|
||||
render({});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCredentials() {
|
||||
try {
|
||||
showError('');
|
||||
showMessage('');
|
||||
const data = await adminFetch('/api/admin/auth', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
accountName: els.steamAccount.value,
|
||||
password: els.steamPassword.value,
|
||||
clearRefreshToken: els.clearToken.checked,
|
||||
}),
|
||||
});
|
||||
credentialsDirty = false;
|
||||
render(data);
|
||||
showMessage('Credentials saved. Reconnect started.');
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSteamGuard() {
|
||||
try {
|
||||
showError('');
|
||||
showMessage('');
|
||||
const data = await adminFetch('/api/admin/steam-guard', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: els.steamGuardCode.value }),
|
||||
});
|
||||
els.steamGuardCode.value = '';
|
||||
render(data);
|
||||
showMessage('Steam Guard code submitted.');
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearRefreshToken() {
|
||||
try {
|
||||
showError('');
|
||||
showMessage('');
|
||||
const data = await adminFetch('/api/admin/clear-token', { method: 'POST', body: '{}' });
|
||||
render(data);
|
||||
showMessage('Refresh token cleared.');
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
els.unlockBtn.addEventListener('click', () => {
|
||||
adminPassword = els.adminPassword.value;
|
||||
sessionStorage.setItem('dokaAdminPassword', adminPassword);
|
||||
loadAuth({ preserveEditedInputs: false });
|
||||
});
|
||||
|
||||
els.adminPassword.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') els.unlockBtn.click();
|
||||
});
|
||||
|
||||
els.reloadBtn.addEventListener('click', () => {
|
||||
credentialsDirty = false;
|
||||
loadAuth({ preserveEditedInputs: false });
|
||||
});
|
||||
els.saveBtn.addEventListener('click', saveCredentials);
|
||||
els.submitGuardBtn.addEventListener('click', submitSteamGuard);
|
||||
els.clearTokenBtn.addEventListener('click', clearRefreshToken);
|
||||
|
||||
els.steamAccount.addEventListener('input', () => {
|
||||
credentialsDirty = true;
|
||||
});
|
||||
|
||||
els.steamPassword.addEventListener('input', () => {
|
||||
credentialsDirty = true;
|
||||
});
|
||||
|
||||
els.steamGuardCode.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') els.submitGuardBtn.click();
|
||||
});
|
||||
|
||||
if (adminPassword) {
|
||||
els.adminPassword.value = adminPassword;
|
||||
}
|
||||
|
||||
loadAuth({ preserveEditedInputs: false });
|
||||
setInterval(loadAuth, 5000);
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
const els = {
|
||||
statusBadge: document.querySelector('#statusBadge'),
|
||||
serverFilter: document.querySelector('#serverFilter'),
|
||||
visibleCount: document.querySelector('#visibleCount'),
|
||||
totalCount: document.querySelector('#totalCount'),
|
||||
lastUpdated: document.querySelector('#lastUpdated'),
|
||||
nextRefresh: document.querySelector('#nextRefresh'),
|
||||
errorBox: document.querySelector('#errorBox'),
|
||||
rows: document.querySelector('#lobbyRows'),
|
||||
qFilter: document.querySelector('#qFilter'),
|
||||
gameFilter: document.querySelector('#gameFilter'),
|
||||
mapFilter: document.querySelector('#mapFilter'),
|
||||
regionFilter: document.querySelector('#regionFilter'),
|
||||
passFilter: document.querySelector('#passFilter'),
|
||||
sortBy: document.querySelector('#sortBy'),
|
||||
openOnly: document.querySelector('#openOnly'),
|
||||
resetBtn: document.querySelector('#resetBtn'),
|
||||
refreshBtn: document.querySelector('#refreshBtn'),
|
||||
};
|
||||
|
||||
let snapshot = null;
|
||||
let regionsReady = false;
|
||||
|
||||
const dateTimeFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
function text(value) {
|
||||
return String(value || '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '-';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return dateTimeFormat.format(date);
|
||||
}
|
||||
|
||||
function formatCountdown(iso) {
|
||||
if (!iso) return snapshot?.refreshInProgress ? 'refreshing' : '-';
|
||||
const ms = new Date(iso).getTime() - Date.now();
|
||||
if (!Number.isFinite(ms) || ms <= 0) return snapshot?.refreshInProgress ? 'refreshing' : 'soon';
|
||||
const seconds = Math.ceil(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = String(seconds % 60).padStart(2, '0');
|
||||
return `${minutes}:${rest}`;
|
||||
}
|
||||
|
||||
function statusInfo(data) {
|
||||
if (!data) return ['starting', 'badge-muted'];
|
||||
if (data.refreshInProgress) return ['refreshing', 'badge-work'];
|
||||
if (data.connection?.steam === 'auth-required') return ['auth required', 'badge-warn'];
|
||||
if (data.connection?.steam === 'steam-guard') return ['steam guard', 'badge-warn'];
|
||||
if (data.status === 'ready') return ['online', 'badge-ok'];
|
||||
if (data.status === 'stale') return ['stale', 'badge-warn'];
|
||||
if (data.status === 'error') return ['error', 'badge-error'];
|
||||
if (data.connection?.steam !== 'online' || data.connection?.gc !== 'ready') return ['connecting', 'badge-work'];
|
||||
return [data.status || 'starting', 'badge-muted'];
|
||||
}
|
||||
|
||||
function setBadge(label, klass) {
|
||||
els.statusBadge.textContent = label;
|
||||
els.statusBadge.className = `badge ${klass}`;
|
||||
}
|
||||
|
||||
function initRegions(regions) {
|
||||
if (regionsReady || !regions) return;
|
||||
const current = els.regionFilter.value || 'all';
|
||||
els.regionFilter.replaceChildren();
|
||||
|
||||
const all = document.createElement('option');
|
||||
all.value = 'all';
|
||||
all.textContent = 'all';
|
||||
els.regionFilter.append(all);
|
||||
|
||||
Object.entries(regions)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.forEach(([id, name]) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = id;
|
||||
option.textContent = name;
|
||||
els.regionFilter.append(option);
|
||||
});
|
||||
|
||||
els.regionFilter.value = current;
|
||||
regionsReady = true;
|
||||
}
|
||||
|
||||
function filterLobbies(lobbies) {
|
||||
const q = text(els.qFilter.value);
|
||||
const game = text(els.gameFilter.value);
|
||||
const map = text(els.mapFilter.value);
|
||||
const region = els.regionFilter.value;
|
||||
const pass = els.passFilter.value;
|
||||
const openOnly = els.openOnly.checked;
|
||||
|
||||
return lobbies.filter((row) => {
|
||||
const haystack = text([
|
||||
row.game,
|
||||
row.lobby,
|
||||
row.map,
|
||||
row.region,
|
||||
row.leader,
|
||||
row.customGameId,
|
||||
row.leaderAccountId,
|
||||
].join(' '));
|
||||
|
||||
if (q && !haystack.includes(q)) return false;
|
||||
if (game && !text(row.game).includes(game)) return false;
|
||||
if (map && !text(row.map).includes(map)) return false;
|
||||
if (region !== 'all' && String(row.regionId) !== region) return false;
|
||||
if (pass === 'yes' && !row.hasPassKey) return false;
|
||||
if (pass === 'no' && row.hasPassKey) return false;
|
||||
if (openOnly && Number(row.openSlots) <= 0) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function sortLobbies(rows) {
|
||||
const sorted = [...rows];
|
||||
const byName = (a, b, key) => String(a[key] || '').localeCompare(String(b[key] || ''), 'en');
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
if (els.sortBy.value === 'open-desc') {
|
||||
return Number(b.openSlots) - Number(a.openSlots) || byName(a, b, 'game');
|
||||
}
|
||||
if (els.sortBy.value === 'game-asc') {
|
||||
return byName(a, b, 'game') || Number(b.memberCount) - Number(a.memberCount);
|
||||
}
|
||||
if (els.sortBy.value === 'region-asc') {
|
||||
return byName(a, b, 'region') || byName(a, b, 'game');
|
||||
}
|
||||
return Number(b.memberCount) - Number(a.memberCount) || byName(a, b, 'game');
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function makeCell(value, className) {
|
||||
const td = document.createElement('td');
|
||||
if (className) td.className = className;
|
||||
td.textContent = value || '-';
|
||||
return td;
|
||||
}
|
||||
|
||||
function renderRows(rows, totalLoaded) {
|
||||
els.rows.replaceChildren();
|
||||
|
||||
if (!rows.length) {
|
||||
const tr = document.createElement('tr');
|
||||
const td = document.createElement('td');
|
||||
td.colSpan = 7;
|
||||
td.className = 'empty';
|
||||
td.textContent = totalLoaded ? 'No lobbies match the selected filters' : 'No data yet';
|
||||
tr.append(td);
|
||||
els.rows.append(tr);
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
const game = document.createElement('td');
|
||||
game.className = 'game';
|
||||
const gameName = document.createElement('div');
|
||||
gameName.textContent = row.game || row.customGameId || '-';
|
||||
game.append(gameName);
|
||||
if (row.customGameId && row.game !== row.customGameId) {
|
||||
const id = document.createElement('div');
|
||||
id.className = 'muted';
|
||||
id.textContent = row.customGameId;
|
||||
game.append(id);
|
||||
}
|
||||
|
||||
const players = document.createElement('td');
|
||||
players.className = 'num';
|
||||
const playersPill = document.createElement('span');
|
||||
playersPill.className = 'pill';
|
||||
playersPill.textContent = row.players || '-';
|
||||
players.append(playersPill);
|
||||
|
||||
const pass = document.createElement('td');
|
||||
const passPill = document.createElement('span');
|
||||
passPill.className = row.hasPassKey ? 'pill pass' : 'muted';
|
||||
passPill.textContent = row.hasPassKey ? 'yes' : 'no';
|
||||
pass.append(passPill);
|
||||
|
||||
tr.append(
|
||||
game,
|
||||
makeCell(row.lobby),
|
||||
makeCell(row.map),
|
||||
players,
|
||||
makeCell(row.region),
|
||||
pass,
|
||||
makeCell(row.leader),
|
||||
);
|
||||
fragment.append(tr);
|
||||
}
|
||||
|
||||
els.rows.append(fragment);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const data = snapshot;
|
||||
const lobbies = data?.lobbies || [];
|
||||
const filtered = sortLobbies(filterLobbies(lobbies));
|
||||
const [label, klass] = statusInfo(data);
|
||||
|
||||
setBadge(label, klass);
|
||||
els.visibleCount.textContent = String(filtered.length);
|
||||
els.totalCount.textContent = String(data?.totalLobbies || lobbies.length || 0);
|
||||
els.lastUpdated.textContent = formatDate(data?.lastUpdatedAt);
|
||||
els.nextRefresh.textContent = formatCountdown(data?.nextRefreshAt);
|
||||
els.refreshBtn.disabled = Boolean(data?.refreshInProgress);
|
||||
els.refreshBtn.textContent = data?.refreshInProgress ? 'Refreshing' : 'Refresh';
|
||||
|
||||
if (data?.serverFilters) {
|
||||
const parts = [`GC: ${data.serverFilters.regionName}`];
|
||||
if (data.serverFilters.customGameId) parts.push(`game ${data.serverFilters.customGameId}`);
|
||||
els.serverFilter.textContent = parts.join(', ');
|
||||
}
|
||||
|
||||
if (data?.lastError) {
|
||||
els.errorBox.hidden = false;
|
||||
els.errorBox.textContent = data.lastError;
|
||||
} else {
|
||||
els.errorBox.hidden = true;
|
||||
els.errorBox.textContent = '';
|
||||
}
|
||||
|
||||
renderRows(filtered, lobbies.length);
|
||||
}
|
||||
|
||||
function setSnapshot(data) {
|
||||
snapshot = data;
|
||||
initRegions(data?.regions);
|
||||
render();
|
||||
}
|
||||
|
||||
async function fetchState() {
|
||||
const res = await fetch('/api/state', { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setSnapshot(await res.json());
|
||||
}
|
||||
|
||||
async function refreshNow() {
|
||||
els.refreshBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/refresh', { method: 'POST', cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setSnapshot(await res.json());
|
||||
} catch (err) {
|
||||
if (snapshot) {
|
||||
snapshot.lastError = err.message;
|
||||
render();
|
||||
} else {
|
||||
els.errorBox.hidden = false;
|
||||
els.errorBox.textContent = err.message;
|
||||
}
|
||||
} finally {
|
||||
els.refreshBtn.disabled = Boolean(snapshot?.refreshInProgress);
|
||||
}
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
if (!window.EventSource) return;
|
||||
const events = new EventSource('/events');
|
||||
events.onmessage = (event) => {
|
||||
try {
|
||||
setSnapshot(JSON.parse(event.data));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[
|
||||
els.qFilter,
|
||||
els.gameFilter,
|
||||
els.mapFilter,
|
||||
els.regionFilter,
|
||||
els.passFilter,
|
||||
els.sortBy,
|
||||
els.openOnly,
|
||||
].forEach((el) => {
|
||||
el.addEventListener('input', render);
|
||||
el.addEventListener('change', render);
|
||||
});
|
||||
|
||||
els.resetBtn.addEventListener('click', () => {
|
||||
els.qFilter.value = '';
|
||||
els.gameFilter.value = '';
|
||||
els.mapFilter.value = '';
|
||||
els.regionFilter.value = 'all';
|
||||
els.passFilter.value = 'all';
|
||||
els.sortBy.value = 'players-desc';
|
||||
els.openOnly.checked = false;
|
||||
render();
|
||||
});
|
||||
|
||||
els.refreshBtn.addEventListener('click', refreshNow);
|
||||
|
||||
setInterval(() => {
|
||||
if (snapshot) els.nextRefresh.textContent = formatCountdown(snapshot.nextRefreshAt);
|
||||
}, 1000);
|
||||
|
||||
setInterval(() => {
|
||||
fetchState().catch((err) => {
|
||||
if (!snapshot) {
|
||||
els.errorBox.hidden = false;
|
||||
els.errorBox.textContent = err.message;
|
||||
}
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
connectEvents();
|
||||
fetchState().catch((err) => {
|
||||
els.errorBox.hidden = false;
|
||||
els.errorBox.textContent = err.message;
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Dota 2 Lobbies</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<h1>Dota 2 lobbies</h1>
|
||||
<div class="subline">
|
||||
<span id="statusBadge" class="badge badge-muted">starting</span>
|
||||
<span id="serverFilter">GC: Auto</span>
|
||||
<a class="admin-link" href="/admin">Admin auth</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metrics" aria-label="Status">
|
||||
<div class="metric">
|
||||
<span class="metric-label">shown</span>
|
||||
<strong id="visibleCount">0</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">total</span>
|
||||
<strong id="totalCount">0</strong>
|
||||
</div>
|
||||
<div class="metric wide">
|
||||
<span class="metric-label">last update</span>
|
||||
<strong id="lastUpdated">-</strong>
|
||||
</div>
|
||||
<div class="metric wide">
|
||||
<span class="metric-label">next update</span>
|
||||
<strong id="nextRefresh">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="filters" aria-label="Filters">
|
||||
<label>
|
||||
<span>search</span>
|
||||
<input id="qFilter" type="search" autocomplete="off" placeholder="game, map, leader">
|
||||
</label>
|
||||
<label>
|
||||
<span>game</span>
|
||||
<input id="gameFilter" type="search" autocomplete="off" placeholder="hero clash">
|
||||
</label>
|
||||
<label>
|
||||
<span>map</span>
|
||||
<input id="mapFilter" type="search" autocomplete="off" placeholder="ffa">
|
||||
</label>
|
||||
<label>
|
||||
<span>region</span>
|
||||
<select id="regionFilter"></select>
|
||||
</label>
|
||||
<label>
|
||||
<span>password</span>
|
||||
<select id="passFilter">
|
||||
<option value="all">any</option>
|
||||
<option value="no">no password</option>
|
||||
<option value="yes">has password</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>sort</span>
|
||||
<select id="sortBy">
|
||||
<option value="players-desc">most players</option>
|
||||
<option value="open-desc">most open slots</option>
|
||||
<option value="game-asc">game A-Z</option>
|
||||
<option value="region-asc">region A-Z</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input id="openOnly" type="checkbox">
|
||||
<span>open slots</span>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="resetBtn" type="button">Reset</button>
|
||||
<button id="refreshBtn" type="button" class="primary">Refresh</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p id="errorBox" class="error" hidden></p>
|
||||
|
||||
<section class="table-wrap" aria-label="Lobbies">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Game</th>
|
||||
<th>Lobby</th>
|
||||
<th>Map</th>
|
||||
<th class="num">Players</th>
|
||||
<th>Region</th>
|
||||
<th>Password</th>
|
||||
<th>Leader</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lobbyRows">
|
||||
<tr>
|
||||
<td colspan="7" class="empty">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,517 @@
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #eef2f4;
|
||||
--text: #18202a;
|
||||
--muted: #667384;
|
||||
--line: #d8dee6;
|
||||
--accent: #246b5a;
|
||||
--accent-strong: #15513f;
|
||||
--blue: #275f9f;
|
||||
--warn: #956a13;
|
||||
--danger: #a33b36;
|
||||
--shadow: 0 8px 24px rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, Segoe UI, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(440px, 1.4fr);
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 18px 24px;
|
||||
background: rgba(246, 247, 249, 0.94);
|
||||
border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.title h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
line-height: 1.1;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.subline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.admin-link {
|
||||
color: var(--accent-strong);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 2px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
border-color: rgba(36, 107, 90, 0.3);
|
||||
background: #e8f4ef;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.badge-work {
|
||||
border-color: rgba(39, 95, 159, 0.28);
|
||||
background: #eaf1fa;
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.badge-warn {
|
||||
border-color: rgba(149, 106, 19, 0.3);
|
||||
background: #fff3d8;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
border-color: rgba(163, 59, 54, 0.28);
|
||||
background: #fdecea;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.badge-muted {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(92px, 0.7fr) minmax(92px, 0.7fr) minmax(150px, 1fr) minmax(150px, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.metric.wide strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1600px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 18px 24px 28px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1.1fr) minmax(160px, 1fr) minmax(140px, 0.8fr) minmax(130px, 0.75fr) minmax(120px, 0.7fr) minmax(150px, 0.8fr) auto auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin-bottom: 14px;
|
||||
padding: 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.filters label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filters label > span,
|
||||
.check span {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filters input[type='search'],
|
||||
.filters select {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
color: var(--text);
|
||||
background: #fbfcfd;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.filters input:focus,
|
||||
.filters select:focus {
|
||||
border-color: rgba(36, 107, 90, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(36, 107, 90, 0.12);
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding-bottom: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check span {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.check input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 36px;
|
||||
padding: 0 13px;
|
||||
color: var(--text);
|
||||
background: #fbfcfd;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #f0f4f5;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
color: #ffffff;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
color: var(--danger);
|
||||
background: #fdecea;
|
||||
border: 1px solid rgba(163, 59, 54, 0.28);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
color: var(--accent-strong);
|
||||
background: #e8f4ef;
|
||||
border: 1px solid rgba(36, 107, 90, 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
margin-bottom: 14px;
|
||||
padding: 16px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.auth-panel h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-grid label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-grid label > span,
|
||||
.details dt,
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-grid input[type='text'],
|
||||
.admin-grid input[type='password'] {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
color: var(--text);
|
||||
background: #fbfcfd;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-grid input:focus {
|
||||
border-color: rgba(36, 107, 90, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(36, 107, 90, 0.12);
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.details div {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
background: #fbfcfd;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.details dt {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.details dd {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.actions.left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 214px);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 10px 12px;
|
||||
color: #3a4654;
|
||||
background: #eef2f4;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #edf0f3;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f7faf9;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.game {
|
||||
max-width: 460px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-2);
|
||||
color: #354150;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pill.pass {
|
||||
background: #fff3d8;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.empty {
|
||||
height: 160px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.topbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.admin-grid,
|
||||
.details {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topbar {
|
||||
padding: 14px 14px;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.filters {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.metric.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
max-height: calc(100vh - 318px);
|
||||
}
|
||||
|
||||
.admin-grid,
|
||||
.details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.metrics,
|
||||
.filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metric.wide,
|
||||
.actions {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user