Compare commits
20 Commits
ef128b47d2
...
agent-v2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b455bf9f14 | ||
|
|
4abf0ab889 | ||
|
|
cea3d66cdd | ||
|
|
1abe57ca40 | ||
|
|
a8722a7a07 | ||
|
|
180631989a | ||
|
|
23decd9b08 | ||
|
|
8b84bba165 | ||
|
|
9a5b93dd08 | ||
|
|
3545e6f5c8 | ||
|
|
1edaaf985d | ||
|
|
f2b09b281a | ||
|
|
be57d2839a | ||
|
|
769d75d937 | ||
|
|
f440fd7751 | ||
|
|
29615cb4f3 | ||
|
|
376ed9a98d | ||
|
|
b42a2d7ea7 | ||
|
|
560d023250 | ||
|
|
f91ef84832 |
@@ -1,4 +1,4 @@
|
|||||||
name: Build Companion Agent
|
name: Build Host Agent
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -26,19 +26,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd companion-agent
|
cd companion-agent
|
||||||
mkdir -p bin
|
mkdir -p bin
|
||||||
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-companion-linux-amd64 ./cmd/agent
|
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-host-agent-linux-amd64 ./cmd/agent
|
||||||
chmod +x bin/corrosion-companion-linux-amd64
|
chmod +x bin/corrosion-host-agent-linux-amd64
|
||||||
|
|
||||||
- name: Build Windows AMD64
|
- name: Build Windows AMD64
|
||||||
run: |
|
run: |
|
||||||
cd companion-agent
|
cd companion-agent
|
||||||
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-companion-windows-amd64.exe ./cmd/agent
|
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=${{ steps.version.outputs.VERSION }}" -o bin/corrosion-host-agent-windows-amd64.exe ./cmd/agent
|
||||||
|
|
||||||
- name: Generate checksums
|
- name: Generate checksums
|
||||||
run: |
|
run: |
|
||||||
cd companion-agent/bin
|
cd companion-agent/bin
|
||||||
sha256sum corrosion-companion-linux-amd64 > checksums.txt
|
sha256sum corrosion-host-agent-linux-amd64 > checksums.txt
|
||||||
sha256sum corrosion-companion-windows-amd64.exe >> checksums.txt
|
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
|
||||||
cat checksums.txt
|
cat checksums.txt
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
RESPONSE=$(curl -s -X POST \
|
RESPONSE=$(curl -s -X POST \
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Companion Agent ${VERSION}\", \"body\": \"Companion Agent release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
|
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Corrosion Host Agent ${VERSION}\", \"body\": \"Corrosion Host Agent release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \
|
||||||
"${API_URL}/repos/${REPO}/releases")
|
"${API_URL}/repos/${REPO}/releases")
|
||||||
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||||
|
|
||||||
@@ -68,15 +68,15 @@ jobs:
|
|||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary @companion-agent/bin/corrosion-companion-linux-amd64 \
|
--data-binary @companion-agent/bin/corrosion-host-agent-linux-amd64 \
|
||||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-companion-linux-amd64"
|
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-host-agent-linux-amd64"
|
||||||
|
|
||||||
# Upload Windows binary
|
# Upload Windows binary
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary @companion-agent/bin/corrosion-companion-windows-amd64.exe \
|
--data-binary @companion-agent/bin/corrosion-host-agent-windows-amd64.exe \
|
||||||
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-companion-windows-amd64.exe"
|
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=corrosion-host-agent-windows-amd64.exe"
|
||||||
|
|
||||||
# Upload checksums
|
# Upload checksums
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
@@ -89,43 +89,43 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
CDN_URL="https://cdn.corrosionmgmt.com"
|
CDN_URL="https://cdn.corrosionmgmt.com"
|
||||||
|
|
||||||
# Upload Linux binary to /companion/latest/
|
# Upload Linux binary to /host-agent/latest/
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
|
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
|
||||||
"${CDN_URL}/companion/latest/corrosion-companion-linux-amd64"
|
"${CDN_URL}/host-agent/latest/corrosion-host-agent-linux-amd64"
|
||||||
|
|
||||||
# Upload Windows binary to /companion/latest/
|
# Upload Windows binary to /host-agent/latest/
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
|
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
|
||||||
"${CDN_URL}/companion/latest/corrosion-companion-windows-amd64.exe"
|
"${CDN_URL}/host-agent/latest/corrosion-host-agent-windows-amd64.exe"
|
||||||
|
|
||||||
# Upload checksums
|
# Upload checksums
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-F "file=@companion-agent/bin/checksums.txt" \
|
-F "file=@companion-agent/bin/checksums.txt" \
|
||||||
"${CDN_URL}/companion/latest/checksums.txt"
|
"${CDN_URL}/host-agent/latest/checksums.txt"
|
||||||
|
|
||||||
# Also upload versioned copies
|
# Also upload versioned copies
|
||||||
VERSION=${{ steps.version.outputs.VERSION }}
|
VERSION=${{ steps.version.outputs.VERSION }}
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-F "file=@companion-agent/bin/corrosion-companion-linux-amd64" \
|
-F "file=@companion-agent/bin/corrosion-host-agent-linux-amd64" \
|
||||||
"${CDN_URL}/companion/${VERSION}/corrosion-companion-linux-amd64"
|
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-linux-amd64"
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-F "file=@companion-agent/bin/corrosion-companion-windows-amd64.exe" \
|
-F "file=@companion-agent/bin/corrosion-host-agent-windows-amd64.exe" \
|
||||||
"${CDN_URL}/companion/${VERSION}/corrosion-companion-windows-amd64.exe"
|
"${CDN_URL}/host-agent/${VERSION}/corrosion-host-agent-windows-amd64.exe"
|
||||||
curl -s -X POST \
|
curl -s -X POST \
|
||||||
-F "file=@companion-agent/bin/checksums.txt" \
|
-F "file=@companion-agent/bin/checksums.txt" \
|
||||||
"${CDN_URL}/companion/${VERSION}/checksums.txt"
|
"${CDN_URL}/host-agent/${VERSION}/checksums.txt"
|
||||||
|
|
||||||
echo "CDN upload complete: ${CDN_URL}/companion/latest/"
|
echo "CDN upload complete: ${CDN_URL}/host-agent/latest/"
|
||||||
|
|
||||||
- name: Build Summary
|
- name: Build Summary
|
||||||
run: |
|
run: |
|
||||||
echo "## Companion Agent Build Complete" >> $GITHUB_STEP_SUMMARY
|
echo "## Corrosion Host Agent Build Complete" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
|
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
|
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- Linux AMD64 ($(stat -c%s companion-agent/bin/corrosion-companion-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
|
echo "- Linux AMD64 ($(stat -c%s companion-agent/bin/corrosion-host-agent-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- Windows AMD64 ($(stat -c%s companion-agent/bin/corrosion-companion-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
|
echo "- Windows AMD64 ($(stat -c%s companion-agent/bin/corrosion-host-agent-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY
|
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
120
.gitea/workflows/build-host-agent.yml
Normal file
120
.gitea/workflows/build-host-agent.yml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
name: Build Host Agent (Rust)
|
||||||
|
|
||||||
|
# Rust agent ships on its own tag namespace (agent-v*) so it never collides
|
||||||
|
# with the legacy Go pipeline (v*.*.*). Artifacts publish to the CDN /alpha/
|
||||||
|
# channel — /host-agent/latest/ stays on the Go build until cutover.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'agent-v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Override the macOS toolchain names in corrosion-host-agent/.cargo/config.toml
|
||||||
|
# (real env beats the config [env] table).
|
||||||
|
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
|
||||||
|
CC_x86_64_unknown_linux_musl: musl-gcc
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/agent-v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Verify tag matches Cargo.toml
|
||||||
|
run: |
|
||||||
|
CARGO_VERSION=$(grep '^version' corrosion-host-agent/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||||
|
if [ "${{ steps.version.outputs.VERSION }}" != "$CARGO_VERSION" ]; then
|
||||||
|
echo "Tag agent-v${{ steps.version.outputs.VERSION }} does not match Cargo.toml version $CARGO_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The Asgard runner executes jobs in a bare node:20-bullseye container
|
||||||
|
# (no Rust, no sudo, runs as root) — bootstrap the toolchain per-run,
|
||||||
|
# same pattern as actions/setup-go in the Go pipeline.
|
||||||
|
- name: Install Rust + cross toolchains
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq build-essential musl-tools gcc-mingw-w64-x86-64 curl
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
"$HOME/.cargo/bin/rustup" target add x86_64-unknown-linux-musl x86_64-pc-windows-gnu
|
||||||
|
|
||||||
|
- name: Build Linux AMD64 (static musl)
|
||||||
|
run: |
|
||||||
|
cd corrosion-host-agent
|
||||||
|
cargo build --release --target x86_64-unknown-linux-musl
|
||||||
|
mkdir -p bin
|
||||||
|
cp target/x86_64-unknown-linux-musl/release/corrosion-host-agent bin/corrosion-host-agent-linux-amd64
|
||||||
|
chmod +x bin/corrosion-host-agent-linux-amd64
|
||||||
|
|
||||||
|
- name: Build Windows AMD64 (mingw)
|
||||||
|
run: |
|
||||||
|
cd corrosion-host-agent
|
||||||
|
cargo build --release --target x86_64-pc-windows-gnu
|
||||||
|
cp target/x86_64-pc-windows-gnu/release/corrosion-host-agent.exe bin/corrosion-host-agent-windows-amd64.exe
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
run: |
|
||||||
|
cd corrosion-host-agent/bin
|
||||||
|
sha256sum corrosion-host-agent-linux-amd64 > checksums.txt
|
||||||
|
sha256sum corrosion-host-agent-windows-amd64.exe >> checksums.txt
|
||||||
|
cat checksums.txt
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
API_URL="${{ github.server_url }}/api/v1"
|
||||||
|
REPO="${{ github.repository }}"
|
||||||
|
VERSION="agent-v${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -X POST \
|
||||||
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"${VERSION}\", \"name\": \"Corrosion Host Agent ${VERSION}\", \"body\": \"Rust host agent release ${VERSION}\", \"draft\": false, \"prerelease\": true}" \
|
||||||
|
"${API_URL}/repos/${REPO}/releases")
|
||||||
|
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||||
|
|
||||||
|
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @corrosion-host-agent/bin/$f \
|
||||||
|
"${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Upload to CDN (alpha channel)
|
||||||
|
run: |
|
||||||
|
CDN_URL="https://cdn.corrosionmgmt.com"
|
||||||
|
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||||
|
|
||||||
|
for f in corrosion-host-agent-linux-amd64 corrosion-host-agent-windows-amd64.exe checksums.txt; do
|
||||||
|
curl -s -X POST \
|
||||||
|
-F "file=@corrosion-host-agent/bin/$f" \
|
||||||
|
"${CDN_URL}/host-agent/alpha/$f"
|
||||||
|
curl -s -X POST \
|
||||||
|
-F "file=@corrosion-host-agent/bin/$f" \
|
||||||
|
"${CDN_URL}/host-agent/${VERSION}/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "CDN upload complete: ${CDN_URL}/host-agent/alpha/"
|
||||||
|
|
||||||
|
- name: Build Summary
|
||||||
|
run: |
|
||||||
|
echo "## Corrosion Host Agent (Rust) Build Complete" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Version:** ${{ steps.version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Commit:** ${GITHUB_SHA:0:7}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Channel:** alpha (latest/ untouched until cutover)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### Built Artifacts:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Linux AMD64 static musl ($(stat -c%s corrosion-host-agent/bin/corrosion-host-agent-linux-amd64) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Windows AMD64 mingw ($(stat -c%s corrosion-host-agent/bin/corrosion-host-agent-windows-amd64.exe) bytes)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- SHA256 checksums" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
### **TYPE 1: THE SCOUT (Intelligence)**
|
### **TYPE 1: THE SCOUT (Intelligence)**
|
||||||
|
|
||||||
- **Model:** haiku
|
- **Model:** sonnet[1m]
|
||||||
|
|
||||||
- **Role:** Reconnaissance, Context Mapping, Log Analysis.
|
- **Role:** Reconnaissance, Context Mapping, Log Analysis.
|
||||||
|
|
||||||
|
|||||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added (Corrosion Host Agent — Rust rewrite Phase 0 — 2026-06-11)
|
||||||
|
|
||||||
|
**New: `corrosion-host-agent/`** — Rust rewrite of the Go companion agent (which stays in-tree as the behavior reference until parity). Wire protocol v2 (COA-B, Commander-approved): instance-scoped subjects `corrosion.{license}.{instance}.*` with host-level `corrosion.{license}.host.*` — full spec in `corrosion-host-agent/PROTOCOL.md`.
|
||||||
|
|
||||||
|
- Multi-instance TOML config baked into the foundation (one agent supervises N game instances; rust/conan/soulmask/dune), env overrides for secrets, strict validation (subject-safe ids, reserved segments)
|
||||||
|
- NATS layer with the production-proven Vigilance profile: infinite reconnect w/ capped backoff, 30s ping, 8192-msg offline send buffer, `tls://` scheme support
|
||||||
|
- Host heartbeat with REAL telemetry via sysinfo (CPU/mem/disks/per-instance state) — the Go agent hardcoded disk=50000MB and cpu=0.0; this is the first true Resources data
|
||||||
|
- Connectivity prober (outbound TCP + latency, periodic jittered + on-demand) — first piece of the support-triage story
|
||||||
|
- Host command channel (`ping`/`probe`/`sysinfo`, request-reply), going-offline beacon, CancellationToken graceful shutdown
|
||||||
|
- Version embedding (semver + git hash + build ts) in `--version` and every heartbeat
|
||||||
|
- Verified live against production NATS: connected, heartbeats published, clean shutdown
|
||||||
|
- Deploy artifacts verified: 3.7MB fully-static linux-musl binary, 3.8MB windows .exe (static CRT, no VC++ redist needed)
|
||||||
|
|
||||||
|
**Next phases**: 1 = process-class adapter (spawn/RCON/SteamCMD/files for Rust/Conan/Soulmask) + NestJS v2 heartbeat consumer; 2 = Dune Docker adapter; 3 = signed self-update (release gate) + service install.
|
||||||
|
|
||||||
|
### Fixed (Site Audit — Fake Data, Resilience, Fonts — 2026-06-11)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `SetupWizardView.vue` — Replaced fake install instructions (`get.corrosionmgmt.com | sh` install script and `corrosion-agent` binary, neither of which exists) with the real host-agent download + run commands matching ServerView; multi-game copy on the completion step
|
||||||
|
- Marketing views (Landing, Pricing, HowItWorks, Roadmap, EarlyAccess) — Replaced "View live demo" CTA (no demo exists; it linked to the panel login) with an honest "Sign in" link
|
||||||
|
- `ErrorBoundary.vue` — Error state now resets on route change (previously one failed view bricked the entire SPA, including marketing pages, until manual reload); added `content` variant
|
||||||
|
- `DashboardLayout.vue` — Routed views are now wrapped in a content-scoped ErrorBoundary so the sidebar/topbar survive a view failure instead of the whole panel unmounting
|
||||||
|
- `index.html` / `styles/tokens/fonts.css` — Google Fonts moved from CSS `@import` to `<link>` tags. The bundler silently dropped the mid-bundle `@import`, so production shipped system fallback fonts (Geist/JetBrains Mono/Oxanium never loaded)
|
||||||
|
- `StatusPageView.vue` — Platform KPIs show "—" until the first successful fetch instead of fake zeros
|
||||||
|
- `LoginView.vue` — Added missing "Forgot password?" link (route + backend endpoint already existed)
|
||||||
|
|
||||||
|
**Backend (NestJS):**
|
||||||
|
- `AdminSeedService` (new, auth module) — Bootstraps a super-admin user + active license from `ADMIN_EMAIL`/`ADMIN_PASSWORD`/`ADMIN_USERNAME`/`ADMIN_LICENSE_KEY` when the users table is empty. A fresh deploy previously had a schema but no possible login. Compose already passes the env vars
|
||||||
|
|
||||||
|
**Purpose:** Findings from the full-site fake-data audit. Show real data or honest empty states — never invented values, dead URLs, or fabricated zeros.
|
||||||
|
|
||||||
### Fixed (Safe Formatting Utilities — 2026-02-15)
|
### Fixed (Safe Formatting Utilities — 2026-02-15)
|
||||||
|
|
||||||
**Frontend:**
|
**Frontend:**
|
||||||
|
|||||||
@@ -431,3 +431,7 @@ Things I discovered about myself building a sister platform across multiple sess
|
|||||||
20. **Parallel state fields that track related things will drift apart — and the bugs are silent.** When two fields represent aspects of the same state (`captureMode` and `vkiMode`, or `isLoading` and `error`, or `connection_status` and `companion_last_seen`), every code path that mutates one must also update the other. But new code paths get added over time, and they only update the field they know about. Future me: when you see two fields tracking related state, grep for ALL mutation sites of each — if any path updates one but not the other, that's a bug waiting to happen. And when you add a new mutation path, check every sibling field, not just the obvious one.
|
20. **Parallel state fields that track related things will drift apart — and the bugs are silent.** When two fields represent aspects of the same state (`captureMode` and `vkiMode`, or `isLoading` and `error`, or `connection_status` and `companion_last_seen`), every code path that mutates one must also update the other. But new code paths get added over time, and they only update the field they know about. Future me: when you see two fields tracking related state, grep for ALL mutation sites of each — if any path updates one but not the other, that's a bug waiting to happen. And when you add a new mutation path, check every sibling field, not just the obvious one.
|
||||||
|
|
||||||
21. **Route through the component that survives transitions, not the one that doesn't.** When two systems can handle the same job but one is resilient to failure modes and the other isn't, route through the survivor. Don't build infrastructure to prop up the fragile path when the robust path already exists. In this project: NATS request-reply through the companion agent is the robust path; direct WebSocket to the browser is the fragile one. If a feature can work through either, prefer the path that handles disconnects, reconnects, and restarts gracefully. One routing change beats an entire retry/recovery subsystem.
|
21. **Route through the component that survives transitions, not the one that doesn't.** When two systems can handle the same job but one is resilient to failure modes and the other isn't, route through the survivor. Don't build infrastructure to prop up the fragile path when the robust path already exists. In this project: NATS request-reply through the companion agent is the robust path; direct WebSocket to the browser is the fragile one. If a feature can work through either, prefer the path that handles disconnects, reconnects, and restarts gracefully. One routing change beats an entire retry/recovery subsystem.
|
||||||
|
|
||||||
|
22. **Build-green is not render-correct — visually verify UI work before calling it done.** The entire design-system re-skin (50+ files, six green commits) rendered almost completely unstyled in the browser — white background, no surfaces, no accent — because the design tokens never loaded. `vue-tsc -b` + `vite build` passed clean the whole time; CSS that *compiles* can still apply *zero* styles. One Playwright screenshot of the login exposed it in seconds. When the deliverable is visual, a green build is necessary but not sufficient: load it in a real browser (Playwright on the dev server at :5174), screenshot it, and assert on `getComputedStyle` — don't trust compilation alone. This is Lesson 17 with teeth.
|
||||||
|
|
||||||
|
23. **Tailwind v4 silently drops a nested `@import` barrel placed after `@import "tailwindcss"`.** `style.css` did `@import "tailwindcss"; @import "./styles/corrosion.css";` where corrosion.css was a barrel of eight `@import` token files. Once Tailwind v4 expands the tailwindcss import in place, the barrel's inner @imports no longer precede all statements, so PostCSS drops them — emitting only an easily-ignored "@import must precede all other statements" warning. Result: every design token resolved empty and the whole panel rendered unstyled. Import token/design CSS files **directly and contiguously** in the entry stylesheet; never via a nested barrel after the Tailwind import. The build warning you wave off as "pre-existing" may be the entire feature silently failing.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { FurnaceSplitterModule } from './modules/furnacesplitter/furnacesplitter
|
|||||||
import { BetterChatModule } from './modules/betterchat/betterchat.module';
|
import { BetterChatModule } from './modules/betterchat/betterchat.module';
|
||||||
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
|
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
|
||||||
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
|
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
|
||||||
|
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||||
|
|
||||||
// Shared Services
|
// Shared Services
|
||||||
import { NatsService } from './services/nats.service';
|
import { NatsService } from './services/nats.service';
|
||||||
@@ -123,6 +124,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
BetterChatModule,
|
BetterChatModule,
|
||||||
TimedExecuteModule,
|
TimedExecuteModule,
|
||||||
RaidableBasesModule,
|
RaidableBasesModule,
|
||||||
|
EarlyAccessModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global guards (order matters: auth first, then license, then permissions)
|
// Global guards (order matters: auth first, then license, then permissions)
|
||||||
|
|||||||
82
backend-nest/src/modules/auth/admin-seed.service.ts
Normal file
82
backend-nest/src/modules/auth/admin-seed.service.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { User } from '../../entities/user.entity';
|
||||||
|
import { License } from '../../entities/license.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstraps the first admin account on a fresh database.
|
||||||
|
*
|
||||||
|
* A fresh deploy builds the schema via docker-entrypoint-initdb.d but contains
|
||||||
|
* zero users, so the panel has no possible login. If ADMIN_EMAIL and
|
||||||
|
* ADMIN_PASSWORD are set and the users table is empty, this creates a
|
||||||
|
* super-admin user plus an active license — the same rows the register flow
|
||||||
|
* would create. It never runs against a database that already has users.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AdminSeedService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(AdminSeedService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
@InjectRepository(User) private readonly userRepository: Repository<User>,
|
||||||
|
@InjectRepository(License) private readonly licenseRepository: Repository<License>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.seedAdminIfEmpty();
|
||||||
|
} catch (err) {
|
||||||
|
// A failed seed must not take the API down — surface it loudly and move on
|
||||||
|
this.logger.error(`Admin bootstrap failed: ${(err as Error).message}`, (err as Error).stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedAdminIfEmpty(): Promise<void> {
|
||||||
|
const email = this.config.get<string>('admin.email');
|
||||||
|
const password = this.config.get<string>('admin.password');
|
||||||
|
const username = this.config.get<string>('admin.username') || 'Commander';
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
this.logger.log('Admin bootstrap skipped: ADMIN_EMAIL / ADMIN_PASSWORD not set');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCount = await this.userRepository.count();
|
||||||
|
if (userCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await argon2.hash(password);
|
||||||
|
const user = this.userRepository.create({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
email_verified: true,
|
||||||
|
is_super_admin: true,
|
||||||
|
});
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
const licenseKey = this.config.get<string>('admin.licenseKey') || this.generateLicenseKey();
|
||||||
|
const license = this.licenseRepository.create({
|
||||||
|
license_key: licenseKey,
|
||||||
|
owner_user_id: user.id,
|
||||||
|
status: 'active',
|
||||||
|
modules_enabled: [],
|
||||||
|
webstore_active: false,
|
||||||
|
});
|
||||||
|
await this.licenseRepository.save(license);
|
||||||
|
|
||||||
|
this.logger.log(`Bootstrap admin created: ${user.email} (license ${license.license_key})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateLicenseKey(): string {
|
||||||
|
const part1 = randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
const part2 = randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
const part3 = randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
return `CORR-${part1}-${part2}-${part3}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { AdminSeedService } from './admin-seed.service';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { User } from '../../entities/user.entity';
|
import { User } from '../../entities/user.entity';
|
||||||
import { License } from '../../entities/license.entity';
|
import { License } from '../../entities/license.entity';
|
||||||
@@ -27,7 +28,7 @@ import { TeamMember } from '../../entities/team-member.entity';
|
|||||||
TypeOrmModule.forFeature([User, License, Role, TeamMember]),
|
TypeOrmModule.forFeature([User, License, Role, TeamMember]),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, AdminSeedService, JwtStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateEarlyAccessDto {
|
||||||
|
@ApiProperty({ example: 'admin@example.com' })
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'rust', description: 'Primary game interest or server count' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
server_count?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
|
import { EarlyAccessService } from './early-access.service';
|
||||||
|
import { CreateEarlyAccessDto } from './dto/create-early-access.dto';
|
||||||
|
|
||||||
|
@ApiTags('early-access')
|
||||||
|
@Controller()
|
||||||
|
export class EarlyAccessController {
|
||||||
|
constructor(private readonly earlyAccessService: EarlyAccessService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('early-access')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Register for early access' })
|
||||||
|
async register(@Body() dto: CreateEarlyAccessDto) {
|
||||||
|
return this.earlyAccessService.register(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend-nest/src/modules/early-access/early-access.module.ts
Normal file
12
backend-nest/src/modules/early-access/early-access.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { EarlyAccessSignup } from '../../entities/early-access-signup.entity';
|
||||||
|
import { EarlyAccessController } from './early-access.controller';
|
||||||
|
import { EarlyAccessService } from './early-access.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([EarlyAccessSignup])],
|
||||||
|
controllers: [EarlyAccessController],
|
||||||
|
providers: [EarlyAccessService],
|
||||||
|
})
|
||||||
|
export class EarlyAccessModule {}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { EarlyAccessSignup } from '../../entities/early-access-signup.entity';
|
||||||
|
import { CreateEarlyAccessDto } from './dto/create-early-access.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EarlyAccessService {
|
||||||
|
private readonly logger = new Logger(EarlyAccessService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(EarlyAccessSignup)
|
||||||
|
private readonly repo: Repository<EarlyAccessSignup>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(dto: CreateEarlyAccessDto): Promise<{ success: true; alreadyRegistered: boolean }> {
|
||||||
|
const existing = await this.repo.findOne({ where: { email: dto.email } });
|
||||||
|
if (existing) {
|
||||||
|
// Duplicate email — return friendly success rather than a 409 that would break the UX
|
||||||
|
return { success: true, alreadyRegistered: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signup = this.repo.create({
|
||||||
|
email: dto.email,
|
||||||
|
server_count: dto.server_count ?? 'not specified',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.repo.save(signup);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Guard against a race-condition duplicate (unique constraint violation)
|
||||||
|
const pg = err as { code?: string };
|
||||||
|
if (pg.code === '23505') {
|
||||||
|
return { success: true, alreadyRegistered: true };
|
||||||
|
}
|
||||||
|
this.logger.error('Failed to save early-access signup', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, alreadyRegistered: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
.PHONY: all build build-linux build-windows clean test run
|
.PHONY: all build build-linux build-windows clean test run
|
||||||
|
|
||||||
# Binary names
|
# Binary names
|
||||||
BINARY_NAME=corrosion-companion
|
BINARY_NAME=corrosion-host-agent
|
||||||
BINARY_LINUX=$(BINARY_NAME)-linux-amd64
|
BINARY_LINUX=$(BINARY_NAME)-linux-amd64
|
||||||
BINARY_WINDOWS=$(BINARY_NAME)-windows-amd64.exe
|
BINARY_WINDOWS=$(BINARY_NAME)-windows-amd64.exe
|
||||||
|
|
||||||
@@ -66,10 +66,10 @@ run: build-local
|
|||||||
install-service:
|
install-service:
|
||||||
@echo "Installing systemd service..."
|
@echo "Installing systemd service..."
|
||||||
@sudo cp $(BUILD_DIR)/$(BINARY_LINUX) /usr/local/bin/$(BINARY_NAME)
|
@sudo cp $(BUILD_DIR)/$(BINARY_LINUX) /usr/local/bin/$(BINARY_NAME)
|
||||||
@sudo cp deployment/corrosion-companion.service /etc/systemd/system/
|
@sudo cp deployment/corrosion-host-agent.service /etc/systemd/system/
|
||||||
@sudo systemctl daemon-reload
|
@sudo systemctl daemon-reload
|
||||||
@sudo systemctl enable corrosion-companion
|
@sudo systemctl enable corrosion-host-agent
|
||||||
@echo "Service installed. Configure /etc/corrosion-companion/.env then start with: sudo systemctl start corrosion-companion"
|
@echo "Service installed. Configure /etc/corrosion-host-agent/.env then start with: sudo systemctl start corrosion-host-agent"
|
||||||
|
|
||||||
# Development helpers
|
# Development helpers
|
||||||
dev: build-local
|
dev: build-local
|
||||||
|
|||||||
22
corrosion-host-agent/.cargo/config.toml
Normal file
22
corrosion-host-agent/.cargo/config.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Corrosion Host Agent — cross-compilation configuration
|
||||||
|
#
|
||||||
|
# Deploy targets:
|
||||||
|
# Linux: x86_64-unknown-linux-musl (fully static — runs on any distro)
|
||||||
|
# Windows: x86_64-pc-windows-msvc (build via `cargo xwin build` on non-Windows)
|
||||||
|
#
|
||||||
|
# Prerequisites on macOS:
|
||||||
|
# brew install filosottile/musl-cross/musl-cross (x86_64-linux-musl-gcc)
|
||||||
|
# cargo install cargo-xwin (bundles MSVC CRT + lld-link)
|
||||||
|
|
||||||
|
[target.x86_64-unknown-linux-musl]
|
||||||
|
linker = "x86_64-linux-musl-gcc"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
CC_x86_64_unknown_linux_musl = "x86_64-linux-musl-gcc"
|
||||||
|
|
||||||
|
[target.x86_64-pc-windows-msvc]
|
||||||
|
linker = "lld-link"
|
||||||
|
# Statically link the MSVC CRT so the agent runs on fresh Windows installs
|
||||||
|
# without the Visual C++ Redistributable (otherwise: STATUS_DLL_NOT_FOUND on
|
||||||
|
# any machine missing VCRUNTIME140.dll — most fresh OEM images).
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static"]
|
||||||
1
corrosion-host-agent/.gitignore
vendored
Normal file
1
corrosion-host-agent/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
2100
corrosion-host-agent/Cargo.lock
generated
Normal file
2100
corrosion-host-agent/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
corrosion-host-agent/Cargo.toml
Normal file
36
corrosion-host-agent/Cargo.toml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
[package]
|
||||||
|
name = "corrosion-host-agent"
|
||||||
|
version = "2.0.0-alpha.2"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Corrosion Host Agent — multi-game ops runtime for self-hosted game servers"
|
||||||
|
license = "UNLICENSED"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "corrosion-host-agent"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["rt"] }
|
||||||
|
futures = "0.3"
|
||||||
|
async-nats = "0.37"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "0.8"
|
||||||
|
sysinfo = "0.33"
|
||||||
|
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
|
# Size-optimized release: single static binary living next to RAM-heavy game
|
||||||
|
# servers. Panic stays 'unwind' so a panicking task surfaces through its
|
||||||
|
# JoinHandle instead of killing the whole agent.
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
143
corrosion-host-agent/PROTOCOL.md
Normal file
143
corrosion-host-agent/PROTOCOL.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Corrosion Wire Protocol v2
|
||||||
|
|
||||||
|
Status: **Phase 0 implemented** (host heartbeat, host commands, going-offline
|
||||||
|
beacon). Per-instance command/status subjects are reserved and specified here
|
||||||
|
for Phase 1.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
One **host agent** per machine supervises **N game instances**. Subjects are
|
||||||
|
scoped license-first, then by addressee:
|
||||||
|
|
||||||
|
```
|
||||||
|
corrosion.{license_id}.host.* host-level (the agent itself)
|
||||||
|
corrosion.{license_id}.{instance_id}.* instance-level (one game server)
|
||||||
|
```
|
||||||
|
|
||||||
|
`instance_id` is a config-defined slug (`[a-z0-9_-]{1,64}`), validated at
|
||||||
|
agent start. `host` is a reserved segment and can never be an instance id.
|
||||||
|
Payloads are JSON. Every heartbeat carries `"schema": 2` so consumers can
|
||||||
|
distinguish v2 from the legacy Go companion protocol (which used
|
||||||
|
`corrosion.{license_id}.companion.heartbeat`, no schema field).
|
||||||
|
|
||||||
|
## Host-level subjects (Phase 0 — live)
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.host.heartbeat` (agent → backend, publish)
|
||||||
|
|
||||||
|
Published every `heartbeat_seconds` (default 60, jittered ±20%).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 2,
|
||||||
|
"timestamp": "2026-06-11T18:00:00Z",
|
||||||
|
"agent": {
|
||||||
|
"version": "2.0.0-alpha.1",
|
||||||
|
"commit": "a8722a7",
|
||||||
|
"os": "linux",
|
||||||
|
"arch": "x86_64",
|
||||||
|
"uptime_seconds": 86400
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"hostname": "asgard-01",
|
||||||
|
"cpu_percent": 12.5,
|
||||||
|
"cpu_cores": 80,
|
||||||
|
"mem_total_mb": 262144,
|
||||||
|
"mem_used_mb": 81920,
|
||||||
|
"uptime_seconds": 1209600,
|
||||||
|
"disks": [
|
||||||
|
{ "mount": "/", "total_mb": 1907729, "free_mb": 1532211 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"instances": [
|
||||||
|
{
|
||||||
|
"id": "rust-main",
|
||||||
|
"game": "rust",
|
||||||
|
"label": "Main 2x Vanilla",
|
||||||
|
"state": "configured",
|
||||||
|
"root_disk_free_mb": 1532211
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"probe": {
|
||||||
|
"timestamp": "2026-06-11T17:58:00Z",
|
||||||
|
"results": [
|
||||||
|
{ "name": "corrosion-cdn", "host": "cdn.corrosionmgmt.com", "port": 443, "ok": true, "latency_ms": 18 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All telemetry is measured, never fabricated. Fields the agent cannot measure
|
||||||
|
are omitted (`probe` before the first probe completes, `hostname` if
|
||||||
|
unavailable).
|
||||||
|
|
||||||
|
Phase 0 instance `state` values: `configured` (root path exists),
|
||||||
|
`missing_root`. Phase 1 adds live process states: `running`, `stopped`,
|
||||||
|
`crashed`, `starting`, `updating`.
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.host.cmd` (backend → agent, request-reply)
|
||||||
|
|
||||||
|
Request: `{ "func": "<name>" }`. Reply: `{ "status": "success" | "error", ... }`.
|
||||||
|
|
||||||
|
| func | Reply payload |
|
||||||
|
| --------- | -------------------------------------------------------- |
|
||||||
|
| `ping` | `version`, `commit`, `uptime_seconds` |
|
||||||
|
| `probe` | `report` — fresh ProbeReport (also cached for heartbeat) |
|
||||||
|
| `sysinfo` | `snapshot` — full heartbeat payload, collected on demand |
|
||||||
|
|
||||||
|
Unknown funcs return `status: "error"` with a message listing supported funcs.
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.host.going_offline` (agent → backend, publish)
|
||||||
|
|
||||||
|
Best-effort beacon (500ms budget) on graceful shutdown so the panel can flip
|
||||||
|
the host to offline immediately instead of waiting out heartbeat staleness.
|
||||||
|
Payload: `{}`.
|
||||||
|
|
||||||
|
## Instance-level subjects (Phase 1 — reserved, not yet implemented)
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.{instance_id}.cmd` (backend → agent, request-reply)
|
||||||
|
|
||||||
|
Lifecycle and control for one game instance. Planned funcs: `start`, `stop`,
|
||||||
|
`restart`, `status`, `rcon` (process-class games), `steam_update`,
|
||||||
|
`oxide_install` (rust), plus game-adapter-specific commands (Dune: docker
|
||||||
|
lifecycle, RabbitMQ bus commands, Coriolis reset).
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.{instance_id}.status` (agent → backend, publish)
|
||||||
|
|
||||||
|
State-change events (started/stopped/crashed) so the panel does not wait for
|
||||||
|
the next heartbeat.
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.{instance_id}.console` (agent → backend, publish)
|
||||||
|
|
||||||
|
Live console/log lines for the panel console view.
|
||||||
|
|
||||||
|
### `corrosion.{license_id}.{instance_id}.files.cmd` (backend → agent, request-reply)
|
||||||
|
|
||||||
|
VueFinder-style file manager ops, jailed to the instance root. Carries over
|
||||||
|
the Go agent's jailed filemanager semantics (`fm_list`, `fm_save`, ...); the
|
||||||
|
legacy UNJAILED `files.get/put/delete/list` API is retired and will not be
|
||||||
|
ported.
|
||||||
|
|
||||||
|
## Backend mapping notes (Phase 0)
|
||||||
|
|
||||||
|
- The NestJS NATS bridge subscribes `corrosion.*.host.heartbeat` and
|
||||||
|
`corrosion.*.host.going_offline`.
|
||||||
|
- Until the license→host→instance schema lands, the backend may map the host
|
||||||
|
heartbeat onto the existing single `server_connections` row per license:
|
||||||
|
`companion_last_seen` ← heartbeat arrival, `connection_status` ←
|
||||||
|
connected/offline, resources ← `host.cpu_percent` / `mem_*` / first disk.
|
||||||
|
Instance-level mapping activates with the fleet schema.
|
||||||
|
|
||||||
|
## Probing — scope honesty
|
||||||
|
|
||||||
|
The Phase 0 prober measures **outbound** reachability from the host (TCP
|
||||||
|
connect + latency). It cannot verify **inbound** port-forwarding (the thing
|
||||||
|
players hit). Inbound verification requires a backend-side reverse probe
|
||||||
|
service that attempts connections to the customer's public IP/ports on
|
||||||
|
request; that is specified as a Phase 1+ feature and will reuse this report
|
||||||
|
format with `direction: "inbound"`.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
- The agent embeds semver + git hash + build timestamp (`--version`,
|
||||||
|
heartbeat `agent` block).
|
||||||
|
- Schema changes bump `schema` and are additive where possible.
|
||||||
36
corrosion-host-agent/README.md
Normal file
36
corrosion-host-agent/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Corrosion Host Agent
|
||||||
|
|
||||||
|
Rust rewrite of the Go companion agent (`companion-agent/`, retained as the
|
||||||
|
behavior reference until parity). One agent per machine supervises every game
|
||||||
|
instance on that host — Rust, Conan Exiles, Soulmask, Dune: Awakening.
|
||||||
|
|
||||||
|
- **Wire protocol**: see [PROTOCOL.md](./PROTOCOL.md) (v2, instance-scoped subjects)
|
||||||
|
- **Config**: see [agent.example.toml](./agent.example.toml)
|
||||||
|
|
||||||
|
## Status — Phase 0
|
||||||
|
|
||||||
|
- [x] Multi-instance TOML config + env overrides (`CORROSION_LICENSE_ID`, `CORROSION_NATS_URL`, `CORROSION_NATS_TOKEN`)
|
||||||
|
- [x] NATS connection (infinite reconnect, capped backoff, 30s ping, offline send-buffering, `tls://` support)
|
||||||
|
- [x] Host heartbeat with real telemetry (sysinfo: CPU, memory, disks) — no fabricated values
|
||||||
|
- [x] Connectivity prober (outbound TCP, periodic + on-demand)
|
||||||
|
- [x] Host command channel (`ping`, `probe`, `sysinfo`)
|
||||||
|
- [x] Graceful shutdown (cancellation token, going-offline beacon, NATS flush)
|
||||||
|
- [ ] Phase 1: process-class game adapter (spawn/RCON/SteamCMD/files) — Rust, Conan, Soulmask
|
||||||
|
- [ ] Phase 2: Dune Docker adapter (compose lifecycle, RabbitMQ bus, Postgres admin)
|
||||||
|
- [ ] Phase 3: signed self-update (enforced ed25519 — release gate), service install, supervisor split
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release # native
|
||||||
|
cargo build --release --target x86_64-unknown-linux-gnu # linux deploy target
|
||||||
|
cargo build --release --target x86_64-pc-windows-msvc # windows (cargo-xwin on non-Windows)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corrosion-host-agent --config ./agent.toml # foreground
|
||||||
|
corrosion-host-agent --config ./agent.toml check # validate config only
|
||||||
|
corrosion-host-agent version # semver + git hash + build ts
|
||||||
|
```
|
||||||
39
corrosion-host-agent/agent.example.toml
Normal file
39
corrosion-host-agent/agent.example.toml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Corrosion Host Agent configuration
|
||||||
|
# Default location: /etc/corrosion/agent.toml (Linux)
|
||||||
|
# C:\ProgramData\Corrosion\agent.toml (Windows)
|
||||||
|
# Override with: corrosion-host-agent --config /path/to/agent.toml
|
||||||
|
#
|
||||||
|
# Secrets can come from the environment instead of this file:
|
||||||
|
# CORROSION_LICENSE_ID, CORROSION_NATS_URL, CORROSION_NATS_TOKEN
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
license_id = "your-license-uuid"
|
||||||
|
nats_url = "nats://nats.corrosionmgmt.com:4222"
|
||||||
|
# nats_token = "set-me-or-use-CORROSION_NATS_TOKEN"
|
||||||
|
heartbeat_seconds = 60
|
||||||
|
log_level = "info"
|
||||||
|
|
||||||
|
# One agent supervises every game instance on this host.
|
||||||
|
# Each instance gets a stable id (lowercase letters, digits, '-', '_') that
|
||||||
|
# the panel uses to address it. Changing an id orphans its panel history.
|
||||||
|
|
||||||
|
[[instance]]
|
||||||
|
id = "rust-main"
|
||||||
|
game = "rust" # rust | conan | soulmask | dune
|
||||||
|
root = "/opt/rustserver"
|
||||||
|
label = "Main 2x Vanilla"
|
||||||
|
|
||||||
|
# [[instance]]
|
||||||
|
# id = "soulmask-main"
|
||||||
|
# game = "soulmask"
|
||||||
|
# root = "/opt/soulmask/main"
|
||||||
|
# label = "Cloud Mist Forest (cluster main)"
|
||||||
|
|
||||||
|
[prober]
|
||||||
|
interval_seconds = 300
|
||||||
|
|
||||||
|
# Extra outbound TCP checks beyond the built-in defaults:
|
||||||
|
# [[prober.target]]
|
||||||
|
# name = "steam-cdn"
|
||||||
|
# host = "steamcdn-a.akamaihd.net"
|
||||||
|
# port = 443
|
||||||
21
corrosion-host-agent/build.rs
Normal file
21
corrosion-host-agent/build.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let git_hash = Command::new("git")
|
||||||
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
let build_ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
println!("cargo:rustc-env=CORROSION_GIT_HASH={git_hash}");
|
||||||
|
println!("cargo:rustc-env=CORROSION_BUILD_TS={build_ts}");
|
||||||
|
println!("cargo:rerun-if-changed=../.git/HEAD");
|
||||||
|
}
|
||||||
16
corrosion-host-agent/src/agent.rs
Normal file
16
corrosion-host-agent/src/agent.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//! Shared agent handle: every subsystem task holds an `Arc<Agent>`.
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::config::Settings;
|
||||||
|
use crate::prober::ProbeReport;
|
||||||
|
|
||||||
|
pub struct Agent {
|
||||||
|
pub cfg: Settings,
|
||||||
|
pub nats: async_nats::Client,
|
||||||
|
pub started: Instant,
|
||||||
|
pub last_probe: RwLock<Option<ProbeReport>>,
|
||||||
|
pub shutdown: CancellationToken,
|
||||||
|
}
|
||||||
58
corrosion-host-agent/src/bus.rs
Normal file
58
corrosion-host-agent/src/bus.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//! NATS connection layer.
|
||||||
|
//!
|
||||||
|
//! Connection parameters follow the production-proven Vigilance profile:
|
||||||
|
//! infinite reconnects with capped exponential backoff, 30s pings to detect
|
||||||
|
//! zombie TCP in ~60s, and a deep client-side send queue so telemetry buffers
|
||||||
|
//! through broker outages instead of erroring.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::config::Settings;
|
||||||
|
|
||||||
|
pub async fn connect(cfg: &Settings) -> Result<async_nats::Client> {
|
||||||
|
let (url, force_tls) = normalize_url(&cfg.nats_url);
|
||||||
|
|
||||||
|
let mut opts = async_nats::ConnectOptions::new()
|
||||||
|
.name("corrosion-host-agent")
|
||||||
|
.retry_on_initial_connect()
|
||||||
|
.max_reconnects(None)
|
||||||
|
.ping_interval(Duration::from_secs(30))
|
||||||
|
.client_capacity(8192)
|
||||||
|
.reconnect_delay_callback(|attempts| {
|
||||||
|
Duration::from_millis(std::cmp::min(attempts as u64 * 100, 8_000))
|
||||||
|
})
|
||||||
|
.event_callback(|event| async move {
|
||||||
|
match event {
|
||||||
|
async_nats::Event::Disconnected => tracing::warn!("nats disconnected"),
|
||||||
|
async_nats::Event::Connected => tracing::info!("nats connected"),
|
||||||
|
other => tracing::debug!("nats event: {other}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if force_tls {
|
||||||
|
opts = opts.require_tls(true);
|
||||||
|
}
|
||||||
|
if let Some(token) = &cfg.nats_token {
|
||||||
|
opts = opts.token(token.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = opts
|
||||||
|
.connect(&url)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("connecting to NATS at {url}"))?;
|
||||||
|
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept `tls://` / `nats+tls://` URL schemes by translating to `nats://` +
|
||||||
|
/// an explicit TLS requirement.
|
||||||
|
fn normalize_url(raw: &str) -> (String, bool) {
|
||||||
|
if let Some(rest) = raw.strip_prefix("tls://") {
|
||||||
|
(format!("nats://{rest}"), true)
|
||||||
|
} else if let Some(rest) = raw.strip_prefix("nats+tls://") {
|
||||||
|
(format!("nats://{rest}"), true)
|
||||||
|
} else {
|
||||||
|
(raw.to_string(), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
186
corrosion-host-agent/src/config.rs
Normal file
186
corrosion-host-agent/src/config.rs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
//! Agent configuration: TOML file + environment overrides.
|
||||||
|
//!
|
||||||
|
//! Multi-instance is foundational, not bolted on: one agent supervises N game
|
||||||
|
//! instances on the host, each declared as an `[[instance]]` block. Connection
|
||||||
|
//! secrets may come from env so the config file can be world-readable-ish
|
||||||
|
//! while the token is not.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Instance ids share the NATS subject namespace with host-level segments.
|
||||||
|
const RESERVED_INSTANCE_IDS: &[&str] = &["host", "cmd", "files", "update", "agent"];
|
||||||
|
|
||||||
|
pub const SUPPORTED_GAMES: &[&str] = &["rust", "conan", "soulmask", "dune"];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ConfigFile {
|
||||||
|
pub agent: AgentSection,
|
||||||
|
#[serde(default, rename = "instance")]
|
||||||
|
pub instances: Vec<InstanceConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub prober: ProberSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct AgentSection {
|
||||||
|
pub license_id: Option<String>,
|
||||||
|
pub nats_url: Option<String>,
|
||||||
|
pub nats_token: Option<String>,
|
||||||
|
#[serde(default = "default_heartbeat_seconds")]
|
||||||
|
pub heartbeat_seconds: u64,
|
||||||
|
#[serde(default = "default_log_level")]
|
||||||
|
pub log_level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct InstanceConfig {
|
||||||
|
/// Short slug, unique per license: becomes a NATS subject segment.
|
||||||
|
pub id: String,
|
||||||
|
/// One of SUPPORTED_GAMES.
|
||||||
|
pub game: String,
|
||||||
|
/// Install root for this instance on the host.
|
||||||
|
pub root: PathBuf,
|
||||||
|
/// Optional human label shown in the panel.
|
||||||
|
#[serde(default)]
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ProberSection {
|
||||||
|
#[serde(default = "default_probe_interval")]
|
||||||
|
pub interval_seconds: u64,
|
||||||
|
/// Extra TCP targets beyond the built-in defaults.
|
||||||
|
#[serde(default, rename = "target")]
|
||||||
|
pub targets: Vec<ProbeTargetConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ProbeTargetConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_heartbeat_seconds() -> u64 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_probe_interval() -> u64 {
|
||||||
|
300
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_log_level() -> String {
|
||||||
|
"info".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fully-resolved settings after merging file + env. Everything required is
|
||||||
|
/// present and validated.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub license_id: String,
|
||||||
|
pub nats_url: String,
|
||||||
|
pub nats_token: Option<String>,
|
||||||
|
pub heartbeat_seconds: u64,
|
||||||
|
pub log_level: String,
|
||||||
|
pub instances: Vec<InstanceConfig>,
|
||||||
|
pub probe_interval_seconds: u64,
|
||||||
|
pub probe_targets: Vec<ProbeTargetConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_config_path() -> PathBuf {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
PathBuf::from(r"C:\ProgramData\Corrosion\agent.toml")
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
PathBuf::from("/etc/corrosion/agent.toml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &Path) -> Result<Settings> {
|
||||||
|
let raw = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("reading config file {}", path.display()))?;
|
||||||
|
let file: ConfigFile = toml::from_str(&raw)
|
||||||
|
.with_context(|| format!("parsing config file {}", path.display()))?;
|
||||||
|
resolve(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge env overrides (env wins) and validate.
|
||||||
|
fn resolve(file: ConfigFile) -> Result<Settings> {
|
||||||
|
let license_id = std::env::var("CORROSION_LICENSE_ID")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.or(file.agent.license_id)
|
||||||
|
.context("license_id missing: set [agent].license_id or CORROSION_LICENSE_ID")?;
|
||||||
|
|
||||||
|
let nats_url = std::env::var("CORROSION_NATS_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.or(file.agent.nats_url)
|
||||||
|
.context("nats_url missing: set [agent].nats_url or CORROSION_NATS_URL")?;
|
||||||
|
|
||||||
|
let nats_token = std::env::var("CORROSION_NATS_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.or(file.agent.nats_token);
|
||||||
|
|
||||||
|
validate_subject_segment("license_id", &license_id)?;
|
||||||
|
|
||||||
|
let mut seen: HashSet<&str> = HashSet::new();
|
||||||
|
for inst in &file.instances {
|
||||||
|
validate_subject_segment("instance id", &inst.id)?;
|
||||||
|
if RESERVED_INSTANCE_IDS.contains(&inst.id.as_str()) {
|
||||||
|
bail!("instance id '{}' is reserved", inst.id);
|
||||||
|
}
|
||||||
|
if !seen.insert(inst.id.as_str()) {
|
||||||
|
bail!("duplicate instance id '{}'", inst.id);
|
||||||
|
}
|
||||||
|
if !SUPPORTED_GAMES.contains(&inst.game.as_str()) {
|
||||||
|
bail!(
|
||||||
|
"instance '{}': unsupported game '{}' (supported: {})",
|
||||||
|
inst.id,
|
||||||
|
inst.game,
|
||||||
|
SUPPORTED_GAMES.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.agent.heartbeat_seconds < 10 {
|
||||||
|
bail!("[agent].heartbeat_seconds must be >= 10");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Settings {
|
||||||
|
license_id,
|
||||||
|
nats_url,
|
||||||
|
nats_token,
|
||||||
|
heartbeat_seconds: file.agent.heartbeat_seconds,
|
||||||
|
log_level: file.agent.log_level,
|
||||||
|
instances: file.instances,
|
||||||
|
probe_interval_seconds: file.prober.interval_seconds.max(30),
|
||||||
|
probe_targets: file.prober.targets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NATS subject segments must not contain '.', '*', '>', whitespace, etc.
|
||||||
|
/// Keep it strict: lowercase alphanumerics plus '-' and '_', max 64 chars.
|
||||||
|
fn validate_subject_segment(what: &str, value: &str) -> Result<()> {
|
||||||
|
if value.is_empty() || value.len() > 64 {
|
||||||
|
bail!("{what} '{value}' must be 1-64 characters");
|
||||||
|
}
|
||||||
|
if !value
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||||
|
{
|
||||||
|
bail!("{what} '{value}' may only contain lowercase letters, digits, '-' and '_'");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
115
corrosion-host-agent/src/hostcmd.rs
Normal file
115
corrosion-host-agent/src/hostcmd.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
//! Host-level command handler: request-reply on `corrosion.{license}.host.cmd`.
|
||||||
|
//!
|
||||||
|
//! One subscriber; each message handled in its own task so a slow command
|
||||||
|
//! never blocks the dispatch loop. Phase 0 commands: ping, probe, sysinfo.
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use sysinfo::System;
|
||||||
|
|
||||||
|
use crate::agent::Agent;
|
||||||
|
use crate::prober;
|
||||||
|
use crate::subjects;
|
||||||
|
use crate::telemetry;
|
||||||
|
use crate::version;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct HostCommand {
|
||||||
|
func: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(agent: Arc<Agent>) -> anyhow::Result<()> {
|
||||||
|
let subject = subjects::host_cmd(&agent.cfg.license_id);
|
||||||
|
let mut sub = agent.nats.subscribe(subject.clone()).await?;
|
||||||
|
tracing::info!("host command handler listening on {subject}");
|
||||||
|
|
||||||
|
let cancel = agent.shutdown.clone();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = sub.next() => {
|
||||||
|
match msg {
|
||||||
|
Some(msg) => {
|
||||||
|
let agent = agent.clone();
|
||||||
|
tokio::spawn(async move { handle(agent, msg).await });
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!("host command subscription ended");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = cancel.cancelled() => {
|
||||||
|
tracing::info!("host command handler stopping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(agent: Arc<Agent>, msg: async_nats::Message) {
|
||||||
|
let Some(reply) = msg.reply.clone() else {
|
||||||
|
tracing::warn!("host command without reply subject ignored");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = match serde_json::from_slice::<HostCommand>(&msg.payload) {
|
||||||
|
Ok(cmd) => dispatch(&agent, &cmd.func).await,
|
||||||
|
Err(e) => json!({ "status": "error", "message": format!("invalid command payload: {e}") }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = match serde_json::to_vec(&response) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("response serialize failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = agent.nats.publish(reply, bytes.into()).await {
|
||||||
|
tracing::warn!("response publish failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch(agent: &Arc<Agent>, func: &str) -> serde_json::Value {
|
||||||
|
match func {
|
||||||
|
"ping" => json!({
|
||||||
|
"status": "success",
|
||||||
|
"func": "ping",
|
||||||
|
"version": version::VERSION,
|
||||||
|
"commit": version::GIT_HASH,
|
||||||
|
"uptime_seconds": agent.started.elapsed().as_secs(),
|
||||||
|
}),
|
||||||
|
"probe" => {
|
||||||
|
let report = prober::run_probe(&agent.cfg.probe_targets).await;
|
||||||
|
*agent.last_probe.write().await = Some(report.clone());
|
||||||
|
match serde_json::to_value(&report) {
|
||||||
|
Ok(report_json) => json!({
|
||||||
|
"status": "success",
|
||||||
|
"func": "probe",
|
||||||
|
"report": report_json,
|
||||||
|
}),
|
||||||
|
Err(e) => json!({ "status": "error", "message": format!("probe serialize: {e}") }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"sysinfo" => {
|
||||||
|
let mut sys = System::new();
|
||||||
|
sys.refresh_cpu_usage();
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
let payload = telemetry::collect(agent, &mut sys).await;
|
||||||
|
match serde_json::to_value(&payload) {
|
||||||
|
Ok(snapshot) => json!({
|
||||||
|
"status": "success",
|
||||||
|
"func": "sysinfo",
|
||||||
|
"snapshot": snapshot,
|
||||||
|
}),
|
||||||
|
Err(e) => json!({ "status": "error", "message": format!("sysinfo serialize: {e}") }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => json!({
|
||||||
|
"status": "error",
|
||||||
|
"message": format!("unknown func '{other}' (supported: ping, probe, sysinfo)"),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
168
corrosion-host-agent/src/main.rs
Normal file
168
corrosion-host-agent/src/main.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
//! Corrosion Host Agent — multi-game ops runtime.
|
||||||
|
//!
|
||||||
|
//! Phase 0: NATS connectivity, real host telemetry, multi-instance config,
|
||||||
|
//! connectivity prober, host command channel. Process control, file ops, and
|
||||||
|
//! game adapters arrive in Phase 1+ (see PROTOCOL.md).
|
||||||
|
|
||||||
|
mod agent;
|
||||||
|
mod bus;
|
||||||
|
mod config;
|
||||||
|
mod hostcmd;
|
||||||
|
mod prober;
|
||||||
|
mod subjects;
|
||||||
|
mod telemetry;
|
||||||
|
mod version;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::agent::Agent;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "corrosion-host-agent", version = version::VERSION, about)]
|
||||||
|
struct Cli {
|
||||||
|
/// Path to agent.toml (default: /etc/corrosion/agent.toml on Linux,
|
||||||
|
/// C:\ProgramData\Corrosion\agent.toml on Windows)
|
||||||
|
#[arg(long, short = 'c')]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Command {
|
||||||
|
/// Validate the config file and exit.
|
||||||
|
Check,
|
||||||
|
/// Print full version (semver, git hash, build timestamp) and exit.
|
||||||
|
Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let config_path = cli.config.unwrap_or_else(config::default_config_path);
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Some(Command::Version) => {
|
||||||
|
println!("corrosion-host-agent {}", version::long());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Some(Command::Check) => {
|
||||||
|
let settings = config::load(&config_path)?;
|
||||||
|
println!(
|
||||||
|
"config ok: license {}, {} instance(s), nats {}",
|
||||||
|
settings.license_id,
|
||||||
|
settings.instances.len(),
|
||||||
|
settings.nats_url
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let settings = config::load(&config_path)?;
|
||||||
|
init_logging(&settings.log_level);
|
||||||
|
tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.context("building tokio runtime")?
|
||||||
|
.block_on(run(settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_logging(level: &str) {
|
||||||
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level));
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(settings: config::Settings) -> Result<()> {
|
||||||
|
tracing::info!(
|
||||||
|
"corrosion-host-agent {} starting: license {}, {} instance(s)",
|
||||||
|
version::long(),
|
||||||
|
settings.license_id,
|
||||||
|
settings.instances.len()
|
||||||
|
);
|
||||||
|
for inst in &settings.instances {
|
||||||
|
tracing::info!(" instance '{}' ({}) at {}", inst.id, inst.game, inst.root.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let nats = bus::connect(&settings).await?;
|
||||||
|
|
||||||
|
let agent = Arc::new(Agent {
|
||||||
|
cfg: settings,
|
||||||
|
nats,
|
||||||
|
started: Instant::now(),
|
||||||
|
last_probe: RwLock::new(None),
|
||||||
|
shutdown: CancellationToken::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
handles.push(tokio::spawn(telemetry::run(agent.clone())));
|
||||||
|
handles.push(tokio::spawn(prober::run_loop(agent.clone())));
|
||||||
|
{
|
||||||
|
let agent = agent.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
if let Err(e) = hostcmd::run(agent).await {
|
||||||
|
tracing::error!("host command handler failed: {e:#}");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_shutdown_signal().await;
|
||||||
|
tracing::info!("shutdown signal received");
|
||||||
|
agent.shutdown.cancel();
|
||||||
|
|
||||||
|
// Best-effort offline beacon so the panel flips to offline immediately
|
||||||
|
// instead of waiting out the heartbeat staleness window.
|
||||||
|
let beacon = subjects::host_going_offline(&agent.cfg.license_id);
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
Duration::from_millis(500),
|
||||||
|
agent.nats.publish(beacon, "{}".into()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match tokio::time::timeout(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
futures::future::join_all(handles),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => tracing::info!("all subsystems stopped cleanly"),
|
||||||
|
Err(_) => tracing::warn!("shutdown timeout: some subsystems did not stop within 10s"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = agent.nats.flush().await;
|
||||||
|
tracing::info!("corrosion-host-agent stopped");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_shutdown_signal() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use tokio::signal::unix::{signal, SignalKind};
|
||||||
|
let mut sigterm = match signal(SignalKind::terminate()) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("SIGTERM handler failed: {e}; falling back to ctrl-c only");
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::signal::ctrl_c() => {}
|
||||||
|
_ = sigterm.recv() => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
corrosion-host-agent/src/prober.rs
Normal file
121
corrosion-host-agent/src/prober.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
//! Connectivity prober.
|
||||||
|
//!
|
||||||
|
//! Answers "is it the box or is it the network?" before a support ticket gets
|
||||||
|
//! written. Phase 0 scope is OUTBOUND reachability: TCP connect timing from
|
||||||
|
//! the host to known endpoints. Inbound port-forward verification (the thing
|
||||||
|
//! panel users actually struggle with) requires a backend-side reverse probe
|
||||||
|
//! and is specified in PROTOCOL.md as a later phase.
|
||||||
|
|
||||||
|
use chrono::{SecondsFormat, Utc};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::agent::Agent;
|
||||||
|
use crate::config::ProbeTargetConfig;
|
||||||
|
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ProbeResult {
|
||||||
|
pub name: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub ok: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub latency_ms: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ProbeReport {
|
||||||
|
pub timestamp: String,
|
||||||
|
pub results: Vec<ProbeResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Built-in targets every agent checks, before config extras.
|
||||||
|
fn default_targets() -> Vec<ProbeTargetConfig> {
|
||||||
|
vec![ProbeTargetConfig {
|
||||||
|
name: "corrosion-cdn".to_string(),
|
||||||
|
host: "cdn.corrosionmgmt.com".to_string(),
|
||||||
|
port: 443,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_probe(extra_targets: &[ProbeTargetConfig]) -> ProbeReport {
|
||||||
|
let mut targets = default_targets();
|
||||||
|
targets.extend(extra_targets.iter().cloned());
|
||||||
|
|
||||||
|
let checks = targets.into_iter().map(|t| async move {
|
||||||
|
let started = Instant::now();
|
||||||
|
let addr = format!("{}:{}", t.host, t.port);
|
||||||
|
let outcome = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr)).await;
|
||||||
|
match outcome {
|
||||||
|
Ok(Ok(_stream)) => ProbeResult {
|
||||||
|
name: t.name,
|
||||||
|
host: t.host,
|
||||||
|
port: t.port,
|
||||||
|
ok: true,
|
||||||
|
latency_ms: Some(started.elapsed().as_millis() as u64),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Ok(Err(e)) => ProbeResult {
|
||||||
|
name: t.name,
|
||||||
|
host: t.host,
|
||||||
|
port: t.port,
|
||||||
|
ok: false,
|
||||||
|
latency_ms: None,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
},
|
||||||
|
Err(_) => ProbeResult {
|
||||||
|
name: t.name,
|
||||||
|
host: t.host,
|
||||||
|
port: t.port,
|
||||||
|
ok: false,
|
||||||
|
latency_ms: None,
|
||||||
|
error: Some(format!("timeout after {}s", CONNECT_TIMEOUT.as_secs())),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let results = futures::future::join_all(checks).await;
|
||||||
|
|
||||||
|
ProbeReport {
|
||||||
|
timestamp: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||||
|
results,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Periodic probe loop; results land in shared state and ride the next
|
||||||
|
/// heartbeat. Jittered interval to avoid fleet-wide synchronization.
|
||||||
|
pub async fn run_loop(agent: Arc<Agent>) {
|
||||||
|
let cancel = agent.shutdown.clone();
|
||||||
|
loop {
|
||||||
|
let report = run_probe(&agent.cfg.probe_targets).await;
|
||||||
|
let failed: Vec<&str> = report
|
||||||
|
.results
|
||||||
|
.iter()
|
||||||
|
.filter(|r| !r.ok)
|
||||||
|
.map(|r| r.name.as_str())
|
||||||
|
.collect();
|
||||||
|
if failed.is_empty() {
|
||||||
|
tracing::debug!("probe ok ({} targets)", report.results.len());
|
||||||
|
} else {
|
||||||
|
tracing::warn!("probe failures: {}", failed.join(", "));
|
||||||
|
}
|
||||||
|
*agent.last_probe.write().await = Some(report);
|
||||||
|
|
||||||
|
let jitter = rand::Rng::gen_range(&mut rand::thread_rng(), 0.8..1.2);
|
||||||
|
let interval =
|
||||||
|
Duration::from_secs_f64(agent.cfg.probe_interval_seconds as f64 * jitter);
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(interval) => {}
|
||||||
|
_ = cancel.cancelled() => {
|
||||||
|
tracing::info!("prober stopping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
corrosion-host-agent/src/subjects.rs
Normal file
30
corrosion-host-agent/src/subjects.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
//! Corrosion wire protocol v2 subject scheme (see PROTOCOL.md).
|
||||||
|
//!
|
||||||
|
//! Host-level subjects live under `corrosion.{license}.host.*`; per-instance
|
||||||
|
//! subjects under `corrosion.{license}.{instance_id}.*`. Instance ids are
|
||||||
|
//! validated at config load so they can never collide with the reserved
|
||||||
|
//! `host` segment or contain subject metacharacters.
|
||||||
|
|
||||||
|
pub fn host_heartbeat(license: &str) -> String {
|
||||||
|
format!("corrosion.{license}.host.heartbeat")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn host_cmd(license: &str) -> String {
|
||||||
|
format!("corrosion.{license}.host.cmd")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn host_going_offline(license: &str) -> String {
|
||||||
|
format!("corrosion.{license}.host.going_offline")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 1: per-instance command channel (start/stop/restart/rcon/...).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn instance_cmd(license: &str, instance: &str) -> String {
|
||||||
|
format!("corrosion.{license}.{instance}.cmd")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 1: per-instance state-change events.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn instance_status(license: &str, instance: &str) -> String {
|
||||||
|
format!("corrosion.{license}.{instance}.status")
|
||||||
|
}
|
||||||
175
corrosion-host-agent/src/telemetry.rs
Normal file
175
corrosion-host-agent/src/telemetry.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
//! Host heartbeat: real telemetry, never fabricated.
|
||||||
|
//!
|
||||||
|
//! The Go agent shipped `disk_free_mb: 50000` and `cpu_percent: 0.0` as
|
||||||
|
//! hardcoded placeholders. This module is the first time the panel's
|
||||||
|
//! Resources view receives the truth. Anything we cannot measure is omitted
|
||||||
|
//! or null — never invented.
|
||||||
|
|
||||||
|
use chrono::{SecondsFormat, Utc};
|
||||||
|
use rand::Rng;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use sysinfo::{Disks, System};
|
||||||
|
|
||||||
|
use crate::agent::Agent;
|
||||||
|
use crate::prober::ProbeReport;
|
||||||
|
use crate::subjects;
|
||||||
|
use crate::version;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HeartbeatPayload {
|
||||||
|
/// Wire schema version — lets the backend distinguish v2 host heartbeats
|
||||||
|
/// from legacy Go companion heartbeats during any transition window.
|
||||||
|
pub schema: u32,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub agent: AgentInfo,
|
||||||
|
pub host: HostInfo,
|
||||||
|
pub instances: Vec<InstanceInfo>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub probe: Option<ProbeReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AgentInfo {
|
||||||
|
pub version: String,
|
||||||
|
pub commit: String,
|
||||||
|
pub os: String,
|
||||||
|
pub arch: String,
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HostInfo {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub cpu_percent: f32,
|
||||||
|
pub cpu_cores: usize,
|
||||||
|
pub mem_total_mb: u64,
|
||||||
|
pub mem_used_mb: u64,
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
pub disks: Vec<DiskInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DiskInfo {
|
||||||
|
pub mount: String,
|
||||||
|
pub total_mb: u64,
|
||||||
|
pub free_mb: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct InstanceInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub game: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub label: Option<String>,
|
||||||
|
/// Phase 0 states: `configured` (root exists) or `missing_root`.
|
||||||
|
/// Phase 1 adds live process states (running/stopped/crashed).
|
||||||
|
pub state: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub root_disk_free_mb: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(agent: Arc<Agent>) {
|
||||||
|
let cancel = agent.shutdown.clone();
|
||||||
|
let mut sys = System::new();
|
||||||
|
|
||||||
|
// CPU usage is a delta between refreshes; prime it once so the first
|
||||||
|
// heartbeat carries a real figure instead of 0.
|
||||||
|
sys.refresh_cpu_usage();
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let payload = collect(&agent, &mut sys).await;
|
||||||
|
match serde_json::to_vec(&payload) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let subject = subjects::host_heartbeat(&agent.cfg.license_id);
|
||||||
|
if let Err(e) = agent.nats.publish(subject, bytes.into()).await {
|
||||||
|
tracing::warn!("heartbeat publish failed: {e}");
|
||||||
|
} else {
|
||||||
|
tracing::debug!(
|
||||||
|
"heartbeat sent: cpu {:.1}%, {} instance(s)",
|
||||||
|
payload.host.cpu_percent,
|
||||||
|
payload.instances.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::error!("heartbeat serialize failed: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let jitter = rand::thread_rng().gen_range(0.8..1.2);
|
||||||
|
let interval = Duration::from_secs_f64(agent.cfg.heartbeat_seconds as f64 * jitter);
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(interval) => {}
|
||||||
|
_ = cancel.cancelled() => {
|
||||||
|
tracing::info!("telemetry stopping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn collect(agent: &Agent, sys: &mut System) -> HeartbeatPayload {
|
||||||
|
sys.refresh_cpu_usage();
|
||||||
|
sys.refresh_memory();
|
||||||
|
let disks = Disks::new_with_refreshed_list();
|
||||||
|
|
||||||
|
let disk_infos: Vec<DiskInfo> = disks
|
||||||
|
.iter()
|
||||||
|
.map(|d| DiskInfo {
|
||||||
|
mount: d.mount_point().to_string_lossy().to_string(),
|
||||||
|
total_mb: d.total_space() / 1_048_576,
|
||||||
|
free_mb: d.available_space() / 1_048_576,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let instances = agent
|
||||||
|
.cfg
|
||||||
|
.instances
|
||||||
|
.iter()
|
||||||
|
.map(|inst| {
|
||||||
|
let exists = inst.root.exists();
|
||||||
|
InstanceInfo {
|
||||||
|
id: inst.id.clone(),
|
||||||
|
game: inst.game.clone(),
|
||||||
|
label: inst.label.clone(),
|
||||||
|
state: if exists { "configured" } else { "missing_root" }.to_string(),
|
||||||
|
root_disk_free_mb: disk_free_for_path(&disks, &inst.root),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
HeartbeatPayload {
|
||||||
|
schema: 2,
|
||||||
|
timestamp: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||||
|
agent: AgentInfo {
|
||||||
|
version: version::VERSION.to_string(),
|
||||||
|
commit: version::GIT_HASH.to_string(),
|
||||||
|
os: std::env::consts::OS.to_string(),
|
||||||
|
arch: std::env::consts::ARCH.to_string(),
|
||||||
|
uptime_seconds: agent.started.elapsed().as_secs(),
|
||||||
|
},
|
||||||
|
host: HostInfo {
|
||||||
|
hostname: System::host_name(),
|
||||||
|
cpu_percent: sys.global_cpu_usage(),
|
||||||
|
cpu_cores: sys.cpus().len(),
|
||||||
|
mem_total_mb: sys.total_memory() / 1_048_576,
|
||||||
|
mem_used_mb: sys.used_memory() / 1_048_576,
|
||||||
|
uptime_seconds: System::uptime(),
|
||||||
|
disks: disk_infos,
|
||||||
|
},
|
||||||
|
instances,
|
||||||
|
probe: agent.last_probe.read().await.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free space on the disk whose mount point is the longest prefix of `path`.
|
||||||
|
fn disk_free_for_path(disks: &Disks, path: &Path) -> Option<u64> {
|
||||||
|
disks
|
||||||
|
.iter()
|
||||||
|
.filter(|d| path.starts_with(d.mount_point()))
|
||||||
|
.max_by_key(|d| d.mount_point().as_os_str().len())
|
||||||
|
.map(|d| d.available_space() / 1_048_576)
|
||||||
|
}
|
||||||
10
corrosion-host-agent/src/version.rs
Normal file
10
corrosion-host-agent/src/version.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//! Build-time identity, embedded so every heartbeat and `--version` can state
|
||||||
|
//! exactly what is running.
|
||||||
|
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
pub const GIT_HASH: &str = env!("CORROSION_GIT_HASH");
|
||||||
|
pub const BUILD_TS: &str = env!("CORROSION_BUILD_TS");
|
||||||
|
|
||||||
|
pub fn long() -> String {
|
||||||
|
format!("{VERSION} ({GIT_HASH}, built {BUILD_TS})")
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-corrosion_dev}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-corrosion_dev}
|
||||||
volumes:
|
volumes:
|
||||||
- pg_data:/var/lib/postgresql/data
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
# Auto-build the schema on a FRESH database. Postgres runs these ONLY when
|
||||||
|
# the data dir is empty (first boot or after a volume reset), so it never
|
||||||
|
# touches an existing volume — it just makes a fresh DB self-heal: the full
|
||||||
|
# schema is applied in order from the sqlx migrations (001..NNN), then the
|
||||||
|
# API's bootstrap seeds the admin. Rebuilds (with the volume kept) are a
|
||||||
|
# no-op here; the data persists. Only `down -v` / volume prune loses data.
|
||||||
|
- ../backend/migrations:/docker-entrypoint-initdb.d:ro
|
||||||
ports:
|
ports:
|
||||||
- "8101:5432"
|
- "8101:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" class="dark">
|
<html lang="en" class="dark" data-theme="dark" data-game="rust">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
@@ -9,8 +9,32 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0a0a0a" />
|
<meta name="theme-color" content="#0a0a0a" />
|
||||||
<title>Corrosion Management</title>
|
<title>Corrosion Management</title>
|
||||||
|
<!-- Fonts via <link>, NOT a CSS @import — the bundler drops @import rules
|
||||||
|
that land mid-file after concatenation, silently shipping system fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Oxanium:wght@500;600;700;800&display=swap"
|
||||||
|
/>
|
||||||
|
<script>
|
||||||
|
/* FOUC guard — apply persisted theme/game to <html> before the app mounts,
|
||||||
|
so the design-system tokens paint with the right skin from frame one. */
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var el = document.documentElement;
|
||||||
|
var t = localStorage.getItem('cc-theme');
|
||||||
|
var g = localStorage.getItem('cc-game');
|
||||||
|
if (t === 'dark' || t === 'light') {
|
||||||
|
el.setAttribute('data-theme', t);
|
||||||
|
el.classList.toggle('dark', t === 'dark');
|
||||||
|
}
|
||||||
|
if (g) el.setAttribute('data-game', g);
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-neutral-950">
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
1
frontend/src/app-version.d.ts
vendored
Normal file
1
frontend/src/app-version.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare const __APP_VERSION__: string
|
||||||
8
frontend/src/assets/corrosion-mark.svg
Normal file
8
frontend/src/assets/corrosion-mark.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Corrosion">
|
||||||
|
<path d="M40.2 9.45 A24 24 0 0 1 54.6 23.8" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||||
|
<path d="M54.6 40.2 A24 24 0 0 1 40.2 54.6" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||||
|
<path d="M23.8 54.6 A24 24 0 0 1 9.45 40.2" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||||
|
<path d="M9.45 23.8 A24 24 0 0 1 23.8 9.45" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||||
|
<path d="M32 16V24M32 40V48M16 32H24M40 32H48" stroke="currentColor" stroke-width="3.6" stroke-linecap="round"></path>
|
||||||
|
<circle cx="32" cy="32" r="4.4" fill="currentColor"></circle>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 770 B |
@@ -1,7 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onErrorCaptured } from 'vue'
|
import { ref, watch, onErrorCaptured } from 'vue'
|
||||||
import { AlertTriangle } from 'lucide-vue-next'
|
import { useRoute } from 'vue-router'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
/** 'screen' fills the viewport (app root); 'content' fills its container (inside layout chrome) */
|
||||||
|
variant?: 'screen' | 'content'
|
||||||
|
}>(), { variant: 'screen' })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const hasError = ref(false)
|
const hasError = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
|
||||||
@@ -12,6 +20,12 @@ onErrorCaptured((err) => {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A failed view must not brick navigation — clear the error when the route changes
|
||||||
|
watch(() => route.fullPath, () => {
|
||||||
|
hasError.value = false
|
||||||
|
errorMessage.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
hasError.value = false
|
hasError.value = false
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
@@ -20,18 +34,72 @@ function retry() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="hasError" class="min-h-screen bg-neutral-950 flex items-center justify-center p-6">
|
<div v-if="hasError" class="eb-screen" :class="{ 'eb-screen--content': variant === 'content' }">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-8 max-w-md w-full text-center">
|
<div class="eb-card">
|
||||||
<AlertTriangle class="w-12 h-12 text-red-500 mx-auto mb-4" />
|
<div class="eb-icon-wrap">
|
||||||
<h1 class="text-xl font-bold text-neutral-100 mb-2">Something went wrong</h1>
|
<Icon name="triangle-alert" :size="24" :stroke-width="1.75" />
|
||||||
<p class="text-sm text-neutral-400 mb-6">{{ errorMessage }}</p>
|
</div>
|
||||||
<button
|
<h1 class="eb-title">Something went wrong</h1>
|
||||||
@click="retry"
|
<p class="eb-msg">{{ errorMessage }}</p>
|
||||||
class="px-4 py-2 bg-oxide-500 hover:bg-oxide-600 text-white font-medium rounded-lg transition-colors"
|
<Button icon="refresh-cw" @click="retry">Retry</Button>
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot v-else />
|
<slot v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.eb-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--surface-canvas);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-screen--content {
|
||||||
|
min-height: 60vh;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-card {
|
||||||
|
background: var(--surface-base);
|
||||||
|
box-shadow: var(--ring-default), var(--shadow-md);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-8);
|
||||||
|
max-width: 380px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-icon-wrap {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--status-offline-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--status-offline-border);
|
||||||
|
color: var(--status-offline);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-msg {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.55;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
29
frontend/src/components/brand/CorrosionMark.vue
Normal file
29
frontend/src/components/brand/CorrosionMark.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Corrosion brand mark — segmented "C-core" reticle.
|
||||||
|
* A bold ring split into four arc segments around a centered control node,
|
||||||
|
* with N/E/S/W targeting ticks. Drawn in `currentColor` so it themes to the
|
||||||
|
* active accent (set `color: var(--accent)` on a parent) and stays crisp to ~12px.
|
||||||
|
* Source: design-system assets/mark.svg (64×64 viewBox).
|
||||||
|
*/
|
||||||
|
withDefaults(defineProps<{ size?: number | string }>(), { size: 24 })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
role="img"
|
||||||
|
aria-label="Corrosion"
|
||||||
|
>
|
||||||
|
<path d="M40.2 9.45 A24 24 0 0 1 54.6 23.8" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
|
||||||
|
<path d="M54.6 40.2 A24 24 0 0 1 40.2 54.6" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
|
||||||
|
<path d="M23.8 54.6 A24 24 0 0 1 9.45 40.2" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
|
||||||
|
<path d="M9.45 23.8 A24 24 0 0 1 23.8 9.45" stroke="currentColor" stroke-width="6.4" stroke-linecap="round" />
|
||||||
|
<path d="M32 16V24M32 40V48M16 32H24M40 32H48" stroke="currentColor" stroke-width="3.6" stroke-linecap="round" />
|
||||||
|
<circle cx="32" cy="32" r="4.4" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
92
frontend/src/components/ds/brand/Logo.vue
Normal file
92
frontend/src/components/ds/brand/Logo.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Logo — Corrosion brand lockup.
|
||||||
|
* Composes the CorrosionMark SVG + Oxanium wordmark + optional tagline.
|
||||||
|
*
|
||||||
|
* The mark renders in `currentColor`, so set `color: var(--accent)` on a
|
||||||
|
* parent (or pass `markColor`) to theme it per active game.
|
||||||
|
*
|
||||||
|
* Props mirror Logo.jsx exactly:
|
||||||
|
* size — base px size; drives mark em-size + wordmark scaling
|
||||||
|
* wordmark — show the "Corrosion" text (default true)
|
||||||
|
* tagline — false | true (→ "Management Panel") | custom string
|
||||||
|
* glow — accent drop-shadow for marketing / login hero use
|
||||||
|
* markColor — force a fixed color on the mark (bypasses currentColor theming)
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
size?: number
|
||||||
|
wordmark?: boolean
|
||||||
|
tagline?: boolean | string
|
||||||
|
glow?: boolean
|
||||||
|
markColor?: string
|
||||||
|
}>(),
|
||||||
|
{ size: 26, wordmark: true, tagline: false, glow: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const gap = computed(() => Math.round(props.size * 0.4) + 'px')
|
||||||
|
const wordmarkGap = computed(() => Math.round(props.size * 0.14) + 'px')
|
||||||
|
const wordmarkFontSize = computed(() => (props.size * 0.62) + 'px')
|
||||||
|
const taglineFontSize = computed(() => Math.max(8, props.size * 0.26) + 'px')
|
||||||
|
const glowFilter = computed(() =>
|
||||||
|
props.glow ? `drop-shadow(0 0 ${props.size * 0.5}px var(--accent-glow))` : 'none'
|
||||||
|
)
|
||||||
|
const tagText = computed(() =>
|
||||||
|
typeof props.tagline === 'string' ? props.tagline : 'Management Panel'
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="cc-logo"
|
||||||
|
:style="{ display: 'inline-flex', alignItems: 'center', gap, lineHeight: 1 }"
|
||||||
|
>
|
||||||
|
<!-- Mark wrapper: sets font-size so CorrosionMark's 1em sizing works; applies glow -->
|
||||||
|
<span
|
||||||
|
:style="{
|
||||||
|
fontSize: size + 'px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
filter: glowFilter,
|
||||||
|
color: markColor ?? undefined,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<CorrosionMark :size="size" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Wordmark + optional tagline -->
|
||||||
|
<span
|
||||||
|
v-if="wordmark"
|
||||||
|
:style="{ display: 'inline-flex', flexDirection: 'column', gap: wordmarkGap }"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:style="{
|
||||||
|
fontFamily: 'var(--font-brand)',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: wordmarkFontSize,
|
||||||
|
letterSpacing: '0.005em',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
lineHeight: 1,
|
||||||
|
}"
|
||||||
|
>Corrosion</span>
|
||||||
|
<span
|
||||||
|
v-if="tagline"
|
||||||
|
:style="{
|
||||||
|
fontFamily: 'var(--font-brand)',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: taglineFontSize,
|
||||||
|
letterSpacing: '0.26em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'var(--accent-text)',
|
||||||
|
lineHeight: 1,
|
||||||
|
}"
|
||||||
|
>{{ tagText }}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-logo { user-select: none; }
|
||||||
|
</style>
|
||||||
62
frontend/src/components/ds/core/Badge.vue
Normal file
62
frontend/src/components/ds/core/Badge.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** Badge — compact status/label chip. Tone drives fg/soft-bg/border; `solid` fills. */
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import StatusDot from './StatusDot.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
tone?: 'neutral' | 'accent' | 'online' | 'offline' | 'warn' | 'info' | 'starting' | 'wiping'
|
||||||
|
solid?: boolean
|
||||||
|
dot?: boolean
|
||||||
|
pulse?: boolean
|
||||||
|
icon?: string
|
||||||
|
size?: 'md' | 'lg'
|
||||||
|
mono?: boolean
|
||||||
|
uppercase?: boolean
|
||||||
|
}>(),
|
||||||
|
{ tone: 'neutral', solid: false, dot: false, pulse: false, size: 'md', mono: false, uppercase: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const NEUTRAL: [string, string, string] = ['var(--text-secondary)', 'var(--surface-raised-2)', 'var(--border-default)']
|
||||||
|
const TONES: Record<string, [string, string, string]> = {
|
||||||
|
neutral: NEUTRAL,
|
||||||
|
accent: ['var(--accent-text)', 'var(--accent-soft)', 'var(--accent-border)'],
|
||||||
|
online: ['var(--status-online)', 'var(--status-online-soft)', 'var(--status-online-border)'],
|
||||||
|
offline: ['var(--status-offline)', 'var(--status-offline-soft)', 'var(--status-offline-border)'],
|
||||||
|
warn: ['var(--status-warn)', 'var(--status-warn-soft)', 'var(--status-warn-border)'],
|
||||||
|
info: ['var(--status-info)', 'var(--status-info-soft)', 'var(--status-info-border)'],
|
||||||
|
starting: ['var(--status-starting)', 'var(--status-starting-soft)', 'var(--status-starting-border)'],
|
||||||
|
wiping: ['var(--status-wiping)', 'var(--status-wiping-soft)', 'var(--status-wiping-border)'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const styleObj = computed(() => {
|
||||||
|
const [fg, soft, border] = TONES[props.tone] ?? NEUTRAL
|
||||||
|
return props.solid
|
||||||
|
? { background: fg, color: 'var(--surface-canvas)', boxShadow: 'none' }
|
||||||
|
: { background: soft, color: fg, boxShadow: `inset 0 0 0 1px ${border}` }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="cc-badge"
|
||||||
|
:class="[size === 'lg' && 'cc-badge--lg', mono && 'cc-badge--mono', uppercase && 'cc-badge--uppercase']"
|
||||||
|
:style="styleObj"
|
||||||
|
>
|
||||||
|
<StatusDot v-if="dot" :tone="tone" :size="6" :pulse="pulse" />
|
||||||
|
<Icon v-if="icon" :name="icon" :size="size === 'lg' ? 13 : 12" :stroke-width="2.5" />
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 0 8px;
|
||||||
|
font-family: var(--font-sans); font-weight: 600; font-size: var(--text-xs); line-height: 1;
|
||||||
|
border-radius: var(--radius-sm); white-space: nowrap; letter-spacing: 0.005em;
|
||||||
|
}
|
||||||
|
.cc-badge--lg { height: 24px; padding: 0 10px; font-size: var(--text-sm); }
|
||||||
|
.cc-badge--mono { font-family: var(--font-mono); font-weight: 500; letter-spacing: 0; font-variant-numeric: tabular-nums; }
|
||||||
|
.cc-badge--uppercase { text-transform: uppercase; letter-spacing: var(--tracking-wider); font-size: var(--text-2xs); }
|
||||||
|
</style>
|
||||||
82
frontend/src/components/ds/core/Button.vue
Normal file
82
frontend/src/components/ds/core/Button.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Button — primary action control; `variant="primary"` carries the live game accent.
|
||||||
|
* Variants: primary | secondary | ghost | outline | danger | danger-soft.
|
||||||
|
* Sizes: sm | md | lg. Pass Lucide names via `icon` / `iconRight`.
|
||||||
|
* Native click bubbles via attribute fall-through (root is the <button>).
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'outline' | 'danger' | 'danger-soft'
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
icon?: string
|
||||||
|
iconRight?: string
|
||||||
|
loading?: boolean
|
||||||
|
block?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
type?: 'button' | 'submit' | 'reset'
|
||||||
|
}>(),
|
||||||
|
{ variant: 'primary', size: 'md', loading: false, block: false, disabled: false, type: 'button' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const iconSize = computed(() => (props.size === 'lg' ? 17 : props.size === 'sm' ? 14 : 15))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
:type="type"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
:class="[
|
||||||
|
'cc-btn',
|
||||||
|
`cc-btn--${variant}`,
|
||||||
|
size !== 'md' && `cc-btn--${size}`,
|
||||||
|
block && 'cc-btn--block',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="cc-btn__spin" />
|
||||||
|
<Icon v-else-if="icon" :name="icon" :size="iconSize" :stroke-width="2.25" />
|
||||||
|
<span v-if="$slots.default"><slot /></span>
|
||||||
|
<Icon v-if="iconRight && !loading" :name="iconRight" :size="iconSize" :stroke-width="2.25" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||||
|
font-family: var(--font-sans); font-weight: 600; font-size: var(--text-sm); line-height: 1;
|
||||||
|
white-space: nowrap; height: var(--control-h-md); padding: 0 14px;
|
||||||
|
border-radius: var(--radius-md); border: 1px solid transparent; cursor: pointer; user-select: none;
|
||||||
|
transition: var(--transition-colors), transform var(--dur-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
.cc-btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||||
|
.cc-btn:active { transform: translateY(0.5px); }
|
||||||
|
.cc-btn[disabled], .cc-btn[aria-disabled="true"] { opacity: 0.45; pointer-events: none; }
|
||||||
|
.cc-btn--block { width: 100%; }
|
||||||
|
.cc-btn--sm { height: var(--control-h-sm); padding: 0 10px; font-size: var(--text-xs); border-radius: var(--radius-sm); gap: 6px; }
|
||||||
|
.cc-btn--lg { height: var(--control-h-lg); padding: 0 18px; font-size: var(--text-base); gap: 9px; }
|
||||||
|
|
||||||
|
.cc-btn--primary { background: var(--accent); color: var(--accent-contrast); }
|
||||||
|
.cc-btn--primary:hover { background: var(--accent-hover); }
|
||||||
|
.cc-btn--primary:active { background: var(--accent-press); }
|
||||||
|
|
||||||
|
.cc-btn--secondary { background: var(--surface-raised-2); color: var(--text-primary); box-shadow: var(--ring-default); }
|
||||||
|
.cc-btn--secondary:hover { background: var(--surface-active); }
|
||||||
|
|
||||||
|
.cc-btn--ghost { background: transparent; color: var(--text-secondary); }
|
||||||
|
.cc-btn--ghost:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||||
|
|
||||||
|
.cc-btn--outline { background: transparent; color: var(--accent-text); box-shadow: inset 0 0 0 1px var(--accent-border); }
|
||||||
|
.cc-btn--outline:hover { background: var(--accent-soft); }
|
||||||
|
|
||||||
|
.cc-btn--danger { background: var(--danger); color: #fff; }
|
||||||
|
.cc-btn--danger:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.cc-btn--danger-soft { background: var(--status-offline-soft); color: var(--danger); box-shadow: inset 0 0 0 1px var(--status-offline-border); }
|
||||||
|
.cc-btn--danger-soft:hover { background: var(--danger); color: #fff; }
|
||||||
|
|
||||||
|
.cc-btn__spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid currentColor; border-top-color: transparent; animation: cc-btn-spin 0.6s linear infinite; }
|
||||||
|
@keyframes cc-btn-spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
88
frontend/src/components/ds/core/Icon.vue
Normal file
88
frontend/src/components/ds/core/Icon.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Icon — renders a Lucide icon by kebab-case name (matches the design system's
|
||||||
|
* string `icon` prop API, e.g. <Icon name="refresh-cw" />). Maps to
|
||||||
|
* `lucide-vue-next` via a registry so the bundle only ships icons we use.
|
||||||
|
* Lucide icons render with `currentColor`, so they theme to the parent's color.
|
||||||
|
* Add new icons to `registry` as the port grows.
|
||||||
|
*/
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import {
|
||||||
|
Play, Pause, RefreshCw, Trash2, Settings, Terminal, Power, Box, Sun, Moon,
|
||||||
|
Loader, LoaderCircle, TrendingUp, TrendingDown, Minus, Plus, Server, Users,
|
||||||
|
Puzzle, FolderOpen, Cpu, BarChart3, Rocket, TriangleAlert, Bell, Search,
|
||||||
|
ChevronDown, ChevronRight, ChevronLeft, ChevronUp, Check, X, Calendar, Clock,
|
||||||
|
ShoppingCart, CreditCard, HardDrive, Activity, Shield, Download, Upload,
|
||||||
|
Wifi, WifiOff, Map, Gauge, Gift, Flame, DoorOpen, Pickaxe, Swords, Crosshair,
|
||||||
|
Navigation, MessageSquare, FileText, Bookmark, ExternalLink, Copy, LogOut,
|
||||||
|
Eye, EyeOff, Globe, Key, Layers, List, MoreVertical, Zap,
|
||||||
|
Info, OctagonAlert, CircleCheck, Sparkles, Inbox,
|
||||||
|
LayoutDashboard, CalendarClock, Drama, ChevronsUpDown, ServerCog,
|
||||||
|
LayoutGrid, SquareDashed, MemoryStick, CornerDownLeft,
|
||||||
|
Ban, Flag,
|
||||||
|
CircleAlert, ArrowDown, Award, DollarSign, FlaskConical, Mail, Package,
|
||||||
|
Pencil, Save, ShoppingBag, Target, User,
|
||||||
|
// Marketing site additions
|
||||||
|
Route, Timer, Megaphone, DatabaseBackup, Store, Undo2,
|
||||||
|
Circle, Send, HelpCircle,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{ name: string; size?: number; strokeWidth?: number }>(),
|
||||||
|
{ size: 16, strokeWidth: 2 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const registry: Record<string, Component> = {
|
||||||
|
play: Play, pause: Pause, 'refresh-cw': RefreshCw, 'trash-2': Trash2,
|
||||||
|
settings: Settings, terminal: Terminal, power: Power, box: Box, sun: Sun,
|
||||||
|
moon: Moon, loader: LoaderCircle, 'loader-2': Loader, 'trending-up': TrendingUp,
|
||||||
|
'trending-down': TrendingDown, minus: Minus, plus: Plus, server: Server,
|
||||||
|
users: Users, puzzle: Puzzle, 'folder-open': FolderOpen, cpu: Cpu,
|
||||||
|
'bar-chart-3': BarChart3, rocket: Rocket, 'triangle-alert': TriangleAlert,
|
||||||
|
bell: Bell, search: Search, 'chevron-down': ChevronDown,
|
||||||
|
'chevron-right': ChevronRight, 'chevron-left': ChevronLeft, 'chevron-up': ChevronUp,
|
||||||
|
check: Check, x: X, calendar: Calendar, clock: Clock,
|
||||||
|
'shopping-cart': ShoppingCart, 'credit-card': CreditCard, 'hard-drive': HardDrive,
|
||||||
|
activity: Activity, shield: Shield, download: Download, upload: Upload,
|
||||||
|
wifi: Wifi, 'wifi-off': WifiOff, map: Map, gauge: Gauge, gift: Gift,
|
||||||
|
flame: Flame, 'door-open': DoorOpen, pickaxe: Pickaxe, swords: Swords,
|
||||||
|
crosshair: Crosshair, navigation: Navigation, 'message-square': MessageSquare,
|
||||||
|
'file-text': FileText, bookmark: Bookmark, 'external-link': ExternalLink,
|
||||||
|
copy: Copy, 'log-out': LogOut, eye: Eye, 'eye-off': EyeOff, globe: Globe,
|
||||||
|
key: Key, layers: Layers, list: List, 'more-vertical': MoreVertical, zap: Zap,
|
||||||
|
info: Info, 'octagon-alert': OctagonAlert, 'circle-check': CircleCheck,
|
||||||
|
sparkles: Sparkles, inbox: Inbox,
|
||||||
|
'layout-dashboard': LayoutDashboard, 'calendar-clock': CalendarClock, drama: Drama,
|
||||||
|
'chevrons-up-down': ChevronsUpDown, 'server-cog': ServerCog, 'layout-grid': LayoutGrid,
|
||||||
|
'square-dashed': SquareDashed, 'memory-stick': MemoryStick, 'corner-down-left': CornerDownLeft,
|
||||||
|
ban: Ban, flag: Flag,
|
||||||
|
'alert-circle': CircleAlert, 'arrow-down': ArrowDown, award: Award,
|
||||||
|
'dollar-sign': DollarSign, 'flask-conical': FlaskConical, mail: Mail,
|
||||||
|
package: Package, pencil: Pencil, save: Save, 'shopping-bag': ShoppingBag,
|
||||||
|
target: Target, user: User,
|
||||||
|
// Marketing site additions
|
||||||
|
route: Route, timer: Timer, megaphone: Megaphone,
|
||||||
|
'database-backup': DatabaseBackup, store: Store, 'undo-2': Undo2,
|
||||||
|
circle: Circle, send: Send, 'help-circle': HelpCircle,
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmp = computed<Component | null>(() => registry[props.name] ?? null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="cmp"
|
||||||
|
v-if="cmp"
|
||||||
|
class="cc-icon"
|
||||||
|
:size="size"
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
:stroke-width="strokeWidth"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-icon { display: inline-block; flex: none; vertical-align: middle; }
|
||||||
|
</style>
|
||||||
62
frontend/src/components/ds/core/IconButton.vue
Normal file
62
frontend/src/components/ds/core/IconButton.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* IconButton — square icon-only action button.
|
||||||
|
* Variants: ghost | solid | accent | danger.
|
||||||
|
* Sizes: sm | md | lg.
|
||||||
|
* Native click bubbles via attribute fall-through (root is <button>).
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
icon: string
|
||||||
|
variant?: 'ghost' | 'solid' | 'accent' | 'danger'
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
active?: boolean
|
||||||
|
label?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>(),
|
||||||
|
{ variant: 'ghost', size: 'md', active: false, disabled: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const iconPx = computed(() => (props.size === 'lg' ? 19 : props.size === 'sm' ? 15 : 17))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:aria-label="label"
|
||||||
|
:title="label"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="[
|
||||||
|
'cc-iconbtn',
|
||||||
|
variant !== 'ghost' && `cc-iconbtn--${variant}`,
|
||||||
|
size !== 'md' && `cc-iconbtn--${size}`,
|
||||||
|
active && 'cc-iconbtn--active',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Icon :name="icon" :size="iconPx" :stroke-width="2" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-iconbtn {
|
||||||
|
display:inline-flex; align-items:center; justify-content:center; flex:none;
|
||||||
|
width:var(--control-h-md); height:var(--control-h-md); border-radius:var(--radius-md);
|
||||||
|
border:1px solid transparent; background:transparent; color:var(--text-secondary);
|
||||||
|
cursor:pointer; transition:var(--transition-colors), transform var(--dur-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
.cc-iconbtn:hover { background:var(--surface-hover); color:var(--text-primary); }
|
||||||
|
.cc-iconbtn:active { transform: translateY(0.5px); }
|
||||||
|
.cc-iconbtn:focus-visible { outline:none; box-shadow:var(--focus-ring); }
|
||||||
|
.cc-iconbtn[disabled] { opacity:.4; pointer-events:none; }
|
||||||
|
.cc-iconbtn--sm { width:var(--control-h-sm); height:var(--control-h-sm); border-radius:var(--radius-sm); }
|
||||||
|
.cc-iconbtn--lg { width:var(--control-h-lg); height:var(--control-h-lg); }
|
||||||
|
.cc-iconbtn--solid { background:var(--surface-raised-2); box-shadow:var(--ring-default); }
|
||||||
|
.cc-iconbtn--solid:hover { background:var(--surface-active); }
|
||||||
|
.cc-iconbtn--accent { background:var(--accent); color:var(--accent-contrast); }
|
||||||
|
.cc-iconbtn--accent:hover { background:var(--accent-hover); }
|
||||||
|
.cc-iconbtn--danger:hover { background:var(--status-offline-soft); color:var(--danger); }
|
||||||
|
.cc-iconbtn--active { background:var(--accent-soft); color:var(--accent-text); box-shadow: inset 0 0 0 1px var(--accent-border); }
|
||||||
|
</style>
|
||||||
20
frontend/src/components/ds/core/Kbd.vue
Normal file
20
frontend/src/components/ds/core/Kbd.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Kbd — keyboard shortcut key chip, rendered as <kbd>.
|
||||||
|
* Uses mono font and inset border + bottom shadow to mimic a physical key.
|
||||||
|
* No props — purely a presentational slot wrapper; native attrs fall through.
|
||||||
|
*/
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<kbd class="cc-kbd"><slot /></kbd>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-kbd {
|
||||||
|
display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 5px;
|
||||||
|
font-family:var(--font-mono); font-size:11px; font-weight:500; line-height:1; color:var(--text-secondary);
|
||||||
|
background:var(--surface-raised-2); border-radius:var(--radius-sm);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--border-default), 0 1px 0 var(--border-default);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
48
frontend/src/components/ds/core/StatusDot.vue
Normal file
48
frontend/src/components/ds/core/StatusDot.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** StatusDot — small live-status dot; pulses when live. Tone maps to status tokens. */
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
tone?: 'online' | 'offline' | 'warn' | 'info' | 'starting' | 'wiping' | 'neutral' | 'accent'
|
||||||
|
size?: number
|
||||||
|
pulse?: boolean
|
||||||
|
}>(),
|
||||||
|
{ tone: 'online', size: 8, pulse: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const TONE: Record<string, string> = {
|
||||||
|
online: 'var(--status-online)',
|
||||||
|
offline: 'var(--status-offline)',
|
||||||
|
warn: 'var(--status-warn)',
|
||||||
|
info: 'var(--status-info)',
|
||||||
|
starting: 'var(--status-starting)',
|
||||||
|
wiping: 'var(--status-wiping)',
|
||||||
|
neutral: 'var(--text-muted)',
|
||||||
|
accent: 'var(--accent)',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="cc-dot"
|
||||||
|
:class="pulse && 'cc-dot--pulse'"
|
||||||
|
:style="{
|
||||||
|
width: size + 'px',
|
||||||
|
height: size + 'px',
|
||||||
|
background: TONE[tone] || TONE.neutral,
|
||||||
|
boxShadow: pulse ? '0 0 8px -1px ' + (TONE[tone] || TONE.neutral) : 'none',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-dot { display: inline-block; flex: none; border-radius: 50%; position: relative; }
|
||||||
|
.cc-dot--pulse::after {
|
||||||
|
content: ''; position: absolute; inset: 0; border-radius: 50%; background: inherit;
|
||||||
|
animation: cc-dot-pulse 1.8s var(--ease-out) infinite;
|
||||||
|
}
|
||||||
|
@keyframes cc-dot-pulse {
|
||||||
|
0% { transform: scale(1); opacity: 0.6; }
|
||||||
|
70%, 100% { transform: scale(2.6); opacity: 0; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .cc-dot--pulse::after { animation: none; } }
|
||||||
|
</style>
|
||||||
52
frontend/src/components/ds/core/Tag.vue
Normal file
52
frontend/src/components/ds/core/Tag.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Tag — removable or static label chip.
|
||||||
|
* Set `removable` to show the dismiss ×; emit `remove` is fired when clicked.
|
||||||
|
* Optional `icon` prefix via Icon registry.
|
||||||
|
*/
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
icon?: string
|
||||||
|
removable?: boolean
|
||||||
|
}>(),
|
||||||
|
{ removable: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
remove: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="['cc-tag', !removable && 'cc-tag--static']">
|
||||||
|
<Icon v-if="icon" :name="icon" :size="12" :stroke-width="2.25" />
|
||||||
|
<span><slot /></span>
|
||||||
|
<button
|
||||||
|
v-if="removable"
|
||||||
|
type="button"
|
||||||
|
class="cc-tag__x"
|
||||||
|
aria-label="Remove"
|
||||||
|
@click="emit('remove')"
|
||||||
|
>
|
||||||
|
<Icon name="x" :size="11" :stroke-width="2.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-tag {
|
||||||
|
display:inline-flex; align-items:center; gap:6px; height:24px; padding:0 4px 0 9px;
|
||||||
|
font-family:var(--font-sans); font-size:var(--text-xs); font-weight:500; line-height:1;
|
||||||
|
color:var(--text-secondary); background:var(--surface-raised-2);
|
||||||
|
border-radius:var(--radius-sm); box-shadow:var(--ring-default);
|
||||||
|
}
|
||||||
|
.cc-tag--static { padding:0 9px; }
|
||||||
|
.cc-tag__x {
|
||||||
|
display:inline-flex; align-items:center; justify-content:center; width:16px; height:16px;
|
||||||
|
border-radius:var(--radius-xs); color:var(--text-tertiary); cursor:pointer; border:0; background:transparent;
|
||||||
|
transition:var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-tag__x:hover { background:var(--surface-active); color:var(--text-primary); }
|
||||||
|
</style>
|
||||||
72
frontend/src/components/ds/data/Avatar.vue
Normal file
72
frontend/src/components/ds/data/Avatar.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Avatar — player / operator avatar. Renders an image, or falls back to initials.
|
||||||
|
* Optional status dot (online / offline / warn / idle) sits bottom-right.
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
name?: string
|
||||||
|
src?: string
|
||||||
|
size?: number
|
||||||
|
shape?: 'rounded' | 'circle'
|
||||||
|
status?: 'online' | 'offline' | 'warn' | 'idle'
|
||||||
|
}>(),
|
||||||
|
{ name: '', size: 32, shape: 'rounded' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const TONE: Record<string, string> = {
|
||||||
|
online: 'var(--status-online)',
|
||||||
|
offline: 'var(--status-offline)',
|
||||||
|
warn: 'var(--status-warn)',
|
||||||
|
idle: 'var(--text-muted)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const initials = computed(() =>
|
||||||
|
props.name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(w => w[0] ?? '')
|
||||||
|
.join('')
|
||||||
|
.toUpperCase() || '?',
|
||||||
|
)
|
||||||
|
|
||||||
|
const dotSize = computed(() => Math.max(7, Math.round(props.size * 0.28)))
|
||||||
|
const dotColor = computed(() => (props.status ? (TONE[props.status] ?? TONE.idle) : ''))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="cc-avatar"
|
||||||
|
:class="shape === 'circle' && 'cc-avatar--circle'"
|
||||||
|
:style="{
|
||||||
|
width: size + 'px',
|
||||||
|
height: size + 'px',
|
||||||
|
fontSize: Math.round(size * 0.4) + 'px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<img v-if="src" :src="src" :alt="name" />
|
||||||
|
<template v-else>{{ initials }}</template>
|
||||||
|
<span
|
||||||
|
v-if="status"
|
||||||
|
class="cc-avatar__status"
|
||||||
|
:style="{ width: dotSize + 'px', height: dotSize + 'px', background: dotColor }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-avatar {
|
||||||
|
position: relative; display: inline-flex; align-items: center; justify-content: center; flex: none;
|
||||||
|
border-radius: var(--radius-md); background: var(--surface-active); color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono); font-weight: 600; overflow: visible; box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.cc-avatar--circle { border-radius: 50%; }
|
||||||
|
.cc-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
||||||
|
.cc-avatar__status {
|
||||||
|
position: absolute; right: -2px; bottom: -2px; border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 2px var(--surface-base);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
54
frontend/src/components/ds/data/ConsoleLine.vue
Normal file
54
frontend/src/components/ds/data/ConsoleLine.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* ConsoleLine — one line in the RCON / server log stream.
|
||||||
|
* Monospace, color-coded by level. Optional timestamp and actor (who).
|
||||||
|
*/
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
time?: string
|
||||||
|
level?: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
|
||||||
|
who?: string
|
||||||
|
}>(),
|
||||||
|
{ level: 'info' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const LABEL: Record<string, string> = {
|
||||||
|
cmd: 'cmd',
|
||||||
|
chat: 'chat',
|
||||||
|
info: 'info',
|
||||||
|
warn: 'warn',
|
||||||
|
error: 'err',
|
||||||
|
connect: 'join',
|
||||||
|
kill: 'kill',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['cc-line', 'cc-line--' + level]">
|
||||||
|
<span v-if="time" class="cc-line__time">{{ time }}</span>
|
||||||
|
<span class="cc-line__tag">{{ LABEL[level ?? 'info'] ?? level }}</span>
|
||||||
|
<span class="cc-line__msg">
|
||||||
|
<span v-if="who" class="cc-line__who">{{ who }} </span>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-line { display: flex; gap: 10px; padding: 2px 12px; font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.65; align-items: baseline; }
|
||||||
|
.cc-line:hover { background: var(--surface-hover); }
|
||||||
|
.cc-line__time { color: var(--text-muted); flex: none; font-variant-numeric: tabular-nums; }
|
||||||
|
.cc-line__tag { flex: none; text-transform: uppercase; font-weight: 600; font-size: 10px; letter-spacing: .05em; padding: 0 5px; border-radius: var(--radius-xs); height: 15px; display: inline-flex; align-items: center; }
|
||||||
|
.cc-line__msg { color: var(--text-secondary); white-space: pre-wrap; word-break: break-word; min-width: 0; }
|
||||||
|
.cc-line__who { color: var(--accent-text); }
|
||||||
|
.cc-line--cmd .cc-line__tag { background: var(--accent-soft); color: var(--accent-text); }
|
||||||
|
.cc-line--cmd .cc-line__msg { color: var(--text-primary); }
|
||||||
|
.cc-line--chat .cc-line__tag { background: var(--surface-active); color: var(--text-secondary); }
|
||||||
|
.cc-line--info .cc-line__tag { background: var(--status-info-soft); color: var(--status-info); }
|
||||||
|
.cc-line--warn .cc-line__tag { background: var(--status-warn-soft); color: var(--status-warn); }
|
||||||
|
.cc-line--warn .cc-line__msg { color: var(--status-warn); }
|
||||||
|
.cc-line--error .cc-line__tag { background: var(--status-offline-soft); color: var(--status-offline); }
|
||||||
|
.cc-line--error .cc-line__msg { color: var(--status-offline); }
|
||||||
|
.cc-line--connect .cc-line__tag { background: var(--status-online-soft); color: var(--status-online); }
|
||||||
|
.cc-line--kill .cc-line__tag { background: var(--status-wiping-soft); color: var(--status-wiping); }
|
||||||
|
</style>
|
||||||
57
frontend/src/components/ds/data/Panel.vue
Normal file
57
frontend/src/components/ds/data/Panel.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Panel — standard section container.
|
||||||
|
* Header: optional eyebrow / title / subtitle / right-aligned actions.
|
||||||
|
* Body: padding removed when flushBody=true (tables / lists manage their own).
|
||||||
|
*/
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
eyebrow?: string
|
||||||
|
variant?: 'base' | 'raised' | 'flush'
|
||||||
|
flushBody?: boolean
|
||||||
|
}>(),
|
||||||
|
{ variant: 'base', flushBody: false },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
:class="[
|
||||||
|
'cc-panel',
|
||||||
|
variant === 'raised' && 'cc-panel--raised',
|
||||||
|
variant === 'flush' && 'cc-panel--flush',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<header v-if="title || subtitle || eyebrow || $slots.actions" class="cc-panel__head">
|
||||||
|
<div class="cc-panel__titles">
|
||||||
|
<div v-if="eyebrow" class="t-eyebrow">{{ eyebrow }}</div>
|
||||||
|
<div v-if="title" class="cc-panel__title">
|
||||||
|
{{ title }}
|
||||||
|
<slot name="title-append" />
|
||||||
|
</div>
|
||||||
|
<div v-if="subtitle" class="cc-panel__sub">{{ subtitle }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions" class="cc-panel__actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div :class="['cc-panel__body', flushBody && 'cc-panel__body--flush']">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-panel { background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default); display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.cc-panel--raised { background: var(--surface-raised); }
|
||||||
|
.cc-panel--flush { box-shadow: none; background: transparent; }
|
||||||
|
.cc-panel__head { display: flex; align-items: center; gap: 12px; padding: 13px 16px; border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.cc-panel__titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||||
|
.cc-panel__title { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 8px; }
|
||||||
|
.cc-panel__sub { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
.cc-panel__actions { display: flex; align-items: center; gap: 6px; flex: none; }
|
||||||
|
.cc-panel__body { padding: 16px; min-width: 0; }
|
||||||
|
.cc-panel__body--flush { padding: 0; }
|
||||||
|
</style>
|
||||||
129
frontend/src/components/ds/data/PlayersChart.vue
Normal file
129
frontend/src/components/ds/data/PlayersChart.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* PlayersChart — themed ECharts area chart of players online.
|
||||||
|
*
|
||||||
|
* Requires real `data` — there is NO fallback series. When `data` is absent
|
||||||
|
* or empty, an "awaiting telemetry" placeholder is shown instead of the chart.
|
||||||
|
* This is intentional: fabricated curves mislead operators.
|
||||||
|
*
|
||||||
|
* Reads live design tokens (--accent etc.) from CSS so it matches the active
|
||||||
|
* theme/game, and re-renders when data-game / data-theme flip on <html>.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{ height?: number; data?: number[]; max?: number }>(),
|
||||||
|
{ height: 200, max: 200 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0)
|
||||||
|
|
||||||
|
const el = useTemplateRef<HTMLDivElement>('el')
|
||||||
|
let chart: echarts.ECharts | null = null
|
||||||
|
let ro: ResizeObserver | null = null
|
||||||
|
let mo: MutationObserver | null = null
|
||||||
|
|
||||||
|
function cssVar(name: string, node?: HTMLElement): string {
|
||||||
|
return getComputedStyle(node || document.documentElement).getPropertyValue(name).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(): void {
|
||||||
|
if (!chart || !el.value || !hasData.value) return
|
||||||
|
const node = el.value
|
||||||
|
const accent = cssVar('--accent', node) || '#f26622'
|
||||||
|
const grid = cssVar('--border-subtle', node) || 'rgba(255,255,255,0.06)'
|
||||||
|
const text = cssVar('--text-tertiary', node) || '#767d89'
|
||||||
|
const mono = 'JetBrains Mono, monospace'
|
||||||
|
const hours = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)
|
||||||
|
const series = props.data as number[]
|
||||||
|
|
||||||
|
chart.setOption({
|
||||||
|
animationDuration: 700,
|
||||||
|
grid: { left: 8, right: 12, top: 14, bottom: 22, containLabel: true },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: cssVar('--surface-overlay', node) || '#1f2329',
|
||||||
|
borderColor: cssVar('--border-default', node) || 'rgba(255,255,255,0.1)',
|
||||||
|
borderWidth: 1,
|
||||||
|
textStyle: { color: cssVar('--text-primary', node) || '#fff', fontFamily: mono, fontSize: 11 },
|
||||||
|
axisPointer: { type: 'line', lineStyle: { color: accent, opacity: 0.5 } },
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category', data: hours, boundaryGap: false,
|
||||||
|
axisLine: { lineStyle: { color: grid } }, axisTick: { show: false },
|
||||||
|
axisLabel: { color: text, fontFamily: mono, fontSize: 10, interval: 3 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value', max: props.max,
|
||||||
|
splitLine: { lineStyle: { color: grid } },
|
||||||
|
axisLabel: { color: text, fontFamily: mono, fontSize: 10 },
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: 'line', smooth: 0.4, symbol: 'none', data: series,
|
||||||
|
lineStyle: { color: accent, width: 2 },
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: accent + '55' },
|
||||||
|
{ offset: 1, color: accent + '00' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
markLine: {
|
||||||
|
silent: true, symbol: 'none',
|
||||||
|
lineStyle: { color: text, type: 'dashed', opacity: 0.5 },
|
||||||
|
data: [{ yAxis: props.max, label: { formatter: `cap ${props.max}`, color: text, fontFamily: mono, fontSize: 9 } }],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!el.value) return
|
||||||
|
if (!hasData.value) return // empty-state slot renders instead
|
||||||
|
chart = echarts.init(el.value, undefined, { renderer: 'canvas' })
|
||||||
|
render()
|
||||||
|
ro = new ResizeObserver(() => chart?.resize())
|
||||||
|
ro.observe(el.value)
|
||||||
|
mo = new MutationObserver(render)
|
||||||
|
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-game', 'data-theme'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
ro?.disconnect()
|
||||||
|
mo?.disconnect()
|
||||||
|
chart?.dispose()
|
||||||
|
chart = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Real data: render the ECharts canvas -->
|
||||||
|
<div v-if="hasData" ref="el" :style="{ width: '100%', height: height + 'px' }" />
|
||||||
|
<!-- No data: honest empty state — never show a fabricated curve -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="pc-empty"
|
||||||
|
:style="{ height: height + 'px' }"
|
||||||
|
>
|
||||||
|
<svg class="pc-empty__icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
|
</svg>
|
||||||
|
<span class="pc-empty__label">Awaiting telemetry</span>
|
||||||
|
<span class="pc-empty__sub">Player data will appear once the server connects and reports stats</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pc-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.pc-empty__icon { margin-bottom: 4px; opacity: 0.5; }
|
||||||
|
.pc-empty__label { font-size: var(--text-sm); font-weight: 500; color: var(--text-tertiary); }
|
||||||
|
.pc-empty__sub { font-size: var(--text-xs); color: var(--text-muted); max-width: 280px; text-align: center; line-height: 1.5; }
|
||||||
|
</style>
|
||||||
53
frontend/src/components/ds/data/ResourceMeter.vue
Normal file
53
frontend/src/components/ds/data/ResourceMeter.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* ResourceMeter — labeled utilization bar (CPU, RAM, disk, network).
|
||||||
|
* tone="auto" colors by threshold: green <70%, amber <90%, red ≥90%.
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
icon?: string
|
||||||
|
value?: number
|
||||||
|
sub?: string
|
||||||
|
tone?: 'auto' | 'ok' | 'warn' | 'danger' | 'accent'
|
||||||
|
}>(),
|
||||||
|
{ value: 0, tone: 'auto' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const pct = computed(() => Math.max(0, Math.min(100, props.value)))
|
||||||
|
|
||||||
|
const resolvedTone = computed(() => {
|
||||||
|
if (props.tone !== 'auto') return props.tone
|
||||||
|
return pct.value >= 90 ? 'danger' : pct.value >= 70 ? 'warn' : 'ok'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['cc-meter', 'cc-meter--' + resolvedTone]">
|
||||||
|
<div class="cc-meter__top">
|
||||||
|
<span class="cc-meter__label">{{ label }}</span>
|
||||||
|
<span class="cc-meter__val">
|
||||||
|
{{ pct }}%<span v-if="sub" class="cc-meter__sub">{{ sub }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="cc-meter__track">
|
||||||
|
<div class="cc-meter__fill" :style="{ width: pct + '%' }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-meter { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||||
|
.cc-meter__top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
||||||
|
.cc-meter__label { font-size: var(--text-xs); color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
|
||||||
|
.cc-meter__val { font-family: var(--font-mono); font-size: var(--text-xs); font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||||
|
.cc-meter__sub { color: var(--text-muted); font-weight: 400; margin-left: 4px; }
|
||||||
|
.cc-meter__track { height: 6px; border-radius: var(--radius-pill); background: var(--surface-active); overflow: hidden; }
|
||||||
|
.cc-meter__fill { height: 100%; border-radius: var(--radius-pill); transition: width var(--dur-slow) var(--ease-out), background var(--dur-base); }
|
||||||
|
.cc-meter--accent .cc-meter__fill { background: var(--accent); }
|
||||||
|
.cc-meter--ok .cc-meter__fill { background: var(--status-online); }
|
||||||
|
.cc-meter--warn .cc-meter__fill { background: var(--status-warn); }
|
||||||
|
.cc-meter--danger .cc-meter__fill { background: var(--status-offline); }
|
||||||
|
</style>
|
||||||
166
frontend/src/components/ds/data/ServerCard.vue
Normal file
166
frontend/src/components/ds/data/ServerCard.vue
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* ServerCard — server instance summary card.
|
||||||
|
* Sets :data-game so per-game accent token re-skins apply via the global [data-game] selector.
|
||||||
|
* Status drives the dot + left rail color.
|
||||||
|
* `offline` dims the card and swaps the power IconButton to a Start action.
|
||||||
|
* Pending state shows when status==='online' && cpu==null && ram==null.
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import ResourceMeter from './ResourceMeter.vue'
|
||||||
|
|
||||||
|
export interface StatItem {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
game?: string
|
||||||
|
gameIcon?: string
|
||||||
|
name: string
|
||||||
|
region?: string
|
||||||
|
map?: string
|
||||||
|
version?: string
|
||||||
|
status?: 'online' | 'offline' | 'starting' | 'wiping' | 'updating'
|
||||||
|
players?: { cur: number; max: number }
|
||||||
|
cpu?: number
|
||||||
|
ram?: number
|
||||||
|
ramSub?: string
|
||||||
|
ip?: string
|
||||||
|
stats?: StatItem[]
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
game: 'rust',
|
||||||
|
gameIcon: 'box',
|
||||||
|
status: 'online',
|
||||||
|
players: () => ({ cur: 0, max: 0 }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
console: []
|
||||||
|
settings: []
|
||||||
|
power: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
interface StatusEntry {
|
||||||
|
tone: 'online' | 'offline' | 'warn' | 'info' | 'starting' | 'wiping' | 'neutral' | 'accent'
|
||||||
|
label: string
|
||||||
|
pulse: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATUS: StatusEntry = { tone: 'online', label: 'Online', pulse: true }
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, StatusEntry> = {
|
||||||
|
online: { tone: 'online', label: 'Online', pulse: true },
|
||||||
|
offline: { tone: 'offline', label: 'Offline', pulse: false },
|
||||||
|
starting: { tone: 'starting', label: 'Booting', pulse: true },
|
||||||
|
wiping: { tone: 'wiping', label: 'Wiping', pulse: true },
|
||||||
|
updating: { tone: 'starting', label: 'Updating', pulse: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
const st = computed<StatusEntry>(() => STATUS_MAP[props.status ?? 'online'] ?? DEFAULT_STATUS)
|
||||||
|
const offline = computed(() => props.status === 'offline')
|
||||||
|
|
||||||
|
const statList = computed<StatItem[]>(() => {
|
||||||
|
if (props.stats) return props.stats
|
||||||
|
const items: StatItem[] = [
|
||||||
|
{ label: 'Players', value: `${props.players?.cur ?? 0} / ${props.players?.max ?? 0}` },
|
||||||
|
]
|
||||||
|
if (props.version) {
|
||||||
|
items.push({ label: 'Build', value: props.version })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const pending = computed(
|
||||||
|
() => props.status === 'online' && props.cpu == null && props.ram == null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const showMeters = computed(
|
||||||
|
() => !offline.value && (props.cpu != null || props.ram != null),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:data-game="game"
|
||||||
|
:class="['cc-server', offline && 'cc-server--offline']"
|
||||||
|
>
|
||||||
|
<!-- Head -->
|
||||||
|
<div class="cc-server__head">
|
||||||
|
<div class="cc-server__game">
|
||||||
|
<Icon :name="gameIcon" :size="18" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
|
<div class="cc-server__id">
|
||||||
|
<div class="cc-server__name">
|
||||||
|
{{ name }}
|
||||||
|
<Badge :tone="st.tone" dot :pulse="st.pulse">{{ st.label }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="cc-server__meta">
|
||||||
|
<span v-if="region">{{ region }}</span>
|
||||||
|
<span v-if="map">{{ map }}</span>
|
||||||
|
<span v-if="ip">{{ ip }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-server__actions">
|
||||||
|
<IconButton icon="terminal" variant="ghost" size="sm" label="Console" @click="emit('console')" />
|
||||||
|
<IconButton icon="settings" variant="ghost" size="sm" label="Settings" @click="emit('settings')" />
|
||||||
|
<IconButton
|
||||||
|
:icon="offline ? 'play' : 'power'"
|
||||||
|
:variant="offline ? 'accent' : 'ghost'"
|
||||||
|
size="sm"
|
||||||
|
:label="offline ? 'Start' : 'Power'"
|
||||||
|
@click="emit('power')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="cc-server__body">
|
||||||
|
<div class="cc-server__stats">
|
||||||
|
<div v-for="(s, i) in statList" :key="i" class="cc-server__stat">
|
||||||
|
<b>{{ s.value }}</b>
|
||||||
|
<span>{{ s.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showMeters" class="cc-server__meters">
|
||||||
|
<ResourceMeter v-if="cpu != null" label="CPU" :value="cpu" />
|
||||||
|
<ResourceMeter v-if="ram != null" label="RAM" :value="ram" :sub="ramSub" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="cc-server__pending">
|
||||||
|
<Icon name="loader" :size="13" :stroke-width="2.5" />
|
||||||
|
Telemetry pending · agent monitoring
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-server { position: relative; background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default); overflow: hidden; transition: var(--transition-colors); }
|
||||||
|
.cc-server::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); opacity: .9; }
|
||||||
|
.cc-server:hover { box-shadow: inset 0 0 0 1px var(--border-strong); }
|
||||||
|
.cc-server--offline::before { background: var(--status-offline); }
|
||||||
|
.cc-server--offline { opacity: .82; }
|
||||||
|
.cc-server__head { display: flex; align-items: center; gap: 12px; padding: 14px 14px 12px 17px; }
|
||||||
|
.cc-server__game { width: 34px; height: 34px; border-radius: var(--radius-md); flex: none; display: flex; align-items: center; justify-content: center; color: var(--accent); background: var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent-border); }
|
||||||
|
.cc-server__id { flex: 1; min-width: 0; }
|
||||||
|
.cc-server__name { font-size: var(--text-base); font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.cc-server__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; display: flex; gap: 10px; }
|
||||||
|
.cc-server__body { padding: 0 14px 13px 17px; display: flex; flex-direction: column; gap: 11px; }
|
||||||
|
.cc-server__stats { display: flex; gap: 18px; }
|
||||||
|
.cc-server__stat { display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.cc-server__stat b { font-family: var(--font-mono); font-weight: 600; font-size: var(--text-sm); color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||||
|
.cc-server__stat span { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
|
||||||
|
.cc-server__meters { display: flex; gap: 14px; }
|
||||||
|
.cc-server__meters > * { flex: 1; }
|
||||||
|
.cc-server__pending { display: flex; align-items: center; gap: 7px; font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); }
|
||||||
|
.cc-server__pending .cc-icon { color: var(--status-starting); }
|
||||||
|
.cc-server__actions { display: flex; gap: 5px; }
|
||||||
|
</style>
|
||||||
62
frontend/src/components/ds/data/StatCard.vue
Normal file
62
frontend/src/components/ds/data/StatCard.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* StatCard — KPI tile with icon, big mono value, and optional delta + note row.
|
||||||
|
* Green delta = up/good, red = down/bad, muted = flat.
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
unit?: string
|
||||||
|
icon?: string
|
||||||
|
delta?: string | number
|
||||||
|
deltaDir?: 'up' | 'down' | 'flat'
|
||||||
|
note?: string
|
||||||
|
}>(),
|
||||||
|
{ deltaDir: 'up' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const deltaIcon = computed(() =>
|
||||||
|
props.deltaDir === 'up' ? 'trending-up' : props.deltaDir === 'down' ? 'trending-down' : 'minus',
|
||||||
|
)
|
||||||
|
|
||||||
|
const showFoot = computed(() => props.delta != null || !!props.note)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cc-stat">
|
||||||
|
<div class="cc-stat__top">
|
||||||
|
<div v-if="icon" class="cc-stat__ico">
|
||||||
|
<Icon :name="icon" :size="15" :stroke-width="2.25" />
|
||||||
|
</div>
|
||||||
|
<div class="cc-stat__label">{{ label }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-stat__value">
|
||||||
|
{{ value }}<span v-if="unit" class="cc-stat__unit">{{ unit }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="showFoot" class="cc-stat__foot">
|
||||||
|
<span v-if="delta != null" :class="['cc-stat__delta', 'cc-stat__delta--' + deltaDir]">
|
||||||
|
<Icon :name="deltaIcon" :size="13" :stroke-width="2.5" />{{ delta }}
|
||||||
|
</span>
|
||||||
|
<span v-if="note" class="cc-stat__note">{{ note }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-stat { background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default); padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; min-width: 0; position: relative; overflow: hidden; }
|
||||||
|
.cc-stat__top { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.cc-stat__ico { width: 28px; height: 28px; border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; background: var(--accent-soft); color: var(--accent-text); flex: none; }
|
||||||
|
.cc-stat__label { font-size: var(--text-xs); font-weight: 500; color: var(--text-tertiary); letter-spacing: .01em; }
|
||||||
|
.cc-stat__value { font-family: var(--font-mono); font-weight: 600; font-size: 28px; letter-spacing: -0.02em; color: var(--text-primary); font-variant-numeric: tabular-nums; line-height: 1; display: flex; align-items: baseline; gap: 4px; }
|
||||||
|
.cc-stat__unit { font-size: 14px; color: var(--text-muted); font-weight: 500; }
|
||||||
|
.cc-stat__foot { display: flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||||
|
.cc-stat__delta { display: inline-flex; align-items: center; gap: 3px; font-weight: 600; }
|
||||||
|
.cc-stat__delta--up { color: var(--status-online); }
|
||||||
|
.cc-stat__delta--down { color: var(--status-offline); }
|
||||||
|
.cc-stat__delta--flat { color: var(--text-tertiary); }
|
||||||
|
.cc-stat__note { color: var(--text-tertiary); }
|
||||||
|
</style>
|
||||||
86
frontend/src/components/ds/feedback/Alert.vue
Normal file
86
frontend/src/components/ds/feedback/Alert.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Alert — contextual inline alert strip.
|
||||||
|
* Tones: info | warn | danger | online | accent | neutral.
|
||||||
|
* Pass `title` for a bold heading, default slot for body text, `actions` slot
|
||||||
|
* for inline action buttons. Set `dismissible` to show an × ghost button that
|
||||||
|
* emits `dismiss`.
|
||||||
|
*/
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
|
||||||
|
type Tone = 'info' | 'warn' | 'danger' | 'online' | 'accent' | 'neutral'
|
||||||
|
|
||||||
|
const ICONS: Record<Tone, string> = {
|
||||||
|
info: 'info',
|
||||||
|
warn: 'triangle-alert',
|
||||||
|
danger: 'octagon-alert',
|
||||||
|
online: 'circle-check',
|
||||||
|
accent: 'sparkles',
|
||||||
|
neutral: 'info',
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
tone?: Tone
|
||||||
|
title?: string
|
||||||
|
dismissible?: boolean
|
||||||
|
icon?: string
|
||||||
|
}>(),
|
||||||
|
{ tone: 'info', dismissible: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
defineEmits<{ dismiss: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="['cc-alert', 'cc-alert--' + tone]"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span class="cc-alert__icon">
|
||||||
|
<Icon :name="icon ?? ICONS[tone]" :size="17" :stroke-width="2" />
|
||||||
|
</span>
|
||||||
|
<div class="cc-alert__main">
|
||||||
|
<div v-if="title" class="cc-alert__title">{{ title }}</div>
|
||||||
|
<div v-if="$slots.default" class="cc-alert__body"><slot /></div>
|
||||||
|
<div v-if="$slots.actions" class="cc-alert__actions"><slot name="actions" /></div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="dismissible"
|
||||||
|
class="cc-alert__dismiss"
|
||||||
|
type="button"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
@click="$emit('dismiss')"
|
||||||
|
>
|
||||||
|
<Icon name="x" :size="15" :stroke-width="2.25" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-alert { display:flex; gap:11px; padding:12px 13px; border-radius:var(--radius-md); background:var(--surface-raised); box-shadow:var(--ring-default); }
|
||||||
|
.cc-alert__icon { flex:none; margin-top:1px; }
|
||||||
|
.cc-alert__main { flex:1; min-width:0; display:flex; flex-direction:column; gap:3px; }
|
||||||
|
.cc-alert__title { font-size:var(--text-sm); font-weight:600; color:var(--text-primary); }
|
||||||
|
.cc-alert__body { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.5; }
|
||||||
|
.cc-alert__actions { display:flex; gap:8px; margin-top:8px; }
|
||||||
|
.cc-alert--info { background:var(--status-info-soft); box-shadow: inset 0 0 0 1px var(--status-info-border); }
|
||||||
|
.cc-alert--info .cc-alert__icon { color:var(--status-info); }
|
||||||
|
.cc-alert--warn { background:var(--status-warn-soft); box-shadow: inset 0 0 0 1px var(--status-warn-border); }
|
||||||
|
.cc-alert--warn .cc-alert__icon { color:var(--status-warn); }
|
||||||
|
.cc-alert--danger { background:var(--status-offline-soft); box-shadow: inset 0 0 0 1px var(--status-offline-border); }
|
||||||
|
.cc-alert--danger .cc-alert__icon { color:var(--status-offline); }
|
||||||
|
.cc-alert--online { background:var(--status-online-soft); box-shadow: inset 0 0 0 1px var(--status-online-border); }
|
||||||
|
.cc-alert--online .cc-alert__icon { color:var(--status-online); }
|
||||||
|
.cc-alert--accent { background:var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent-border); }
|
||||||
|
.cc-alert--accent .cc-alert__icon { color:var(--accent-text); }
|
||||||
|
.cc-alert__dismiss {
|
||||||
|
flex: none; display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
width:26px; height:26px; border-radius:var(--radius-sm); border:none; cursor:pointer;
|
||||||
|
background:transparent; color:var(--text-secondary);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
margin-top:-3px; margin-right:-3px;
|
||||||
|
}
|
||||||
|
.cc-alert__dismiss:hover { background:var(--surface-hover); color:var(--text-primary); }
|
||||||
|
.cc-alert__dismiss:focus-visible { outline:none; box-shadow:var(--focus-ring); }
|
||||||
|
</style>
|
||||||
39
frontend/src/components/ds/feedback/EmptyState.vue
Normal file
39
frontend/src/components/ds/feedback/EmptyState.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* EmptyState — zero-data placeholder with icon, title, description, and an
|
||||||
|
* optional action slot (pass a Button or link).
|
||||||
|
*
|
||||||
|
* Icon registry note: default icon 'inbox' is not in the registry — it will
|
||||||
|
* silently not render per Icon.vue's null guard. Callers should pass a
|
||||||
|
* registered icon name (e.g. icon="server", icon="folder-open").
|
||||||
|
*/
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
icon?: string
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}>(),
|
||||||
|
{ icon: 'inbox' },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cc-empty">
|
||||||
|
<div class="cc-empty__icon">
|
||||||
|
<Icon :name="icon" :size="22" :stroke-width="1.75" />
|
||||||
|
</div>
|
||||||
|
<div v-if="title" class="cc-empty__title">{{ title }}</div>
|
||||||
|
<div v-if="description" class="cc-empty__desc">{{ description }}</div>
|
||||||
|
<div v-if="$slots.action" class="cc-empty__action"><slot name="action" /></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-empty { display:flex; flex-direction:column; align-items:center; text-align:center; gap:5px; padding:36px 24px; }
|
||||||
|
.cc-empty__icon { width:46px; height:46px; border-radius:var(--radius-lg); display:flex; align-items:center; justify-content:center; margin-bottom:8px; color:var(--text-tertiary); background:var(--surface-raised-2); box-shadow:var(--ring-default); }
|
||||||
|
.cc-empty__title { font-size:var(--text-base); font-weight:600; color:var(--text-primary); }
|
||||||
|
.cc-empty__desc { font-size:var(--text-sm); color:var(--text-tertiary); max-width:340px; line-height:1.5; }
|
||||||
|
.cc-empty__action { margin-top:12px; }
|
||||||
|
</style>
|
||||||
50
frontend/src/components/ds/forms/Checkbox.vue
Normal file
50
frontend/src/components/ds/forms/Checkbox.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Checkbox — square toggle; checked/indeterminate state carries the live game accent.
|
||||||
|
* v-model binds to boolean checked state via defineModel<boolean>().
|
||||||
|
* The hidden <input type="checkbox"> drives CSS :checked/:indeterminate/:focus-visible/:disabled.
|
||||||
|
*/
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label?: string
|
||||||
|
disabled?: boolean
|
||||||
|
id?: string
|
||||||
|
}>(),
|
||||||
|
{ disabled: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const model = defineModel<boolean>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label class="cc-check" :for="id">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:id="id"
|
||||||
|
:checked="model"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="model = ($event.target as HTMLInputElement).checked"
|
||||||
|
/>
|
||||||
|
<span class="cc-check__box">
|
||||||
|
<Icon name="check" :size="12" :stroke-width="3" />
|
||||||
|
</span>
|
||||||
|
<span v-if="label" class="cc-check__label">{{ label }}</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-check { display:inline-flex; align-items:center; gap:9px; cursor:pointer; user-select:none; }
|
||||||
|
.cc-check input { position:absolute; opacity:0; width:0; height:0; }
|
||||||
|
.cc-check__box {
|
||||||
|
width:17px; height:17px; flex:none; border-radius:var(--radius-xs); background:var(--surface-inset);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--border-strong); display:flex; align-items:center; justify-content:center;
|
||||||
|
color:transparent; transition:var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-check input:checked + .cc-check__box { background:var(--accent); box-shadow: inset 0 0 0 1px var(--accent); color:var(--accent-contrast); }
|
||||||
|
.cc-check input:indeterminate + .cc-check__box { background:var(--accent); box-shadow: inset 0 0 0 1px var(--accent); color:var(--accent-contrast); }
|
||||||
|
.cc-check input:focus-visible + .cc-check__box { box-shadow: var(--focus-ring); }
|
||||||
|
.cc-check input:disabled + .cc-check__box { opacity:.5; }
|
||||||
|
.cc-check__label { font-size:var(--text-sm); color:var(--text-primary); }
|
||||||
|
</style>
|
||||||
90
frontend/src/components/ds/forms/Input.vue
Normal file
90
frontend/src/components/ds/forms/Input.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Input — text field with label, hint/error, leading icon and affixes.
|
||||||
|
* Reach for `mono` on any technical value (ports, tokens, IDs).
|
||||||
|
* v-model binds to the inner <input> value via defineModel<string>().
|
||||||
|
*/
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label?: string
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
icon?: string
|
||||||
|
prefix?: string
|
||||||
|
suffix?: string
|
||||||
|
size?: 'md' | 'sm'
|
||||||
|
mono?: boolean
|
||||||
|
required?: boolean
|
||||||
|
id?: string
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
type?: string
|
||||||
|
}>(),
|
||||||
|
{ size: 'md', mono: false, required: false, disabled: false, type: 'text' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const model = defineModel<string>()
|
||||||
|
|
||||||
|
const invalid = () => !!props.error
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label
|
||||||
|
class="cc-field"
|
||||||
|
:for="id"
|
||||||
|
>
|
||||||
|
<span v-if="label" class="cc-field__label">
|
||||||
|
{{ label }}<span v-if="required" class="req">*</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'cc-input',
|
||||||
|
size === 'sm' && 'cc-input--sm',
|
||||||
|
mono && 'cc-input--mono',
|
||||||
|
invalid() && 'cc-input--invalid',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Icon v-if="icon" :name="icon" :size="15" />
|
||||||
|
<span v-if="prefix" class="cc-input__affix">{{ prefix }}</span>
|
||||||
|
<input
|
||||||
|
:id="id"
|
||||||
|
:type="type"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="model"
|
||||||
|
@input="model = ($event.target as HTMLInputElement).value"
|
||||||
|
/>
|
||||||
|
<span v-if="suffix" class="cc-input__affix">{{ suffix }}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="hint || error"
|
||||||
|
:class="['cc-field__hint', invalid() && 'cc-field__hint--error']"
|
||||||
|
>{{ error ?? hint }}</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-field { display:flex; flex-direction:column; gap:6px; }
|
||||||
|
.cc-field__label { font-size:var(--text-xs); font-weight:600; color:var(--text-secondary); }
|
||||||
|
.cc-field__label .req { color:var(--accent-text); margin-left:2px; }
|
||||||
|
.cc-input {
|
||||||
|
display:flex; align-items:center; gap:8px; height:var(--control-h-md); padding:0 11px;
|
||||||
|
background:var(--surface-inset); border-radius:var(--radius-md); box-shadow:var(--ring-default);
|
||||||
|
transition:var(--transition-colors); color:var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.cc-input:focus-within { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-input--sm { height:var(--control-h-sm); padding:0 9px; }
|
||||||
|
.cc-input--invalid { box-shadow: inset 0 0 0 1px var(--status-offline-border); }
|
||||||
|
.cc-input--invalid:focus-within { box-shadow: inset 0 0 0 1px var(--danger); }
|
||||||
|
.cc-input input {
|
||||||
|
flex:1; min-width:0; background:transparent; border:0; outline:0; padding:0; margin:0;
|
||||||
|
font-family:var(--font-sans); font-size:var(--text-sm); color:var(--text-primary);
|
||||||
|
}
|
||||||
|
.cc-input input::placeholder { color:var(--text-muted); }
|
||||||
|
.cc-input--mono input { font-family:var(--font-mono); }
|
||||||
|
.cc-input__affix { font-family:var(--font-mono); font-size:var(--text-xs); color:var(--text-muted); white-space:nowrap; }
|
||||||
|
.cc-field__hint { font-size:var(--text-xs); color:var(--text-tertiary); }
|
||||||
|
.cc-field__hint--error { color:var(--danger); }
|
||||||
|
</style>
|
||||||
86
frontend/src/components/ds/forms/Select.vue
Normal file
86
frontend/src/components/ds/forms/Select.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Select — styled native <select> with chevron overlay.
|
||||||
|
* With `label` the root becomes a <label> wrapping the control.
|
||||||
|
* v-model binds to the selected value via defineModel<string>().
|
||||||
|
*/
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
|
||||||
|
type SelectOption = string | { value: string; label: string }
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label?: string
|
||||||
|
options?: SelectOption[]
|
||||||
|
size?: 'md' | 'sm'
|
||||||
|
id?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>(),
|
||||||
|
{ options: () => [], size: 'md', disabled: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const model = defineModel<string>()
|
||||||
|
|
||||||
|
function optionValue(o: SelectOption): string {
|
||||||
|
return typeof o === 'string' ? o : o.value
|
||||||
|
}
|
||||||
|
function optionLabel(o: SelectOption): string {
|
||||||
|
return typeof o === 'string' ? o : o.label
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- With label: wrap in <label> matching React's cc-field layout -->
|
||||||
|
<label v-if="label" class="cc-field" :for="id">
|
||||||
|
<span class="cc-field__label">{{ label }}</span>
|
||||||
|
<span :class="['cc-select', size === 'sm' && 'cc-select--sm']">
|
||||||
|
<select
|
||||||
|
:id="id"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="model"
|
||||||
|
@change="model = ($event.target as HTMLSelectElement).value"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="(o, i) in options"
|
||||||
|
:key="i"
|
||||||
|
:value="optionValue(o)"
|
||||||
|
>{{ optionLabel(o) }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="cc-select__chev">
|
||||||
|
<Icon name="chevron-down" :size="15" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Without label: bare control -->
|
||||||
|
<span v-else :class="['cc-select', size === 'sm' && 'cc-select--sm']">
|
||||||
|
<select
|
||||||
|
:id="id"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="model"
|
||||||
|
@change="model = ($event.target as HTMLSelectElement).value"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="(o, i) in options"
|
||||||
|
:key="i"
|
||||||
|
:value="optionValue(o)"
|
||||||
|
>{{ optionLabel(o) }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="cc-select__chev">
|
||||||
|
<Icon name="chevron-down" :size="15" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-select { position:relative; display:flex; align-items:center; }
|
||||||
|
.cc-select select {
|
||||||
|
appearance:none; width:100%; height:var(--control-h-md); padding:0 32px 0 11px;
|
||||||
|
background:var(--surface-inset); color:var(--text-primary); border:0; border-radius:var(--radius-md);
|
||||||
|
box-shadow:var(--ring-default); font-family:var(--font-sans); font-size:var(--text-sm); cursor:pointer;
|
||||||
|
transition:var(--transition-colors); outline:0;
|
||||||
|
}
|
||||||
|
.cc-select select:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-select--sm select { height:var(--control-h-sm); padding:0 28px 0 9px; }
|
||||||
|
.cc-select__chev { position:absolute; right:9px; pointer-events:none; color:var(--text-tertiary); display:flex; }
|
||||||
|
</style>
|
||||||
59
frontend/src/components/ds/forms/Switch.vue
Normal file
59
frontend/src/components/ds/forms/Switch.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Switch — toggle control; checked state carries the live game accent.
|
||||||
|
* v-model binds to boolean checked state via defineModel<boolean>().
|
||||||
|
* The hidden <input type="checkbox"> drives CSS :checked/:focus-visible/:disabled.
|
||||||
|
*/
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label?: string
|
||||||
|
size?: 'md' | 'sm'
|
||||||
|
disabled?: boolean
|
||||||
|
id?: string
|
||||||
|
}>(),
|
||||||
|
{ size: 'md', disabled: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const model = defineModel<boolean>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label
|
||||||
|
:class="['cc-switch', size === 'sm' && 'cc-switch--sm']"
|
||||||
|
:for="id"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:id="id"
|
||||||
|
:checked="model"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="model = ($event.target as HTMLInputElement).checked"
|
||||||
|
/>
|
||||||
|
<span class="cc-switch__track">
|
||||||
|
<span class="cc-switch__thumb" />
|
||||||
|
</span>
|
||||||
|
<span v-if="label" class="cc-switch__label">{{ label }}</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-switch { display:inline-flex; align-items:center; gap:10px; cursor:pointer; user-select:none; }
|
||||||
|
.cc-switch input { position:absolute; opacity:0; width:0; height:0; }
|
||||||
|
.cc-switch__track {
|
||||||
|
position:relative; width:36px; height:20px; border-radius:var(--radius-pill); flex:none;
|
||||||
|
background:var(--surface-active); box-shadow: inset 0 0 0 1px var(--border-default);
|
||||||
|
transition: background var(--dur-base) var(--ease-standard), box-shadow var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
.cc-switch__thumb {
|
||||||
|
position:absolute; top:2px; left:2px; width:16px; height:16px; border-radius:50%;
|
||||||
|
background:var(--text-secondary); transition: transform var(--dur-base) var(--ease-emphasized), background var(--dur-base);
|
||||||
|
}
|
||||||
|
.cc-switch input:checked + .cc-switch__track { background:var(--accent); box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-switch input:checked + .cc-switch__track .cc-switch__thumb { transform:translateX(16px); background:var(--accent-contrast); }
|
||||||
|
.cc-switch input:focus-visible + .cc-switch__track { box-shadow: var(--focus-ring); }
|
||||||
|
.cc-switch input:disabled + .cc-switch__track { opacity:.5; }
|
||||||
|
.cc-switch__label { font-size:var(--text-sm); color:var(--text-primary); }
|
||||||
|
.cc-switch--sm .cc-switch__track { width:30px; height:17px; }
|
||||||
|
.cc-switch--sm .cc-switch__thumb { width:13px; height:13px; }
|
||||||
|
.cc-switch--sm input:checked + .cc-switch__track .cc-switch__thumb { transform:translateX(13px); }
|
||||||
|
</style>
|
||||||
71
frontend/src/components/ds/navigation/GameSwitcher.vue
Normal file
71
frontend/src/components/ds/navigation/GameSwitcher.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* GameSwitcher — segmented control for switching the active game context.
|
||||||
|
* Set `data-game` on a root shell element to the chosen key so the global
|
||||||
|
* [data-game] CSS custom properties re-skin the entire panel.
|
||||||
|
* Per-game accent comes from var(--accent) which is resolved by the [data-game] token scope.
|
||||||
|
*/
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
|
||||||
|
export interface GameOption {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
games: (string | GameOption)[]
|
||||||
|
showLabels?: boolean
|
||||||
|
class?: string
|
||||||
|
}>(),
|
||||||
|
{ showLabels: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const model = defineModel<string>({ required: true })
|
||||||
|
|
||||||
|
function normalise(g: string | GameOption): GameOption {
|
||||||
|
return typeof g === 'string' ? { key: g, label: g } : g
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['cc-gameswitch', props.class]" role="group">
|
||||||
|
<button
|
||||||
|
v-for="raw in games"
|
||||||
|
:key="normalise(raw).key"
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="normalise(raw).key === model"
|
||||||
|
:data-game="normalise(raw).key"
|
||||||
|
class="cc-gameswitch__opt"
|
||||||
|
:title="normalise(raw).label"
|
||||||
|
@click="model = normalise(raw).key"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
v-if="normalise(raw).icon"
|
||||||
|
:name="normalise(raw).icon ?? ''"
|
||||||
|
:size="14"
|
||||||
|
:stroke-width="2.25"
|
||||||
|
:style="{ color: normalise(raw).key === model ? 'var(--accent)' : 'inherit' }"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="cc-gameswitch__dot"
|
||||||
|
:style="{ background: normalise(raw).key === model ? 'var(--accent)' : 'var(--text-muted)' }"
|
||||||
|
/>
|
||||||
|
<span v-if="showLabels">{{ normalise(raw).label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-gameswitch { display:inline-flex; align-items:center; gap:3px; padding:3px; background:var(--surface-inset); border-radius:var(--radius-md); box-shadow:var(--ring-default); }
|
||||||
|
.cc-gameswitch__opt {
|
||||||
|
display:inline-flex; align-items:center; gap:7px; height:28px; padding:0 11px; border:0; background:transparent;
|
||||||
|
font-family:var(--font-sans); font-size:var(--text-xs); font-weight:600; color:var(--text-tertiary);
|
||||||
|
border-radius:var(--radius-sm); cursor:pointer; transition:var(--transition-colors); white-space:nowrap;
|
||||||
|
}
|
||||||
|
.cc-gameswitch__opt:hover { color:var(--text-primary); }
|
||||||
|
.cc-gameswitch__dot { width:8px; height:8px; border-radius:50%; background:var(--accent); flex:none; }
|
||||||
|
.cc-gameswitch__opt[aria-pressed="true"] { background:var(--surface-raised-2); color:var(--text-primary); box-shadow:var(--ring-default); }
|
||||||
|
</style>
|
||||||
50
frontend/src/components/ds/navigation/NavItem.vue
Normal file
50
frontend/src/components/ds/navigation/NavItem.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* NavItem — sidebar navigation row: icon + label, active state with accent rail,
|
||||||
|
* optional trailing count. Collapsed mode renders icon-only at 40 px wide.
|
||||||
|
*/
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
active?: boolean
|
||||||
|
count?: number | string
|
||||||
|
collapsed?: boolean
|
||||||
|
}>(),
|
||||||
|
{ active: false, collapsed: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ click: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="['cc-nav', active && 'cc-nav--active', collapsed && 'cc-nav--collapsed']"
|
||||||
|
role="button"
|
||||||
|
:aria-current="active ? 'page' : undefined"
|
||||||
|
:title="collapsed ? label : undefined"
|
||||||
|
@click="emit('click')"
|
||||||
|
>
|
||||||
|
<span class="cc-nav__icon">
|
||||||
|
<Icon :name="icon" :size="17" :stroke-width="2" />
|
||||||
|
</span>
|
||||||
|
<span v-if="!collapsed" class="cc-nav__label">{{ label }}</span>
|
||||||
|
<span v-if="!collapsed && count != null" class="cc-nav__count">{{ count }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-nav { display:flex; align-items:center; gap:10px; height:34px; padding:0 10px; border-radius:var(--radius-md);
|
||||||
|
color:var(--text-secondary); cursor:pointer; transition:var(--transition-colors); position:relative; user-select:none; }
|
||||||
|
.cc-nav:hover { background:var(--surface-hover); color:var(--text-primary); }
|
||||||
|
.cc-nav__icon { flex:none; color:var(--text-tertiary); display:flex; transition:var(--transition-colors); }
|
||||||
|
.cc-nav__label { flex:1; font-size:var(--text-sm); font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.cc-nav__count { font-family:var(--font-mono); font-size:11px; color:var(--text-tertiary); padding:1px 6px; border-radius:var(--radius-pill); background:var(--surface-active); }
|
||||||
|
.cc-nav--active { background:var(--accent-soft); color:var(--accent-text); }
|
||||||
|
.cc-nav--active .cc-nav__icon { color:var(--accent-text); }
|
||||||
|
.cc-nav--active::before { content:''; position:absolute; left:-10px; top:7px; bottom:7px; width:3px; border-radius:var(--radius-pill); background:var(--accent); }
|
||||||
|
.cc-nav--active .cc-nav__count { background:var(--accent-soft-strong); color:var(--accent-text); }
|
||||||
|
.cc-nav--collapsed { justify-content:center; padding:0; width:40px; }
|
||||||
|
</style>
|
||||||
67
frontend/src/components/ds/navigation/Tabs.vue
Normal file
67
frontend/src/components/ds/navigation/Tabs.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Tabs — horizontal tab bar. variant="pill" fills active tab with accent-soft;
|
||||||
|
* variant="line" underlines with accent. Items can be bare strings or TabItem objects.
|
||||||
|
*/
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
|
||||||
|
export interface TabItem {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
icon?: string
|
||||||
|
count?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
items: (string | TabItem)[]
|
||||||
|
variant?: 'pill' | 'line'
|
||||||
|
class?: string
|
||||||
|
}>(),
|
||||||
|
{ variant: 'pill' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const model = defineModel<string>({ required: true })
|
||||||
|
|
||||||
|
function normalise(it: string | TabItem): TabItem {
|
||||||
|
return typeof it === 'string' ? { value: it, label: it } : it
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="['cc-tabs', `cc-tabs--${variant}`, props.class]"
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="raw in items"
|
||||||
|
:key="normalise(raw).value"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="normalise(raw).value === model"
|
||||||
|
class="cc-tab"
|
||||||
|
@click="model = normalise(raw).value"
|
||||||
|
>
|
||||||
|
<Icon v-if="normalise(raw).icon" :name="normalise(raw).icon ?? ''" :size="15" />
|
||||||
|
{{ normalise(raw).label }}
|
||||||
|
<span v-if="normalise(raw).count != null" class="cc-tab__count">{{ normalise(raw).count }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.cc-tabs { display:flex; align-items:center; gap:2px; position:relative; }
|
||||||
|
.cc-tabs--line { box-shadow: inset 0 -1px 0 var(--border-subtle); gap:4px; }
|
||||||
|
.cc-tab {
|
||||||
|
display:inline-flex; align-items:center; gap:7px; height:32px; padding:0 11px; border:0; background:transparent;
|
||||||
|
font-family:var(--font-sans); font-size:var(--text-sm); font-weight:500; color:var(--text-tertiary);
|
||||||
|
cursor:pointer; border-radius:var(--radius-sm); transition:var(--transition-colors); white-space:nowrap; position:relative;
|
||||||
|
}
|
||||||
|
.cc-tab:hover { color:var(--text-primary); background:var(--surface-hover); }
|
||||||
|
.cc-tabs--pill .cc-tab[aria-selected="true"] { color:var(--accent-text); background:var(--accent-soft); }
|
||||||
|
.cc-tabs--line .cc-tab { border-radius:0; height:38px; padding:0 4px; margin:0 7px; }
|
||||||
|
.cc-tabs--line .cc-tab:hover { background:transparent; }
|
||||||
|
.cc-tabs--line .cc-tab[aria-selected="true"] { color:var(--text-primary); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||||
|
.cc-tab__count { font-family:var(--font-mono); font-size:11px; padding:1px 6px; border-radius:var(--radius-pill); background:var(--surface-active); color:var(--text-tertiary); }
|
||||||
|
.cc-tab[aria-selected="true"] .cc-tab__count { background:var(--accent-soft); color:var(--accent-text); }
|
||||||
|
</style>
|
||||||
@@ -1,103 +1,79 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
/**
|
||||||
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router'
|
* DashboardLayout — game-aware app shell (Phase C redesign).
|
||||||
|
* Nav is driven by GAME_PROFILES[activeGame].nav — switching the GameSwitcher
|
||||||
|
* visibly changes nav items, labels, and sections per game.
|
||||||
|
* Preserves: permission gating, super-admin section, logout, mobile sidebar,
|
||||||
|
* GameSwitcher, agent-health footer, topbar.
|
||||||
|
*/
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import {
|
import { useThemeGame } from '@/composables/useThemeGame'
|
||||||
LayoutDashboard,
|
import { useGameProfile } from '@/config/gameProfiles'
|
||||||
Server,
|
import type { NavSection, NavItemDef } from '@/config/gameProfiles'
|
||||||
Terminal,
|
import { safeDate } from '@/utils/formatters'
|
||||||
Users,
|
import ErrorBoundary from '@/components/ErrorBoundary.vue'
|
||||||
Puzzle,
|
import Logo from '@/components/ds/brand/Logo.vue'
|
||||||
RefreshCw,
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
Map,
|
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
||||||
MessageSquare,
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
BarChart3,
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
Bell,
|
import Avatar from '@/components/ds/data/Avatar.vue'
|
||||||
UserPlus,
|
import NavItem from '@/components/ds/navigation/NavItem.vue'
|
||||||
ShoppingBag,
|
import GameSwitcher from '@/components/ds/navigation/GameSwitcher.vue'
|
||||||
Package,
|
import type { GameOption } from '@/components/ds/navigation/GameSwitcher.vue'
|
||||||
Settings,
|
import type { ActiveGame } from '@/composables/useThemeGame'
|
||||||
LogOut,
|
|
||||||
Shield,
|
|
||||||
Key,
|
|
||||||
CreditCard,
|
|
||||||
Network,
|
|
||||||
Clock,
|
|
||||||
AlertTriangle,
|
|
||||||
FileText,
|
|
||||||
FolderOpen,
|
|
||||||
Menu,
|
|
||||||
X,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
|
// ---- Stores / composables ----
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
|
const { theme, activeGame, setActiveGame, toggleTheme } = useThemeGame()
|
||||||
|
|
||||||
|
// ---- Mobile sidebar ----
|
||||||
const sidebarOpen = ref(false)
|
const sidebarOpen = ref(false)
|
||||||
|
function closeSidebar() { sidebarOpen.value = false }
|
||||||
|
|
||||||
type NavItem = { name: string; path: string; icon: any; permission: string | null }
|
// ---- App version ----
|
||||||
type NavSection = { label: string; items: NavItem[] }
|
const APP_VERSION = __APP_VERSION__
|
||||||
|
|
||||||
const navSections: NavSection[] = [
|
// ---- Game switcher ----
|
||||||
{
|
const GAME_OPTIONS: GameOption[] = [
|
||||||
label: '',
|
{ key: 'all', label: 'All games', icon: 'layers' },
|
||||||
items: [
|
{ key: 'rust', label: 'Rust', icon: 'box' },
|
||||||
{ name: 'Dashboard', path: '/', icon: LayoutDashboard, permission: null },
|
{ key: 'dune', label: 'Dune', icon: 'sun' },
|
||||||
],
|
{ key: 'conan', label: 'Conan Exiles', icon: 'swords' },
|
||||||
},
|
{ key: 'soulmask', label: 'Soulmask', icon: 'drama' },
|
||||||
{
|
|
||||||
label: 'Server',
|
|
||||||
items: [
|
|
||||||
{ name: 'Server', path: '/server', icon: Server, permission: 'server.view' },
|
|
||||||
{ name: 'Console', path: '/console', icon: Terminal, permission: 'console.view' },
|
|
||||||
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
|
|
||||||
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
|
|
||||||
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Plugin Configs',
|
|
||||||
items: [
|
|
||||||
{ name: 'Plugin Configs', path: '/plugin-configs', icon: Puzzle, permission: null },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Operations',
|
|
||||||
items: [
|
|
||||||
{ name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
|
|
||||||
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
|
|
||||||
{ name: 'Schedules', path: '/schedules', icon: Clock, permission: 'schedules.view' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Monitoring',
|
|
||||||
items: [
|
|
||||||
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
|
|
||||||
{ name: 'Analytics', path: '/analytics', icon: BarChart3, permission: 'analytics.view' },
|
|
||||||
{ name: 'Alerts', path: '/alerts', icon: AlertTriangle, permission: 'alerts.view' },
|
|
||||||
{ name: 'Notifications', path: '/notifications', icon: Bell, permission: 'notifications.view' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Management',
|
|
||||||
items: [
|
|
||||||
{ name: 'Team', path: '/team', icon: UserPlus, permission: null },
|
|
||||||
{ name: 'Store', path: '/store/config', icon: ShoppingBag, permission: 'store.view' },
|
|
||||||
{ name: 'Modules', path: '/modules', icon: Package, permission: 'modules.view' },
|
|
||||||
{ name: 'Changelog', path: '/changelog', icon: FileText, permission: 'changelog.view' },
|
|
||||||
{ name: 'Settings', path: '/settings', icon: Settings, permission: 'settings.view' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const GAME_LABEL: Record<string, string> = {
|
||||||
|
all: 'All games', rust: 'Rust', dune: 'Dune',
|
||||||
|
conan: 'Conan Exiles', soulmask: 'Soulmask',
|
||||||
|
}
|
||||||
|
|
||||||
|
function onActiveGame(val: string) {
|
||||||
|
setActiveGame(val as ActiveGame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Navigation — driven by the game profile registry ----
|
||||||
|
/**
|
||||||
|
* For 'all', fall back to rust (superset nav). For a specific game, look up
|
||||||
|
* its profile. noUncheckedIndexedAccess-safe: always ?? GAME_PROFILES.rust.
|
||||||
|
*/
|
||||||
|
const activeNavSections = computed<NavSection[]>(() => {
|
||||||
|
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
|
||||||
|
return (useGameProfile(game)).nav
|
||||||
|
})
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
{ name: 'Admin Home', path: '/admin', icon: Shield },
|
{ name: 'Admin home', path: '/admin', icon: 'shield' },
|
||||||
{ name: 'Licenses', path: '/admin/licenses', icon: Key },
|
{ name: 'Licenses', path: '/admin/licenses', icon: 'key' },
|
||||||
{ name: 'Subscriptions', path: '/admin/subscriptions', icon: CreditCard },
|
{ name: 'Subscriptions', path: '/admin/subscriptions', icon: 'credit-card' },
|
||||||
{ name: 'Users', path: '/admin/users', icon: Users },
|
{ name: 'Users', path: '/admin/users', icon: 'users' },
|
||||||
{ name: 'Server Fleet', path: '/admin/servers', icon: Network },
|
{ name: 'Server fleet', path: '/admin/servers', icon: 'server' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function isActive(path: string): boolean {
|
function isActive(path: string): boolean {
|
||||||
@@ -105,16 +81,12 @@ function isActive(path: string): boolean {
|
|||||||
return route.path.startsWith(path)
|
return route.path.startsWith(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function navigate(path: string) {
|
||||||
auth.logout()
|
router.push(path)
|
||||||
router.push('/login')
|
closeSidebar()
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSidebar() {
|
function canShowNavItem(item: NavItemDef): boolean {
|
||||||
sidebarOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function canShowNavItem(item: NavItem): boolean {
|
|
||||||
if (!item.permission) return true
|
if (!item.permission) return true
|
||||||
return auth.hasPermission(item.permission)
|
return auth.hasPermission(item.permission)
|
||||||
}
|
}
|
||||||
@@ -122,134 +94,508 @@ function canShowNavItem(item: NavItem): boolean {
|
|||||||
function hasVisibleItems(section: NavSection): boolean {
|
function hasVisibleItems(section: NavSection): boolean {
|
||||||
return section.items.some(canShowNavItem)
|
return section.items.some(canShowNavItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Agent health ----
|
||||||
|
const hasAgent = computed(() => server.connection !== null)
|
||||||
|
|
||||||
|
const agentTone = computed(() => {
|
||||||
|
const cs = server.connection?.connection_status
|
||||||
|
if (cs === 'connected') return 'online' as const
|
||||||
|
if (cs === 'degraded') return 'warn' as const
|
||||||
|
return 'offline' as const
|
||||||
|
})
|
||||||
|
const agentLabel = computed(() => {
|
||||||
|
const cs = server.connection?.connection_status
|
||||||
|
if (cs === 'connected') return 'Healthy'
|
||||||
|
if (cs === 'degraded') return 'Degraded'
|
||||||
|
return 'Offline'
|
||||||
|
})
|
||||||
|
const agentName = computed(() => server.connection?.server_ip ?? 'Host agent')
|
||||||
|
|
||||||
|
const agentMetaLine = computed(() => {
|
||||||
|
const cs = server.connection?.connection_status
|
||||||
|
let line = cs === 'connected' ? 'Connected' : server.connection?.companion_last_seen
|
||||||
|
? `Last seen ${safeDate(server.connection.companion_last_seen)}`
|
||||||
|
: 'Awaiting first heartbeat'
|
||||||
|
if (server.stats) {
|
||||||
|
line += ` · ${server.stats.player_count}/${server.stats.max_players} players`
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Topbar ----
|
||||||
|
const serverName = computed(() => auth.license?.server_name ?? 'Your servers')
|
||||||
|
const userName = computed(() => auth.user?.username ?? '')
|
||||||
|
const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-screen bg-neutral-950">
|
<!-- Outer app grid: sidebar | main -->
|
||||||
<!-- Mobile Hamburger -->
|
<div class="app">
|
||||||
<button
|
<!-- ===================================================== SIDEBAR ===== -->
|
||||||
@click="sidebarOpen = true"
|
<!-- Mobile overlay -->
|
||||||
class="md:hidden fixed top-4 left-4 z-40 p-2 bg-neutral-900 border border-neutral-800 rounded-lg text-neutral-300 hover:text-oxide-400 transition-colors"
|
|
||||||
>
|
|
||||||
<Menu class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Sidebar Overlay (Mobile) -->
|
|
||||||
<div
|
<div
|
||||||
v-if="sidebarOpen"
|
v-if="sidebarOpen"
|
||||||
|
class="sidebar-overlay"
|
||||||
@click="closeSidebar"
|
@click="closeSidebar"
|
||||||
class="md:hidden fixed inset-0 bg-black/50 z-40"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<aside
|
<aside
|
||||||
class="w-64 bg-neutral-900 border-r border-neutral-800 flex flex-col fixed inset-y-0 left-0 z-50 transform transition-transform"
|
class="app__sidebar"
|
||||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
|
:class="sidebarOpen ? 'app__sidebar--open' : ''"
|
||||||
>
|
>
|
||||||
<!-- Logo -->
|
<!-- Brand -->
|
||||||
<div class="p-4 border-b border-neutral-800">
|
<div class="side__brand">
|
||||||
<div class="flex items-center justify-between">
|
<Logo :size="22" />
|
||||||
<div class="flex items-center gap-3">
|
<Badge tone="neutral" :mono="true" class="side__ver">{{ APP_VERSION }}</Badge>
|
||||||
<img src="/logo.png" alt="Corrosion" class="h-8 w-8" />
|
|
||||||
<div>
|
|
||||||
<h1 class="text-sm font-bold text-oxide-500 tracking-wider">CORROSION</h1>
|
|
||||||
<p class="text-xs text-neutral-500">{{ auth.license?.server_name || 'Server Management' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="closeSidebar"
|
|
||||||
class="md:hidden text-neutral-400 hover:text-neutral-200 transition-colors"
|
|
||||||
>
|
|
||||||
<X class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Server Status Indicator -->
|
<!-- Active game switcher -->
|
||||||
<div class="px-4 py-3 border-b border-neutral-800">
|
<div class="side__game">
|
||||||
<div class="flex items-center gap-2">
|
<div class="t-eyebrow side__lbl">
|
||||||
<div
|
Active game · {{ GAME_LABEL[activeGame] ?? 'All games' }}
|
||||||
class="w-2 h-2 rounded-full"
|
|
||||||
:class="{
|
|
||||||
'bg-green-500': server.connection?.connection_status === 'connected',
|
|
||||||
'bg-yellow-500': server.connection?.connection_status === 'degraded',
|
|
||||||
'bg-red-500': server.connection?.connection_status === 'offline' || !server.connection,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-neutral-400">
|
|
||||||
{{ server.stats?.player_count ?? 0 }}/{{ server.stats?.max_players ?? 0 }} players
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<GameSwitcher
|
||||||
|
:model-value="activeGame"
|
||||||
|
:games="GAME_OPTIONS"
|
||||||
|
:show-labels="false"
|
||||||
|
@update:model-value="onActiveGame"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation — sections driven by GAME_PROFILES[activeGame].nav -->
|
||||||
<nav class="flex-1 overflow-y-auto py-2">
|
<nav class="side__nav">
|
||||||
<template v-for="section in navSections" :key="section.label">
|
<template v-for="section in activeNavSections" :key="section.label">
|
||||||
<template v-if="hasVisibleItems(section)">
|
<template v-if="hasVisibleItems(section)">
|
||||||
<!-- Section Header -->
|
<div class="side__sec">
|
||||||
<div v-if="section.label" class="mt-4 mb-1 px-4">
|
<div v-if="section.label" class="t-eyebrow side__lbl">{{ section.label }}</div>
|
||||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-neutral-500">{{ section.label }}</span>
|
<NavItem
|
||||||
|
v-for="item in section.items"
|
||||||
|
v-show="canShowNavItem(item)"
|
||||||
|
:key="item.route"
|
||||||
|
:icon="item.icon"
|
||||||
|
:label="item.label"
|
||||||
|
:active="isActive(item.route)"
|
||||||
|
@click="navigate(item.route)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section Items -->
|
|
||||||
<RouterLink
|
|
||||||
v-for="item in section.items"
|
|
||||||
v-show="canShowNavItem(item)"
|
|
||||||
:key="item.path"
|
|
||||||
:to="item.path"
|
|
||||||
@click="closeSidebar"
|
|
||||||
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
|
|
||||||
:class="isActive(item.path)
|
|
||||||
? 'bg-oxide-500/10 text-oxide-400'
|
|
||||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
<component :is="item.icon" class="w-4 h-4" />
|
|
||||||
{{ item.name }}
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Platform Admin Section (super-admin only) -->
|
<!-- Platform admin section (super-admin only) -->
|
||||||
<template v-if="auth.isSuperAdmin">
|
<div v-if="auth.isSuperAdmin" class="side__sec">
|
||||||
<div class="mt-4 mb-1 px-4">
|
<div class="t-eyebrow side__lbl side__lbl--platform">Platform</div>
|
||||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-oxide-500">Platform</span>
|
<NavItem
|
||||||
</div>
|
|
||||||
<RouterLink
|
|
||||||
v-for="item in adminNavItems"
|
v-for="item in adminNavItems"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
:to="item.path"
|
:icon="item.icon"
|
||||||
@click="closeSidebar"
|
:label="item.name"
|
||||||
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
|
:active="isActive(item.path)"
|
||||||
:class="isActive(item.path)
|
@click="navigate(item.path)"
|
||||||
? 'bg-oxide-500/10 text-oxide-400'
|
/>
|
||||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
</div>
|
||||||
>
|
|
||||||
<component :is="item.icon" class="w-4 h-4" />
|
|
||||||
{{ item.name }}
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- User -->
|
<!-- Host agent footer -->
|
||||||
<div class="p-4 border-t border-neutral-800">
|
<div class="side__foot">
|
||||||
<div class="flex items-center justify-between">
|
<!-- Connected: real IP + status badge + meta line -->
|
||||||
<div>
|
<div v-if="hasAgent" class="agent">
|
||||||
<p class="text-sm text-neutral-300">{{ auth.user?.username }}</p>
|
<div class="agent__row">
|
||||||
<p class="text-xs text-neutral-500">{{ auth.user?.email }}</p>
|
<StatusDot :tone="agentTone" :pulse="agentTone === 'online'" />
|
||||||
|
<span class="agent__name">{{ agentName }}</span>
|
||||||
|
<Badge :tone="agentTone" size="md">{{ agentLabel }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="agent__meta">{{ agentMetaLine }}</div>
|
||||||
|
</div>
|
||||||
|
<!-- Not connected: honest empty state -->
|
||||||
|
<div v-else class="agent agent--empty">
|
||||||
|
<div class="agent__row">
|
||||||
|
<StatusDot tone="offline" />
|
||||||
|
<span class="agent__name agent__name--muted">No host agent connected</span>
|
||||||
|
</div>
|
||||||
|
<div class="agent__meta">Install the Corrosion host agent from the Server page</div>
|
||||||
|
</div>
|
||||||
|
<!-- User / logout row -->
|
||||||
|
<div class="side__user">
|
||||||
|
<span class="side__user-name">{{ auth.user?.username ?? '' }}</span>
|
||||||
<button
|
<button
|
||||||
@click="handleLogout"
|
type="button"
|
||||||
class="text-neutral-500 hover:text-oxide-400 transition-colors"
|
class="side__logout"
|
||||||
|
title="Sign out"
|
||||||
|
@click="() => { auth.logout(); router.push('/login') }"
|
||||||
>
|
>
|
||||||
<LogOut class="w-4 h-4" />
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<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" y1="12" x2="9" y2="12"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content (offset by sidebar width on desktop) -->
|
<!-- ======================================================= MAIN ===== -->
|
||||||
<main class="flex-1 overflow-y-auto md:pl-64">
|
<div class="app__main">
|
||||||
<RouterView />
|
<!-- Topbar -->
|
||||||
</main>
|
<header class="app__topbar">
|
||||||
|
<!-- Mobile hamburger (left of topbar on small screens) -->
|
||||||
|
<button
|
||||||
|
class="topbar-hamburger"
|
||||||
|
type="button"
|
||||||
|
aria-label="Open navigation"
|
||||||
|
@click="sidebarOpen = true"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="top__crumbs">
|
||||||
|
<span class="crumb">Corrosion</span>
|
||||||
|
<span class="crumb__sep">/</span>
|
||||||
|
<span class="crumb crumb--cluster">{{ serverName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="top__search">
|
||||||
|
<svg class="top__search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
<input placeholder="Search servers, players, configs…" readonly />
|
||||||
|
<span class="top__kbd">
|
||||||
|
<kbd class="cc-kbd">⌘</kbd><kbd class="cc-kbd">K</kbd>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="top__actions">
|
||||||
|
<IconButton
|
||||||
|
:icon="themeIcon"
|
||||||
|
label="Toggle theme"
|
||||||
|
@click="toggleTheme"
|
||||||
|
/>
|
||||||
|
<IconButton icon="bell" label="Alerts" @click="router.push('/alerts')" />
|
||||||
|
<Button size="sm" icon="rocket">Deploy server</Button>
|
||||||
|
<Avatar
|
||||||
|
:name="userName"
|
||||||
|
:size="30"
|
||||||
|
status="online"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Page content — boundary keeps sidebar/topbar alive when a view fails -->
|
||||||
|
<main class="app__content">
|
||||||
|
<ErrorBoundary variant="content">
|
||||||
|
<RouterView />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ============================================================ SHELL ===== */
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body { margin: 0; overflow: hidden; }
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidebar-w, 228px) 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--surface-canvas, #0a0a0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Sidebar ---- */
|
||||||
|
.app__sidebar {
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 15px 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side__ver {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side__game {
|
||||||
|
padding: 2px 14px 13px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side__lbl {
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side__lbl--platform {
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side__nav {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 13px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side__sec {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side__sec .t-eyebrow {
|
||||||
|
margin: 0 0 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side__foot {
|
||||||
|
padding: 11px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent__name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent__meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 5px;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent--empty { opacity: 0.7; }
|
||||||
|
|
||||||
|
.agent__name--muted {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side__user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 4px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side__user-name {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side__logout {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.side__logout:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ---- Main ---- */
|
||||||
|
.app__main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app__topbar {
|
||||||
|
height: var(--topbar-h, 52px);
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--surface-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top__crumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb { color: var(--text-tertiary); }
|
||||||
|
.crumb__sep { color: var(--text-muted); }
|
||||||
|
.crumb--cluster {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
border: 0;
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top__search {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 440px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 11px;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top__search-icon { flex: none; }
|
||||||
|
|
||||||
|
.top__search input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top__search input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.top__kbd { display: flex; gap: 3px; }
|
||||||
|
|
||||||
|
.top__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app__content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 22px 24px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Mobile hamburger ---- */
|
||||||
|
.topbar-hamburger {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.topbar-hamburger:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ---- Sidebar overlay (mobile) ---- */
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 49;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Kbd styling ---- */
|
||||||
|
.cc-kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
|
background: var(--surface-active);
|
||||||
|
border-radius: var(--radius-xs, 3px);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Responsive ---- */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app__sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 228px;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 220ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app__sidebar--open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-hamburger {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top__search {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,76 +1,79 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView, RouterLink } from 'vue-router'
|
import { RouterView, RouterLink } from 'vue-router'
|
||||||
|
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||||
|
import '@/styles/marketing.css'
|
||||||
|
|
||||||
const panelUrl = import.meta.env.VITE_PANEL_URL || ''
|
const panelUrl = import.meta.env.VITE_PANEL_URL ?? ''
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-neutral-950 flex flex-col">
|
<div>
|
||||||
<!-- Navigation -->
|
<!-- Nav -->
|
||||||
<nav class="border-b border-neutral-800 bg-neutral-950/80 backdrop-blur-sm sticky top-0 z-50">
|
<nav class="mkt-nav">
|
||||||
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div class="wrap mkt-nav__in">
|
||||||
<RouterLink :to="{ name: 'landing' }" class="flex items-center gap-3">
|
<RouterLink :to="{ name: 'landing' }" class="brand">
|
||||||
<img src="/logo.png" alt="Corrosion" class="h-8 w-8" />
|
<span class="mark"><CorrosionMark :size="26" /></span>
|
||||||
<span class="text-lg font-bold text-neutral-100">Corrosion</span>
|
<b>Corrosion</b>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<div class="hidden md:flex items-center gap-6">
|
<div class="mkt-nav__links">
|
||||||
<RouterLink :to="{ name: 'how-it-works' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">How It Works</RouterLink>
|
<RouterLink :to="{ name: 'landing' }" class="scroll-link">Features</RouterLink>
|
||||||
<RouterLink :to="{ name: 'pricing' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Pricing</RouterLink>
|
<RouterLink :to="{ name: 'pricing' }">Pricing</RouterLink>
|
||||||
<RouterLink :to="{ name: 'roadmap' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Roadmap</RouterLink>
|
<RouterLink :to="{ name: 'how-it-works' }">How it works</RouterLink>
|
||||||
<RouterLink :to="{ name: 'faq' }" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">FAQ</RouterLink>
|
<RouterLink :to="{ name: 'faq' }">FAQ</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'roadmap' }">Roadmap</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="mkt-nav__cta">
|
||||||
<a :href="panelUrl + '/login'" class="text-sm text-neutral-400 hover:text-neutral-100 transition-colors">Sign In</a>
|
<a class="mkt-nav__signin" :href="panelUrl + '/login'">Sign in</a>
|
||||||
<a :href="panelUrl + '/register'" class="px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors">Get Started</a>
|
<RouterLink class="btn btn--primary btn--sm" :to="{ name: 'early-access' }">
|
||||||
|
Early access
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Page content -->
|
<!-- Page content -->
|
||||||
<main class="flex-1">
|
<RouterView />
|
||||||
<RouterView />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="border-t border-neutral-800 py-12">
|
<footer class="mkt-footer">
|
||||||
<div class="max-w-6xl mx-auto px-6">
|
<div class="wrap">
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
|
<div class="footer__cols">
|
||||||
<div>
|
<div class="footer__brand">
|
||||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Product</h4>
|
<RouterLink :to="{ name: 'landing' }" class="brand">
|
||||||
<div class="space-y-2">
|
<span class="mark"><CorrosionMark :size="24" /></span>
|
||||||
<RouterLink :to="{ name: 'how-it-works' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">How It Works</RouterLink>
|
<b>Corrosion</b>
|
||||||
<RouterLink :to="{ name: 'pricing' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Pricing</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink :to="{ name: 'roadmap' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Roadmap</RouterLink>
|
<p>Game server operations for self-hosted communities.</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="footer__col">
|
||||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Support</h4>
|
<h5>Product</h5>
|
||||||
<div class="space-y-2">
|
<RouterLink :to="{ name: 'landing' }">Supported games</RouterLink>
|
||||||
<RouterLink :to="{ name: 'faq' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">FAQ</RouterLink>
|
<RouterLink :to="{ name: 'landing' }">Features</RouterLink>
|
||||||
<a href="https://discord.gg/corrosion" target="_blank" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Discord</a>
|
<RouterLink :to="{ name: 'pricing' }">Pricing</RouterLink>
|
||||||
</div>
|
<RouterLink :to="{ name: 'roadmap' }">Roadmap</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="footer__col">
|
||||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Company</h4>
|
<h5>Games</h5>
|
||||||
<div class="space-y-2">
|
<RouterLink :to="{ name: 'landing' }">Rust</RouterLink>
|
||||||
<RouterLink :to="{ name: 'landing' }" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">About</RouterLink>
|
<RouterLink :to="{ name: 'landing' }">Dune: Awakening</RouterLink>
|
||||||
<RouterLink to="/status" class="block text-sm text-neutral-500 hover:text-neutral-300 transition-colors">Status</RouterLink>
|
<RouterLink :to="{ name: 'landing' }">Soulmask</RouterLink>
|
||||||
</div>
|
<RouterLink :to="{ name: 'landing' }">Conan Exiles</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="footer__col">
|
||||||
<h4 class="text-sm font-semibold text-neutral-300 mb-3">Legal</h4>
|
<h5>Support</h5>
|
||||||
<div class="space-y-2">
|
<RouterLink :to="{ name: 'faq' }">FAQ</RouterLink>
|
||||||
<span class="block text-sm text-neutral-600">Terms of Service</span>
|
<RouterLink to="/status">Status</RouterLink>
|
||||||
<span class="block text-sm text-neutral-600">Privacy Policy</span>
|
</div>
|
||||||
</div>
|
<div class="footer__col">
|
||||||
|
<h5>Company</h5>
|
||||||
|
<RouterLink :to="{ name: 'landing' }">About</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'roadmap' }">Changelog</RouterLink>
|
||||||
|
<a href="mailto:support@corrosionmgmt.com">Contact</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-neutral-800 pt-6 flex items-center justify-between">
|
<div class="footer__bar">
|
||||||
<div class="flex items-center gap-2">
|
<span>© 2026 Corrosion. All rights reserved.</span>
|
||||||
<img src="/logo.png" alt="Corrosion" class="h-4 w-4 opacity-60" />
|
<span>One control plane. Every game.</span>
|
||||||
<span class="text-sm text-neutral-600">© 2026 Corrosion. All rights reserved.</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-neutral-700">The Control Plane for Rust Servers.</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
import Logo from '@/components/ds/brand/Logo.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-neutral-950">
|
<div class="pub-shell">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|
||||||
<footer class="py-6 text-center text-neutral-600 text-sm border-t border-neutral-800">
|
<footer class="pub-footer">
|
||||||
<div class="flex items-center justify-center gap-2">
|
<Logo :size="18" :wordmark="true" />
|
||||||
<img src="/logo.png" alt="Corrosion" class="h-4 w-4 opacity-60" />
|
|
||||||
<span>Powered by <span class="text-oxide-500 font-semibold">Corrosion</span></span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pub-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--surface-canvas);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: var(--space-6) var(--space-6);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { rustContainers, containerCategories } from '@/data/rust-containers'
|
import { rustContainers, containerCategories } from '@/data/rust-containers'
|
||||||
import { Search, Box, Cylinder, Shield, Users, HelpCircle } from 'lucide-vue-next'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import DsInput from '@/components/ds/forms/Input.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
lootTable: Record<string, any>
|
lootTable: Record<string, any>
|
||||||
@@ -14,20 +15,21 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
const categoryIcons: Record<string, any> = {
|
// Map container categories to DS icon names
|
||||||
crates: Box,
|
const categoryIcons: Record<string, string> = {
|
||||||
barrels: Cylinder,
|
crates: 'box',
|
||||||
military: Shield,
|
barrels: 'flask-conical',
|
||||||
npcs: Users,
|
military: 'shield',
|
||||||
other: HelpCircle,
|
npcs: 'users',
|
||||||
|
other: 'info',
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
const categoryLabels: Record<string, string> = {
|
||||||
crates: 'CRATES',
|
crates: 'Crates',
|
||||||
barrels: 'BARRELS',
|
barrels: 'Barrels',
|
||||||
military: 'MILITARY',
|
military: 'Military',
|
||||||
npcs: 'NPCs',
|
npcs: 'NPCs',
|
||||||
other: 'OTHER',
|
other: 'Other',
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredContainers = computed(() => {
|
const filteredContainers = computed(() => {
|
||||||
@@ -56,48 +58,136 @@ function isConfigured(prefab: string): boolean {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-64 shrink-0 bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden flex flex-col">
|
<aside class="lcs-root">
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="p-3 border-b border-neutral-800">
|
<div class="lcs-search">
|
||||||
<div class="relative">
|
<DsInput
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
v-model="searchQuery"
|
||||||
<input
|
icon="search"
|
||||||
v-model="searchQuery"
|
placeholder="Search containers…"
|
||||||
placeholder="Search containers..."
|
size="sm"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200 placeholder-neutral-500"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Container List -->
|
<!-- Container list -->
|
||||||
<div class="flex-1 overflow-y-auto py-2">
|
<div class="lcs-list">
|
||||||
<template v-for="(containers, category) in groupedContainers" :key="category">
|
<template v-for="(containers, category) in groupedContainers" :key="category">
|
||||||
<div class="px-3 pt-3 pb-1">
|
<!-- Category heading -->
|
||||||
<div class="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-widest text-neutral-500">
|
<div class="lcs-cat">
|
||||||
<component :is="categoryIcons[category]" class="w-3 h-3" />
|
<Icon
|
||||||
{{ categoryLabels[category] || category }}
|
:name="categoryIcons[category] ?? 'box'"
|
||||||
</div>
|
:size="12"
|
||||||
|
class="lcs-cat__icon"
|
||||||
|
/>
|
||||||
|
<span class="lcs-cat__label">{{ categoryLabels[category] ?? category }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Container rows -->
|
||||||
<button
|
<button
|
||||||
v-for="c in containers"
|
v-for="c in containers"
|
||||||
:key="c.prefab"
|
:key="c.prefab"
|
||||||
|
class="lcs-item"
|
||||||
|
:class="{ 'lcs-item--active': selected === c.prefab }"
|
||||||
@click="emit('select', c.prefab)"
|
@click="emit('select', c.prefab)"
|
||||||
class="w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 transition-colors"
|
|
||||||
:class="selected === c.prefab
|
|
||||||
? 'bg-oxide-500/10 text-oxide-400'
|
|
||||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
|
||||||
>
|
>
|
||||||
<span class="truncate flex-1">{{ c.name }}</span>
|
<span class="lcs-item__name">{{ c.name }}</span>
|
||||||
<span
|
<span v-if="isConfigured(c.prefab)" class="lcs-item__dot" />
|
||||||
v-if="isConfigured(c.prefab)"
|
|
||||||
class="w-1.5 h-1.5 rounded-full bg-oxide-500 shrink-0"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="Object.keys(groupedContainers).length === 0" class="px-3 py-6 text-center text-neutral-500 text-sm">
|
<div v-if="Object.keys(groupedContainers).length === 0" class="lcs-empty">
|
||||||
No containers match
|
No containers match
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lcs-root {
|
||||||
|
width: 240px;
|
||||||
|
flex: none;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lcs-search {
|
||||||
|
padding: 10px 10px 8px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lcs-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category heading */
|
||||||
|
.lcs-cat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px 4px;
|
||||||
|
}
|
||||||
|
.lcs-cat__icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.lcs-cat__label {
|
||||||
|
font-size: var(--text-2xs, 10px);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container row */
|
||||||
|
.lcs-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: left;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.lcs-item:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.lcs-item--active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
.lcs-item__name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lcs-item__dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lcs-empty {
|
||||||
|
padding: 20px 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { rustItems } from '@/data/rust-items'
|
import { rustItems } from '@/data/rust-items'
|
||||||
import { Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import DsInput from '@/components/ds/forms/Input.vue'
|
||||||
import type { LootGroupProfile } from '@/types'
|
import type { LootGroupProfile } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -65,120 +71,260 @@ function updateGroupItemField(groupName: string, shortname: string, field: strin
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="lge-root">
|
||||||
<!-- Add Group -->
|
<!-- Add group panel -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel>
|
||||||
<div class="flex gap-2">
|
<div class="lge-add">
|
||||||
<input
|
<DsInput
|
||||||
v-model="newGroupName"
|
v-model="newGroupName"
|
||||||
placeholder="New group name..."
|
placeholder="New group name…"
|
||||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
|
||||||
@keydown.enter="addGroup"
|
@keydown.enter="addGroup"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
@click="addGroup"
|
icon="plus"
|
||||||
:disabled="!newGroupName.trim()"
|
:disabled="!newGroupName.trim()"
|
||||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
@click="addGroup"
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4" />
|
Add group
|
||||||
Add Group
|
</Button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Group List -->
|
<!-- Empty state -->
|
||||||
<div v-if="groupEntries.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
<Panel v-if="groupEntries.length === 0">
|
||||||
No loot groups defined. Groups let you create reusable item pools that can be assigned to multiple containers.
|
<EmptyState
|
||||||
</div>
|
icon="layers"
|
||||||
|
title="No loot groups"
|
||||||
|
description="Groups let you create reusable item pools that can be assigned to multiple containers."
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Group cards -->
|
||||||
<div
|
<div
|
||||||
v-for="entry in groupEntries"
|
v-for="entry in groupEntries"
|
||||||
:key="entry.name"
|
:key="entry.name"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden"
|
class="lge-card"
|
||||||
>
|
>
|
||||||
<!-- Group Header -->
|
<!-- Group header -->
|
||||||
<button
|
<button
|
||||||
|
class="lge-card__head"
|
||||||
@click="toggleGroup(entry.name)"
|
@click="toggleGroup(entry.name)"
|
||||||
class="w-full flex items-center justify-between px-4 py-3 hover:bg-neutral-800/50 transition-colors"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<Icon
|
||||||
<component
|
:name="expandedGroup === entry.name ? 'chevron-down' : 'chevron-right'"
|
||||||
:is="expandedGroup === entry.name ? ChevronDown : ChevronRight"
|
:size="16"
|
||||||
class="w-4 h-4 text-neutral-500"
|
class="lge-card__chevron"
|
||||||
/>
|
/>
|
||||||
<span class="text-neutral-200 font-medium">{{ entry.name }}</span>
|
<span class="lge-card__name">{{ entry.name }}</span>
|
||||||
<span class="text-xs text-neutral-500">{{ entry.itemCount }} items</span>
|
<Badge tone="neutral" mono>{{ entry.itemCount }}</Badge>
|
||||||
</div>
|
<IconButton
|
||||||
<button
|
icon="trash-2"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
label="Delete group"
|
||||||
|
class="lge-card__del"
|
||||||
@click.stop="deleteGroup(entry.name)"
|
@click.stop="deleteGroup(entry.name)"
|
||||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
/>
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Group Items -->
|
<!-- Expanded items -->
|
||||||
<div v-if="expandedGroup === entry.name" class="border-t border-neutral-800 p-4">
|
<div v-if="expandedGroup === entry.name" class="lge-card__body">
|
||||||
<table v-if="entry.itemCount > 0" class="w-full text-sm">
|
<table v-if="entry.itemCount > 0" class="lge-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800">
|
<tr>
|
||||||
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
|
<th class="lge-th">Item</th>
|
||||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
|
<th class="lge-th lge-th--num">Min</th>
|
||||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
|
<th class="lge-th lge-th--num">Max</th>
|
||||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
|
<th class="lge-th lge-th--num">Prob %</th>
|
||||||
<th class="w-10"></th>
|
<th class="lge-th lge-th--action"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="(itemData, shortname) in entry.data.ItemList"
|
v-for="(itemData, shortname) in entry.data.ItemList"
|
||||||
:key="shortname"
|
:key="shortname"
|
||||||
class="border-b border-neutral-800/50"
|
class="lge-tr"
|
||||||
>
|
>
|
||||||
<td class="py-2 px-2 text-neutral-200">{{ getItemName(shortname as string) }}</td>
|
<td class="lge-td">{{ getItemName(shortname as string) }}</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lge-td lge-td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="(itemData as any).Min ?? 1"
|
:value="(itemData as any).Min ?? 1"
|
||||||
@input="updateGroupItemField(entry.name, shortname as string, 'Min', Number(($event.target as HTMLInputElement).value))"
|
@input="updateGroupItemField(entry.name, shortname as string, 'Min', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
class="cc-num-input cc-num-input--center"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lge-td lge-td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="(itemData as any).Max ?? 1"
|
:value="(itemData as any).Max ?? 1"
|
||||||
@input="updateGroupItemField(entry.name, shortname as string, 'Max', Number(($event.target as HTMLInputElement).value))"
|
@input="updateGroupItemField(entry.name, shortname as string, 'Max', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
class="cc-num-input cc-num-input--center"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lge-td lge-td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="(itemData as any).Probability ?? 100"
|
:value="(itemData as any).Probability ?? 100"
|
||||||
@input="updateGroupItemField(entry.name, shortname as string, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
@input="updateGroupItemField(entry.name, shortname as string, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
class="cc-num-input cc-num-input--center"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lge-td lge-td--action">
|
||||||
<button
|
<IconButton
|
||||||
|
icon="trash-2"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
label="Remove item"
|
||||||
@click="removeItemFromGroup(entry.name, shortname as string)"
|
@click="removeItemFromGroup(entry.name, shortname as string)"
|
||||||
class="text-neutral-600 hover:text-red-400"
|
/>
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p v-else class="text-neutral-500 text-sm text-center py-4">
|
<p v-else class="lge-card__empty">
|
||||||
No items in this group yet. Add items from the container editor.
|
No items in this group yet. Add items from the container editor.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lge-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add group row */
|
||||||
|
.lge-add {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.lge-add > :first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group card */
|
||||||
|
.lge-card {
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.lge-card__head {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
text-align: left;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.lge-card__head:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
.lge-card__chevron {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.lge-card__name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.lge-card__del {
|
||||||
|
margin-left: auto;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded body */
|
||||||
|
.lge-card__body {
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.lge-card__empty {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.lge-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.lge-th {
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lge-th--num {
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
.lge-th--action {
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
.lge-tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.lge-tr:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.lge-tr:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
.lge-td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.lge-td--num {
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
.lge-td--action {
|
||||||
|
width: 40px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared number input (same as LootItemEditor) */
|
||||||
|
.cc-num-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-num-input:focus {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm);
|
||||||
|
}
|
||||||
|
.cc-num-input--center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { rustItems } from '@/data/rust-items'
|
import { rustItems } from '@/data/rust-items'
|
||||||
import { rustContainers } from '@/data/rust-containers'
|
import { rustContainers } from '@/data/rust-containers'
|
||||||
import { Trash2, Plus, Settings2 } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
import type { PrefabLoot } from '@/types'
|
import type { PrefabLoot } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -76,157 +81,273 @@ const ungroupedItems = computed(() => {
|
|||||||
...(data as any),
|
...(data as any),
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Computed boolean for the Switch v-model
|
||||||
|
const isEnabled = computed({
|
||||||
|
get: () => containerData.value?.Enabled ?? true,
|
||||||
|
set: () => toggleEnabled(),
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="lie-root">
|
||||||
<!-- Container Header -->
|
<!-- Container settings panel -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel :title="containerName">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<template #actions>
|
||||||
<div class="flex items-center gap-3">
|
<Badge tone="neutral" mono class="lie-prefab">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100">{{ containerName }}</h2>
|
{{ containerKey.split('/').pop() }}
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
</Badge>
|
||||||
<input
|
<Switch v-model="isEnabled" label="Enabled" size="sm" />
|
||||||
type="checkbox"
|
</template>
|
||||||
:checked="containerData?.Enabled ?? true"
|
|
||||||
@change="toggleEnabled"
|
|
||||||
class="rounded bg-neutral-800 border-neutral-600 text-oxide-500 focus:ring-oxide-500"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-neutral-400">Enabled</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Settings2 class="w-4 h-4 text-neutral-500" />
|
|
||||||
<span class="text-xs text-neutral-500 font-mono">{{ containerKey.split('/').pop() }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item Settings -->
|
<!-- Item settings grid -->
|
||||||
<div class="grid grid-cols-4 gap-3" v-if="containerData">
|
<div v-if="containerData" class="lie-settings">
|
||||||
<div>
|
<div class="lie-setting">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Items Min</label>
|
<label class="lie-setting__label">Items min</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="containerData.ItemSettings?.ItemsMin ?? 1"
|
:value="containerData.ItemSettings?.ItemsMin ?? 1"
|
||||||
@input="updateSettings('ItemsMin', Number(($event.target as HTMLInputElement).value))"
|
@input="updateSettings('ItemsMin', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
class="cc-num-input"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="lie-setting">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Items Max</label>
|
<label class="lie-setting__label">Items max</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="containerData.ItemSettings?.ItemsMax ?? 6"
|
:value="containerData.ItemSettings?.ItemsMax ?? 6"
|
||||||
@input="updateSettings('ItemsMax', Number(($event.target as HTMLInputElement).value))"
|
@input="updateSettings('ItemsMax', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
class="cc-num-input"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="lie-setting">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Min Scrap</label>
|
<label class="lie-setting__label">Min scrap</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="containerData.ItemSettings?.MinScrap ?? 0"
|
:value="containerData.ItemSettings?.MinScrap ?? 0"
|
||||||
@input="updateSettings('MinScrap', Number(($event.target as HTMLInputElement).value))"
|
@input="updateSettings('MinScrap', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
class="cc-num-input"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="lie-setting">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Max Scrap</label>
|
<label class="lie-setting__label">Max scrap</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="containerData.ItemSettings?.MaxScrap ?? 0"
|
:value="containerData.ItemSettings?.MaxScrap ?? 0"
|
||||||
@input="updateSettings('MaxScrap', Number(($event.target as HTMLInputElement).value))"
|
@input="updateSettings('MaxScrap', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
class="cc-num-input"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p v-else class="lie-unconfigured">
|
||||||
|
Container not yet configured. Add an item to initialise its settings.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Ungrouped Items Table -->
|
<!-- Ungrouped items panel -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel title="Ungrouped items" :flush-body="ungroupedItems.length > 0">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<template #actions>
|
||||||
<h3 class="text-sm font-semibold text-neutral-300">Ungrouped Items</h3>
|
<Button size="sm" variant="outline" icon="plus" @click="emit('add-item')">
|
||||||
<button
|
Add item
|
||||||
@click="emit('add-item')"
|
</Button>
|
||||||
class="flex items-center gap-1 px-3 py-1.5 bg-oxide-500/10 text-oxide-400 rounded-lg hover:bg-oxide-500/20 text-sm"
|
</template>
|
||||||
>
|
|
||||||
<Plus class="w-3.5 h-3.5" />
|
|
||||||
Add Item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="ungroupedItems.length > 0" class="overflow-x-auto">
|
<div v-if="ungroupedItems.length > 0" class="lie-table-wrap">
|
||||||
<table class="w-full text-sm">
|
<table class="lie-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800">
|
<tr>
|
||||||
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
|
<th class="lie-th">Item</th>
|
||||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
|
<th class="lie-th lie-th--num">Min</th>
|
||||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
|
<th class="lie-th lie-th--num">Max</th>
|
||||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
|
<th class="lie-th lie-th--num">Prob %</th>
|
||||||
<th class="w-10"></th>
|
<th class="lie-th lie-th--action"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="item in ungroupedItems"
|
v-for="item in ungroupedItems"
|
||||||
:key="item.shortname"
|
:key="item.shortname"
|
||||||
class="border-b border-neutral-800/50 hover:bg-neutral-800/30"
|
class="lie-tr"
|
||||||
>
|
>
|
||||||
<td class="py-2 px-2">
|
<td class="lie-td">
|
||||||
<div>
|
<span class="lie-item-name">{{ item.name }}</span>
|
||||||
<span class="text-neutral-200">{{ item.name }}</span>
|
<span class="lie-item-short">{{ item.shortname }}</span>
|
||||||
<span class="text-neutral-600 text-xs ml-2">{{ item.shortname }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lie-td lie-td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="item.Min"
|
:value="item.Min"
|
||||||
@input="updateItemField(item.shortname, 'Min', Number(($event.target as HTMLInputElement).value))"
|
@input="updateItemField(item.shortname, 'Min', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
class="cc-num-input cc-num-input--center"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lie-td lie-td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="item.Max"
|
:value="item.Max"
|
||||||
@input="updateItemField(item.shortname, 'Max', Number(($event.target as HTMLInputElement).value))"
|
@input="updateItemField(item.shortname, 'Max', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
class="cc-num-input cc-num-input--center"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lie-td lie-td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="item.Probability ?? 100"
|
:value="item.Probability ?? 100"
|
||||||
@input="updateItemField(item.shortname, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
@input="updateItemField(item.shortname, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
class="cc-num-input cc-num-input--center"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lie-td lie-td--action">
|
||||||
<button
|
<IconButton
|
||||||
|
icon="trash-2"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
label="Remove item"
|
||||||
@click="removeItem(item.shortname)"
|
@click="removeItem(item.shortname)"
|
||||||
class="text-neutral-600 hover:text-red-400 transition-colors"
|
/>
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="text-center py-6 text-neutral-500 text-sm">
|
<EmptyState
|
||||||
No items configured for this container.
|
v-else
|
||||||
<button @click="emit('add-item')" class="text-oxide-400 hover:underline ml-1">Add one</button>
|
icon="package"
|
||||||
</div>
|
title="No items configured"
|
||||||
</div>
|
description="Add items to configure what this container can spawn."
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<Button size="sm" variant="outline" icon="plus" @click="emit('add-item')">
|
||||||
|
Add item
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lie-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge for prefab key */
|
||||||
|
.lie-prefab {
|
||||||
|
font-size: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lie-unconfigured {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings grid */
|
||||||
|
.lie-settings {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.lie-setting {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.lie-setting__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.lie-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.lie-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.lie-th {
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lie-th--num {
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
.lie-th--action {
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
.lie-tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.lie-tr:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.lie-tr:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
.lie-td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.lie-td--num {
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
.lie-td--action {
|
||||||
|
width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.lie-item-name {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.lie-item-short {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared number input */
|
||||||
|
.cc-num-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-num-input:focus {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm);
|
||||||
|
}
|
||||||
|
.cc-num-input--center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { rustItems, itemCategories } from '@/data/rust-items'
|
import { rustItems, itemCategories } from '@/data/rust-items'
|
||||||
import { Search, X } from 'lucide-vue-next'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import DsInput from '@/components/ds/forms/Input.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [shortname: string]
|
select: [shortname: string]
|
||||||
@@ -25,64 +27,200 @@ const filteredItems = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="emit('close')">
|
<Teleport to="body">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
<div class="lip-overlay" @click.self="emit('close')">
|
||||||
<!-- Header -->
|
<div class="lip-modal">
|
||||||
<div class="p-4 border-b border-neutral-800 flex items-center justify-between">
|
<!-- Header -->
|
||||||
<h2 class="text-lg font-semibold text-neutral-100">Add Item</h2>
|
<div class="lip-head">
|
||||||
<button @click="emit('close')" class="text-neutral-500 hover:text-neutral-300">
|
<span class="lip-head__title">Add item</span>
|
||||||
<X class="w-5 h-5" />
|
<IconButton icon="x" size="sm" label="Close" @click="emit('close')" />
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search + Filter -->
|
<!-- Search + category filter -->
|
||||||
<div class="p-4 space-y-3 border-b border-neutral-800">
|
<div class="lip-filters">
|
||||||
<div class="relative">
|
<DsInput
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
placeholder="Search items..."
|
icon="search"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200"
|
placeholder="Search items…"
|
||||||
autofocus
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
<div class="lip-cats">
|
||||||
|
<button
|
||||||
|
class="lip-cat"
|
||||||
|
:class="{ 'lip-cat--active': selectedCategory === 'all' }"
|
||||||
|
@click="selectedCategory = 'all'"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="cat in itemCategories"
|
||||||
|
:key="cat"
|
||||||
|
class="lip-cat"
|
||||||
|
:class="{ 'lip-cat--active': selectedCategory === cat }"
|
||||||
|
@click="selectedCategory = cat"
|
||||||
|
>
|
||||||
|
{{ cat }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<button
|
|
||||||
@click="selectedCategory = 'all'"
|
|
||||||
class="px-2 py-1 rounded text-xs"
|
|
||||||
:class="selectedCategory === 'all' ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-for="cat in itemCategories"
|
|
||||||
:key="cat"
|
|
||||||
@click="selectedCategory = cat"
|
|
||||||
class="px-2 py-1 rounded text-xs capitalize"
|
|
||||||
:class="selectedCategory === cat ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
{{ cat }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item Grid -->
|
<!-- Item grid -->
|
||||||
<div class="flex-1 overflow-y-auto p-4">
|
<div class="lip-grid-wrap">
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
<div v-if="filteredItems.length > 0" class="lip-grid">
|
||||||
<button
|
<button
|
||||||
v-for="item in filteredItems"
|
v-for="item in filteredItems"
|
||||||
:key="item.shortname"
|
:key="item.shortname"
|
||||||
@click="emit('select', item.shortname)"
|
class="lip-item"
|
||||||
class="text-left px-3 py-2 bg-neutral-800 rounded-lg hover:bg-neutral-700 transition-colors group"
|
@click="emit('select', item.shortname)"
|
||||||
>
|
>
|
||||||
<div class="text-sm text-neutral-200 group-hover:text-oxide-400">{{ item.name }}</div>
|
<span class="lip-item__name">{{ item.name }}</span>
|
||||||
<div class="text-xs text-neutral-500">{{ item.shortname }}</div>
|
<span class="lip-item__short">{{ item.shortname }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="filteredItems.length === 0" class="text-center py-8 text-neutral-500">
|
<div v-else class="lip-empty">
|
||||||
No items found
|
<Icon name="search" :size="20" class="lip-empty__icon" />
|
||||||
|
<span>No items found</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lip-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lip-modal {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.lip-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.lip-head__title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.lip-filters {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.lip-cats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.lip-cat {
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: capitalize;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.lip-cat:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.lip-cat--active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.lip-cat--active:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid */
|
||||||
|
.lip-grid-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.lip-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.lip-item {
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.lip-item:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.lip-item__name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.lip-item:hover .lip-item__name {
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
.lip-item__short {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty */
|
||||||
|
.lip-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 48px 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.lip-empty__icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
configData: Record<string, any>
|
configData: Record<string, any>
|
||||||
@@ -47,7 +49,6 @@ function ensurePaths(data: Record<string, any>) {
|
|||||||
function addGroup() {
|
function addGroup() {
|
||||||
const name = newGroupName.value.trim()
|
const name = newGroupName.value.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
// Check if already exists
|
|
||||||
if (groups.value.some(g => g.name === name)) return
|
if (groups.value.some(g => g.name === name)) return
|
||||||
|
|
||||||
const updated = { ...props.configData }
|
const updated = { ...props.configData }
|
||||||
@@ -95,96 +96,95 @@ function updateField(groupName: string, field: string, value: number) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="pge">
|
||||||
<div class="flex items-center justify-between">
|
<div class="pge__head">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">VIP Permission Groups</h3>
|
<div class="pge__section-label">VIP permission groups</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Group -->
|
<!-- Add group row -->
|
||||||
<div class="flex gap-2">
|
<div class="pge__add-row">
|
||||||
<input
|
<input
|
||||||
v-model="newGroupName"
|
v-model="newGroupName"
|
||||||
placeholder="New group name (e.g. vip, vip+, mvp)..."
|
type="text"
|
||||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
class="pge__name-input"
|
||||||
|
placeholder="New group name (e.g. vip, vip+, mvp)…"
|
||||||
@keydown.enter="addGroup"
|
@keydown.enter="addGroup"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
@click="addGroup"
|
size="sm"
|
||||||
|
icon="plus"
|
||||||
:disabled="!newGroupName.trim()"
|
:disabled="!newGroupName.trim()"
|
||||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
@click="addGroup"
|
||||||
>
|
>Add group</Button>
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
Add Group
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty state -->
|
||||||
<div v-if="groups.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
<EmptyState
|
||||||
No VIP groups defined. Add groups to configure per-permission teleport limits, cooldowns, and countdowns.
|
v-if="groups.length === 0"
|
||||||
</div>
|
icon="users"
|
||||||
|
title="No VIP groups defined"
|
||||||
|
description="Add groups to configure per-permission teleport limits, cooldowns, and countdowns."
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Groups Table -->
|
<!-- Groups table -->
|
||||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
|
<div v-else class="pge__table-wrap">
|
||||||
<table class="w-full text-sm">
|
<table class="pge__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800">
|
<tr>
|
||||||
<th class="text-left py-3 px-4 text-neutral-500 font-medium">Group Name</th>
|
<th class="pge__th pge__th--left">Group name</th>
|
||||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Homes Limit</th>
|
<th class="pge__th">Homes limit</th>
|
||||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Cooldown (s)</th>
|
<th class="pge__th">Cooldown (s)</th>
|
||||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Countdown (s)</th>
|
<th class="pge__th">Countdown (s)</th>
|
||||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Daily Limit</th>
|
<th class="pge__th">Daily limit</th>
|
||||||
<th class="w-12"></th>
|
<th class="pge__th pge__th--action" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="group in groups"
|
v-for="group in groups"
|
||||||
:key="group.name"
|
:key="group.name"
|
||||||
class="border-b border-neutral-800/50"
|
class="pge__tr"
|
||||||
>
|
>
|
||||||
<td class="py-3 px-4 text-neutral-200 font-medium">{{ group.name }}</td>
|
<td class="pge__td pge__td--name">{{ group.name }}</td>
|
||||||
<td class="py-3 px-4">
|
<td class="pge__td pge__td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
class="pge__num-input"
|
||||||
:value="group.homesLimit"
|
:value="group.homesLimit"
|
||||||
|
min="0"
|
||||||
@input="updateField(group.name, 'homesLimit', Number(($event.target as HTMLInputElement).value))"
|
@input="updateField(group.name, 'homesLimit', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4">
|
<td class="pge__td pge__td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
class="pge__num-input"
|
||||||
:value="group.cooldown"
|
:value="group.cooldown"
|
||||||
|
min="0"
|
||||||
@input="updateField(group.name, 'cooldown', Number(($event.target as HTMLInputElement).value))"
|
@input="updateField(group.name, 'cooldown', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4">
|
<td class="pge__td pge__td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
class="pge__num-input"
|
||||||
:value="group.countdown"
|
:value="group.countdown"
|
||||||
@input="updateField(group.name, 'countdown', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
|
||||||
min="0"
|
min="0"
|
||||||
|
@input="updateField(group.name, 'countdown', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4">
|
<td class="pge__td pge__td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
class="pge__num-input"
|
||||||
:value="group.dailyLimit"
|
:value="group.dailyLimit"
|
||||||
@input="updateField(group.name, 'dailyLimit', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
|
||||||
min="0"
|
min="0"
|
||||||
|
@input="updateField(group.name, 'dailyLimit', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4">
|
<td class="pge__td pge__td--action">
|
||||||
<button
|
<button class="pge__del" type="button" @click="removeGroup(group.name)">
|
||||||
@click="removeGroup(group.name)"
|
<Icon name="trash-2" :size="15" />
|
||||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -193,3 +193,63 @@ function updateField(groupName: string, field: string, value: number) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ---------- Shell ---------- */
|
||||||
|
.pge { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
|
/* ---------- Head ---------- */
|
||||||
|
.pge__head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.pge__section-label {
|
||||||
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Add row ---------- */
|
||||||
|
.pge__add-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.pge__name-input {
|
||||||
|
flex: 1; height: var(--control-h-sm); padding: 0 10px;
|
||||||
|
background: var(--surface-inset); border: 0; border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--ring-default); font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-sm); color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.pge__name-input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.pge__name-input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ---------- Table ---------- */
|
||||||
|
.pge__table-wrap { border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--ring-default); }
|
||||||
|
.pge__table { width: 100%; border-collapse: collapse; }
|
||||||
|
.pge__th {
|
||||||
|
padding: 9px 12px; font-size: var(--text-xs); font-weight: 600;
|
||||||
|
color: var(--text-tertiary); text-align: center;
|
||||||
|
background: var(--surface-raised); border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.pge__th--left { text-align: left; }
|
||||||
|
.pge__th--action { width: 44px; }
|
||||||
|
.pge__tr { border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.pge__tr:last-child { border-bottom: 0; }
|
||||||
|
.pge__tr:hover { background: var(--surface-hover); }
|
||||||
|
.pge__td { padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); }
|
||||||
|
.pge__td--name { font-weight: 500; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.pge__td--num { text-align: center; }
|
||||||
|
.pge__td--action { text-align: center; }
|
||||||
|
|
||||||
|
/* ---------- Number input (table cell) ---------- */
|
||||||
|
.pge__num-input {
|
||||||
|
width: 80px; height: 28px; padding: 0 8px; text-align: center;
|
||||||
|
background: var(--surface-inset); border: 0; border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--ring-default); font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm); color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.pge__num-input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
|
||||||
|
/* ---------- Delete button ---------- */
|
||||||
|
.pge__del {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; border-radius: var(--radius-sm); border: none;
|
||||||
|
background: transparent; color: var(--text-muted); cursor: pointer;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.pge__del:hover { color: var(--danger); background: var(--status-offline-soft); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
warps: Record<string, { x: number; y: number; z: number }>
|
warps: Record<string, { x: number; y: number; z: number }>
|
||||||
@@ -28,49 +30,139 @@ function removeWarp(name: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="warp-editor">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Warps</h3>
|
<div class="warp-editor__label">Warps</div>
|
||||||
|
|
||||||
<!-- Add Warp -->
|
<!-- Add warp row -->
|
||||||
<div class="flex gap-2">
|
<div class="warp-editor__add">
|
||||||
<input
|
<Input
|
||||||
v-model="newWarpName"
|
v-model="newWarpName"
|
||||||
placeholder="Warp name..."
|
placeholder="Warp name..."
|
||||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
:mono="true"
|
||||||
|
style="flex: 1"
|
||||||
@keydown.enter="addWarp"
|
@keydown.enter="addWarp"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
@click="addWarp"
|
size="sm"
|
||||||
|
icon="plus"
|
||||||
:disabled="!newWarpName.trim()"
|
:disabled="!newWarpName.trim()"
|
||||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
@click="addWarp"
|
||||||
>
|
>Add</Button>
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Warp List -->
|
<!-- Empty state -->
|
||||||
<div v-if="Object.keys(warps).length === 0" class="text-neutral-500 text-sm text-center py-4">
|
<div v-if="Object.keys(warps).length === 0" class="warp-editor__empty">
|
||||||
No warps defined. Add warps here and set coordinates in-game.
|
No warps defined. Add warps here and set coordinates in-game.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Warp list -->
|
||||||
<div
|
<div
|
||||||
v-for="(coords, name) in warps"
|
v-for="(coords, name) in warps"
|
||||||
:key="name"
|
:key="name"
|
||||||
class="flex items-center justify-between bg-neutral-800/50 border border-neutral-700/50 rounded-lg px-4 py-3"
|
class="warp-row"
|
||||||
>
|
>
|
||||||
<div>
|
<div class="warp-row__id">
|
||||||
<span class="text-neutral-200 font-medium">{{ name }}</span>
|
<span class="warp-row__name">{{ name }}</span>
|
||||||
<span class="text-neutral-500 text-xs ml-3">
|
<span class="warp-row__coords">
|
||||||
{{ coords.x.toFixed(1) }}, {{ coords.y.toFixed(1) }}, {{ coords.z.toFixed(1) }}
|
{{ (coords as { x: number; y: number; z: number }).x.toFixed(1) }},
|
||||||
|
{{ (coords as { x: number; y: number; z: number }).y.toFixed(1) }},
|
||||||
|
{{ (coords as { x: number; y: number; z: number }).z.toFixed(1) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
class="warp-row__remove"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Remove warp ${name}`"
|
||||||
@click="removeWarp(name as string)"
|
@click="removeWarp(name as string)"
|
||||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4" />
|
<Icon name="trash-2" :size="14" :stroke-width="2" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.warp-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-editor__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-editor__add {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-editor__empty {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2-5) var(--space-3);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row__id {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row__name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row__coords {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row__remove {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: background var(--dur-fast) var(--ease-standard),
|
||||||
|
color var(--dur-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row__remove:hover {
|
||||||
|
background: var(--status-offline-soft);
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row__remove:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
114
frontend/src/composables/useThemeGame.ts
Normal file
114
frontend/src/composables/useThemeGame.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* useThemeGame — the Corrosion design-system theming contract.
|
||||||
|
*
|
||||||
|
* Drives `data-theme` and `data-game` on <html>, the two attributes the token
|
||||||
|
* system keys off (see styles/tokens/colors.css + game-themes.css):
|
||||||
|
* <html data-theme="dark|light" data-game="rust|dune|conan|soulmask|...">
|
||||||
|
*
|
||||||
|
* Dark is primary; Rust (Oxide Orange) is the default/brand accent.
|
||||||
|
*
|
||||||
|
* Runtime swaps add the `cc-skin-swap` class for one frame so every
|
||||||
|
* accent-consuming surface repaints immediately — without it Chrome leaves
|
||||||
|
* elements that read var(--accent) AND have a color/bg transition on the old
|
||||||
|
* accent until the next reflow (see styles/tokens/base.css + readme).
|
||||||
|
*/
|
||||||
|
import { ref, readonly } from 'vue'
|
||||||
|
|
||||||
|
export type Theme = 'dark' | 'light'
|
||||||
|
export type Game =
|
||||||
|
| 'rust'
|
||||||
|
| 'dune'
|
||||||
|
| 'conan'
|
||||||
|
| 'soulmask'
|
||||||
|
| 'ark'
|
||||||
|
| 'valheim'
|
||||||
|
| 'palworld'
|
||||||
|
|
||||||
|
/** The fleet filter: 'all' (every game) plus each individual game. */
|
||||||
|
export type ActiveGame = 'all' | Game
|
||||||
|
|
||||||
|
const THEME_KEY = 'cc-theme'
|
||||||
|
const GAME_KEY = 'cc-game'
|
||||||
|
const ACTIVE_GAME_KEY = 'cc-active-game'
|
||||||
|
|
||||||
|
const VALID_THEMES: readonly Theme[] = ['dark', 'light']
|
||||||
|
const VALID_GAMES: readonly Game[] = [
|
||||||
|
'rust',
|
||||||
|
'dune',
|
||||||
|
'conan',
|
||||||
|
'soulmask',
|
||||||
|
'ark',
|
||||||
|
'valheim',
|
||||||
|
'palworld',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Module-scope singletons so every caller shares one reactive source.
|
||||||
|
const theme = ref<Theme>('dark')
|
||||||
|
const game = ref<Game>('rust')
|
||||||
|
// Fleet filter: 'all' shows every game and uses the neutral house skin (Oxide);
|
||||||
|
// a specific game both filters the fleet AND re-skins the shell (the drill-in rule).
|
||||||
|
const activeGame = ref<ActiveGame>('all')
|
||||||
|
|
||||||
|
function apply(): void {
|
||||||
|
const el = document.documentElement
|
||||||
|
el.classList.add('cc-skin-swap')
|
||||||
|
el.setAttribute('data-theme', theme.value)
|
||||||
|
el.setAttribute('data-game', game.value)
|
||||||
|
// Keep Tailwind's `dark` class in sync — existing views may use `dark:` utilities.
|
||||||
|
el.classList.toggle('dark', theme.value === 'dark')
|
||||||
|
// Drop the swap guard after the paint that picked up the new accent.
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() => el.classList.remove('cc-skin-swap')),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialized = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read persisted prefs and apply them to <html>. Call once at app start
|
||||||
|
* (after a tiny inline FOUC guard in index.html has set the initial attrs).
|
||||||
|
*/
|
||||||
|
export function initThemeGame(): void {
|
||||||
|
if (initialized) return
|
||||||
|
const t = localStorage.getItem(THEME_KEY)
|
||||||
|
if (t && (VALID_THEMES as string[]).includes(t)) theme.value = t as Theme
|
||||||
|
const ag = localStorage.getItem(ACTIVE_GAME_KEY)
|
||||||
|
if (ag && (ag === 'all' || (VALID_GAMES as string[]).includes(ag))) {
|
||||||
|
activeGame.value = ag as ActiveGame
|
||||||
|
}
|
||||||
|
// Skin follows the filter: 'all' -> neutral house (rust/oxide), else the game.
|
||||||
|
game.value = activeGame.value === 'all' ? 'rust' : activeGame.value
|
||||||
|
apply()
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemeGame() {
|
||||||
|
function setTheme(t: Theme): void {
|
||||||
|
theme.value = t
|
||||||
|
localStorage.setItem(THEME_KEY, t)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
function setGame(g: Game): void {
|
||||||
|
game.value = g
|
||||||
|
localStorage.setItem(GAME_KEY, g)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
function setActiveGame(g: ActiveGame): void {
|
||||||
|
activeGame.value = g
|
||||||
|
localStorage.setItem(ACTIVE_GAME_KEY, g)
|
||||||
|
// 'all' uses the neutral house skin (rust/oxide); a game re-skins to itself.
|
||||||
|
setGame(g === 'all' ? 'rust' : g)
|
||||||
|
}
|
||||||
|
function toggleTheme(): void {
|
||||||
|
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
theme: readonly(theme),
|
||||||
|
game: readonly(game),
|
||||||
|
activeGame: readonly(activeGame),
|
||||||
|
setTheme,
|
||||||
|
setGame,
|
||||||
|
setActiveGame,
|
||||||
|
toggleTheme,
|
||||||
|
}
|
||||||
|
}
|
||||||
342
frontend/src/config/gameProfiles.ts
Normal file
342
frontend/src/config/gameProfiles.ts
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* gameProfiles.ts — Source of truth for per-game UI adaptation.
|
||||||
|
*
|
||||||
|
* Every game-specific label, terminology, Steam app ID, management model,
|
||||||
|
* stat field list, AND sidebar nav lives here. The dashboard, server cards,
|
||||||
|
* wipe manager, sidebar, and any future multi-game surface should key off this
|
||||||
|
* registry — never hard-code game-specific strings in components.
|
||||||
|
*
|
||||||
|
* Backend status: the backend has NO game field on licenses yet. Today every
|
||||||
|
* license is implicitly Rust. This registry is ready: when the backend adds a
|
||||||
|
* `game` column to `licenses` (or `server_config`), the frontend only needs to
|
||||||
|
* read that field and call `useGameProfile(id)` — no component changes required.
|
||||||
|
*
|
||||||
|
* To add a new game: add a GameId union member and a corresponding entry in
|
||||||
|
* GAME_PROFILES. Nothing else changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Nav structure — drives the per-game sidebar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A single sidebar nav item. route must be an existing panel route path. */
|
||||||
|
export interface NavItemDef {
|
||||||
|
label: string
|
||||||
|
route: string
|
||||||
|
icon: string
|
||||||
|
/** Permission key required to show this item (e.g. 'plugins.view'). Null = always visible. */
|
||||||
|
permission: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A labelled section grouping nav items in the sidebar. */
|
||||||
|
export interface NavSection {
|
||||||
|
/** Section heading (eyebrow text). Empty string = no heading. */
|
||||||
|
label: string
|
||||||
|
items: NavItemDef[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Union types — exhaustive, never widen to string
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Every supported game identifier. */
|
||||||
|
export type GameId = 'rust' | 'conan' | 'soulmask' | 'dune'
|
||||||
|
|
||||||
|
/** How the server process is managed. */
|
||||||
|
export type ManagementModel = 'process+rcon' | 'docker-compose'
|
||||||
|
|
||||||
|
/** Mod ecosystem the game uses. */
|
||||||
|
export type ModSystem = 'umod' | 'workshop' | 'none'
|
||||||
|
|
||||||
|
/** Primary console / remote-admin interface. */
|
||||||
|
export type ConsoleType = 'rcon' | 'rcon+ingame' | 'rcon+gm' | 'rabbitmq'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a "reset" is performed — each value maps to a distinct wipe code path.
|
||||||
|
* Pipe-delimited strings intentionally encode composite operations.
|
||||||
|
*/
|
||||||
|
export type ResetModel =
|
||||||
|
| 'map-bp-wipe'
|
||||||
|
| 'wipe-world-structures+decay'
|
||||||
|
| 'worlddb-delete+decay'
|
||||||
|
| 'deep-desert-coriolis-seed'
|
||||||
|
|
||||||
|
/** Cross-server or character-sharing mechanism. */
|
||||||
|
export type ClusteringModel = 'none' | 'character-transfer' | 'main-client' | 'battlegroup'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GameProfile shape
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface GameTerminology {
|
||||||
|
/** What the operator calls a reset / wipe. */
|
||||||
|
reset: string
|
||||||
|
/** What the operator calls plugins / mods (null if no mod system). */
|
||||||
|
mods: string | null
|
||||||
|
/** What the operator calls a player group / faction. */
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamePorts {
|
||||||
|
game: number
|
||||||
|
query: number
|
||||||
|
rcon: number
|
||||||
|
cluster?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameProfile {
|
||||||
|
/** Human-readable game name. */
|
||||||
|
label: string
|
||||||
|
/** CSS design-token key — maps to data-game attr and --accent token. */
|
||||||
|
accent: string
|
||||||
|
managementModel: ManagementModel
|
||||||
|
steamAppId: number | { windows: number; linux: number }
|
||||||
|
/** Default ports (game-specific defaults; operator can override). */
|
||||||
|
ports?: GamePorts
|
||||||
|
mods: ModSystem
|
||||||
|
console: ConsoleType
|
||||||
|
resetModel: ResetModel
|
||||||
|
clustering: ClusteringModel
|
||||||
|
/** Available map names, if the game ships with named maps. */
|
||||||
|
maps?: string[]
|
||||||
|
terminology: GameTerminology
|
||||||
|
/** Notable game-specific mechanics that affect server administration. */
|
||||||
|
special?: string[]
|
||||||
|
/**
|
||||||
|
* Stat field labels shown on server cards and the dashboard.
|
||||||
|
* First entry is always Players; subsequent entries are game-specific.
|
||||||
|
*/
|
||||||
|
statFields: [string, string, string]
|
||||||
|
/**
|
||||||
|
* Per-game sidebar navigation. Ordered list of sections, each with items.
|
||||||
|
* Items MUST use only existing panel routes (see router/index.ts).
|
||||||
|
* The sidebar renders exactly these sections for the active game.
|
||||||
|
*/
|
||||||
|
nav: NavSection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared nav building blocks — reused across game nav definitions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const NAV_DASHBOARD: NavItemDef = { label: 'Dashboard', route: '/', icon: 'layout-dashboard', permission: null }
|
||||||
|
const NAV_SERVER: NavItemDef = { label: 'Server', route: '/server', icon: 'server', permission: 'server.view' }
|
||||||
|
const NAV_CONSOLE: NavItemDef = { label: 'Console', route: '/console', icon: 'terminal', permission: 'console.view' }
|
||||||
|
const NAV_PLAYERS: NavItemDef = { label: 'Players', route: '/players', icon: 'users', permission: 'players.view' }
|
||||||
|
const NAV_PLUGINS: NavItemDef = { label: 'Plugins (uMod)', route: '/plugins', icon: 'puzzle', permission: 'plugins.view' }
|
||||||
|
const NAV_FILES: NavItemDef = { label: 'File manager', route: '/files', icon: 'folder-open', permission: 'files.view' }
|
||||||
|
const NAV_PLUGIN_CONFIGS: NavItemDef = { label: 'Plugin configs', route: '/plugin-configs', icon: 'sliders', permission: null }
|
||||||
|
const NAV_SCHEDULES: NavItemDef = { label: 'Schedules', route: '/schedules', icon: 'calendar-clock', permission: 'schedules.view' }
|
||||||
|
const NAV_CHAT: NavItemDef = { label: 'Chat log', route: '/chat', icon: 'message-square', permission: 'chat.view' }
|
||||||
|
const NAV_ANALYTICS: NavItemDef = { label: 'Analytics', route: '/analytics', icon: 'bar-chart-3', permission: 'analytics.view' }
|
||||||
|
const NAV_ALERTS: NavItemDef = { label: 'Alerts', route: '/alerts', icon: 'triangle-alert', permission: 'alerts.view' }
|
||||||
|
const NAV_NOTIFICATIONS: NavItemDef = { label: 'Notifications', route: '/notifications', icon: 'bell', permission: 'notifications.view' }
|
||||||
|
const NAV_TEAM: NavItemDef = { label: 'Team', route: '/team', icon: 'users', permission: null }
|
||||||
|
const NAV_STORE: NavItemDef = { label: 'Store', route: '/store/config', icon: 'shopping-cart', permission: 'store.view' }
|
||||||
|
const NAV_MODULES: NavItemDef = { label: 'Modules', route: '/modules', icon: 'layers', permission: 'modules.view' }
|
||||||
|
const NAV_CHANGELOG: NavItemDef = { label: 'Changelog', route: '/changelog', icon: 'file-text', permission: 'changelog.view' }
|
||||||
|
const NAV_SETTINGS: NavItemDef = { label: 'Settings', route: '/settings', icon: 'settings', permission: 'settings.view' }
|
||||||
|
const NAV_MAPS: NavItemDef = { label: 'Maps', route: '/maps', icon: 'map', permission: 'maps.view' }
|
||||||
|
|
||||||
|
/** Full Rust / 'all' nav — superset used as fallback. */
|
||||||
|
const RUST_NAV: NavSection[] = [
|
||||||
|
{ label: '', items: [NAV_DASHBOARD] },
|
||||||
|
{
|
||||||
|
label: 'Server',
|
||||||
|
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_PLUGINS, NAV_FILES],
|
||||||
|
},
|
||||||
|
{ label: 'Plugin configs', items: [NAV_PLUGIN_CONFIGS] },
|
||||||
|
{
|
||||||
|
label: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ label: 'Wipe', route: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
|
||||||
|
NAV_MAPS,
|
||||||
|
NAV_SCHEDULES,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Monitoring',
|
||||||
|
items: [NAV_CHAT, NAV_ANALYTICS, NAV_ALERTS, NAV_NOTIFICATIONS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Management',
|
||||||
|
items: [NAV_TEAM, NAV_STORE, NAV_MODULES, NAV_CHANGELOG, NAV_SETTINGS],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const GAME_PROFILES: Record<GameId, GameProfile> = {
|
||||||
|
rust: {
|
||||||
|
label: 'Rust',
|
||||||
|
accent: 'rust',
|
||||||
|
managementModel: 'process+rcon',
|
||||||
|
steamAppId: 258550,
|
||||||
|
mods: 'umod',
|
||||||
|
console: 'rcon',
|
||||||
|
resetModel: 'map-bp-wipe',
|
||||||
|
clustering: 'none',
|
||||||
|
terminology: {
|
||||||
|
reset: 'Wipe',
|
||||||
|
mods: 'Plugins',
|
||||||
|
group: 'Team',
|
||||||
|
},
|
||||||
|
statFields: ['Players', 'uMod', 'Wipe'],
|
||||||
|
nav: RUST_NAV,
|
||||||
|
},
|
||||||
|
|
||||||
|
conan: {
|
||||||
|
label: 'Conan Exiles',
|
||||||
|
accent: 'conan',
|
||||||
|
managementModel: 'process+rcon',
|
||||||
|
steamAppId: 443030,
|
||||||
|
ports: { game: 7777, query: 27015, rcon: 25575 },
|
||||||
|
mods: 'workshop',
|
||||||
|
console: 'rcon+ingame',
|
||||||
|
// Player progress persists across world wipes — only structures are cleared.
|
||||||
|
resetModel: 'wipe-world-structures+decay',
|
||||||
|
clustering: 'character-transfer',
|
||||||
|
maps: ['Exiled Lands', 'Isle of Siptah'],
|
||||||
|
terminology: {
|
||||||
|
reset: 'Wipe World',
|
||||||
|
mods: 'Mods',
|
||||||
|
group: 'Clan',
|
||||||
|
},
|
||||||
|
special: ['Clans', 'Thralls', 'Avatars', 'Purge', 'PvP windows'],
|
||||||
|
statFields: ['Players', 'Clans', 'Purge'],
|
||||||
|
nav: [
|
||||||
|
{ label: '', items: [NAV_DASHBOARD] },
|
||||||
|
{
|
||||||
|
label: 'Server',
|
||||||
|
// Conan: no uMod/Oxide; has RCON console, maps, players, files
|
||||||
|
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ label: 'Wipe World', route: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
|
||||||
|
NAV_MAPS,
|
||||||
|
NAV_SCHEDULES,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Monitoring',
|
||||||
|
items: [NAV_CHAT, NAV_ANALYTICS, NAV_ALERTS, NAV_NOTIFICATIONS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Management',
|
||||||
|
items: [NAV_TEAM, NAV_STORE, NAV_MODULES, NAV_CHANGELOG, NAV_SETTINGS],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
soulmask: {
|
||||||
|
label: 'Soulmask',
|
||||||
|
accent: 'soulmask',
|
||||||
|
managementModel: 'process+rcon',
|
||||||
|
// Different Steam app IDs per OS (uncommon — store this explicitly).
|
||||||
|
steamAppId: { windows: 3017310, linux: 3017300 },
|
||||||
|
ports: { game: 8777, query: 27015, rcon: 19000, cluster: 20000 },
|
||||||
|
mods: 'workshop',
|
||||||
|
console: 'rcon+gm',
|
||||||
|
resetModel: 'worlddb-delete+decay',
|
||||||
|
clustering: 'main-client',
|
||||||
|
maps: ['Cloud Mist Forest', 'Shifting Sands'],
|
||||||
|
terminology: {
|
||||||
|
reset: 'World Reset',
|
||||||
|
mods: 'Workshop Mods',
|
||||||
|
group: 'Tribe',
|
||||||
|
},
|
||||||
|
special: ['Cluster', 'Tribes'],
|
||||||
|
statFields: ['Players', 'Tribe', 'Mask'],
|
||||||
|
nav: [
|
||||||
|
{ label: '', items: [NAV_DASHBOARD] },
|
||||||
|
{
|
||||||
|
label: 'Server',
|
||||||
|
// Soulmask: no uMod/Oxide; has RCON+GM console, players, files
|
||||||
|
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ label: 'World Reset', route: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
|
||||||
|
NAV_SCHEDULES,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Monitoring',
|
||||||
|
items: [NAV_CHAT, NAV_ANALYTICS, NAV_ALERTS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Management',
|
||||||
|
items: [NAV_TEAM, NAV_STORE, NAV_MODULES, NAV_CHANGELOG, NAV_SETTINGS],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
dune: {
|
||||||
|
label: 'Dune: Awakening',
|
||||||
|
accent: 'dune',
|
||||||
|
managementModel: 'docker-compose',
|
||||||
|
steamAppId: 4754530,
|
||||||
|
mods: 'none',
|
||||||
|
// Dune uses RabbitMQ for its admin messaging — not a standard RCON port.
|
||||||
|
console: 'rabbitmq',
|
||||||
|
resetModel: 'deep-desert-coriolis-seed',
|
||||||
|
clustering: 'battlegroup',
|
||||||
|
terminology: {
|
||||||
|
reset: 'Deep Desert reset',
|
||||||
|
mods: null,
|
||||||
|
group: 'Guild',
|
||||||
|
},
|
||||||
|
special: ['Sietches', 'Deep Desert', 'Bases', 'Landsraad'],
|
||||||
|
statFields: ['Players', 'Sietches', 'Control'],
|
||||||
|
nav: [
|
||||||
|
{ label: '', items: [NAV_DASHBOARD] },
|
||||||
|
{
|
||||||
|
label: 'Server',
|
||||||
|
// Dune: no RCON (uses RabbitMQ); label console "Broadcast"; no maps route; no plugins
|
||||||
|
items: [
|
||||||
|
NAV_SERVER,
|
||||||
|
{ label: 'Broadcast', route: '/console', icon: 'radio', permission: 'console.view' },
|
||||||
|
NAV_PLAYERS,
|
||||||
|
NAV_FILES,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ label: 'Deep Desert', route: '/wipes', icon: 'wind', permission: 'wipes.view' },
|
||||||
|
NAV_SCHEDULES,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Monitoring',
|
||||||
|
items: [NAV_ANALYTICS, NAV_ALERTS],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Management',
|
||||||
|
items: [NAV_TEAM, NAV_STORE, NAV_CHANGELOG, NAV_SETTINGS],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the GameProfile for the given id, falling back to Rust if the id is
|
||||||
|
* unknown (forward-compatibility: unknown games show Rust defaults until their
|
||||||
|
* profile is added).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const profile = useGameProfile('rust')
|
||||||
|
* console.log(profile.terminology.reset) // 'Wipe'
|
||||||
|
*/
|
||||||
|
export function useGameProfile(id: string): GameProfile {
|
||||||
|
return (GAME_PROFILES as Record<string, GameProfile>)[id] ?? GAME_PROFILES.rust
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
|||||||
import { VueFinderPlugin } from 'vuefinder'
|
import { VueFinderPlugin } from 'vuefinder'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { initThemeGame } from './composables/useThemeGame'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import 'vuefinder/dist/vuefinder.css'
|
import 'vuefinder/dist/vuefinder.css'
|
||||||
|
|
||||||
@@ -17,4 +18,7 @@ app.use(pinia)
|
|||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(VueFinderPlugin)
|
app.use(VueFinderPlugin)
|
||||||
|
|
||||||
|
// Apply the design-system theming contract (data-theme/data-game on <html>).
|
||||||
|
initThemeGame()
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* Corrosion Brand — Oxide Orange #F26622 */
|
/* Corrosion design tokens — load order matters: fonts → primitives → colors →
|
||||||
|
game themes → elevation → base (base references color/accent tokens).
|
||||||
|
Imported DIRECTLY here (not via a nested ./styles/corrosion.css barrel): a
|
||||||
|
nested @import barrel placed after `@import "tailwindcss"` gets its inner
|
||||||
|
@imports dropped, because once Tailwind v4 expands in place they no longer
|
||||||
|
precede all other statements. Keeping them flat + contiguous fixes that. */
|
||||||
|
@import "./styles/tokens/fonts.css";
|
||||||
|
@import "./styles/tokens/spacing.css";
|
||||||
|
@import "./styles/tokens/typography.css";
|
||||||
|
@import "./styles/tokens/motion.css";
|
||||||
|
@import "./styles/tokens/colors.css";
|
||||||
|
@import "./styles/tokens/game-themes.css";
|
||||||
|
@import "./styles/tokens/elevation.css";
|
||||||
|
@import "./styles/tokens/base.css";
|
||||||
|
|
||||||
|
/* Tailwind utility colors — Oxide ramp (existing views use bg-oxide-*).
|
||||||
|
The full design-token system (neutral ramp, surfaces, per-game accents,
|
||||||
|
typography, spacing, elevation, motion) lives in ./styles/ and is the
|
||||||
|
source of truth for the redesign. */
|
||||||
@theme {
|
@theme {
|
||||||
--color-oxide-50: #FEF3EB;
|
--color-oxide-50: #FEF3EB;
|
||||||
--color-oxide-100: #FDE3D0;
|
--color-oxide-100: #FDE3D0;
|
||||||
@@ -15,7 +33,8 @@
|
|||||||
--color-oxide-950: #3D1506;
|
--color-oxide-950: #3D1506;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode is default — Rust servers run at night */
|
/* Legacy brand vars — retained during the redesign port so any view still
|
||||||
|
referencing them keeps working; superseded by the ./styles tokens. */
|
||||||
:root {
|
:root {
|
||||||
--corrosion-accent: #F26622;
|
--corrosion-accent: #F26622;
|
||||||
--corrosion-dark: #000000;
|
--corrosion-dark: #000000;
|
||||||
@@ -24,12 +43,8 @@
|
|||||||
--corrosion-border: #2a2a2a;
|
--corrosion-border: #2a2a2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
/* Body background / text / font now come from the design system
|
||||||
@apply bg-neutral-950 text-neutral-100 antialiased;
|
(./styles/tokens/base.css → var(--surface-canvas), var(--text-primary)). */
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|||||||
15
frontend/src/styles/corrosion.css
Normal file
15
frontend/src/styles/corrosion.css
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Design System
|
||||||
|
Root stylesheet. Consumers link THIS one file.
|
||||||
|
Import order matters: fonts → primitives → colors → game themes
|
||||||
|
→ base. (base.css references color/accent tokens.)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
@import url("tokens/fonts.css");
|
||||||
|
@import url("tokens/spacing.css");
|
||||||
|
@import url("tokens/typography.css");
|
||||||
|
@import url("tokens/motion.css");
|
||||||
|
@import url("tokens/colors.css");
|
||||||
|
@import url("tokens/game-themes.css");
|
||||||
|
@import url("tokens/elevation.css");
|
||||||
|
@import url("tokens/base.css");
|
||||||
846
frontend/src/styles/marketing.css
Normal file
846
frontend/src/styles/marketing.css
Normal file
@@ -0,0 +1,846 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion — Marketing site styles
|
||||||
|
Consumes the design-system tokens already loaded globally
|
||||||
|
via frontend/src/style.css (tokens/fonts → colors → etc.).
|
||||||
|
Class names match the design kit exactly.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.wrap { max-width: 1140px; margin: 0 auto; padding: 0 32px; }
|
||||||
|
section { position: relative; }
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: var(--tracking-caps);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2.title {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-4xl);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin: 16px auto 0;
|
||||||
|
max-width: 660px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent { color: var(--accent-text); }
|
||||||
|
|
||||||
|
.mark { display: inline-block; color: var(--accent); }
|
||||||
|
.mark svg { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
/* ---- Buttons ---- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 46px;
|
||||||
|
padding: 0 22px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: var(--transition-colors), transform var(--dur-fast) var(--ease-standard);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:active { transform: translateY(1px); }
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
.btn--primary:hover { background: var(--accent-hover); }
|
||||||
|
|
||||||
|
.btn--ghost {
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.btn--ghost:hover { background: var(--surface-active); }
|
||||||
|
|
||||||
|
.btn--sm { height: 36px; padding: 0 14px; font-size: var(--text-sm); }
|
||||||
|
.btn--lg { height: 52px; padding: 0 28px; font-size: var(--text-md); }
|
||||||
|
|
||||||
|
/* ---- Nav ---- */
|
||||||
|
.mkt-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
height: var(--topbar-h);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: color-mix(in srgb, var(--surface-canvas) 84%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.mkt-nav__in { display: flex; align-items: center; gap: 24px; width: 100%; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; }
|
||||||
|
.brand .mark { width: 26px; height: 26px; }
|
||||||
|
.brand b {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.mkt-nav__links { display: flex; gap: 24px; margin-left: 14px; }
|
||||||
|
.mkt-nav__links a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color var(--dur-fast);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.mkt-nav__links a:hover { color: var(--text-primary); }
|
||||||
|
.mkt-nav__cta { margin-left: auto; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.mkt-nav__signin {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--dur-fast);
|
||||||
|
}
|
||||||
|
.mkt-nav__signin:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ---- Hero ---- */
|
||||||
|
.hero { overflow: hidden; border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.hero__atmo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
transition: background var(--dur-slower) var(--ease-standard);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 80% at 50% -10%, var(--atmo-haze), transparent 55%),
|
||||||
|
radial-gradient(70% 50% at 85% 110%, color-mix(in srgb, var(--accent) 9%, transparent), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--atmo-1), var(--surface-canvas) 72%);
|
||||||
|
}
|
||||||
|
.hero__grain {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: .5;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
background-image: radial-gradient(rgba(255,255,255,.05) 1px, transparent 1px);
|
||||||
|
background-size: 3px 3px;
|
||||||
|
}
|
||||||
|
.hero__grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: .32;
|
||||||
|
-webkit-mask-image: radial-gradient(75% 60% at 50% 24%, #000, transparent 78%);
|
||||||
|
mask-image: radial-gradient(75% 60% at 50% 24%, #000, transparent 78%);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--border-subtle) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--border-subtle) 1px, transparent 1px);
|
||||||
|
background-size: 46px 46px;
|
||||||
|
}
|
||||||
|
.hero__in {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 74px 0 88px;
|
||||||
|
}
|
||||||
|
.hero__mark {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin: 0 auto 22px;
|
||||||
|
color: var(--accent);
|
||||||
|
filter: drop-shadow(0 0 26px var(--accent-glow));
|
||||||
|
transition: color var(--dur-slow);
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: var(--text-6xl);
|
||||||
|
line-height: 1.04;
|
||||||
|
letter-spacing: 0.005em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.hero h1 .accent { display: block; }
|
||||||
|
.hero__sub {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin: 22px auto 0;
|
||||||
|
max-width: 640px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.hero__cta {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
.hero__games {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 34px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.gpill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.gpill[data-on="true"] {
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.hero__foot {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.notpill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.notpill b { color: var(--accent-text); }
|
||||||
|
|
||||||
|
/* ---- Panel mockup ---- */
|
||||||
|
.mock {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 54px auto 0;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-base);
|
||||||
|
box-shadow: 0 50px 130px -34px rgba(0,0,0,.85), var(--ring-default);
|
||||||
|
}
|
||||||
|
.mock__bar {
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.mock__dots { display: flex; gap: 7px; }
|
||||||
|
.mock__dots span {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-active);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.mock__url {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
.mock__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 188px 1fr;
|
||||||
|
min-height: 316px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.mock__side {
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
padding: 14px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.mock__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.mock__brand .mark { width: 18px; height: 18px; }
|
||||||
|
.mock__brand b {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mock__gs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.mock__gs span {
|
||||||
|
flex: 1;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.mock__gs .on {
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.mock__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.mock__nav.on { background: var(--accent-soft); color: var(--accent-text); }
|
||||||
|
.mock__main { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.mock__kpis { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; }
|
||||||
|
.mock__kpi {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.mock__kpi .l { font-size: 10px; color: var(--text-tertiary); }
|
||||||
|
.mock__kpi .v {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 19px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.mock__kpi .v small { color: var(--text-muted); font-size: 12px; }
|
||||||
|
.mock__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 12px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mock__row::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.mock__row .g {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.mock__row .nm { flex: 1; font-size: 12px; font-weight: 600; }
|
||||||
|
.mock__row .nm small {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.mock__row .st {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--status-online);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.mock__row .st b {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--status-online);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Section spacing ---- */
|
||||||
|
.sec { padding: 88px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.sec__head { text-align: center; margin-bottom: 48px; }
|
||||||
|
.sec__head .eyebrow { display: block; margin-bottom: 12px; }
|
||||||
|
|
||||||
|
/* ---- Problem cards ---- */
|
||||||
|
.pain {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4,1fr);
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.pain__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.pain__x {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--status-offline-soft);
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
.closing {
|
||||||
|
text-align: center;
|
||||||
|
margin: 40px auto 0;
|
||||||
|
max-width: 720px;
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Steps ---- */
|
||||||
|
.steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3,1fr);
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
padding: 28px 24px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.step__n {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.step b { font-size: var(--text-md); font-weight: 600; }
|
||||||
|
.step p { color: var(--text-tertiary); font-size: var(--text-sm); margin: 8px 0 0; }
|
||||||
|
.nots {
|
||||||
|
display: flex;
|
||||||
|
gap: 26px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 34px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.nots span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Blueprints (game cards) ---- */
|
||||||
|
.blueprints {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.bp {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 90% at 100% 0%, var(--atmo-haze), transparent 55%),
|
||||||
|
linear-gradient(160deg, color-mix(in srgb, var(--atmo-1) 80%, transparent), var(--surface-base) 70%);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.bp__head { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
|
||||||
|
.bp__ic {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.bp__name { font-family: var(--font-brand); font-weight: 700; font-size: var(--text-xl); }
|
||||||
|
.bp__accent {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-text);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}
|
||||||
|
.bp__role { font-size: var(--text-sm); font-weight: 600; color: var(--text-secondary); margin: 10px 0 14px; }
|
||||||
|
.bp__list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.bp__list div { display: flex; align-items: center; gap: 9px; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ---- Capabilities (3 col) ---- */
|
||||||
|
.caps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3,1fr);
|
||||||
|
gap: 30px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.caps__col > .eyebrow { display: block; margin-bottom: 8px; }
|
||||||
|
.feat { display: flex; gap: 12px; padding: 14px 0; border-top: 1px solid var(--border-subtle); }
|
||||||
|
.feat:first-of-type { border-top: 0; }
|
||||||
|
.feat__ic {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.feat b { font-size: var(--text-sm); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ---- Pipeline ---- */
|
||||||
|
.pipe {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.pchip {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 15px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.pchip--last {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.stack-lines { display: flex; flex-direction: column; gap: 8px; align-items: center; margin-top: 32px; }
|
||||||
|
.stack-lines span { color: var(--text-tertiary); font-size: var(--text-md); }
|
||||||
|
.stack-lines .hi { color: var(--accent-text); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ---- Infra ---- */
|
||||||
|
.infra {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5,1fr);
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.icard {
|
||||||
|
padding: 20px 16px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.icard__ic {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent-text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.icard b { font-size: var(--text-sm); font-weight: 600; display: block; }
|
||||||
|
.icard p { margin: 5px 0 0; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.5; }
|
||||||
|
.techrow {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.techrow span {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Store ---- */
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.chip-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.chip-card--accent {
|
||||||
|
color: var(--accent-text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Pricing ---- */
|
||||||
|
.pricing {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4,1fr);
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.plan {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px 22px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.plan--feature {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border), var(--glow-accent-sm);
|
||||||
|
background: linear-gradient(180deg, var(--accent-soft), var(--surface-base) 40%);
|
||||||
|
}
|
||||||
|
.plan__tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: var(--accent-text);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
.plan__name { font-family: var(--font-brand); font-weight: 700; font-size: var(--text-xl); }
|
||||||
|
.plan__price {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
margin: 12px 0 2px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.plan__price small { font-size: var(--text-sm); color: var(--text-muted); font-weight: 400; }
|
||||||
|
.plan__scope { font-size: var(--text-sm); color: var(--text-tertiary); min-height: 40px; }
|
||||||
|
.plan .btn { margin-top: 18px; width: 100%; justify-content: center; }
|
||||||
|
|
||||||
|
.fleetblock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 14px auto 0;
|
||||||
|
padding: 16px 22px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.fleetblock b { font-family: var(--font-brand); font-weight: 700; }
|
||||||
|
.fleetblock .p { font-family: var(--font-mono); color: var(--accent-text); font-weight: 600; }
|
||||||
|
.fleetblock span { color: var(--text-tertiary); font-size: var(--text-sm); }
|
||||||
|
|
||||||
|
.commercial {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 26px auto 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.commercial b { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ---- Support block (below pricing) ---- */
|
||||||
|
.support-note {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 20px auto 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.support-note b { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ---- Admins ---- */
|
||||||
|
.admins {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 11px;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.admins span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Final CTA ---- */
|
||||||
|
.finalcta {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
padding: 104px 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.finalcta__atmo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
background: radial-gradient(60% 100% at 50% 100%, var(--atmo-haze), transparent 60%);
|
||||||
|
}
|
||||||
|
.finalcta__in { position: relative; z-index: 1; }
|
||||||
|
.finalcta h2 {
|
||||||
|
font-family: var(--font-brand);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: var(--text-5xl);
|
||||||
|
margin: 0 0 28px;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.finalcta .cta-row { display: flex; gap: 14px; justify-content: center; }
|
||||||
|
|
||||||
|
/* ---- Footer ---- */
|
||||||
|
.mkt-footer { padding: 56px 0 40px; }
|
||||||
|
.footer__cols { display: grid; grid-template-columns: 1.5fr 1fr 1fr 1fr 1fr; gap: 24px; }
|
||||||
|
.footer__brand .mark { width: 24px; height: 24px; }
|
||||||
|
.footer__brand p {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 12px 0 0;
|
||||||
|
max-width: 230px;
|
||||||
|
}
|
||||||
|
.footer__col h5 {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.footer__col a {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-bottom: 9px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--dur-fast);
|
||||||
|
}
|
||||||
|
.footer__col a:hover { color: var(--text-primary); }
|
||||||
|
.footer__bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 44px;
|
||||||
|
padding-top: 22px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scroll reveal ---- */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
transition: opacity .6s var(--ease-out), transform .6s var(--ease-out);
|
||||||
|
}
|
||||||
|
.reveal.in { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Responsive ---- */
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.pain { grid-template-columns: 1fr 1fr; }
|
||||||
|
.steps, .caps, .blueprints, .pricing { grid-template-columns: 1fr; }
|
||||||
|
.infra { grid-template-columns: 1fr 1fr; }
|
||||||
|
.footer__cols { grid-template-columns: 1fr 1fr; }
|
||||||
|
.mock__body { grid-template-columns: 1fr; }
|
||||||
|
.mock__side { display: none; }
|
||||||
|
.hero h1 { font-size: var(--text-5xl); }
|
||||||
|
.mkt-nav__links { display: none; }
|
||||||
|
}
|
||||||
75
frontend/src/styles/tokens/base.css
Normal file
75
frontend/src/styles/tokens/base.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Base / Reset
|
||||||
|
Minimal, opinionated. Applies the token system to bare HTML so
|
||||||
|
specimen cards and kits inherit the look without boilerplate.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--surface-canvas);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
font-feature-settings: "cv01", "ss01";
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6, p, figure { margin: 0; }
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--accent-soft-strong);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabular numbers everywhere numbers matter */
|
||||||
|
.tnum { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Custom scrollbars — quiet, on-brand */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-strong) transparent;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-strong);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background: var(--text-muted); background-clip: content-box; }
|
||||||
|
|
||||||
|
/* Focus-visible default */
|
||||||
|
:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion guard */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Skin-swap repaint guard ----
|
||||||
|
When flipping data-game or data-theme on the root, add the .cc-skin-swap
|
||||||
|
class for ONE frame. This suppresses transitions during the swap so every
|
||||||
|
accent-consuming surface repaints immediately — works around a Chrome
|
||||||
|
custom-property + transition staleness where elements that both read
|
||||||
|
var(--accent)/var(--accent-text) AND have a color/background transition keep
|
||||||
|
the old accent until the next reflow. See readme "Theming contract". */
|
||||||
|
.cc-skin-swap *,
|
||||||
|
.cc-skin-swap *::before,
|
||||||
|
.cc-skin-swap *::after { transition: none !important; }
|
||||||
136
frontend/src/styles/tokens/colors.css
Normal file
136
frontend/src/styles/tokens/colors.css
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Color System
|
||||||
|
------------------------------------------------------------
|
||||||
|
Three layers:
|
||||||
|
1. Raw neutral ramp (absolute; identical in both themes)
|
||||||
|
2. Semantic surface / text / border tokens (flip per theme)
|
||||||
|
3. Status colors (online/offline/warn/info/...) — game-agnostic
|
||||||
|
Game ACCENT colors live in game-themes.css.
|
||||||
|
|
||||||
|
Theme + game are set together on <html>:
|
||||||
|
<html data-theme="dark" data-game="rust">
|
||||||
|
Dark is primary. Light is full-parity.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ---- Raw neutral ramp (warm-cool slate, absolute) ---- */
|
||||||
|
--n-0: #ffffff;
|
||||||
|
--n-25: #fafbfc;
|
||||||
|
--n-50: #f3f5f7;
|
||||||
|
--n-100: #e8ebef;
|
||||||
|
--n-150: #dce0e6;
|
||||||
|
--n-200: #ccd2da;
|
||||||
|
--n-300: #aeb6c1;
|
||||||
|
--n-400: #8a929e;
|
||||||
|
--n-500: #6b7280;
|
||||||
|
--n-600: #515862;
|
||||||
|
--n-650: #444a53;
|
||||||
|
--n-700: #363b43;
|
||||||
|
--n-750: #2b2f36;
|
||||||
|
--n-800: #1f2329;
|
||||||
|
--n-850: #181b20;
|
||||||
|
--n-900: #121419;
|
||||||
|
--n-925: #0e0f13;
|
||||||
|
--n-950: #0a0b0e;
|
||||||
|
--n-975: #060709;
|
||||||
|
|
||||||
|
/* ---- Semantic: DARK (default) ---- */
|
||||||
|
--surface-canvas: var(--n-950); /* app background */
|
||||||
|
--surface-sunken: var(--n-975); /* wells, deep insets */
|
||||||
|
--surface-base: var(--n-925); /* primary panels */
|
||||||
|
--surface-raised: var(--n-850); /* cards, rows */
|
||||||
|
--surface-raised-2:var(--n-800); /* nested cards, hover cards */
|
||||||
|
--surface-overlay: var(--n-800); /* menus, popovers, dialogs */
|
||||||
|
--surface-inset: #07080a; /* inputs, console, code */
|
||||||
|
--surface-hover: rgba(255, 255, 255, 0.045);
|
||||||
|
--surface-active: rgba(255, 255, 255, 0.075);
|
||||||
|
--surface-selected:rgba(255, 255, 255, 0.06);
|
||||||
|
|
||||||
|
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||||
|
--border-default: rgba(255, 255, 255, 0.10);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.16);
|
||||||
|
|
||||||
|
--text-primary: #f2f4f7;
|
||||||
|
--text-secondary: #aeb4bf;
|
||||||
|
--text-tertiary: #767d89;
|
||||||
|
--text-muted: #565d68;
|
||||||
|
--text-inverse: #0a0b0e;
|
||||||
|
--text-on-accent: var(--accent-contrast);
|
||||||
|
|
||||||
|
/* Scrim for modals / image overlays */
|
||||||
|
--scrim: rgba(4, 5, 7, 0.66);
|
||||||
|
|
||||||
|
/* ---- Status / semantic (consistent across themes) ---- */
|
||||||
|
--status-online: #36c780;
|
||||||
|
--status-online-soft: rgba(54, 199, 128, 0.14);
|
||||||
|
--status-online-border: rgba(54, 199, 128, 0.38);
|
||||||
|
|
||||||
|
--status-offline: #e5484d;
|
||||||
|
--status-offline-soft: rgba(229, 72, 77, 0.14);
|
||||||
|
--status-offline-border: rgba(229, 72, 77, 0.40);
|
||||||
|
|
||||||
|
--status-warn: #e8a33c;
|
||||||
|
--status-warn-soft: rgba(232, 163, 60, 0.15);
|
||||||
|
--status-warn-border: rgba(232, 163, 60, 0.40);
|
||||||
|
|
||||||
|
--status-info: #4c8df0;
|
||||||
|
--status-info-soft: rgba(76, 141, 240, 0.15);
|
||||||
|
--status-info-border: rgba(76, 141, 240, 0.40);
|
||||||
|
|
||||||
|
--status-starting: #2bc2d4; /* booting / updating / restarting */
|
||||||
|
--status-starting-soft: rgba(43, 194, 212, 0.15);
|
||||||
|
--status-starting-border: rgba(43, 194, 212, 0.40);
|
||||||
|
|
||||||
|
--status-wiping: #9b7bf0; /* Rust map wipe / maintenance */
|
||||||
|
--status-wiping-soft: rgba(155, 123, 240, 0.16);
|
||||||
|
--status-wiping-border: rgba(155, 123, 240, 0.40);
|
||||||
|
|
||||||
|
/* Aliases used by components */
|
||||||
|
--success: var(--status-online);
|
||||||
|
--danger: var(--status-offline);
|
||||||
|
--warning: var(--status-warn);
|
||||||
|
--info: var(--status-info);
|
||||||
|
|
||||||
|
/* Data-viz categorical (ECharts-friendly) */
|
||||||
|
--viz-1: var(--accent);
|
||||||
|
--viz-2: #4c8df0;
|
||||||
|
--viz-3: #36c780;
|
||||||
|
--viz-4: #9b7bf0;
|
||||||
|
--viz-5: #2bc2d4;
|
||||||
|
--viz-6: #e8a33c;
|
||||||
|
--viz-grid: var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Semantic: LIGHT (full parity) ---- */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--surface-canvas: #f5f6f8;
|
||||||
|
--surface-sunken: #eceef1;
|
||||||
|
--surface-base: #ffffff;
|
||||||
|
--surface-raised: #ffffff;
|
||||||
|
--surface-raised-2:#fbfcfd;
|
||||||
|
--surface-overlay: #ffffff;
|
||||||
|
--surface-inset: #f1f3f5;
|
||||||
|
--surface-hover: rgba(12, 16, 22, 0.04);
|
||||||
|
--surface-active: rgba(12, 16, 22, 0.07);
|
||||||
|
--surface-selected:rgba(12, 16, 22, 0.05);
|
||||||
|
|
||||||
|
--border-subtle: rgba(12, 16, 22, 0.07);
|
||||||
|
--border-default: rgba(12, 16, 22, 0.12);
|
||||||
|
--border-strong: rgba(12, 16, 22, 0.20);
|
||||||
|
|
||||||
|
--text-primary: #14171c;
|
||||||
|
--text-secondary: #474e58;
|
||||||
|
--text-tertiary: #6b727e;
|
||||||
|
--text-muted: #969ca6;
|
||||||
|
--text-inverse: #ffffff;
|
||||||
|
|
||||||
|
--scrim: rgba(16, 20, 28, 0.45);
|
||||||
|
|
||||||
|
/* Status soft fills need a touch more alpha on light */
|
||||||
|
--status-online-soft: rgba(33, 160, 98, 0.12);
|
||||||
|
--status-offline-soft: rgba(206, 44, 49, 0.10);
|
||||||
|
--status-warn-soft: rgba(193, 124, 18, 0.13);
|
||||||
|
--status-info-soft: rgba(43, 105, 214, 0.10);
|
||||||
|
--status-starting-soft: rgba(18, 150, 168, 0.12);
|
||||||
|
--status-wiping-soft: rgba(118, 86, 214, 0.12);
|
||||||
|
}
|
||||||
40
frontend/src/styles/tokens/elevation.css
Normal file
40
frontend/src/styles/tokens/elevation.css
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Elevation, Borders, Glows
|
||||||
|
Shadows are quiet on dark surfaces; depth comes from layered
|
||||||
|
fills + hairline borders. Accent "glow" is reserved for
|
||||||
|
active/live states and game-themed focus.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Hairline rings (use as box-shadow inset or border) */
|
||||||
|
--ring-subtle: inset 0 0 0 1px var(--border-subtle);
|
||||||
|
--ring-default: inset 0 0 0 1px var(--border-default);
|
||||||
|
--ring-strong: inset 0 0 0 1px var(--border-strong);
|
||||||
|
|
||||||
|
/* Drop shadows — tuned for dark; subtle and tight */
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.40);
|
||||||
|
--shadow-md: 0 6px 18px -4px rgba(0, 0, 0, 0.50);
|
||||||
|
--shadow-lg: 0 18px 40px -10px rgba(0, 0, 0, 0.60);
|
||||||
|
--shadow-xl: 0 32px 70px -16px rgba(0, 0, 0, 0.70);
|
||||||
|
|
||||||
|
/* Popover / menu — combines ring + drop for crisp edges on dark */
|
||||||
|
--shadow-pop: 0 0 0 1px var(--border-default), 0 12px 36px -8px rgba(0, 0, 0, 0.66);
|
||||||
|
|
||||||
|
/* Accent glow — game-themed; used on live/active controls & focus */
|
||||||
|
--glow-accent: 0 0 0 1px var(--accent-border), 0 0 22px -2px var(--accent-glow);
|
||||||
|
--glow-accent-sm: 0 0 14px -2px var(--accent-glow);
|
||||||
|
--glow-online: 0 0 12px -1px rgba(54, 199, 128, 0.55);
|
||||||
|
|
||||||
|
/* Focus ring */
|
||||||
|
--focus-ring: 0 0 0 2px var(--surface-canvas), 0 0 0 4px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--shadow-xs: 0 1px 2px rgba(16, 20, 28, 0.06);
|
||||||
|
--shadow-sm: 0 2px 6px rgba(16, 20, 28, 0.08);
|
||||||
|
--shadow-md: 0 8px 20px -6px rgba(16, 20, 28, 0.12);
|
||||||
|
--shadow-lg: 0 20px 44px -12px rgba(16, 20, 28, 0.16);
|
||||||
|
--shadow-xl: 0 32px 70px -18px rgba(16, 20, 28, 0.20);
|
||||||
|
--shadow-pop: 0 0 0 1px var(--border-default), 0 14px 38px -10px rgba(16, 20, 28, 0.20);
|
||||||
|
}
|
||||||
22
frontend/src/styles/tokens/fonts.css
Normal file
22
frontend/src/styles/tokens/fonts.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Fonts
|
||||||
|
Geist — UI / body / app headings
|
||||||
|
JetBrains Mono — console, data, IDs, telemetry
|
||||||
|
Oxanium — brand wordmark + marketing display (game-ops flavor)
|
||||||
|
------------------------------------------------------------
|
||||||
|
NOTE: The Google Fonts stylesheet is loaded via <link> tags in
|
||||||
|
index.html — NOT @import here. A CSS @import that ends up
|
||||||
|
mid-bundle after concatenation is silently dropped by the
|
||||||
|
optimizer (fonts never load in production). If you want these
|
||||||
|
self-hosted (offline), send the woff2 files and they become
|
||||||
|
@font-face rules here.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
|
||||||
|
/* Brand wordmark + big marketing display — squared, technical, gamey */
|
||||||
|
--font-brand: 'Oxanium', 'Geist', system-ui, sans-serif;
|
||||||
|
/* App-level display headings stay on the neutral sans */
|
||||||
|
--font-display: var(--font-sans);
|
||||||
|
}
|
||||||
150
frontend/src/styles/tokens/game-themes.css
Normal file
150
frontend/src/styles/tokens/game-themes.css
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Game Themes (the re-skin layer)
|
||||||
|
------------------------------------------------------------
|
||||||
|
The shell is neutral. Each game declares an ACCENT ramp + an
|
||||||
|
ATMOSPHERE (backdrop hues for hero headers, login, glows).
|
||||||
|
Set on <html data-game="rust"> (or dune / ark / valheim / ...).
|
||||||
|
Default (no data-game) = Corrosion brand = Oxide Orange.
|
||||||
|
|
||||||
|
Accent contract every game must define:
|
||||||
|
--accent base fill
|
||||||
|
--accent-hover hover fill
|
||||||
|
--accent-press pressed / darker
|
||||||
|
--accent-contrast text/icon ON the accent fill
|
||||||
|
--accent-text accent used AS text on dark surfaces (lightened for AA)
|
||||||
|
--accent-soft low-alpha tint background
|
||||||
|
--accent-soft-strong
|
||||||
|
--accent-border accent hairline
|
||||||
|
--accent-glow glow color (box-shadow)
|
||||||
|
--game-label printable name
|
||||||
|
--atmo-1 / --atmo-2 backdrop gradient stops
|
||||||
|
--atmo-haze radial haze color
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---------- Default brand / RUST — Oxide Orange #F26622 ---------- */
|
||||||
|
:root,
|
||||||
|
[data-game="rust"] {
|
||||||
|
--accent: #f26622;
|
||||||
|
--accent-hover: #ff7a38;
|
||||||
|
--accent-press: #d9550f;
|
||||||
|
--accent-contrast: #190d05;
|
||||||
|
--accent-text: #ff8a4c;
|
||||||
|
--accent-soft: rgba(242, 102, 34, 0.13);
|
||||||
|
--accent-soft-strong: rgba(242, 102, 34, 0.22);
|
||||||
|
--accent-border: rgba(242, 102, 34, 0.42);
|
||||||
|
--accent-glow: rgba(242, 102, 34, 0.50);
|
||||||
|
--game-label: "Rust"; /* @kind other */
|
||||||
|
--atmo-1: #2c1206;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(242, 102, 34, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- DUNE: AWAKENING — Spice Amber / desert gold ---------- */
|
||||||
|
[data-game="dune"] {
|
||||||
|
--accent: #e9a53a;
|
||||||
|
--accent-hover: #f7b94f;
|
||||||
|
--accent-press: #c9851f;
|
||||||
|
--accent-contrast: #1c1303;
|
||||||
|
--accent-text: #f0bc5e;
|
||||||
|
--accent-soft: rgba(233, 165, 58, 0.14);
|
||||||
|
--accent-soft-strong: rgba(233, 165, 58, 0.24);
|
||||||
|
--accent-border: rgba(233, 165, 58, 0.44);
|
||||||
|
--accent-glow: rgba(233, 165, 58, 0.46);
|
||||||
|
--game-label: "Dune: Awakening"; /* @kind other */
|
||||||
|
--atmo-1: #2c1e08;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(233, 165, 58, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- SOULMASK: Ritual Jade ---------- */
|
||||||
|
[data-game="soulmask"] {
|
||||||
|
--accent: #43c47e;
|
||||||
|
--accent-hover: #59d792;
|
||||||
|
--accent-press: #2c9c5f;
|
||||||
|
--accent-contrast: #04140c;
|
||||||
|
--accent-text: #63d894;
|
||||||
|
--accent-soft: rgba(67, 196, 126, 0.14);
|
||||||
|
--accent-soft-strong: rgba(67, 196, 126, 0.24);
|
||||||
|
--accent-border: rgba(67, 196, 126, 0.42);
|
||||||
|
--accent-glow: rgba(67, 196, 126, 0.46);
|
||||||
|
--game-label: "Soulmask"; /* @kind other */
|
||||||
|
--atmo-1: #08231a;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(67, 196, 126, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- CONAN EXILES: Hyborian Bronze ---------- */
|
||||||
|
[data-game="conan"] {
|
||||||
|
--accent: #bb7637;
|
||||||
|
--accent-hover: #d28d4b;
|
||||||
|
--accent-press: #985c24;
|
||||||
|
--accent-contrast: #160d03;
|
||||||
|
--accent-text: #d59a5e;
|
||||||
|
--accent-soft: rgba(187, 118, 55, 0.15);
|
||||||
|
--accent-soft-strong: rgba(187, 118, 55, 0.26);
|
||||||
|
--accent-border: rgba(187, 118, 55, 0.44);
|
||||||
|
--accent-glow: rgba(187, 118, 55, 0.46);
|
||||||
|
--game-label: "Conan Exiles"; /* @kind other */
|
||||||
|
--atmo-1: #2a1b0b;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(187, 118, 55, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Room to add more (stubs, ready to ship) ---------- */
|
||||||
|
[data-game="ark"] {
|
||||||
|
--accent: #36c2a8;
|
||||||
|
--accent-hover: #4ad7bd;
|
||||||
|
--accent-press: #1f9c86;
|
||||||
|
--accent-contrast: #04140f;
|
||||||
|
--accent-text: #54dcc2;
|
||||||
|
--accent-soft: rgba(54, 194, 168, 0.14);
|
||||||
|
--accent-soft-strong: rgba(54, 194, 168, 0.24);
|
||||||
|
--accent-border: rgba(54, 194, 168, 0.42);
|
||||||
|
--accent-glow: rgba(54, 194, 168, 0.46);
|
||||||
|
--game-label: "ARK"; /* @kind other */
|
||||||
|
--atmo-1: #07241f;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(54, 194, 168, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-game="valheim"] {
|
||||||
|
--accent: #5b9bf0;
|
||||||
|
--accent-hover: #74afff;
|
||||||
|
--accent-press: #3c7ad4;
|
||||||
|
--accent-contrast: #050d1a;
|
||||||
|
--accent-text: #7fb2ff;
|
||||||
|
--accent-soft: rgba(91, 155, 240, 0.14);
|
||||||
|
--accent-soft-strong: rgba(91, 155, 240, 0.24);
|
||||||
|
--accent-border: rgba(91, 155, 240, 0.42);
|
||||||
|
--accent-glow: rgba(91, 155, 240, 0.46);
|
||||||
|
--game-label: "Valheim"; /* @kind other */
|
||||||
|
--atmo-1: #0a1c2e;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(91, 155, 240, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-game="palworld"] {
|
||||||
|
--accent: #58b6e8;
|
||||||
|
--accent-hover: #71c8f6;
|
||||||
|
--accent-press: #3a92c4;
|
||||||
|
--accent-contrast: #04121b;
|
||||||
|
--accent-text: #7fcaf2;
|
||||||
|
--accent-soft: rgba(88, 182, 232, 0.14);
|
||||||
|
--accent-soft-strong: rgba(88, 182, 232, 0.24);
|
||||||
|
--accent-border: rgba(88, 182, 232, 0.42);
|
||||||
|
--accent-glow: rgba(88, 182, 232, 0.46);
|
||||||
|
--game-label: "Palworld"; /* @kind other */
|
||||||
|
--atmo-1: #08222e;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(88, 182, 232, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Light-theme accent legibility ----------
|
||||||
|
On light surfaces, accent-as-text reads better at the pressed
|
||||||
|
(darker) value. Placed last so it wins for --accent-text when
|
||||||
|
data-theme="light" and data-game=* are both on <html>. */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--accent-text: var(--accent-press);
|
||||||
|
--accent-soft: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
--accent-soft-strong: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
|
}
|
||||||
27
frontend/src/styles/tokens/motion.css
Normal file
27
frontend/src/styles/tokens/motion.css
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Motion
|
||||||
|
Fast, mechanical, precise. No bounce on chrome. Subtle spring
|
||||||
|
reserved for "live" telemetry (pulses, meters). Respect
|
||||||
|
prefers-reduced-motion in components.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--dur-instant: 80ms; /* @kind other */
|
||||||
|
--dur-fast: 120ms; /* @kind other */
|
||||||
|
--dur-base: 170ms; /* @kind other */
|
||||||
|
--dur-slow: 240ms; /* @kind other */
|
||||||
|
--dur-slower: 360ms; /* @kind other */
|
||||||
|
|
||||||
|
/* Easings */
|
||||||
|
--ease-standard: cubic-bezier(0.2, 0, 0, 1); /* @kind other */
|
||||||
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* @kind other */
|
||||||
|
--ease-in: cubic-bezier(0.5, 0, 0.84, 0); /* @kind other */
|
||||||
|
--ease-emphasized: cubic-bezier(0.34, 1.4, 0.5, 1); /* @kind other */
|
||||||
|
|
||||||
|
/* Common transition bundles */
|
||||||
|
--transition-colors: color var(--dur-fast) var(--ease-standard),
|
||||||
|
background-color var(--dur-fast) var(--ease-standard),
|
||||||
|
border-color var(--dur-fast) var(--ease-standard),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-standard);
|
||||||
|
--transition-transform: transform var(--dur-base) var(--ease-out);
|
||||||
|
}
|
||||||
50
frontend/src/styles/tokens/spacing.css
Normal file
50
frontend/src/styles/tokens/spacing.css
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Spacing, Radius, Sizing
|
||||||
|
Dense ops-cockpit scale. 4px base grid, tightened.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Space scale (px) — used for padding, gap, margins */
|
||||||
|
--space-0: 0;
|
||||||
|
--space-px: 1px;
|
||||||
|
--space-0-5: 2px;
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-1-5: 6px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-2-5: 10px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-7: 28px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-10: 40px;
|
||||||
|
--space-12: 48px;
|
||||||
|
--space-14: 56px;
|
||||||
|
--space-16: 64px;
|
||||||
|
--space-20: 80px;
|
||||||
|
--space-24: 96px;
|
||||||
|
--space-32: 128px;
|
||||||
|
|
||||||
|
/* Radius — small & technical; cards stay crisp, not rounded-blobby */
|
||||||
|
--radius-xs: 3px;
|
||||||
|
--radius-sm: 5px;
|
||||||
|
--radius-md: 7px;
|
||||||
|
--radius-lg: 10px;
|
||||||
|
--radius-xl: 14px;
|
||||||
|
--radius-2xl: 20px;
|
||||||
|
--radius-pill: 999px;
|
||||||
|
|
||||||
|
/* Control heights — compact rows for data-dense UI */
|
||||||
|
--control-h-xs: 24px;
|
||||||
|
--control-h-sm: 30px;
|
||||||
|
--control-h-md: 36px;
|
||||||
|
--control-h-lg: 44px;
|
||||||
|
|
||||||
|
/* Layout primitives */
|
||||||
|
--sidebar-w: 248px;
|
||||||
|
--sidebar-w-collapsed: 64px;
|
||||||
|
--topbar-h: 56px;
|
||||||
|
--content-max: 1440px;
|
||||||
|
--container-pad: var(--space-6);
|
||||||
|
}
|
||||||
75
frontend/src/styles/tokens/typography.css
Normal file
75
frontend/src/styles/tokens/typography.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Typography
|
||||||
|
Geist for UI & display; JetBrains Mono for telemetry, IDs,
|
||||||
|
console, and any numeric/code data. Dense reading sizes.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Font sizes (px) — base UI is 14 (dense) */
|
||||||
|
--text-2xs: 11px;
|
||||||
|
--text-xs: 12px;
|
||||||
|
--text-sm: 13px;
|
||||||
|
--text-base: 14px;
|
||||||
|
--text-md: 15px;
|
||||||
|
--text-lg: 17px;
|
||||||
|
--text-xl: 20px;
|
||||||
|
--text-2xl: 24px;
|
||||||
|
--text-3xl: 30px;
|
||||||
|
--text-4xl: 38px;
|
||||||
|
--text-5xl: 50px;
|
||||||
|
--text-6xl: 66px;
|
||||||
|
--text-7xl: 88px;
|
||||||
|
|
||||||
|
/* Line heights */
|
||||||
|
--leading-none: 1;
|
||||||
|
--leading-tight: 1.15;
|
||||||
|
--leading-snug: 1.3;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.65;
|
||||||
|
|
||||||
|
/* Weights */
|
||||||
|
--weight-light: 300;
|
||||||
|
--weight-regular: 400;
|
||||||
|
--weight-medium: 500;
|
||||||
|
--weight-semibold: 600;
|
||||||
|
--weight-bold: 700;
|
||||||
|
--weight-black: 800;
|
||||||
|
|
||||||
|
/* Letter spacing */
|
||||||
|
--tracking-tighter: -0.03em;
|
||||||
|
--tracking-tight: -0.015em;
|
||||||
|
--tracking-normal: 0;
|
||||||
|
--tracking-wide: 0.02em;
|
||||||
|
--tracking-wider: 0.06em;
|
||||||
|
--tracking-caps: 0.10em; /* eyebrows / overlines / labels */
|
||||||
|
|
||||||
|
/* ---- Semantic roles (font shorthands via custom props) ---- */
|
||||||
|
--font-display: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Optional helper classes (cards/kits can use these) ---- */
|
||||||
|
.t-display {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
.t-eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-caps);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.t-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: var(--tracking-normal);
|
||||||
|
}
|
||||||
|
.t-metric {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { AlertTriangle, Save, Loader2 } from 'lucide-vue-next'
|
|
||||||
import { safeDate } from '@/utils/formatters'
|
import { safeDate } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Checkbox from '@/components/ds/forms/Checkbox.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
interface AlertConfig {
|
interface AlertConfig {
|
||||||
population_drop_enabled: boolean
|
population_drop_enabled: boolean
|
||||||
@@ -62,15 +68,29 @@ async function saveConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSeverityColor(severity: string): string {
|
function severityTone(severity: string): 'info' | 'warn' | 'offline' | 'neutral' {
|
||||||
switch (severity) {
|
if (severity === 'info') return 'info'
|
||||||
case 'info': return 'bg-blue-500/10 text-blue-400'
|
if (severity === 'warning') return 'warn'
|
||||||
case 'warning': return 'bg-yellow-500/10 text-yellow-400'
|
if (severity === 'critical') return 'offline'
|
||||||
case 'critical': return 'bg-red-500/10 text-red-400'
|
return 'neutral'
|
||||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
}
|
||||||
|
|
||||||
|
// Range input handler (range emits string, keep numeric in config)
|
||||||
|
function onThresholdInput(e: Event) {
|
||||||
|
const val = parseInt((e.target as HTMLInputElement).value, 10)
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
config.value.population_drop_threshold_percent = val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FPS threshold: DS Input binds string; sync via a string ref
|
||||||
|
const fpsStr = ref(String(config.value.fps_threshold))
|
||||||
|
function onFpsUpdate(val: string | undefined) {
|
||||||
|
fpsStr.value = val ?? ''
|
||||||
|
const n = parseInt(val ?? '', 10)
|
||||||
|
if (!isNaN(n)) config.value.fps_threshold = n
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchConfig()
|
fetchConfig()
|
||||||
fetchHistory()
|
fetchHistory()
|
||||||
@@ -78,156 +98,258 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="alerts">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="page__head">
|
||||||
<AlertTriangle class="w-5 h-5 text-oxide-500" />
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Alerts</h1>
|
<div class="t-eyebrow">Monitoring</div>
|
||||||
</div>
|
<h1 class="page__title">Alerts</h1>
|
||||||
|
|
||||||
<!-- Alert Configuration -->
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Alert Configuration</h2>
|
|
||||||
|
|
||||||
<div v-if="isLoading" class="py-8 flex justify-center">
|
|
||||||
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-6">
|
|
||||||
<!-- Population Drop Alert -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<label class="text-sm font-medium text-neutral-200">Population Drop Alert</label>
|
|
||||||
<button
|
|
||||||
@click="config.population_drop_enabled = !config.population_drop_enabled"
|
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
|
||||||
:class="config.population_drop_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
|
||||||
:class="config.population_drop_enabled ? 'translate-x-6' : 'translate-x-1'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="config.population_drop_enabled">
|
|
||||||
<label class="block text-xs text-neutral-500 mb-2">Threshold (%)</label>
|
|
||||||
<input
|
|
||||||
v-model.number="config.population_drop_threshold_percent"
|
|
||||||
type="range"
|
|
||||||
min="10"
|
|
||||||
max="100"
|
|
||||||
class="w-full h-2 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-oxide-500"
|
|
||||||
/>
|
|
||||||
<div class="text-xs text-neutral-400 mt-1">{{ config.population_drop_threshold_percent }}%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FPS Degradation Alert -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<label class="text-sm font-medium text-neutral-200">FPS Degradation Alert</label>
|
|
||||||
<button
|
|
||||||
@click="config.fps_degradation_enabled = !config.fps_degradation_enabled"
|
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
|
||||||
:class="config.fps_degradation_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
|
||||||
:class="config.fps_degradation_enabled ? 'translate-x-6' : 'translate-x-1'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="config.fps_degradation_enabled">
|
|
||||||
<label class="block text-xs text-neutral-500 mb-2">FPS Threshold</label>
|
|
||||||
<input
|
|
||||||
v-model.number="config.fps_threshold"
|
|
||||||
type="number"
|
|
||||||
min="10"
|
|
||||||
max="60"
|
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notification Channels -->
|
|
||||||
<div class="border-t border-neutral-800 pt-4">
|
|
||||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-3">Notification Channels</h3>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
v-model="config.notify_discord"
|
|
||||||
type="checkbox"
|
|
||||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-500 focus:ring-2 focus:ring-oxide-500"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-neutral-200">Discord</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
v-model="config.notify_pushbullet"
|
|
||||||
type="checkbox"
|
|
||||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-500 focus:ring-2 focus:ring-oxide-500"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-neutral-200">Pushbullet</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
v-model="config.notify_email"
|
|
||||||
type="checkbox"
|
|
||||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-500 focus:ring-2 focus:ring-oxide-500"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-neutral-200">Email</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Save Button -->
|
|
||||||
<button
|
|
||||||
@click="saveConfig"
|
|
||||||
:disabled="isSaving"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="isSaving" class="w-4 h-4 animate-spin" />
|
|
||||||
<Save v-else class="w-4 h-4" />
|
|
||||||
Save Configuration
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alert History -->
|
<!-- Alert configuration -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel title="Alert configuration" subtitle="Trigger conditions and notification channels">
|
||||||
<div class="p-5 border-b border-neutral-800">
|
<div v-if="isLoading" class="loading-row">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Alert History</h2>
|
<span class="cc-btn__spin" style="width:20px;height:20px;border-width:2.5px;" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="history.length === 0" class="p-8 text-center text-neutral-500">
|
|
||||||
No alerts triggered yet.
|
<div v-else class="config-body">
|
||||||
|
<!-- Triggers section -->
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="t-eyebrow config-section__eyebrow">Triggers</div>
|
||||||
|
|
||||||
|
<!-- Population drop -->
|
||||||
|
<div class="alert-rule">
|
||||||
|
<div class="alert-rule__head">
|
||||||
|
<div>
|
||||||
|
<div class="alert-rule__name">Population drop alert</div>
|
||||||
|
<div class="alert-rule__desc">Fire when player count falls by this percentage</div>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="config.population_drop_enabled" />
|
||||||
|
</div>
|
||||||
|
<div v-if="config.population_drop_enabled" class="alert-rule__body">
|
||||||
|
<div class="range-field">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:value="config.population_drop_threshold_percent"
|
||||||
|
min="10"
|
||||||
|
max="100"
|
||||||
|
class="cc-range"
|
||||||
|
@input="onThresholdInput"
|
||||||
|
/>
|
||||||
|
<span class="range-value">{{ config.population_drop_threshold_percent }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FPS degradation -->
|
||||||
|
<div class="alert-rule">
|
||||||
|
<div class="alert-rule__head">
|
||||||
|
<div>
|
||||||
|
<div class="alert-rule__name">FPS degradation alert</div>
|
||||||
|
<div class="alert-rule__desc">Fire when server FPS drops below threshold</div>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="config.fps_degradation_enabled" />
|
||||||
|
</div>
|
||||||
|
<div v-if="config.fps_degradation_enabled" class="alert-rule__body">
|
||||||
|
<Input
|
||||||
|
:model-value="fpsStr"
|
||||||
|
label="FPS threshold"
|
||||||
|
type="number"
|
||||||
|
:mono="true"
|
||||||
|
hint="Minimum acceptable FPS (10–60)"
|
||||||
|
@update:model-value="onFpsUpdate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification channels -->
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="t-eyebrow config-section__eyebrow">Notification channels</div>
|
||||||
|
<div class="channels">
|
||||||
|
<Checkbox v-model="config.notify_discord" label="Discord" />
|
||||||
|
<Checkbox v-model="config.notify_pushbullet" label="Pushbullet" />
|
||||||
|
<Checkbox v-model="config.notify_email" label="Email" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<Button icon="save" :loading="isSaving" @click="saveConfig">
|
||||||
|
Save configuration
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<table v-else class="w-full">
|
</Panel>
|
||||||
<thead class="bg-neutral-800/50 border-b border-neutral-800">
|
|
||||||
|
<!-- Alert history -->
|
||||||
|
<Panel :flush-body="true" title="Alert history">
|
||||||
|
<EmptyState
|
||||||
|
v-if="history.length === 0"
|
||||||
|
icon="triangle-alert"
|
||||||
|
title="No alerts triggered"
|
||||||
|
description="Alerts will appear here when trigger conditions are met."
|
||||||
|
/>
|
||||||
|
<table v-else class="cc-table">
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Triggered</th>
|
<th>Triggered</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Type</th>
|
<th>Type</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Severity</th>
|
<th>Severity</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Title</th>
|
<th>Title</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Message</th>
|
<th>Message</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-for="alert in history" :key="alert.id" class="hover:bg-neutral-800/30">
|
<tr v-for="entry in history" :key="entry.id">
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(alert.triggered_at) }}</td>
|
<td class="td-mono">{{ safeDate(entry.triggered_at) }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ alert.alert_type }}</td>
|
<td>{{ entry.alert_type }}</td>
|
||||||
<td class="px-4 py-3">
|
<td>
|
||||||
<span
|
<Badge :tone="severityTone(entry.severity)">{{ entry.severity }}</Badge>
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full capitalize"
|
|
||||||
:class="getSeverityColor(alert.severity)"
|
|
||||||
>
|
|
||||||
{{ alert.severity }}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-200">{{ alert.title }}</td>
|
<td class="td-primary">{{ entry.title }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ alert.message }}</td>
|
<td>{{ entry.message }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.alerts {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.page__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading row */
|
||||||
|
.loading-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config body */
|
||||||
|
.config-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.config-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.config-section__eyebrow { margin-bottom: 4px; }
|
||||||
|
|
||||||
|
/* Alert rule card */
|
||||||
|
.alert-rule {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.alert-rule__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 13px 15px;
|
||||||
|
}
|
||||||
|
.alert-rule__name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.alert-rule__desc {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.alert-rule__body {
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
padding: 13px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Range slider */
|
||||||
|
.range-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.cc-range {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
appearance: none;
|
||||||
|
background: var(--surface-active);
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
.range-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--accent-text);
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channels */
|
||||||
|
.channels {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.cc-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.cc-table thead tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.cc-table th {
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cc-table tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||||
|
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||||
|
.cc-table td {
|
||||||
|
padding: 11px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.td-primary { color: var(--text-primary); font-weight: 500; }
|
||||||
|
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||||
import { BarChart3, TrendingUp, Users, Clock, Download } from 'lucide-vue-next'
|
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import type { ECharts } from 'echarts'
|
import type { ECharts } from 'echarts'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
@@ -8,6 +7,11 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { AnalyticsSummary, TimeseriesData } from '@/types'
|
import type { AnalyticsSummary, TimeseriesData } from '@/types'
|
||||||
import { safeFixed } from '@/utils/formatters'
|
import { safeFixed } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -54,9 +58,22 @@ const loadAnalytics = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cssVar(name: string): string {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||||
|
}
|
||||||
|
|
||||||
const renderCharts = () => {
|
const renderCharts = () => {
|
||||||
if (!timeseries.value) return
|
if (!timeseries.value) return
|
||||||
|
|
||||||
|
const accent = cssVar('--accent') || '#CE422B'
|
||||||
|
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
|
||||||
|
const axisLine = cssVar('--border-default') || '#404040'
|
||||||
|
const labelColor = cssVar('--text-tertiary') || '#808080'
|
||||||
|
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
|
||||||
|
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
|
||||||
|
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
|
||||||
|
const mono = 'JetBrains Mono, monospace'
|
||||||
|
|
||||||
// Player count chart
|
// Player count chart
|
||||||
if (playerChart.value) {
|
if (playerChart.value) {
|
||||||
if (playerChartInstance) {
|
if (playerChartInstance) {
|
||||||
@@ -68,9 +85,9 @@ const renderCharts = () => {
|
|||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: tooltipBg,
|
||||||
borderColor: '#2a2a2a',
|
borderColor: tooltipBorder,
|
||||||
textStyle: { color: '#e5e5e5' }
|
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
left: '3%',
|
left: '3%',
|
||||||
@@ -86,14 +103,14 @@ const renderCharts = () => {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit'
|
hour: '2-digit'
|
||||||
})),
|
})),
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
axisLabel: { color: '#808080', rotate: 45 }
|
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
splitLine: { lineStyle: { color: grid } },
|
||||||
axisLabel: { color: '#808080' }
|
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
@@ -101,14 +118,14 @@ const renderCharts = () => {
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
data: timeseries.value.player_count,
|
data: timeseries.value.player_count,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
lineStyle: { color: '#CE422B', width: 2 },
|
lineStyle: { color: accent, width: 2 },
|
||||||
areaStyle: {
|
areaStyle: {
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
{ offset: 0, color: 'rgba(206, 66, 43, 0.3)' },
|
{ offset: 0, color: accent + '55' },
|
||||||
{ offset: 1, color: 'rgba(206, 66, 43, 0)' }
|
{ offset: 1, color: accent + '00' }
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
itemStyle: { color: '#CE422B' }
|
itemStyle: { color: accent }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -125,13 +142,13 @@ const renderCharts = () => {
|
|||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: tooltipBg,
|
||||||
borderColor: '#2a2a2a',
|
borderColor: tooltipBorder,
|
||||||
textStyle: { color: '#e5e5e5' }
|
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
data: ['FPS', 'Entities'],
|
data: ['FPS', 'Entities'],
|
||||||
textStyle: { color: '#a3a3a3' },
|
textStyle: { color: labelColor, fontFamily: mono },
|
||||||
top: 0
|
top: 0
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
@@ -148,25 +165,25 @@ const renderCharts = () => {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit'
|
hour: '2-digit'
|
||||||
})),
|
})),
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
axisLabel: { color: '#808080', rotate: 45 }
|
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
yAxis: [
|
yAxis: [
|
||||||
{
|
{
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: 'FPS',
|
name: 'FPS',
|
||||||
position: 'left',
|
position: 'left',
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
splitLine: { lineStyle: { color: grid } },
|
||||||
axisLabel: { color: '#808080' }
|
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: 'Entities',
|
name: 'Entities',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
splitLine: { show: false },
|
splitLine: { show: false },
|
||||||
axisLabel: { color: '#808080' }
|
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
series: [
|
series: [
|
||||||
@@ -223,114 +240,209 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="analytics-view">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="analytics-view__header">
|
||||||
<div class="flex items-center gap-3">
|
<h1 class="analytics-view__title">Analytics</h1>
|
||||||
<BarChart3 class="w-5 h-5 text-oxide-500" />
|
<div class="analytics-view__controls">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Analytics</h1>
|
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
@click="downloadCSV"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
|
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</Button>
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
<Tabs
|
||||||
<button
|
:items="(['24h', '7d', '30d'] as const).map(v => ({ value: v, label: v }))"
|
||||||
v-for="opt in (['24h', '7d', '30d'] as const)"
|
v-model="timeRange"
|
||||||
:key="opt"
|
variant="pill"
|
||||||
@click="timeRange = opt"
|
/>
|
||||||
class="px-3 py-2 text-sm font-medium transition-colors"
|
|
||||||
:class="timeRange === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
{{ opt }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
<div v-if="loading" class="analytics-view__loading">
|
||||||
<div class="text-neutral-500">Loading analytics...</div>
|
<span class="analytics-view__loading-text">Loading analytics...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="summary">
|
<template v-else-if="summary">
|
||||||
<!-- Stat cards -->
|
<!-- Stat cards -->
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="analytics-view__stats">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<StatCard
|
||||||
<div class="flex items-center gap-2 mb-2">
|
label="Peak players"
|
||||||
<Users class="w-4 h-4 text-neutral-500" />
|
:value="summary.peak_players ?? '—'"
|
||||||
<p class="text-sm text-neutral-400">Peak Players</p>
|
icon="users"
|
||||||
</div>
|
:note="`Last ${timeRange}`"
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ summary.peak_players }}</p>
|
/>
|
||||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
<StatCard
|
||||||
</div>
|
label="Avg players"
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
:value="safeFixed(summary?.avg_players, 1)"
|
||||||
<div class="flex items-center gap-2 mb-2">
|
icon="trending-up"
|
||||||
<TrendingUp class="w-4 h-4 text-neutral-500" />
|
:note="`Last ${timeRange}`"
|
||||||
<p class="text-sm text-neutral-400">Avg Players</p>
|
/>
|
||||||
</div>
|
<StatCard
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(summary?.avg_players, 1) }}</p>
|
label="Uptime"
|
||||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
:value="safeFixed(summary?.uptime_percentage, 1)"
|
||||||
</div>
|
unit="%"
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
icon="clock"
|
||||||
<div class="flex items-center gap-2 mb-2">
|
:note="`Last ${timeRange}`"
|
||||||
<Clock class="w-4 h-4 text-neutral-500" />
|
/>
|
||||||
<p class="text-sm text-neutral-400">Uptime</p>
|
<StatCard
|
||||||
</div>
|
label="Unique players"
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(summary?.uptime_percentage, 1) }}%</p>
|
:value="summary.unique_players ?? '—'"
|
||||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
icon="bar-chart-3"
|
||||||
</div>
|
note="Phase 2.2"
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
/>
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<BarChart3 class="w-4 h-4 text-neutral-500" />
|
|
||||||
<p class="text-sm text-neutral-400">Unique Players</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ summary.unique_players ?? '—' }}</p>
|
|
||||||
<p class="text-xs text-neutral-600 mt-1">Phase 2.2</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Charts -->
|
<!-- Charts -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div class="analytics-view__charts">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Player count over time">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Player Count Over Time</h2>
|
<div ref="playerChart" class="analytics-view__chart-area"></div>
|
||||||
<div ref="playerChart" class="h-64"></div>
|
</Panel>
|
||||||
</div>
|
<Panel title="Server performance">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<div ref="perfChart" class="analytics-view__chart-area"></div>
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Server Performance</h2>
|
</Panel>
|
||||||
<div ref="perfChart" class="h-64"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Player Retention -->
|
<!-- Player Retention placeholder -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel eyebrow="Coming in phase 2" title="Player retention">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<template #title-append>
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Player Retention</h2>
|
<Badge tone="neutral">Phase 2</Badge>
|
||||||
<span class="text-xs font-medium px-2 py-0.5 bg-neutral-800 text-neutral-500 rounded-full border border-neutral-700">Phase 2</span>
|
</template>
|
||||||
</div>
|
<div class="analytics-view__retention-grid">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div class="analytics-view__retention-cell">
|
||||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
<p class="analytics-view__retention-label">New players</p>
|
||||||
<p class="text-xs text-neutral-500 mb-1">New Players</p>
|
<p class="analytics-view__retention-value">—</p>
|
||||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
<p class="analytics-view__retention-note">First-time visitors</p>
|
||||||
<p class="text-xs text-neutral-600 mt-1">First-time visitors</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
<div class="analytics-view__retention-cell">
|
||||||
<p class="text-xs text-neutral-500 mb-1">Returning Players</p>
|
<p class="analytics-view__retention-label">Returning players</p>
|
||||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
<p class="analytics-view__retention-value">—</p>
|
||||||
<p class="text-xs text-neutral-600 mt-1">Seen more than once</p>
|
<p class="analytics-view__retention-note">Seen more than once</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
<div class="analytics-view__retention-cell">
|
||||||
<p class="text-xs text-neutral-500 mb-1">Avg Session Duration</p>
|
<p class="analytics-view__retention-label">Avg session duration</p>
|
||||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
<p class="analytics-view__retention-value">—</p>
|
||||||
<p class="text-xs text-neutral-600 mt-1">Per visit</p>
|
<p class="analytics-view__retention-note">Per visit</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-neutral-600 mt-4 text-center">Player retention analytics will be available in Phase 2</p>
|
<p class="analytics-view__retention-footer">
|
||||||
</div>
|
Player retention analytics will be available in phase 2.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.analytics-view {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__loading-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.analytics-view__stats {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__charts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.analytics-view__charts {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__chart-area {
|
||||||
|
height: 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__retention-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.analytics-view__retention-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__retention-cell {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__retention-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__retention-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__retention-note {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__retention-footer {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useAutoDoorsStore } from '@/stores/autodoors'
|
import { useAutoDoorsStore } from '@/stores/autodoors'
|
||||||
import {
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
Save,
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
Play,
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
Download,
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
Plus,
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
Trash2,
|
|
||||||
DoorOpen,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const store = useAutoDoorsStore()
|
const store = useAutoDoorsStore()
|
||||||
|
|
||||||
@@ -21,13 +17,13 @@ const importConfigName = ref('')
|
|||||||
|
|
||||||
// Door types from the AutoDoors plugin
|
// Door types from the AutoDoors plugin
|
||||||
const doorTypes = [
|
const doorTypes = [
|
||||||
{ key: 'door.hinged.wood', label: 'Wooden Door', displayName: 'Wooden Door' },
|
{ key: 'door.hinged.wood', label: 'Wooden door', displayName: 'Wooden Door' },
|
||||||
{ key: 'door.hinged.metal', label: 'Sheet Metal Door', displayName: 'Sheet Metal Door' },
|
{ key: 'door.hinged.metal', label: 'Sheet metal door', displayName: 'Sheet Metal Door' },
|
||||||
{ key: 'door.hinged.toptier', label: 'Armored Door', displayName: 'Armored Door' },
|
{ key: 'door.hinged.toptier', label: 'Armored door', displayName: 'Armored Door' },
|
||||||
{ key: 'door.double.hinged.wood', label: 'Double Wooden Door', displayName: 'Double Wooden Door' },
|
{ key: 'door.double.hinged.wood', label: 'Double wooden door', displayName: 'Double Wooden Door' },
|
||||||
{ key: 'door.double.hinged.metal', label: 'Double Sheet Metal Door', displayName: 'Double Sheet Metal Door' },
|
{ key: 'door.double.hinged.metal', label: 'Double sheet metal door', displayName: 'Double Sheet Metal Door' },
|
||||||
{ key: 'door.double.hinged.toptier', label: 'Double Armored Door', displayName: 'Double Armored Door' },
|
{ key: 'door.double.hinged.toptier', label: 'Double armored door', displayName: 'Double Armored Door' },
|
||||||
{ key: 'floor.ladder.hatch', label: 'Ladder Hatch', displayName: 'Ladder Hatch' },
|
{ key: 'floor.ladder.hatch', label: 'Ladder hatch', displayName: 'Ladder Hatch' },
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -154,442 +150,472 @@ async function handleImport() {
|
|||||||
importConfigName.value = ''
|
importConfigName.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: coerce getConfigValue result to boolean for Switch
|
||||||
|
function getBool(path: string, def: boolean): boolean {
|
||||||
|
return !!getConfigValue(path, def)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="adv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="adv__head">
|
||||||
<h1 class="text-2xl font-bold text-white">Auto Doors</h1>
|
<div class="adv__head-id">
|
||||||
<div class="flex items-center gap-3">
|
<div class="adv__head-chip">
|
||||||
<button
|
<Icon name="door-open" :size="20" :stroke-width="2" />
|
||||||
@click="showCreateModal = true"
|
</div>
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
<div>
|
||||||
>
|
<div class="t-eyebrow">Plugin config</div>
|
||||||
<Plus class="w-4 h-4" />
|
<h1 class="adv__title">Auto doors</h1>
|
||||||
New Config
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button size="sm" icon="plus" @click="showCreateModal = true">New config</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Config Selector + Action Bar -->
|
<!-- Config action bar -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel>
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="adv__bar">
|
||||||
<!-- Config Selector -->
|
|
||||||
<select
|
<select
|
||||||
v-if="store.configs.length > 0"
|
v-if="store.configs.length > 0"
|
||||||
:value="store.currentConfig?.id || ''"
|
:value="store.currentConfig?.id ?? ''"
|
||||||
|
class="adv__config-select"
|
||||||
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||||
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
|
||||||
>
|
>
|
||||||
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||||
{{ c.config_name }}
|
{{ c.config_name }}{{ c.is_active ? ' (Active)' : '' }}
|
||||||
<template v-if="c.is_active"> (Active)</template>
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
<span v-else class="adv__no-configs">No configs yet</span>
|
||||||
|
|
||||||
<!-- Save -->
|
<Button
|
||||||
<button
|
icon="save"
|
||||||
@click="store.saveCurrentConfig()"
|
size="sm"
|
||||||
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="store.isSaving"
|
||||||
>
|
@click="store.saveCurrentConfig()"
|
||||||
<Save class="w-4 h-4" />
|
>{{ store.isSaving ? 'Saving…' : 'Save' }}</Button>
|
||||||
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Apply to Server -->
|
<Button
|
||||||
<button
|
variant="outline"
|
||||||
@click="handleApply"
|
icon="play"
|
||||||
|
size="sm"
|
||||||
:disabled="!store.currentConfig || store.isApplying"
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="store.isApplying"
|
||||||
>
|
@click="handleApply"
|
||||||
<Play class="w-4 h-4" />
|
>{{ store.isApplying ? 'Applying…' : 'Apply to server' }}</Button>
|
||||||
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Import from Server -->
|
<Button
|
||||||
<button
|
variant="secondary"
|
||||||
|
icon="download"
|
||||||
|
size="sm"
|
||||||
@click="showImportModal = true"
|
@click="showImportModal = true"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
>Import from server</Button>
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Import from Server
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Delete -->
|
<Button
|
||||||
<button
|
variant="danger-soft"
|
||||||
@click="handleDeleteConfig"
|
icon="trash-2"
|
||||||
|
size="sm"
|
||||||
:disabled="!store.currentConfig"
|
:disabled="!store.currentConfig"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
class="adv__bar-delete"
|
||||||
>
|
@click="handleDeleteConfig"
|
||||||
<Trash2 class="w-4 h-4" />
|
>Delete</Button>
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.isLoading" class="adv__loading">
|
||||||
|
<span class="adv__spinner" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Empty state -->
|
||||||
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
<Panel v-else-if="!store.currentConfig">
|
||||||
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
<EmptyState
|
||||||
</div>
|
icon="door-open"
|
||||||
|
title="No AutoDoors config selected"
|
||||||
<!-- No Config Selected -->
|
description="Create a new config, import from server, or select one from the dropdown above."
|
||||||
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
|
||||||
<DoorOpen class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
|
||||||
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No AutoDoors Config Selected</h2>
|
|
||||||
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
|
||||||
<button
|
|
||||||
@click="showCreateModal = true"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
|
||||||
>
|
>
|
||||||
Create First Config
|
<template #action>
|
||||||
</button>
|
<Button icon="plus" @click="showCreateModal = true">Create first config</Button>
|
||||||
</div>
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Config Editor -->
|
<!-- Config editor -->
|
||||||
<div v-else class="space-y-6">
|
<template v-else>
|
||||||
<!-- Settings Section -->
|
<!-- Global settings -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
<Panel title="Global settings">
|
||||||
<div class="flex items-center gap-2">
|
<!-- Delay sliders -->
|
||||||
<SettingsIcon class="w-4 h-4 text-neutral-400" />
|
<div class="adv__delay-grid">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Global Settings</h3>
|
<div class="adv__field">
|
||||||
</div>
|
<div class="adv__field-label">Default delay (seconds)</div>
|
||||||
|
<div class="adv__field-hint">Time before door auto-closes</div>
|
||||||
<!-- Delay Settings -->
|
<div class="adv__slider-row">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-neutral-200 mb-1">Default Delay (seconds)</label>
|
|
||||||
<p class="text-xs text-neutral-500 mb-2">Time before door auto-closes</p>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue('DefaultDelay', 5)"
|
:value="getConfigValue('DefaultDelay', 5)"
|
||||||
@input="setConfigValue('DefaultDelay', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="30"
|
max="30"
|
||||||
step="1"
|
step="1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="adv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue('DefaultDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue('DefaultDelay', 5)"
|
:value="getConfigValue('DefaultDelay', 5)"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
class="cc-num-input adv__delay-num"
|
||||||
@input="setConfigValue('DefaultDelay', Number(($event.target as HTMLInputElement).value))"
|
@input="setConfigValue('DefaultDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
min="1"
|
|
||||||
max="30"
|
|
||||||
class="w-16 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500">sec</span>
|
<span class="adv__unit">sec</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="adv__field">
|
||||||
<label class="block text-sm text-neutral-200 mb-1">Minimum Delay (seconds)</label>
|
<div class="adv__field-label">Minimum delay (seconds)</div>
|
||||||
<p class="text-xs text-neutral-500 mb-2">Lowest delay a player can set</p>
|
<div class="adv__field-hint">Lowest delay a player can set</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="adv__slider-row">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue('MinimumDelay', 5)"
|
:value="getConfigValue('MinimumDelay', 5)"
|
||||||
@input="setConfigValue('MinimumDelay', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="30"
|
max="30"
|
||||||
step="1"
|
step="1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="adv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue('MinimumDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue('MinimumDelay', 5)"
|
:value="getConfigValue('MinimumDelay', 5)"
|
||||||
@input="setConfigValue('MinimumDelay', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="30"
|
max="30"
|
||||||
class="w-16 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input adv__delay-num"
|
||||||
|
@input="setConfigValue('MinimumDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500">sec</span>
|
<span class="adv__unit">sec</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="adv__field">
|
||||||
<label class="block text-sm text-neutral-200 mb-1">Maximum Delay (seconds)</label>
|
<div class="adv__field-label">Maximum delay (seconds)</div>
|
||||||
<p class="text-xs text-neutral-500 mb-2">Highest delay a player can set</p>
|
<div class="adv__field-hint">Highest delay a player can set</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="adv__slider-row">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue('MaximumDelay', 30)"
|
:value="getConfigValue('MaximumDelay', 30)"
|
||||||
@input="setConfigValue('MaximumDelay', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="60"
|
max="60"
|
||||||
step="1"
|
step="1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="adv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue('MaximumDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue('MaximumDelay', 30)"
|
:value="getConfigValue('MaximumDelay', 30)"
|
||||||
@input="setConfigValue('MaximumDelay', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="60"
|
max="60"
|
||||||
class="w-16 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input adv__delay-num"
|
||||||
|
@input="setConfigValue('MaximumDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500">sec</span>
|
<span class="adv__unit">sec</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global Toggles -->
|
<!-- Global toggles -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="adv__toggles adv__mt">
|
||||||
<div class="flex items-center justify-between">
|
<div class="adv__toggle-row">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">Default Enabled</label>
|
<div class="adv__toggle-label">Default enabled</div>
|
||||||
<p class="text-xs text-neutral-500">Auto-close enabled for new players by default</p>
|
<div class="adv__toggle-sub">Auto-close enabled for new players by default</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue('GlobalSettings.defaultEnabled', !getConfigValue('GlobalSettings.defaultEnabled', true))"
|
:model-value="getBool('GlobalSettings.defaultEnabled', true)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue('GlobalSettings.defaultEnabled', v)"
|
||||||
:class="getConfigValue('GlobalSettings.defaultEnabled', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
/>
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('GlobalSettings.defaultEnabled', true) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adv__toggle-row">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">Allow Unowned Doors</label>
|
<div class="adv__toggle-label">Allow unowned doors</div>
|
||||||
<p class="text-xs text-neutral-500">Auto-close doors that the player does not own</p>
|
<div class="adv__toggle-sub">Auto-close doors that the player does not own</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue('GlobalSettings.useUnownedDoor', !getConfigValue('GlobalSettings.useUnownedDoor', false))"
|
:model-value="getBool('GlobalSettings.useUnownedDoor', false)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue('GlobalSettings.useUnownedDoor', v)"
|
||||||
:class="getConfigValue('GlobalSettings.useUnownedDoor', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
/>
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('GlobalSettings.useUnownedDoor', false) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adv__toggle-row">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">Exclude Door Controller</label>
|
<div class="adv__toggle-label">Exclude door controller</div>
|
||||||
<p class="text-xs text-neutral-500">Skip doors that have a Code Lock or Key Lock</p>
|
<div class="adv__toggle-sub">Skip doors that have a code lock or key lock</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue('GlobalSettings.excludeDoorController', !getConfigValue('GlobalSettings.excludeDoorController', false))"
|
:model-value="getBool('GlobalSettings.excludeDoorController', false)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue('GlobalSettings.excludeDoorController', v)"
|
||||||
:class="getConfigValue('GlobalSettings.excludeDoorController', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
/>
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('GlobalSettings.excludeDoorController', false) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adv__toggle-row">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">Cancel on Player Death</label>
|
<div class="adv__toggle-label">Cancel on player death</div>
|
||||||
<p class="text-xs text-neutral-500">Cancel auto-close if the player dies</p>
|
<div class="adv__toggle-sub">Cancel auto-close if the player dies</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue('GlobalSettings.cancelOnKill', !getConfigValue('GlobalSettings.cancelOnKill', false))"
|
:model-value="getBool('GlobalSettings.cancelOnKill', false)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue('GlobalSettings.cancelOnKill', v)"
|
||||||
:class="getConfigValue('GlobalSettings.cancelOnKill', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
/>
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('GlobalSettings.cancelOnKill', false) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adv__toggle-row">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">Use Permissions</label>
|
<div class="adv__toggle-label">Use permissions</div>
|
||||||
<p class="text-xs text-neutral-500">Require Oxide permission to use auto-close</p>
|
<div class="adv__toggle-sub">Require Oxide permission to use auto-close</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue('UsePermissions', !getConfigValue('UsePermissions', false))"
|
:model-value="getBool('UsePermissions', false)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue('UsePermissions', v)"
|
||||||
:class="getConfigValue('UsePermissions', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
/>
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('UsePermissions', false) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adv__toggle-row">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">Clear Data on Map Wipe</label>
|
<div class="adv__toggle-label">Clear data on map wipe</div>
|
||||||
<p class="text-xs text-neutral-500">Reset all player preferences on map wipe</p>
|
<div class="adv__toggle-sub">Reset all player preferences on map wipe</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue('ClearDataOnWipe', !getConfigValue('ClearDataOnWipe', false))"
|
:model-value="getBool('ClearDataOnWipe', false)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue('ClearDataOnWipe', v)"
|
||||||
:class="getConfigValue('ClearDataOnWipe', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
/>
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('ClearDataOnWipe', false) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Door Types Section -->
|
<!-- Door types -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Door types" subtitle="Enable or disable auto-close for each door type.">
|
||||||
<div class="flex items-center gap-2">
|
<div class="adv__toggles">
|
||||||
<DoorOpen class="w-4 h-4 text-neutral-400" />
|
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Door Types</h3>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-neutral-500">Enable or disable auto-close for each door type.</p>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div
|
<div
|
||||||
v-for="door in doorTypes"
|
v-for="door in doorTypes"
|
||||||
:key="door.key"
|
:key="door.key"
|
||||||
class="flex items-center justify-between py-2 border-b border-neutral-800 last:border-0"
|
class="adv__toggle-row"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">{{ door.label }}</label>
|
<div class="adv__toggle-label">{{ door.label }}</div>
|
||||||
<p class="text-xs text-neutral-500 font-mono">{{ door.key }}</p>
|
<div class="adv__toggle-sub adv__mono">{{ door.key }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue(`DoorSettings.${door.key}.enabled`, !getConfigValue(`DoorSettings.${door.key}.enabled`, true))"
|
:model-value="getBool(`DoorSettings.${door.key}.enabled`, true)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue(`DoorSettings.${door.key}.enabled`, v)"
|
||||||
:class="getConfigValue(`DoorSettings.${door.key}.enabled`, true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
/>
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue(`DoorSettings.${door.key}.enabled`, true) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Permission Groups Section -->
|
<!-- Permission group overrides -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Permission group overrides" subtitle="Override the default delay for specific Oxide permission groups.">
|
||||||
<div class="flex items-center justify-between">
|
<template #actions>
|
||||||
<div class="flex items-center gap-2">
|
<Button size="sm" icon="plus" variant="secondary" @click="addPermissionGroup">Add group</Button>
|
||||||
<SettingsIcon class="w-4 h-4 text-neutral-400" />
|
</template>
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Permission Group Overrides</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="addPermissionGroup"
|
|
||||||
class="flex items-center gap-1 px-3 py-1 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
|
||||||
<Plus class="w-3 h-3" />
|
|
||||||
Add Group
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-neutral-500">Override the default delay for specific Oxide permission groups.</p>
|
|
||||||
|
|
||||||
<div v-if="getPermissionGroups().length === 0" class="text-sm text-neutral-500 text-center py-4">
|
<div v-if="getPermissionGroups().length === 0" class="adv__perm-empty">
|
||||||
No permission group overrides configured.
|
No permission group overrides configured.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="adv__perm-list">
|
||||||
<div
|
<div
|
||||||
v-for="(group, index) in getPermissionGroups()"
|
v-for="(group, index) in getPermissionGroups()"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex items-center gap-3"
|
class="adv__perm-row"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
:value="group.name"
|
:value="group.name"
|
||||||
@input="updatePermissionGroupName(group.name, ($event.target as HTMLInputElement).value)"
|
type="text"
|
||||||
|
class="cc-text-input adv__perm-name"
|
||||||
placeholder="Group name (e.g. vip)"
|
placeholder="Group name (e.g. vip)"
|
||||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
@input="updatePermissionGroupName(group.name, ($event.target as HTMLInputElement).value)"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="group.delay"
|
:value="group.delay"
|
||||||
@input="updatePermissionGroupDelay(group.name, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="60"
|
max="60"
|
||||||
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-2 text-neutral-200 text-sm text-center"
|
class="cc-num-input adv__perm-delay"
|
||||||
|
@input="updatePermissionGroupDelay(group.name, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500">sec</span>
|
<span class="adv__unit">sec</span>
|
||||||
<button
|
<button class="adv__del-btn" title="Remove group" @click="removePermissionGroup(group.name)">
|
||||||
@click="removePermissionGroup(group.name)"
|
<Icon name="trash-2" :size="15" />
|
||||||
class="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Create Config Modal -->
|
<!-- Create config modal -->
|
||||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
<div v-if="showCreateModal" class="adv__modal-backdrop" @click.self="showCreateModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="adv__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New AutoDoors Config</h2>
|
<h2 class="adv__modal-title">New AutoDoors config</h2>
|
||||||
<div class="space-y-4">
|
<div class="adv__modal-body">
|
||||||
<div>
|
<div class="adv__field">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
<label class="adv__field-label">Config name</label>
|
||||||
<input
|
<input
|
||||||
v-model="newConfigName"
|
v-model="newConfigName"
|
||||||
placeholder="e.g. 5 Second Close"
|
type="text"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
class="cc-text-input"
|
||||||
|
placeholder="e.g. 5 second close"
|
||||||
@keydown.enter="handleCreateConfig"
|
@keydown.enter="handleCreateConfig"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="adv__field">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
<label class="adv__field-label">Description (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newConfigDesc"
|
v-model="newConfigDesc"
|
||||||
rows="2"
|
rows="2"
|
||||||
|
class="cc-textarea"
|
||||||
placeholder="What is this config for?"
|
placeholder="What is this config for?"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
</div>
|
||||||
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
<div class="adv__modal-footer">
|
||||||
<button
|
<Button variant="ghost" @click="showCreateModal = false">Cancel</Button>
|
||||||
@click="handleCreateConfig"
|
<Button :disabled="!newConfigName.trim()" @click="handleCreateConfig">Create</Button>
|
||||||
:disabled="!newConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import from Server Modal -->
|
<!-- Import from server modal -->
|
||||||
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
<div v-if="showImportModal" class="adv__modal-backdrop" @click.self="showImportModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="adv__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
<h2 class="adv__modal-title">Import from server</h2>
|
||||||
<p class="text-sm text-neutral-400 mb-4">
|
<p class="adv__modal-desc">Import the current AutoDoors config from your live server. This will create a new config profile.</p>
|
||||||
Import the current AutoDoors config from your live server. This will create a new config profile.
|
<div class="adv__modal-body">
|
||||||
</p>
|
<div class="adv__field">
|
||||||
<div class="space-y-4">
|
<label class="adv__field-label">Config name</label>
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
|
||||||
<input
|
<input
|
||||||
v-model="importConfigName"
|
v-model="importConfigName"
|
||||||
placeholder="e.g. Imported Server Config"
|
type="text"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
class="cc-text-input"
|
||||||
|
placeholder="e.g. Imported server config"
|
||||||
@keydown.enter="handleImport"
|
@keydown.enter="handleImport"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
</div>
|
||||||
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
<div class="adv__modal-footer">
|
||||||
<button
|
<Button variant="ghost" @click="showImportModal = false">Cancel</Button>
|
||||||
@click="handleImport"
|
<Button :disabled="!importConfigName.trim()" @click="handleImport">Import</Button>
|
||||||
:disabled="!importConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.adv { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.adv__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.adv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.adv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.adv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config action bar */
|
||||||
|
.adv__bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.adv__config-select {
|
||||||
|
appearance: none; height: var(--control-h-md); padding: 0 11px;
|
||||||
|
background: var(--surface-inset); color: var(--text-primary); border: 0;
|
||||||
|
border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
font-family: var(--font-sans); font-size: var(--text-sm); cursor: pointer;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.adv__config-select:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.adv__no-configs { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
|
.adv__bar-delete { margin-left: auto; }
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.adv__loading { display: flex; align-items: center; justify-content: center; padding: 60px; }
|
||||||
|
.adv__spinner {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--accent);
|
||||||
|
border-top-color: transparent; animation: adv-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes adv-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Delay grid */
|
||||||
|
.adv__delay-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
||||||
|
@media (max-width: 700px) { .adv__delay-grid { grid-template-columns: 1fr; } }
|
||||||
|
.adv__slider-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
||||||
|
.adv__slider { flex: 1; cursor: pointer; }
|
||||||
|
.adv__delay-num { width: 60px; flex: none; text-align: center; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.adv__unit { font-size: var(--text-xs); color: var(--text-tertiary); flex: none; }
|
||||||
|
.adv__mt { margin-top: 20px; }
|
||||||
|
|
||||||
|
/* Fields */
|
||||||
|
.adv__field { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.adv__field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.adv__field-hint { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
/* Toggle rows */
|
||||||
|
.adv__toggles { display: flex; flex-direction: column; }
|
||||||
|
.adv__toggle-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.adv__toggle-row:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||||
|
.adv__toggle-row:first-child { padding-top: 0; }
|
||||||
|
.adv__toggle-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.adv__toggle-sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||||
|
.adv__mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Permission groups */
|
||||||
|
.adv__perm-empty { font-size: var(--text-sm); color: var(--text-tertiary); text-align: center; padding: 20px 0; }
|
||||||
|
.adv__perm-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.adv__perm-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.adv__perm-name { flex: 1; }
|
||||||
|
.adv__perm-delay { width: 72px; flex: none; text-align: center; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.adv__del-btn {
|
||||||
|
width: 34px; height: 34px; border-radius: var(--radius-sm); border: none; background: transparent;
|
||||||
|
color: var(--danger); display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer; transition: var(--transition-colors); flex: none;
|
||||||
|
}
|
||||||
|
.adv__del-btn:hover { background: var(--status-offline-soft); }
|
||||||
|
|
||||||
|
/* Shared token inputs */
|
||||||
|
.cc-textarea {
|
||||||
|
width: 100%; background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 11px; font-family: var(--font-sans); font-size: var(--text-sm);
|
||||||
|
resize: none; outline: 0; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.cc-textarea:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-text-input {
|
||||||
|
width: 100%; height: var(--control-h-md); background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 0 11px; font-family: var(--font-sans); font-size: var(--text-sm); outline: 0;
|
||||||
|
}
|
||||||
|
.cc-text-input:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-num-input {
|
||||||
|
width: 100%; height: var(--control-h-md); background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 0 11px; font-family: var(--font-sans); font-size: var(--text-sm); outline: 0;
|
||||||
|
}
|
||||||
|
.cc-num-input:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.adv__modal-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 50;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||||
|
}
|
||||||
|
.adv__modal {
|
||||||
|
background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default);
|
||||||
|
width: 100%; max-width: 440px; padding: 24px; display: flex; flex-direction: column; gap: 16px;
|
||||||
|
}
|
||||||
|
.adv__modal-title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
|
||||||
|
.adv__modal-desc { font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.5; }
|
||||||
|
.adv__modal-body { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.adv__modal-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { FileText, Tag, Loader2 } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
interface ChangelogEntry {
|
interface ChangelogEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -38,13 +42,13 @@ function loadMore() {
|
|||||||
fetchChangelog()
|
fetchChangelog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCategoryColor(category: string): string {
|
function categoryTone(category: string): 'online' | 'offline' | 'info' | 'warn' | 'neutral' {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case 'feature': return 'bg-green-500/10 text-green-400'
|
case 'feature': return 'online'
|
||||||
case 'bugfix': return 'bg-red-500/10 text-red-400'
|
case 'bugfix': return 'offline'
|
||||||
case 'module': return 'bg-blue-500/10 text-blue-400'
|
case 'module': return 'info'
|
||||||
case 'security': return 'bg-yellow-500/10 text-yellow-400'
|
case 'security': return 'warn'
|
||||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
default: return 'neutral'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,60 +58,127 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="cl">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="cl__head">
|
||||||
<FileText class="w-5 h-5 text-oxide-500" />
|
<div class="cl__head-id">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Changelog</h1>
|
<div class="cl__head-chip">
|
||||||
|
<Icon name="file-text" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="t-eyebrow">Platform</div>
|
||||||
|
<h1 class="cl__title">Changelog</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Changelog Feed -->
|
<!-- Loading state — first load -->
|
||||||
<div class="space-y-4">
|
<div v-if="isLoading && entries.length === 0" class="cl__loading">
|
||||||
<div
|
<Icon name="loader" :size="22" class="cl__spin" />
|
||||||
|
<span>Loading changelog…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<Panel v-else-if="!isLoading && entries.length === 0" title="Changelog">
|
||||||
|
<EmptyState
|
||||||
|
icon="file-text"
|
||||||
|
title="No entries yet"
|
||||||
|
description="Platform changelog entries will appear here."
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Entry feed -->
|
||||||
|
<template v-else>
|
||||||
|
<Panel
|
||||||
v-for="entry in entries"
|
v-for="entry in entries"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"
|
:title="entry.title"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between mb-3">
|
<template #actions>
|
||||||
<div class="flex items-center gap-3">
|
<Badge tone="accent" :mono="true">{{ entry.version }}</Badge>
|
||||||
<div class="flex items-center gap-2 px-2 py-1 bg-oxide-500/10 border border-oxide-500/20 rounded-lg">
|
<Badge :tone="categoryTone(entry.category)">{{ entry.category }}</Badge>
|
||||||
<Tag class="w-3 h-3 text-oxide-400" />
|
<span class="cl__date">{{ new Date(entry.published_at).toLocaleDateString() }}</span>
|
||||||
<span class="text-xs font-mono text-oxide-400">{{ entry.version }}</span>
|
</template>
|
||||||
</div>
|
<div class="cl__body">{{ entry.body }}</div>
|
||||||
<span
|
</Panel>
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full capitalize"
|
|
||||||
:class="getCategoryColor(entry.category)"
|
<!-- Load more spinner -->
|
||||||
>
|
<div v-if="isLoading" class="cl__loading">
|
||||||
{{ entry.category }}
|
<Icon name="loader" :size="20" class="cl__spin" />
|
||||||
</span>
|
<span>Loading more…</span>
|
||||||
</div>
|
|
||||||
<span class="text-xs text-neutral-500">{{ new Date(entry.published_at).toLocaleDateString() }}</span>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-bold text-neutral-100 mb-2">{{ entry.title }}</h3>
|
|
||||||
<div class="text-sm text-neutral-300 whitespace-pre-line leading-relaxed">
|
|
||||||
{{ entry.body }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Load more button -->
|
||||||
<div v-if="isLoading" class="flex justify-center py-6">
|
<div v-else-if="hasMore" class="cl__more">
|
||||||
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" />
|
<Button variant="secondary" @click="loadMore">Load more</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Load More -->
|
<!-- End of list -->
|
||||||
<div v-else-if="hasMore" class="flex justify-center">
|
<div v-else class="cl__end">No more changelog entries</div>
|
||||||
<button
|
</template>
|
||||||
@click="loadMore"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Load More
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- End of List -->
|
|
||||||
<div v-else class="text-center py-6 text-sm text-neutral-500">
|
|
||||||
No more changelog entries
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cl {
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.cl__head { display: flex; align-items: center; }
|
||||||
|
.cl__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.cl__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.cl__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entry body */
|
||||||
|
.cl__body {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-line;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date label in actions slot */
|
||||||
|
.cl__date {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading / footer states */
|
||||||
|
.cl__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 28px 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
@keyframes cl-spin { to { transform: rotate(360deg); } }
|
||||||
|
.cl__spin { animation: cl-spin 0.7s linear infinite; }
|
||||||
|
|
||||||
|
.cl__more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.cl__end {
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { ChatMessage } from '@/types'
|
import type { ChatMessage } from '@/types'
|
||||||
import { MessageSquare, Search, Flag, RefreshCw } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
@@ -32,12 +39,18 @@ const filteredMessages = computed(() => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
function channelBadgeClass(channel: string): string {
|
const channelTabItems = computed(() => [
|
||||||
|
{ value: 'all', label: 'All', count: messages.value.length },
|
||||||
|
{ value: 'global', label: 'Global' },
|
||||||
|
{ value: 'team', label: 'Team' },
|
||||||
|
{ value: 'server', label: 'Server' },
|
||||||
|
])
|
||||||
|
|
||||||
|
function channelTone(channel: string): 'accent' | 'info' | 'neutral' {
|
||||||
switch (channel) {
|
switch (channel) {
|
||||||
case 'global': return 'bg-oxide-500/15 text-oxide-400'
|
case 'global': return 'accent'
|
||||||
case 'team': return 'bg-blue-500/15 text-blue-400'
|
case 'team': return 'info'
|
||||||
case 'server': return 'bg-neutral-700/50 text-neutral-400'
|
default: return 'neutral'
|
||||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,89 +89,163 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="clv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="clv__head">
|
||||||
<div class="flex items-center gap-3">
|
<div class="clv__head-id">
|
||||||
<MessageSquare class="w-5 h-5 text-oxide-500" />
|
<div class="clv__head-chip">
|
||||||
|
<Icon name="message-square" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Chat Log</h1>
|
<div class="t-eyebrow">Chat log</div>
|
||||||
<p class="text-sm text-neutral-500 mt-0.5">{{ messages.length }} messages</p>
|
<h1 class="clv__title">Chat log</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="clv__head-actions">
|
||||||
@click="fetchMessages"
|
<div class="clv__stat-pill">
|
||||||
:disabled="isLoading"
|
<span class="clv__stat-num">{{ messages.length }}</span>
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 rounded-lg transition-colors"
|
<span class="clv__stat-label">messages</span>
|
||||||
>
|
</div>
|
||||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
<Button
|
||||||
Refresh
|
variant="secondary"
|
||||||
</button>
|
size="sm"
|
||||||
|
icon="refresh-cw"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="fetchMessages"
|
||||||
|
>Refresh</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="clv__filters">
|
||||||
<div class="relative flex-1 max-w-sm">
|
<Input
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
v-model="searchQuery"
|
||||||
<input
|
icon="search"
|
||||||
v-model="searchQuery"
|
placeholder="Search messages, players, or Steam IDs…"
|
||||||
type="text"
|
size="sm"
|
||||||
placeholder="Search messages, players, or Steam IDs..."
|
style="max-width: 340px;"
|
||||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
/>
|
||||||
/>
|
<Tabs v-model="channelFilter" :items="channelTabItems" />
|
||||||
</div>
|
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
|
||||||
<button
|
|
||||||
v-for="opt in (['all', 'global', 'team', 'server'] as const)"
|
|
||||||
:key="opt"
|
|
||||||
@click="channelFilter = opt"
|
|
||||||
class="px-3 py-2 text-sm font-medium transition-colors capitalize"
|
|
||||||
:class="channelFilter === opt
|
|
||||||
? 'bg-oxide-500/15 text-oxide-400'
|
|
||||||
: 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
{{ opt }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages panel -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg divide-y divide-neutral-800">
|
<Panel :flush-body="true">
|
||||||
<div v-if="filteredMessages.length === 0" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
<!-- Empty state -->
|
||||||
<template v-if="isLoading">Loading chat messages...</template>
|
<EmptyState
|
||||||
<template v-else-if="searchQuery">No messages matching "{{ searchQuery }}"</template>
|
v-if="filteredMessages.length === 0 && !isLoading"
|
||||||
<template v-else>No chat messages yet. Messages will appear when the server is active.</template>
|
icon="message-square"
|
||||||
|
:title="searchQuery ? 'No messages found' : 'No chat messages'"
|
||||||
|
:description="searchQuery
|
||||||
|
? `No messages matching "${searchQuery}".`
|
||||||
|
: 'No chat messages yet. Messages will appear when the server is active.'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-else-if="isLoading && filteredMessages.length === 0" class="clv__loading">
|
||||||
|
<Icon name="loader" :size="20" class="clv__spin" />
|
||||||
|
<span>Loading chat messages…</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-for="msg in filteredMessages"
|
<!-- Message list -->
|
||||||
:key="msg.id"
|
<div v-else class="clv__messages">
|
||||||
class="flex items-start gap-4 px-4 py-3 hover:bg-neutral-800/50 transition-colors"
|
<div
|
||||||
:class="{ 'bg-red-500/5 border-l-2 border-l-red-500/30': msg.flagged }"
|
v-for="msg in filteredMessages"
|
||||||
>
|
:key="msg.id"
|
||||||
<div class="shrink-0 text-right w-20">
|
class="clv__row"
|
||||||
<p class="text-xs text-neutral-500">{{ formatDate(msg.created_at) }}</p>
|
:class="{ 'clv__row--flagged': msg.flagged }"
|
||||||
<p class="text-xs text-neutral-600">{{ formatTime(msg.created_at) }}</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="shrink-0 text-xs font-medium px-2 py-0.5 rounded-full mt-0.5"
|
|
||||||
:class="channelBadgeClass(msg.channel)"
|
|
||||||
>
|
>
|
||||||
{{ msg.channel }}
|
<!-- Timestamp -->
|
||||||
</span>
|
<div class="clv__ts">
|
||||||
<div class="flex-1 min-w-0">
|
<span class="clv__date">{{ formatDate(msg.created_at) }}</span>
|
||||||
<span class="text-sm font-medium text-oxide-400">{{ msg.player_name }}</span>
|
<span class="clv__time">{{ formatTime(msg.created_at) }}</span>
|
||||||
<span class="text-sm text-neutral-500 ml-2 font-mono">{{ msg.steam_id }}</span>
|
</div>
|
||||||
<p class="text-sm text-neutral-300 mt-0.5 break-words">{{ msg.message }}</p>
|
|
||||||
|
<!-- Channel badge -->
|
||||||
|
<Badge :tone="channelTone(msg.channel)" size="md">{{ msg.channel }}</Badge>
|
||||||
|
|
||||||
|
<!-- Message body -->
|
||||||
|
<div class="clv__body">
|
||||||
|
<span class="clv__player">{{ msg.player_name }}</span>
|
||||||
|
<span class="clv__steam">{{ msg.steam_id }}</span>
|
||||||
|
<p class="clv__text">{{ msg.message }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flag toggle -->
|
||||||
|
<IconButton
|
||||||
|
icon="bookmark"
|
||||||
|
:variant="msg.flagged ? 'accent' : 'ghost'"
|
||||||
|
size="sm"
|
||||||
|
:label="msg.flagged ? 'Unflag message' : 'Flag message'"
|
||||||
|
@click="toggleFlag(msg)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
@click="toggleFlag(msg)"
|
|
||||||
class="shrink-0 p-1 rounded transition-colors"
|
|
||||||
:class="msg.flagged ? 'text-red-400 hover:text-red-300' : 'text-neutral-600 hover:text-neutral-400'"
|
|
||||||
:title="msg.flagged ? 'Unflag message' : 'Flag message'"
|
|
||||||
>
|
|
||||||
<Flag class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.clv { max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.clv__head {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between;
|
||||||
|
flex-wrap: wrap; gap: 12px;
|
||||||
|
}
|
||||||
|
.clv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.clv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.clv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
.clv__head-actions { display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
|
/* Stat pill */
|
||||||
|
.clv__stat-pill { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.clv__stat-num { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 700; color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||||
|
.clv__stat-label { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.clv__filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.clv__loading {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 10px;
|
||||||
|
padding: 48px; color: var(--text-tertiary); font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
@keyframes clv-spin { to { transform: rotate(360deg); } }
|
||||||
|
.clv__spin { animation: clv-spin 0.7s linear infinite; }
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.clv__messages { display: flex; flex-direction: column; }
|
||||||
|
.clv__row {
|
||||||
|
display: flex; align-items: flex-start; gap: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.clv__row:last-child { border-bottom: 0; }
|
||||||
|
.clv__row:hover { background: var(--surface-hover); }
|
||||||
|
.clv__row--flagged {
|
||||||
|
background: var(--status-offline-soft);
|
||||||
|
border-left: 3px solid var(--status-offline-border);
|
||||||
|
}
|
||||||
|
.clv__row--flagged:hover { background: var(--status-offline-soft); filter: brightness(1.04); }
|
||||||
|
|
||||||
|
/* Timestamp */
|
||||||
|
.clv__ts { display: flex; flex-direction: column; align-items: flex-end; min-width: 68px; flex: none; padding-top: 1px; }
|
||||||
|
.clv__date { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
.clv__time { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Message body */
|
||||||
|
.clv__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.clv__player { font-size: var(--text-sm); font-weight: 600; color: var(--accent-text); }
|
||||||
|
.clv__steam { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); margin-left: 8px; }
|
||||||
|
.clv__text { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.5; word-break: break-word; margin: 0; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,18 +2,23 @@
|
|||||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
|
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
|
||||||
import { Send, Terminal, Trash2 } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import ConsoleLine from '@/components/ds/data/ConsoleLine.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
const ws = useWebSocket()
|
const ws = useWebSocket()
|
||||||
|
|
||||||
interface ConsoleLine {
|
interface LogLine {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
text: string
|
text: string
|
||||||
type: 'info' | 'warning' | 'error' | 'command' | 'system'
|
type: 'info' | 'warning' | 'error' | 'command' | 'system'
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = ref<ConsoleLine[]>([])
|
const lines = ref<LogLine[]>([])
|
||||||
const commandInput = ref('')
|
const commandInput = ref('')
|
||||||
const consoleEl = ref<HTMLElement | null>(null)
|
const consoleEl = ref<HTMLElement | null>(null)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
@@ -22,7 +27,7 @@ function now(): string {
|
|||||||
return new Date().toLocaleTimeString('en-US', { hour12: false })
|
return new Date().toLocaleTimeString('en-US', { hour12: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLine(text: string, type: ConsoleLine['type'] = 'info') {
|
function addLine(text: string, type: LogLine['type'] = 'info') {
|
||||||
lines.value.push({ timestamp: now(), text, type })
|
lines.value.push({ timestamp: now(), text, type })
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
@@ -60,13 +65,12 @@ function clearConsole() {
|
|||||||
lines.value = []
|
lines.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineColor(type: ConsoleLine['type']): string {
|
function lineLevel(type: LogLine['type']): 'cmd' | 'warn' | 'error' | 'info' {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'command': return 'text-oxide-400'
|
case 'command': return 'cmd'
|
||||||
case 'warning': return 'text-yellow-400'
|
case 'warning': return 'warn'
|
||||||
case 'error': return 'text-red-400'
|
case 'error': return 'error'
|
||||||
case 'system': return 'text-neutral-500'
|
default: return 'info'
|
||||||
default: return 'text-neutral-300'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,10 +87,10 @@ function handleWebSocketMessage(message: WebSocketMessage) {
|
|||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
addLine('Corrosion Console initialized.', 'system')
|
addLine('Corrosion console initialized.', 'system')
|
||||||
addLine('Type a command and press Enter to send it to the server.', 'system')
|
addLine('Type a command and press Enter to send it to the server.', 'system')
|
||||||
if (server.connection?.connection_status !== 'connected') {
|
if (server.connection?.connection_status !== 'connected') {
|
||||||
addLine('WARNING: Server is not connected. Commands will fail.', 'warning')
|
addLine('Warning: server is not connected. Commands will fail.', 'warning')
|
||||||
}
|
}
|
||||||
unsubscribe = ws.subscribe(handleWebSocketMessage)
|
unsubscribe = ws.subscribe(handleWebSocketMessage)
|
||||||
})
|
})
|
||||||
@@ -100,70 +104,127 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 h-full flex flex-col">
|
<div class="cv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="cv__head">
|
||||||
<div class="flex items-center gap-3">
|
<div class="cv__head-id">
|
||||||
<Terminal class="w-5 h-5 text-oxide-500" />
|
<div class="cv__head-chip">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Server Console</h1>
|
<Icon name="terminal" :size="20" :stroke-width="2" />
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="flex items-center gap-2 text-sm">
|
|
||||||
<span
|
|
||||||
class="h-2 w-2 rounded-full"
|
|
||||||
:class="server.connection?.connection_status === 'connected' ? 'bg-green-500' : 'bg-red-500'"
|
|
||||||
/>
|
|
||||||
<span class="text-neutral-400">
|
|
||||||
{{ server.connection?.connection_status === 'connected' ? 'Connected' : 'Disconnected' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div>
|
||||||
@click="clearConsole"
|
<div class="t-eyebrow">Server management</div>
|
||||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
<h1 class="cv__title">Console</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cv__head-actions">
|
||||||
|
<Badge
|
||||||
|
:tone="server.connection?.connection_status === 'connected' ? 'online' : 'offline'"
|
||||||
|
:dot="true"
|
||||||
|
:pulse="server.connection?.connection_status === 'connected'"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5" />
|
{{ server.connection?.connection_status === 'connected' ? 'Connected' : 'Disconnected' }}
|
||||||
Clear
|
</Badge>
|
||||||
</button>
|
<Button variant="ghost" size="sm" icon="trash-2" @click="clearConsole">Clear</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Console output -->
|
<!-- Console panel -->
|
||||||
<div
|
<Panel :flush-body="true" title="Output">
|
||||||
ref="consoleEl"
|
<div ref="consoleEl" class="cv__output">
|
||||||
class="flex-1 bg-black/80 border border-neutral-800 rounded-t-lg p-4 overflow-y-auto font-mono text-sm leading-relaxed min-h-0"
|
<div v-if="lines.length === 0" class="cv__empty">
|
||||||
>
|
No output yet. Send a command to get started.
|
||||||
<div v-if="lines.length === 0" class="text-neutral-600 italic">
|
</div>
|
||||||
No output yet. Send a command to get started.
|
<ConsoleLine
|
||||||
|
v-for="(line, i) in lines"
|
||||||
|
:key="i"
|
||||||
|
:time="line.timestamp"
|
||||||
|
:level="lineLevel(line.type)"
|
||||||
|
>{{ line.text }}</ConsoleLine>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="cv__bar">
|
||||||
v-for="(line, i) in lines"
|
<span class="cv__bar-prompt">$</span>
|
||||||
:key="i"
|
<Input
|
||||||
class="flex gap-3"
|
v-model="commandInput"
|
||||||
>
|
:mono="true"
|
||||||
<span class="text-neutral-600 shrink-0 select-none">{{ line.timestamp }}</span>
|
size="sm"
|
||||||
<span :class="lineColor(line.type)" class="whitespace-pre-wrap break-all">{{ line.text }}</span>
|
placeholder="say Hello everyone…"
|
||||||
|
:disabled="sending"
|
||||||
|
style="flex: 1"
|
||||||
|
@keydown.enter="handleSend"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="corner-down-left"
|
||||||
|
:loading="sending"
|
||||||
|
:disabled="!commandInput.trim() || sending"
|
||||||
|
@click="handleSend"
|
||||||
|
>Send</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Command input -->
|
|
||||||
<div class="flex bg-neutral-900 border border-t-0 border-neutral-800 rounded-b-lg overflow-hidden">
|
|
||||||
<span class="flex items-center px-3 text-oxide-500 font-mono text-sm select-none">$</span>
|
|
||||||
<input
|
|
||||||
v-model="commandInput"
|
|
||||||
@keydown.enter="handleSend"
|
|
||||||
type="text"
|
|
||||||
placeholder="say Hello everyone..."
|
|
||||||
:disabled="sending"
|
|
||||||
class="flex-1 bg-transparent py-3 text-neutral-100 placeholder-neutral-600 font-mono text-sm focus:outline-none disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
@click="handleSend"
|
|
||||||
:disabled="!commandInput.trim() || sending"
|
|
||||||
class="flex items-center gap-2 px-4 text-sm font-medium text-oxide-400 hover:text-oxide-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
<Send class="w-4 h-4" />
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cv {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.cv__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.cv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.cv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.cv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
.cv__head-actions { display: flex; align-items: center; gap: 9px; }
|
||||||
|
|
||||||
|
/* Output area */
|
||||||
|
.cv__output {
|
||||||
|
min-height: 420px;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.cv__empty {
|
||||||
|
padding: 16px 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Command bar */
|
||||||
|
.cv__bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
background: var(--surface-base);
|
||||||
|
}
|
||||||
|
.cv__bar-prompt {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent-text);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,155 +1,555 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
/**
|
||||||
|
* DashboardView — Single-server cockpit wired entirely to real data.
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* - useServerStore → connection + config + live stats (WebSocket updateStats)
|
||||||
|
* - useApi → /analytics/timeseries for 24h player history (PlayersChart)
|
||||||
|
* - useGameProfile → per-game labels/terminology (defaults to 'rust' today)
|
||||||
|
* - useWebSocket → subscribes to console_output and server_stats events
|
||||||
|
*
|
||||||
|
* Empty states:
|
||||||
|
* - No connection record → "No server connected" EmptyState with CTA to /server
|
||||||
|
* - Connection exists but stats absent → meters show '—', chart shows awaiting telemetry
|
||||||
|
* - No upcoming wipe schedules → honest empty state in the wipes panel
|
||||||
|
*
|
||||||
|
* No fabricated data anywhere in this file.
|
||||||
|
* The fleet/multi-server view has been removed — the current backend is
|
||||||
|
* single-server-per-license. When the backend supports multiple servers per
|
||||||
|
* license, restore a fleet tab wired to real data.
|
||||||
|
*/
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useWipeStore } from '@/stores/wipe'
|
import { useWipeStore } from '@/stores/wipe'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
|
||||||
|
import { useGameProfile } from '@/config/gameProfiles'
|
||||||
|
import { useThemeGame } from '@/composables/useThemeGame'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import ConsoleLineDS from '@/components/ds/data/ConsoleLine.vue'
|
||||||
|
import ResourceMeter from '@/components/ds/data/ResourceMeter.vue'
|
||||||
|
import PlayersChart from '@/components/ds/data/PlayersChart.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import type { TimeseriesData, WipeSchedule } from '@/types'
|
||||||
|
import { safeDate } from '@/utils/formatters'
|
||||||
|
|
||||||
const router = useRouter()
|
// ---------------------------------------------------------------------------
|
||||||
const auth = useAuthStore()
|
// Stores / composables
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
const wipe = useWipeStore()
|
const wipeStore = useWipeStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const api = useApi()
|
||||||
|
const { activeGame } = useThemeGame()
|
||||||
|
|
||||||
|
// Profile follows the GameSwitcher selection. 'all' falls back to rust (neutral house skin).
|
||||||
|
// When the backend adds a `game` field on licenses, swap activeGame for server.config?.game.
|
||||||
|
const profile = computed(() => {
|
||||||
|
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
|
||||||
|
return useGameProfile(game)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Derived server state — all real, no fallbacks to fabricated values
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const hasConnection = computed(() => server.connection !== null)
|
||||||
|
const isConnected = computed(() => server.connection?.connection_status === 'connected')
|
||||||
|
|
||||||
|
const soloName = computed(() => server.config?.server_name ?? null)
|
||||||
|
|
||||||
|
const soloPlayers = computed(() => server.stats?.player_count ?? null)
|
||||||
|
const soloMaxPlayers = computed(() => server.stats?.max_players ?? server.config?.max_players ?? null)
|
||||||
|
const soloFps = computed(() => server.stats?.fps ?? null)
|
||||||
|
|
||||||
|
// Memory: store gives memory_usage_mb; max must come from agent telemetry.
|
||||||
|
// We do NOT hard-code a "representative" max — show raw MB and no percentage
|
||||||
|
// until the agent reports a known max.
|
||||||
|
const soloRamMb = computed(() => server.stats?.memory_usage_mb ?? null)
|
||||||
|
const soloRamPct = computed(() => {
|
||||||
|
// ServerStats has no ram_max field — we cannot compute a real percentage.
|
||||||
|
// Return null; ResourceMeter and StatCard will show '—'.
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
const soloRamSub = computed(() => {
|
||||||
|
const mb = soloRamMb.value
|
||||||
|
if (mb === null) return null
|
||||||
|
return `${(mb / 1024).toFixed(1)} GB used`
|
||||||
|
})
|
||||||
|
|
||||||
|
// CPU: not in ServerStats today. Show null — never fabricate.
|
||||||
|
const soloCpu = computed(() => null as number | null)
|
||||||
|
|
||||||
|
const soloStatus = computed<'online' | 'offline' | 'starting'>(() => {
|
||||||
|
const cs = server.connection?.connection_status
|
||||||
|
if (cs === 'connected') return 'online'
|
||||||
|
if (cs === 'degraded') return 'starting'
|
||||||
|
return 'offline'
|
||||||
|
})
|
||||||
|
const soloStatusTone = computed<'online' | 'offline' | 'warn'>(() => {
|
||||||
|
if (soloStatus.value === 'online') return 'online'
|
||||||
|
if (soloStatus.value === 'starting') return 'warn'
|
||||||
|
return 'offline'
|
||||||
|
})
|
||||||
|
const soloStatusLabel = computed(() => {
|
||||||
|
if (soloStatus.value === 'online') return 'Online'
|
||||||
|
if (soloStatus.value === 'starting') return 'Degraded'
|
||||||
|
return 'Offline'
|
||||||
|
})
|
||||||
|
|
||||||
|
const soloIp = computed(() => {
|
||||||
|
const ip = server.connection?.server_ip
|
||||||
|
const port = server.connection?.game_port ?? server.connection?.server_port
|
||||||
|
if (ip && port) return `${ip}:${port}`
|
||||||
|
if (ip) return ip
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const soloUptime = computed(() => {
|
||||||
|
const sec = server.stats?.uptime_seconds ?? 0
|
||||||
|
if (sec === 0) return null
|
||||||
|
const d = Math.floor(sec / 86400)
|
||||||
|
const h = Math.floor((sec % 86400) / 3600)
|
||||||
|
if (d > 0) return `${d}d ${h}h`
|
||||||
|
return `${h}h`
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Players chart — real 24h timeseries from /analytics/timeseries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const chartData = ref<number[] | null>(null)
|
||||||
|
const chartLoading = ref(false)
|
||||||
|
|
||||||
|
async function loadChartData() {
|
||||||
|
chartLoading.value = true
|
||||||
|
try {
|
||||||
|
const ts = await api.get<TimeseriesData>('/analytics/timeseries?range=24&granularity=hourly')
|
||||||
|
chartData.value = ts.player_count.length > 0 ? ts.player_count : null
|
||||||
|
} catch {
|
||||||
|
// API unavailable or no data yet — chart will show "awaiting telemetry"
|
||||||
|
chartData.value = null
|
||||||
|
} finally {
|
||||||
|
chartLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wipe schedules — real data from wipeStore
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const nextWipe = computed<WipeSchedule | null>(() => {
|
||||||
|
const schedules = wipeStore.schedules.filter((s) => s.is_active && s.next_scheduled_run)
|
||||||
|
if (schedules.length === 0) return null
|
||||||
|
return schedules.slice().sort((a, b) => {
|
||||||
|
const at = a.next_scheduled_run ? new Date(a.next_scheduled_run).getTime() : Infinity
|
||||||
|
const bt = b.next_scheduled_run ? new Date(b.next_scheduled_run).getTime() : Infinity
|
||||||
|
return at - bt
|
||||||
|
})[0] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextWipeLabel = computed(() => {
|
||||||
|
const w = nextWipe.value
|
||||||
|
if (!w?.next_scheduled_run) return null
|
||||||
|
return safeDate(w.next_scheduled_run)
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextWipeType = computed(() => {
|
||||||
|
const w = nextWipe.value
|
||||||
|
if (!w) return null
|
||||||
|
const t = w.wipe_type
|
||||||
|
if (t === 'full') return `Full ${profile.value.terminology.reset}`
|
||||||
|
if (t === 'blueprint') return 'Blueprint wipe'
|
||||||
|
return `Map ${profile.value.terminology.reset}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Console lines — real WebSocket events only
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface ConsoleLine {
|
||||||
|
time: string
|
||||||
|
level: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
|
||||||
|
who?: string
|
||||||
|
msg: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const consoleLines = ref<ConsoleLine[]>([])
|
||||||
|
const MAX_CONSOLE_LINES = 100
|
||||||
|
|
||||||
|
function now(): string {
|
||||||
|
return new Date().toLocaleTimeString('en-US', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWsMessage(msg: WebSocketMessage) {
|
||||||
|
if (msg.type !== 'event') return
|
||||||
|
|
||||||
|
// Live server stats
|
||||||
|
if (msg.event === 'server_stats' && msg.data) {
|
||||||
|
server.updateStats(msg.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console output lines
|
||||||
|
if (msg.event === 'console_output') {
|
||||||
|
const text = msg.data?.line ?? msg.data?.output ?? msg.raw ?? ''
|
||||||
|
if (!text) return
|
||||||
|
consoleLines.value.push({
|
||||||
|
time: now(),
|
||||||
|
level: 'info',
|
||||||
|
msg: String(text),
|
||||||
|
})
|
||||||
|
if (consoleLines.value.length > MAX_CONSOLE_LINES) {
|
||||||
|
consoleLines.value.splice(0, consoleLines.value.length - MAX_CONSOLE_LINES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Console input
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const consoleInput = ref('')
|
||||||
|
|
||||||
|
function sendConsoleCommand() {
|
||||||
|
const cmd = consoleInput.value.trim()
|
||||||
|
if (!cmd) return
|
||||||
|
consoleLines.value.push({ time: now(), level: 'cmd', who: 'admin', msg: cmd })
|
||||||
|
server.sendCommand(cmd).catch(() => {})
|
||||||
|
consoleInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
server.fetchServer()
|
await server.fetchServer()
|
||||||
try {
|
await wipeStore.fetchSchedules()
|
||||||
await wipe.fetchSchedules()
|
await loadChartData()
|
||||||
} catch {
|
|
||||||
// Non-critical — dashboard still loads without wipe data
|
const ws = useWebSocket()
|
||||||
}
|
unsubscribe = ws.subscribe(handleWsMessage)
|
||||||
})
|
})
|
||||||
|
|
||||||
const nextWipeDate = computed<string>(() => {
|
onUnmounted(() => {
|
||||||
const upcoming = wipe.schedules
|
unsubscribe?.()
|
||||||
.filter(s => s.is_active && s.next_scheduled_run)
|
|
||||||
.map(s => new Date(s.next_scheduled_run!))
|
|
||||||
.sort((a, b) => a.getTime() - b.getTime())
|
|
||||||
|
|
||||||
if (upcoming.length === 0) return 'Not Scheduled'
|
|
||||||
|
|
||||||
return upcoming[0]!.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function statusColor(status: string | undefined): string {
|
// Navigation helpers
|
||||||
switch (status) {
|
function navConsole() { router.push('/console') }
|
||||||
case 'connected': return 'bg-green-500'
|
function navWipes() { router.push('/wipes') }
|
||||||
case 'degraded': return 'bg-yellow-500'
|
function navServer() { router.push('/server') }
|
||||||
default: return 'bg-red-500'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: string | undefined): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'connected': return 'Online'
|
|
||||||
case 'degraded': return 'Degraded'
|
|
||||||
default: return 'Offline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUptime(seconds: number | undefined): string {
|
|
||||||
if (!seconds) return '\u2014'
|
|
||||||
const h = Math.floor(seconds / 3600)
|
|
||||||
const m = Math.floor((seconds % 3600) / 60)
|
|
||||||
if (h > 0) return `${h}h ${m}m`
|
|
||||||
return `${m}m`
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-8">
|
<div class="dash">
|
||||||
<!-- Welcome header -->
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">
|
|
||||||
Welcome back, {{ auth.user?.username }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">Here's what's happening with your server.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stat cards grid -->
|
<!-- ===== NO CONNECTION: honest empty state ===== -->
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<template v-if="!server.isLoading && !hasConnection">
|
||||||
<!-- Server Status -->
|
<div class="page__head">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<div>
|
||||||
<p class="text-sm text-neutral-400 mb-2">Server Status</p>
|
<div class="t-eyebrow">Dashboard</div>
|
||||||
<div class="flex items-center gap-2">
|
<h1 class="page__title">Server cockpit</h1>
|
||||||
<span class="h-2.5 w-2.5 rounded-full" :class="statusColor(server.connection?.connection_status)"></span>
|
|
||||||
<span class="text-2xl font-bold text-neutral-100">{{ statusLabel(server.connection?.connection_status) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Panel>
|
||||||
<!-- Players Online -->
|
<EmptyState
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
icon="server"
|
||||||
<p class="text-sm text-neutral-400 mb-2">Players Online</p>
|
title="No server connected"
|
||||||
<p class="text-2xl font-bold text-neutral-100">
|
description="Install the Corrosion host agent on your host machine to begin managing your server from Corrosion."
|
||||||
{{ server.stats?.player_count ?? 0 }}/{{ server.stats?.max_players ?? server.config?.max_players ?? 0 }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Next Wipe -->
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
|
||||||
<p class="text-sm text-neutral-400 mb-2">Next Wipe</p>
|
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ nextWipeDate }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Uptime -->
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
|
||||||
<p class="text-sm text-neutral-400 mb-2">Uptime</p>
|
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ formatUptime(server.stats?.uptime_seconds) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold text-neutral-200 mb-4">Quick Actions</h2>
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<button
|
|
||||||
:disabled="server.connection?.connection_status === 'connected'"
|
|
||||||
@click="server.startServer()"
|
|
||||||
class="px-4 py-2.5 bg-green-600/20 hover:bg-green-600/30 disabled:opacity-30 disabled:cursor-not-allowed text-green-400 border border-green-600/30 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
>
|
||||||
Start Server
|
<template #action>
|
||||||
</button>
|
<Button icon="server" @click="navServer">Set up server</Button>
|
||||||
<button
|
</template>
|
||||||
:disabled="server.connection?.connection_status !== 'connected'"
|
</EmptyState>
|
||||||
@click="server.stopServer()"
|
</Panel>
|
||||||
class="px-4 py-2.5 bg-red-600/20 hover:bg-red-600/30 disabled:opacity-30 disabled:cursor-not-allowed text-red-400 border border-red-600/30 rounded-lg text-sm font-medium transition-colors"
|
</template>
|
||||||
>
|
|
||||||
Stop Server
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="router.push('/wipes')"
|
|
||||||
class="px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Trigger Wipe
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Server Info (if configured) -->
|
<!-- ===== SERVER COCKPIT ===== -->
|
||||||
<div v-if="server.config" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<template v-else-if="hasConnection">
|
||||||
<h2 class="text-lg font-semibold text-neutral-200 mb-3">Server Configuration</h2>
|
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
<!-- Page head -->
|
||||||
<div>
|
<div class="page__head">
|
||||||
<span class="text-neutral-500">Server Name</span>
|
<div class="solo-id">
|
||||||
<p class="text-neutral-200 mt-0.5">{{ server.config.server_name || 'Not set' }}</p>
|
<div class="solo-id__chip">
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<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" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="solo-id__name">
|
||||||
|
{{ soloName ?? 'Server' }}
|
||||||
|
<Badge :tone="soloStatusTone" :dot="true" :pulse="soloStatus === 'online'">{{ soloStatusLabel }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="solo-id__meta">
|
||||||
|
<template v-if="soloIp">{{ soloIp }}</template>
|
||||||
|
<template v-else>No IP registered</template>
|
||||||
|
<template v-if="soloUptime"> · up {{ soloUptime }}</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="page__actions">
|
||||||
<span class="text-neutral-500">Max Players</span>
|
<Button variant="secondary" size="sm" icon="refresh-cw" @click="server.restartServer()">Restart</Button>
|
||||||
<p class="text-neutral-200 mt-0.5">{{ server.config.max_players ?? 'Not set' }}</p>
|
<Button variant="danger-soft" size="sm" icon="power" @click="server.stopServer()">Stop</Button>
|
||||||
</div>
|
<Button size="sm" icon="terminal" @click="navConsole">Console</Button>
|
||||||
<div>
|
|
||||||
<span class="text-neutral-500">World Size</span>
|
|
||||||
<p class="text-neutral-200 mt-0.5">{{ server.config.world_size ?? 'Not set' }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-neutral-500">Current Seed</span>
|
|
||||||
<p class="text-neutral-200 mt-0.5">{{ server.config.current_seed ?? 'Not set' }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- KPIs — game profile drives stat labels; null values show '—' -->
|
||||||
|
<div class="dash__kpis">
|
||||||
|
<StatCard
|
||||||
|
icon="users"
|
||||||
|
:label="(profile.statFields[0] ?? 'Players') + ' online'"
|
||||||
|
:value="soloPlayers !== null ? String(soloPlayers) : '—'"
|
||||||
|
:unit="soloMaxPlayers !== null ? '/' + soloMaxPlayers : ''"
|
||||||
|
note="live via agent"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="cpu"
|
||||||
|
label="CPU"
|
||||||
|
:value="soloCpu !== null ? String(soloCpu) : '—'"
|
||||||
|
:unit="soloCpu !== null ? '%' : ''"
|
||||||
|
note="agent telemetry"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="memory-stick"
|
||||||
|
label="Memory"
|
||||||
|
:value="soloRamMb !== null ? (soloRamMb / 1024).toFixed(1) : '—'"
|
||||||
|
:unit="soloRamMb !== null ? 'GB' : ''"
|
||||||
|
:note="soloRamSub ?? 'agent telemetry'"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="gauge"
|
||||||
|
label="Server FPS"
|
||||||
|
:value="soloFps !== null ? String(soloFps) : '—'"
|
||||||
|
:unit="soloFps !== null ? 'fps' : ''"
|
||||||
|
note="live via agent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main grid -->
|
||||||
|
<div class="dash__grid">
|
||||||
|
|
||||||
|
<!-- Left column -->
|
||||||
|
<div class="dash__col">
|
||||||
|
|
||||||
|
<!-- Players chart — real 24h data or honest empty state -->
|
||||||
|
<Panel
|
||||||
|
title="Players online"
|
||||||
|
:subtitle="(soloName ?? 'Server') + ' · last 24 hours'"
|
||||||
|
>
|
||||||
|
<div v-if="chartLoading" class="chart-loading">Loading telemetry…</div>
|
||||||
|
<PlayersChart
|
||||||
|
v-else
|
||||||
|
:height="196"
|
||||||
|
:max="soloMaxPlayers ?? 200"
|
||||||
|
:data="chartData ?? undefined"
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Console — real WebSocket lines only -->
|
||||||
|
<Panel :flush-body="true" title="Console">
|
||||||
|
<template #actions>
|
||||||
|
<Badge
|
||||||
|
:tone="isConnected ? 'online' : 'offline'"
|
||||||
|
:dot="true"
|
||||||
|
:pulse="isConnected"
|
||||||
|
>{{ isConnected ? 'Live' : 'Disconnected' }}</Badge>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="feed feed--solo">
|
||||||
|
<template v-if="consoleLines.length > 0">
|
||||||
|
<ConsoleLineDS
|
||||||
|
v-for="(line, i) in consoleLines"
|
||||||
|
:key="i"
|
||||||
|
:time="line.time"
|
||||||
|
:level="line.level"
|
||||||
|
:who="line.who"
|
||||||
|
>{{ line.msg }}</ConsoleLineDS>
|
||||||
|
</template>
|
||||||
|
<div v-else class="feed__empty">
|
||||||
|
<span v-if="isConnected">Waiting for output — try sending a command below</span>
|
||||||
|
<span v-else>Console offline — server is not connected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="console-bar">
|
||||||
|
<span class="console-bar__prompt">></span>
|
||||||
|
<Input
|
||||||
|
v-model="consoleInput"
|
||||||
|
:mono="true"
|
||||||
|
size="sm"
|
||||||
|
placeholder="say, kick, ban, oxide.reload …"
|
||||||
|
:disabled="!isConnected"
|
||||||
|
style="flex: 1"
|
||||||
|
@keydown.enter="sendConsoleCommand"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="corner-down-left"
|
||||||
|
:disabled="!isConnected"
|
||||||
|
@click="sendConsoleCommand"
|
||||||
|
>Send</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right sidebar -->
|
||||||
|
<div class="dash__col dash__col--side">
|
||||||
|
|
||||||
|
<!-- Resources — real stats from agent; null = '—' -->
|
||||||
|
<Panel title="Resources" subtitle="Host agent telemetry">
|
||||||
|
<div class="solo-meters">
|
||||||
|
<ResourceMeter
|
||||||
|
label="CPU"
|
||||||
|
:value="soloCpu ?? 0"
|
||||||
|
:sub="soloCpu !== null ? soloCpu + '%' : 'awaiting telemetry'"
|
||||||
|
/>
|
||||||
|
<ResourceMeter
|
||||||
|
label="Memory"
|
||||||
|
:value="soloRamPct ?? 0"
|
||||||
|
:sub="soloRamSub ?? 'awaiting telemetry'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="soloCpu === null && soloRamMb === null" class="meters-note">
|
||||||
|
Resource metrics arrive via the host agent heartbeat.
|
||||||
|
<Button size="sm" variant="ghost" icon="server" class="meters-cta" @click="navServer">
|
||||||
|
Agent setup
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Next wipe/reset — title follows game terminology -->
|
||||||
|
<Panel :title="'Next ' + profile.terminology.reset.toLowerCase()">
|
||||||
|
<div v-if="nextWipe" class="solo-wipe">
|
||||||
|
<div>
|
||||||
|
<div class="solo-wipe__type">{{ nextWipeType }}</div>
|
||||||
|
<div class="solo-wipe__when">{{ nextWipeLabel }}</div>
|
||||||
|
<div class="solo-wipe__name">{{ nextWipe.schedule_name }}</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">Edit</Button>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
v-else
|
||||||
|
icon="calendar"
|
||||||
|
:title="'No ' + profile.terminology.reset.toLowerCase() + ' scheduled'"
|
||||||
|
:description="'Configure automatic ' + profile.terminology.reset.toLowerCase() + 's in the wipe manager.'"
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">
|
||||||
|
Open wipe manager
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="page__head">
|
||||||
|
<div>
|
||||||
|
<div class="t-eyebrow">Dashboard</div>
|
||||||
|
<h1 class="page__title">Server cockpit</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Panel>
|
||||||
|
<div class="dash-loading">Loading server data…</div>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ---------- Shared shell ---------- */
|
||||||
|
.dash { max-width: 1480px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
|
||||||
|
.page__head {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between;
|
||||||
|
gap: 16px; flex-wrap: wrap; row-gap: 12px;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 5px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.page__actions { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.dash__kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; }
|
||||||
|
.dash__grid { display: grid; grid-template-columns: minmax(0, 1fr) 366px; gap: 16px; align-items: start; }
|
||||||
|
.dash__col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
||||||
|
|
||||||
|
/* ---------- Solo identity header ---------- */
|
||||||
|
.solo-id { display: flex; align-items: center; gap: 13px; }
|
||||||
|
.solo-id__chip {
|
||||||
|
width: 42px; height: 42px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.solo-id__name {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
font-size: var(--text-xl); font-weight: 700; letter-spacing: -0.01em;
|
||||||
|
color: var(--text-primary); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.solo-id__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 3px; }
|
||||||
|
|
||||||
|
/* ---------- Chart loading ---------- */
|
||||||
|
.chart-loading {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
height: 196px; font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Console feed ---------- */
|
||||||
|
.feed { padding: 8px 2px; max-height: 340px; overflow-y: auto; }
|
||||||
|
.feed--solo { max-height: 230px; }
|
||||||
|
.feed__empty {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
height: 100px; font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Console bar ---------- */
|
||||||
|
.console-bar {
|
||||||
|
display: flex; align-items: center; gap: 9px; padding: 11px 12px;
|
||||||
|
border-top: 1px solid var(--border-subtle); background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.console-bar__prompt { font-family: var(--font-mono); color: var(--accent-text); font-weight: 700; }
|
||||||
|
|
||||||
|
/* ---------- Resources ---------- */
|
||||||
|
.solo-meters { display: flex; flex-direction: column; gap: 13px; }
|
||||||
|
.meters-note {
|
||||||
|
margin-top: 14px; font-size: var(--text-xs); color: var(--text-muted);
|
||||||
|
border-top: 1px solid var(--border-subtle); padding-top: 12px;
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.meters-cta { margin-left: auto; }
|
||||||
|
|
||||||
|
/* ---------- Next wipe ---------- */
|
||||||
|
.solo-wipe { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||||
|
.solo-wipe__type { font-size: var(--text-xs); color: var(--text-tertiary); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-wider); margin-bottom: 3px; }
|
||||||
|
.solo-wipe__when { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
|
||||||
|
.solo-wipe__name { font-size: var(--text-xs); color: var(--text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ---------- Loading ---------- */
|
||||||
|
.dash-loading {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 60px; font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Responsive ---------- */
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.dash__grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dash__kpis { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { VueFinder, RemoteDriver } from 'vuefinder'
|
import { VueFinder, RemoteDriver } from 'vuefinder'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
@@ -26,18 +27,22 @@ const finderConfig = {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6 p-6">
|
<div class="fm">
|
||||||
<div class="flex items-center justify-between">
|
<!-- Page head -->
|
||||||
<div>
|
<div class="fm__head">
|
||||||
<h1 class="text-2xl font-bold text-white">File Manager</h1>
|
<div class="fm__head-id">
|
||||||
<p class="text-sm text-gray-400 mt-1">Browse and edit your server files</p>
|
<div class="fm__head-chip">
|
||||||
|
<Icon name="folder-open" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="t-eyebrow">Server management</div>
|
||||||
|
<h1 class="fm__title">File manager</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<!-- VueFinder wrapper — only the outer chrome is re-skinned; internals untouched -->
|
||||||
class="bg-neutral-900 rounded-lg border border-neutral-800 overflow-hidden"
|
<div class="fm__finder">
|
||||||
style="min-height: 640px;"
|
|
||||||
>
|
|
||||||
<VueFinder
|
<VueFinder
|
||||||
id="corrosion-filemanager"
|
id="corrosion-filemanager"
|
||||||
:driver="driver"
|
:driver="driver"
|
||||||
@@ -47,3 +52,36 @@ const finderConfig = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fm {
|
||||||
|
max-width: 1480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.fm__head { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.fm__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.fm__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.fm__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Finder container — surface panel chrome, VueFinder renders inside */
|
||||||
|
.fm__finder {
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 640px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
|
import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
|
||||||
import {
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
Save,
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
Play,
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
Download,
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
Plus,
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
Trash2,
|
|
||||||
Flame,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const store = useFurnaceSplitterStore()
|
const store = useFurnaceSplitterStore()
|
||||||
|
|
||||||
@@ -57,13 +53,13 @@ function setConfigValue(path: string, value: any) {
|
|||||||
|
|
||||||
// Furnace types with display names
|
// Furnace types with display names
|
||||||
const furnaceTypes = [
|
const furnaceTypes = [
|
||||||
{ key: 'furnace', label: 'Small Furnace', description: 'Standard furnace for smelting ores' },
|
{ key: 'furnace', label: 'Small furnace', description: 'Standard furnace for smelting ores' },
|
||||||
{ key: 'furnace.large', label: 'Large Furnace', description: 'Large furnace with more slots' },
|
{ key: 'furnace.large', label: 'Large furnace', description: 'Large furnace with more slots' },
|
||||||
{ key: 'campfire', label: 'Campfire', description: 'Basic campfire for cooking' },
|
{ key: 'campfire', label: 'Campfire', description: 'Basic campfire for cooking' },
|
||||||
{ key: 'refinery_small_deployed', label: 'Small Oil Refinery', description: 'Refines crude oil into low grade fuel' },
|
{ key: 'refinery_small_deployed', label: 'Small oil refinery', description: 'Refines crude oil into low grade fuel' },
|
||||||
{ key: 'skull_fire_pit', label: 'Skull Fire Pit', description: 'Decorative fire pit for cooking' },
|
{ key: 'skull_fire_pit', label: 'Skull fire pit', description: 'Decorative fire pit for cooking' },
|
||||||
{ key: 'hobobarrel_static', label: 'Hobo Barrel', description: 'Barrel fire for cooking' },
|
{ key: 'hobobarrel_static', label: 'Hobo barrel', description: 'Barrel fire for cooking' },
|
||||||
{ key: 'electricfurnace.deployed', label: 'Electric Furnace', description: 'Electricity-powered furnace' },
|
{ key: 'electricfurnace.deployed', label: 'Electric furnace', description: 'Electricity-powered furnace' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// --- Action handlers ---
|
// --- Action handlers ---
|
||||||
@@ -111,261 +107,334 @@ async function handleImport() {
|
|||||||
importConfigName.value = ''
|
importConfigName.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: coerce getConfigValue result to boolean for Switch
|
||||||
|
function getBool(path: string, def: boolean): boolean {
|
||||||
|
return !!getConfigValue(path, def)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="fsv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="fsv__head">
|
||||||
<h1 class="text-2xl font-bold text-white">Furnace Splitter Config</h1>
|
<div class="fsv__head-id">
|
||||||
<div class="flex items-center gap-3">
|
<div class="fsv__head-chip">
|
||||||
<button
|
<Icon name="flame" :size="20" :stroke-width="2" />
|
||||||
@click="showCreateModal = true"
|
</div>
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
<div>
|
||||||
>
|
<div class="t-eyebrow">Plugin config</div>
|
||||||
<Plus class="w-4 h-4" />
|
<h1 class="fsv__title">Furnace splitter</h1>
|
||||||
New Config
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button size="sm" icon="plus" @click="showCreateModal = true">New config</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Config Selector + Action Bar -->
|
<!-- Config action bar -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel>
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="fsv__bar">
|
||||||
<!-- Config Selector -->
|
|
||||||
<select
|
<select
|
||||||
v-if="store.configs.length > 0"
|
v-if="store.configs.length > 0"
|
||||||
:value="store.currentConfig?.id || ''"
|
:value="store.currentConfig?.id ?? ''"
|
||||||
|
class="fsv__config-select"
|
||||||
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||||
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
|
||||||
>
|
>
|
||||||
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||||
{{ c.config_name }}
|
{{ c.config_name }}{{ c.is_active ? ' (Active)' : '' }}
|
||||||
<template v-if="c.is_active"> (Active)</template>
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
<span v-else class="fsv__no-configs">No configs yet</span>
|
||||||
|
|
||||||
<!-- Save -->
|
<Button
|
||||||
<button
|
icon="save"
|
||||||
@click="store.saveCurrentConfig()"
|
size="sm"
|
||||||
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="store.isSaving"
|
||||||
>
|
@click="store.saveCurrentConfig()"
|
||||||
<Save class="w-4 h-4" />
|
>{{ store.isSaving ? 'Saving…' : 'Save' }}</Button>
|
||||||
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Apply to Server -->
|
<Button
|
||||||
<button
|
variant="outline"
|
||||||
@click="handleApply"
|
icon="play"
|
||||||
|
size="sm"
|
||||||
:disabled="!store.currentConfig || store.isApplying"
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="store.isApplying"
|
||||||
>
|
@click="handleApply"
|
||||||
<Play class="w-4 h-4" />
|
>{{ store.isApplying ? 'Applying…' : 'Apply to server' }}</Button>
|
||||||
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Import from Server -->
|
<Button
|
||||||
<button
|
variant="secondary"
|
||||||
|
icon="download"
|
||||||
|
size="sm"
|
||||||
@click="showImportModal = true"
|
@click="showImportModal = true"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
>Import from server</Button>
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Import from Server
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Delete -->
|
<Button
|
||||||
<button
|
variant="danger-soft"
|
||||||
@click="handleDeleteConfig"
|
icon="trash-2"
|
||||||
|
size="sm"
|
||||||
:disabled="!store.currentConfig"
|
:disabled="!store.currentConfig"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
class="fsv__bar-delete"
|
||||||
>
|
@click="handleDeleteConfig"
|
||||||
<Trash2 class="w-4 h-4" />
|
>Delete</Button>
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.isLoading" class="fsv__loading">
|
||||||
|
<span class="fsv__spinner" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Empty state -->
|
||||||
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
<Panel v-else-if="!store.currentConfig">
|
||||||
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
<EmptyState
|
||||||
</div>
|
icon="flame"
|
||||||
|
title="No FurnaceSplitter config selected"
|
||||||
<!-- No Config Selected -->
|
description="Create a new config, import from server, or select one from the dropdown above."
|
||||||
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
|
||||||
<Flame class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
|
||||||
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No FurnaceSplitter Config Selected</h2>
|
|
||||||
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
|
||||||
<button
|
|
||||||
@click="showCreateModal = true"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
|
||||||
>
|
>
|
||||||
Create First Config
|
<template #action>
|
||||||
</button>
|
<Button icon="plus" @click="showCreateModal = true">Create first config</Button>
|
||||||
</div>
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Config Editor -->
|
<!-- Config editor -->
|
||||||
<div v-else class="space-y-6">
|
<template v-else>
|
||||||
<!-- Furnace Splitter Settings -->
|
<!-- Splitter settings -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
<Panel title="Splitter settings">
|
||||||
<div class="flex items-center gap-2">
|
<div class="fsv__toggles">
|
||||||
<SettingsIcon class="w-5 h-5 text-neutral-400" />
|
<div class="fsv__toggle-row">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Splitter Settings</h3>
|
<div>
|
||||||
</div>
|
<div class="fsv__toggle-label">Enabled</div>
|
||||||
|
<div class="fsv__toggle-sub">Globally enable or disable furnace splitting</div>
|
||||||
<!-- Global enabled -->
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<Switch
|
||||||
<div>
|
:model-value="getBool('Enabled', true)"
|
||||||
<label class="text-sm text-neutral-200">Enabled</label>
|
@update:model-value="v => setConfigValue('Enabled', v)"
|
||||||
<p class="text-xs text-neutral-500">Globally enable or disable furnace splitting</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="setConfigValue('Enabled', !getConfigValue('Enabled', true))"
|
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
|
||||||
:class="getConfigValue('Enabled', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('Enabled', true) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Per-Furnace Type Settings -->
|
<!-- Per-furnace type settings -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
<Panel title="Furnace type settings">
|
||||||
<div class="flex items-center gap-2">
|
<div class="fsv__furnace-list">
|
||||||
<Flame class="w-5 h-5 text-neutral-400" />
|
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Furnace Type Settings</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="furnace in furnaceTypes"
|
v-for="furnace in furnaceTypes"
|
||||||
:key="furnace.key"
|
:key="furnace.key"
|
||||||
class="bg-neutral-800/50 border border-neutral-700/50 rounded-lg p-4"
|
class="fsv__furnace-card"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="fsv__furnace-head">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-medium text-neutral-200">{{ furnace.label }}</h4>
|
<div class="fsv__furnace-name">{{ furnace.label }}</div>
|
||||||
<p class="text-xs text-neutral-500">{{ furnace.description }}</p>
|
<div class="fsv__furnace-desc">{{ furnace.description }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue(`Furnaces.${furnace.key}.Enabled`, !getConfigValue(`Furnaces.${furnace.key}.Enabled`, true))"
|
:model-value="getBool(`Furnaces.${furnace.key}.Enabled`, true)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue(`Furnaces.${furnace.key}.Enabled`, v)"
|
||||||
:class="getConfigValue(`Furnaces.${furnace.key}.Enabled`, true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
/>
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue(`Furnaces.${furnace.key}.Enabled`, true) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="fsv__furnace-fields">
|
||||||
<div>
|
<div class="fsv__field">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Default Split Stacks</label>
|
<label class="fsv__field-label">Default split stacks</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
class="cc-num-input"
|
||||||
:value="getConfigValue(`Furnaces.${furnace.key}.DefaultSplitCount`, 0)"
|
:value="getConfigValue(`Furnaces.${furnace.key}.DefaultSplitCount`, 0)"
|
||||||
@input="setConfigValue(`Furnaces.${furnace.key}.DefaultSplitCount`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0"
|
min="0"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-neutral-200 text-sm"
|
|
||||||
placeholder="0 = fill all slots"
|
placeholder="0 = fill all slots"
|
||||||
|
@input="setConfigValue(`Furnaces.${furnace.key}.DefaultSplitCount`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="fsv__field">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Fuel Multiplier</label>
|
<label class="fsv__field-label">Fuel multiplier</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
|
class="cc-num-input fsv__mono"
|
||||||
:value="getConfigValue(`Furnaces.${furnace.key}.FuelMultiplier`, 1.0)"
|
:value="getConfigValue(`Furnaces.${furnace.key}.FuelMultiplier`, 1.0)"
|
||||||
@input="setConfigValue(`Furnaces.${furnace.key}.FuelMultiplier`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0"
|
min="0"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-neutral-200 text-sm"
|
@input="setConfigValue(`Furnaces.${furnace.key}.FuelMultiplier`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Permission Groups -->
|
<!-- Permission -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Permission">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Permission</h3>
|
<p class="fsv__perm-text">
|
||||||
<p class="text-xs text-neutral-500">
|
The permission <code class="fsv__code">furnacesplitter.use</code> controls which players can use the furnace splitting feature. Assign this permission via your Oxide permission system.
|
||||||
The permission <code class="text-neutral-300 bg-neutral-800 px-1 rounded">furnacesplitter.use</code> controls which players can use the furnace splitting feature. Assign this permission via your Oxide permission system.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Create Config Modal -->
|
<!-- Create config modal -->
|
||||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
<div v-if="showCreateModal" class="fsv__modal-backdrop" @click.self="showCreateModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="fsv__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New FurnaceSplitter Config</h2>
|
<h2 class="fsv__modal-title">New FurnaceSplitter config</h2>
|
||||||
<div class="space-y-4">
|
<div class="fsv__modal-body">
|
||||||
<div>
|
<div class="fsv__field">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
<label class="fsv__field-label">Config name</label>
|
||||||
<input
|
<input
|
||||||
v-model="newConfigName"
|
v-model="newConfigName"
|
||||||
placeholder="e.g. Default Furnace Settings"
|
type="text"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
class="cc-text-input"
|
||||||
|
placeholder="e.g. Default furnace settings"
|
||||||
@keydown.enter="handleCreateConfig"
|
@keydown.enter="handleCreateConfig"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="fsv__field">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
<label class="fsv__field-label">Description (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newConfigDesc"
|
v-model="newConfigDesc"
|
||||||
rows="2"
|
rows="2"
|
||||||
|
class="cc-textarea"
|
||||||
placeholder="What is this config for?"
|
placeholder="What is this config for?"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
</div>
|
||||||
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
<div class="fsv__modal-footer">
|
||||||
<button
|
<Button variant="ghost" @click="showCreateModal = false">Cancel</Button>
|
||||||
@click="handleCreateConfig"
|
<Button :disabled="!newConfigName.trim()" @click="handleCreateConfig">Create</Button>
|
||||||
:disabled="!newConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import from Server Modal -->
|
<!-- Import from server modal -->
|
||||||
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
<div v-if="showImportModal" class="fsv__modal-backdrop" @click.self="showImportModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="fsv__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
<h2 class="fsv__modal-title">Import from server</h2>
|
||||||
<p class="text-sm text-neutral-400 mb-4">
|
<p class="fsv__modal-desc">Import the current FurnaceSplitter config from your live server. This will create a new config profile.</p>
|
||||||
Import the current FurnaceSplitter config from your live server. This will create a new config profile.
|
<div class="fsv__modal-body">
|
||||||
</p>
|
<div class="fsv__field">
|
||||||
<div class="space-y-4">
|
<label class="fsv__field-label">Config name</label>
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
|
||||||
<input
|
<input
|
||||||
v-model="importConfigName"
|
v-model="importConfigName"
|
||||||
placeholder="e.g. Imported Server Config"
|
type="text"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
class="cc-text-input"
|
||||||
|
placeholder="e.g. Imported server config"
|
||||||
@keydown.enter="handleImport"
|
@keydown.enter="handleImport"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
</div>
|
||||||
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
<div class="fsv__modal-footer">
|
||||||
<button
|
<Button variant="ghost" @click="showImportModal = false">Cancel</Button>
|
||||||
@click="handleImport"
|
<Button :disabled="!importConfigName.trim()" @click="handleImport">Import</Button>
|
||||||
:disabled="!importConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fsv { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.fsv__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.fsv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.fsv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.fsv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config action bar */
|
||||||
|
.fsv__bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.fsv__config-select {
|
||||||
|
appearance: none; height: var(--control-h-md); padding: 0 11px;
|
||||||
|
background: var(--surface-inset); color: var(--text-primary); border: 0;
|
||||||
|
border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
font-family: var(--font-sans); font-size: var(--text-sm); cursor: pointer;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.fsv__config-select:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.fsv__no-configs { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
|
.fsv__bar-delete { margin-left: auto; }
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.fsv__loading { display: flex; align-items: center; justify-content: center; padding: 60px; }
|
||||||
|
.fsv__spinner {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--accent);
|
||||||
|
border-top-color: transparent; animation: fsv-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes fsv-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Toggles */
|
||||||
|
.fsv__toggles { display: flex; flex-direction: column; }
|
||||||
|
.fsv__toggle-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.fsv__toggle-row:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||||
|
.fsv__toggle-row:first-child { padding-top: 0; }
|
||||||
|
.fsv__toggle-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.fsv__toggle-sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Furnace cards */
|
||||||
|
.fsv__furnace-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.fsv__furnace-card {
|
||||||
|
background: var(--surface-raised-2); border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default); padding: 14px; display: flex; flex-direction: column; gap: 12px;
|
||||||
|
}
|
||||||
|
.fsv__furnace-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||||
|
.fsv__furnace-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.fsv__furnace-desc { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||||
|
.fsv__furnace-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
@media (max-width: 500px) { .fsv__furnace-fields { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* Fields */
|
||||||
|
.fsv__field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.fsv__field-label { font-size: var(--text-xs); font-weight: 600; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Permission text */
|
||||||
|
.fsv__perm-text { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
|
||||||
|
.fsv__code {
|
||||||
|
font-family: var(--font-mono); font-size: var(--text-xs); font-variant-numeric: tabular-nums;
|
||||||
|
background: var(--surface-raised-2); color: var(--text-primary); padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm); box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.fsv__mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Shared token inputs */
|
||||||
|
.cc-textarea {
|
||||||
|
width: 100%; background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 11px; font-family: var(--font-sans); font-size: var(--text-sm);
|
||||||
|
resize: none; outline: 0; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.cc-textarea:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-text-input {
|
||||||
|
width: 100%; height: var(--control-h-md); background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 0 11px; font-family: var(--font-sans); font-size: var(--text-sm); outline: 0;
|
||||||
|
}
|
||||||
|
.cc-text-input:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-num-input {
|
||||||
|
width: 100%; height: var(--control-h-md); background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 0 11px; font-family: var(--font-sans); font-size: var(--text-sm); outline: 0;
|
||||||
|
}
|
||||||
|
.cc-num-input:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.fsv__modal-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 50;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||||
|
}
|
||||||
|
.fsv__modal {
|
||||||
|
background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default);
|
||||||
|
width: 100%; max-width: 440px; padding: 24px; display: flex; flex-direction: column; gap: 16px;
|
||||||
|
}
|
||||||
|
.fsv__modal-title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
|
||||||
|
.fsv__modal-desc { font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.5; }
|
||||||
|
.fsv__modal-body { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.fsv__modal-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useGatherStore } from '@/stores/gather'
|
import { useGatherStore } from '@/stores/gather'
|
||||||
import {
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
Save,
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
Play,
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
Download,
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
Plus,
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
Trash2,
|
|
||||||
Pickaxe,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const store = useGatherStore()
|
const store = useGatherStore()
|
||||||
|
|
||||||
@@ -20,51 +16,51 @@ const newConfigName = ref('')
|
|||||||
const newConfigDesc = ref('')
|
const newConfigDesc = ref('')
|
||||||
const importConfigName = ref('')
|
const importConfigName = ref('')
|
||||||
|
|
||||||
const tabs = [
|
const tabItems = [
|
||||||
{ key: 'resources', label: 'Resource Rates', icon: Pickaxe },
|
{ value: 'resources', label: 'Resource rates', icon: 'pickaxe' },
|
||||||
{ key: 'advanced', label: 'Advanced', icon: SettingsIcon },
|
{ value: 'advanced', label: 'Advanced', icon: 'settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Resource definitions for the main gather tab
|
// Resource definitions for the main gather tab
|
||||||
const gatherResources = [
|
const gatherResources = [
|
||||||
{ key: 'Wood', label: 'Wood' },
|
{ key: 'Wood', label: 'Wood' },
|
||||||
{ key: 'Stones', label: 'Stones' },
|
{ key: 'Stones', label: 'Stones' },
|
||||||
{ key: 'Metal Ore', label: 'Metal Ore' },
|
{ key: 'Metal Ore', label: 'Metal ore' },
|
||||||
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
{ key: 'Sulfur Ore', label: 'Sulfur ore' },
|
||||||
{ key: 'HQM Ore', label: 'HQM Ore' },
|
{ key: 'HQM Ore', label: 'HQM ore' },
|
||||||
{ key: 'Cloth', label: 'Cloth' },
|
{ key: 'Cloth', label: 'Cloth' },
|
||||||
{ key: 'Leather', label: 'Leather' },
|
{ key: 'Leather', label: 'Leather' },
|
||||||
{ key: 'Animal Fat', label: 'Animal Fat' },
|
{ key: 'Animal Fat', label: 'Animal fat' },
|
||||||
{ key: 'Bone Fragments', label: 'Bone Fragments' },
|
{ key: 'Bone Fragments', label: 'Bone fragments' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Advanced resource categories
|
// Advanced resource categories
|
||||||
const pickupResources = [
|
const pickupResources = [
|
||||||
{ key: 'Wood', label: 'Wood' },
|
{ key: 'Wood', label: 'Wood' },
|
||||||
{ key: 'Stones', label: 'Stones' },
|
{ key: 'Stones', label: 'Stones' },
|
||||||
{ key: 'Metal Ore', label: 'Metal Ore' },
|
{ key: 'Metal Ore', label: 'Metal ore' },
|
||||||
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
{ key: 'Sulfur Ore', label: 'Sulfur ore' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const quarryResources = [
|
const quarryResources = [
|
||||||
{ key: 'HQM Ore', label: 'HQM Ore' },
|
{ key: 'HQM Ore', label: 'HQM ore' },
|
||||||
{ key: 'Metal Ore', label: 'Metal Ore' },
|
{ key: 'Metal Ore', label: 'Metal ore' },
|
||||||
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
{ key: 'Sulfur Ore', label: 'Sulfur ore' },
|
||||||
{ key: 'Stones', label: 'Stones' },
|
{ key: 'Stones', label: 'Stones' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const excavatorResources = [
|
const excavatorResources = [
|
||||||
{ key: 'HQM Ore', label: 'HQM Ore' },
|
{ key: 'HQM Ore', label: 'HQM ore' },
|
||||||
{ key: 'Metal Ore', label: 'Metal Ore' },
|
{ key: 'Metal Ore', label: 'Metal ore' },
|
||||||
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
{ key: 'Sulfur Ore', label: 'Sulfur ore' },
|
||||||
{ key: 'Stones', label: 'Stones' },
|
{ key: 'Stones', label: 'Stones' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const surveyResources = [
|
const surveyResources = [
|
||||||
{ key: 'Metal Ore', label: 'Metal Ore' },
|
{ key: 'Metal Ore', label: 'Metal ore' },
|
||||||
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
{ key: 'Sulfur Ore', label: 'Sulfur ore' },
|
||||||
{ key: 'Stones', label: 'Stones' },
|
{ key: 'Stones', label: 'Stones' },
|
||||||
{ key: 'HQM Ore', label: 'HQM Ore' },
|
{ key: 'HQM Ore', label: 'HQM ore' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const presets = [
|
const presets = [
|
||||||
@@ -167,368 +163,428 @@ async function handleImport() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="gv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="gv__head">
|
||||||
<h1 class="text-2xl font-bold text-white">Gather Rates</h1>
|
<div class="gv__head-id">
|
||||||
<div class="flex items-center gap-3">
|
<div class="gv__head-chip">
|
||||||
<button
|
<Icon name="pickaxe" :size="20" :stroke-width="2" />
|
||||||
@click="showCreateModal = true"
|
</div>
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
<div>
|
||||||
>
|
<div class="t-eyebrow">Plugin config</div>
|
||||||
<Plus class="w-4 h-4" />
|
<h1 class="gv__title">Gather rates</h1>
|
||||||
New Config
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button size="sm" icon="plus" @click="showCreateModal = true">New config</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Config Selector + Action Bar -->
|
<!-- Config action bar -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel>
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="gv__bar">
|
||||||
<!-- Config Selector -->
|
|
||||||
<select
|
<select
|
||||||
v-if="store.configs.length > 0"
|
v-if="store.configs.length > 0"
|
||||||
:value="store.currentConfig?.id || ''"
|
:value="store.currentConfig?.id ?? ''"
|
||||||
|
class="gv__config-select"
|
||||||
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||||
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
|
||||||
>
|
>
|
||||||
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||||
{{ c.config_name }}
|
{{ c.config_name }}{{ c.is_active ? ' (Active)' : '' }}
|
||||||
<template v-if="c.is_active"> (Active)</template>
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
<span v-else class="gv__no-configs">No configs yet</span>
|
||||||
|
|
||||||
<!-- Save -->
|
<Button
|
||||||
<button
|
icon="save"
|
||||||
@click="store.saveCurrentConfig()"
|
size="sm"
|
||||||
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="store.isSaving"
|
||||||
>
|
@click="store.saveCurrentConfig()"
|
||||||
<Save class="w-4 h-4" />
|
>{{ store.isSaving ? 'Saving…' : 'Save' }}</Button>
|
||||||
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Apply to Server -->
|
<Button
|
||||||
<button
|
variant="outline"
|
||||||
@click="handleApply"
|
icon="play"
|
||||||
|
size="sm"
|
||||||
:disabled="!store.currentConfig || store.isApplying"
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="store.isApplying"
|
||||||
>
|
@click="handleApply"
|
||||||
<Play class="w-4 h-4" />
|
>{{ store.isApplying ? 'Applying…' : 'Apply to server' }}</Button>
|
||||||
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Import from Server -->
|
<Button
|
||||||
<button
|
variant="secondary"
|
||||||
|
icon="download"
|
||||||
|
size="sm"
|
||||||
@click="showImportModal = true"
|
@click="showImportModal = true"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
>Import from server</Button>
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Import from Server
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Delete -->
|
<Button
|
||||||
<button
|
variant="danger-soft"
|
||||||
@click="handleDeleteConfig"
|
icon="trash-2"
|
||||||
|
size="sm"
|
||||||
:disabled="!store.currentConfig"
|
:disabled="!store.currentConfig"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
class="gv__bar-delete"
|
||||||
>
|
@click="handleDeleteConfig"
|
||||||
<Trash2 class="w-4 h-4" />
|
>Delete</Button>
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.isLoading" class="gv__loading">
|
||||||
|
<span class="gv__spinner" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Empty state -->
|
||||||
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
<Panel v-else-if="!store.currentConfig">
|
||||||
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
<EmptyState
|
||||||
</div>
|
icon="pickaxe"
|
||||||
|
title="No gather config selected"
|
||||||
<!-- No Config Selected -->
|
description="Create a new config, import from server, or select one from the dropdown above."
|
||||||
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
|
||||||
<Pickaxe class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
|
||||||
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Gather Config Selected</h2>
|
|
||||||
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
|
||||||
<button
|
|
||||||
@click="showCreateModal = true"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
|
||||||
>
|
>
|
||||||
Create First Config
|
<template #action>
|
||||||
</button>
|
<Button icon="plus" @click="showCreateModal = true">Create first config</Button>
|
||||||
</div>
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Config Editor -->
|
<!-- Config editor -->
|
||||||
<div v-else class="space-y-6">
|
<template v-else>
|
||||||
<!-- Tab Bar -->
|
<Tabs v-model="activeTab" :items="tabItems" variant="line" />
|
||||||
<div class="flex border-b border-neutral-800">
|
|
||||||
<button
|
|
||||||
v-for="tab in tabs"
|
|
||||||
:key="tab.key"
|
|
||||||
@click="activeTab = tab.key as typeof activeTab"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
|
||||||
:class="activeTab === tab.key
|
|
||||||
? 'border-oxide-500 text-oxide-400'
|
|
||||||
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
|
||||||
>
|
|
||||||
<component :is="tab.icon" class="w-4 h-4" />
|
|
||||||
{{ tab.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Resource Rates Tab -->
|
<!-- Resource rates tab -->
|
||||||
<div v-if="activeTab === 'resources'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
<Panel v-if="activeTab === 'resources'" title="Gather resource modifiers">
|
||||||
<div class="flex items-center justify-between">
|
<template #actions>
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Gather Resource Modifiers</h3>
|
<div class="gv__presets">
|
||||||
<div class="flex items-center gap-2">
|
<span class="gv__presets-label">Presets:</span>
|
||||||
<span class="text-xs text-neutral-500 mr-2">Presets:</span>
|
|
||||||
<button
|
<button
|
||||||
v-for="preset in presets"
|
v-for="preset in presets"
|
||||||
:key="preset.value"
|
:key="preset.value"
|
||||||
|
class="gv__preset-btn"
|
||||||
@click="applyPreset(preset.value)"
|
@click="applyPreset(preset.value)"
|
||||||
class="px-3 py-1 text-xs bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 hover:text-white transition-colors"
|
>{{ preset.label }}</button>
|
||||||
>
|
|
||||||
{{ preset.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="gv__rate-list">
|
||||||
<div
|
<div
|
||||||
v-for="resource in gatherResources"
|
v-for="resource in gatherResources"
|
||||||
:key="resource.key"
|
:key="resource.key"
|
||||||
class="flex items-center gap-4"
|
class="gv__rate-row"
|
||||||
>
|
>
|
||||||
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
<label class="gv__rate-label">{{ resource.label }}</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue(`GatherResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`GatherResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`GatherResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="gv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue(`GatherResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue(`GatherResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`GatherResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`GatherResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="1000"
|
max="1000"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input gv__rate-num"
|
||||||
|
@input="setConfigValue(`GatherResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500 w-4">x</span>
|
<span class="gv__rate-unit">x</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Advanced Tab -->
|
<!-- Advanced tab -->
|
||||||
<div v-else-if="activeTab === 'advanced'" class="space-y-6">
|
<template v-else-if="activeTab === 'advanced'">
|
||||||
<!-- Pickup Resource Modifiers -->
|
<!-- Pickup -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Pickup resource modifiers" subtitle="Modify rates for resources picked up from the ground (small rocks, wood piles).">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Pickup Resource Modifiers</h3>
|
<div class="gv__rate-list">
|
||||||
<p class="text-xs text-neutral-500">Modify rates for resources picked up from the ground (small rocks, wood piles).</p>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="resource in pickupResources"
|
v-for="resource in pickupResources"
|
||||||
:key="resource.key"
|
:key="resource.key"
|
||||||
class="flex items-center gap-4"
|
class="gv__rate-row"
|
||||||
>
|
>
|
||||||
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
<label class="gv__rate-label">{{ resource.label }}</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue(`PickupResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`PickupResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`PickupResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="gv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue(`PickupResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue(`PickupResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`PickupResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`PickupResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="1000"
|
max="1000"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input gv__rate-num"
|
||||||
|
@input="setConfigValue(`PickupResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500 w-4">x</span>
|
<span class="gv__rate-unit">x</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Quarry Resource Modifiers -->
|
<!-- Quarry -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Quarry resource modifiers" subtitle="Scale resource output from mining quarries.">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Quarry Resource Modifiers</h3>
|
<div class="gv__rate-list">
|
||||||
<p class="text-xs text-neutral-500">Scale resource output from Mining Quarries.</p>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="resource in quarryResources"
|
v-for="resource in quarryResources"
|
||||||
:key="resource.key"
|
:key="resource.key"
|
||||||
class="flex items-center gap-4"
|
class="gv__rate-row"
|
||||||
>
|
>
|
||||||
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
<label class="gv__rate-label">{{ resource.label }}</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue(`QuarryResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`QuarryResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`QuarryResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="gv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue(`QuarryResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue(`QuarryResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`QuarryResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`QuarryResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="1000"
|
max="1000"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input gv__rate-num"
|
||||||
|
@input="setConfigValue(`QuarryResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500 w-4">x</span>
|
<span class="gv__rate-unit">x</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Excavator Resource Modifiers -->
|
<!-- Excavator -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Excavator resource modifiers" subtitle="Scale resource output from the giant excavator.">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Excavator Resource Modifiers</h3>
|
<div class="gv__rate-list">
|
||||||
<p class="text-xs text-neutral-500">Scale resource output from the Giant Excavator.</p>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="resource in excavatorResources"
|
v-for="resource in excavatorResources"
|
||||||
:key="resource.key"
|
:key="resource.key"
|
||||||
class="flex items-center gap-4"
|
class="gv__rate-row"
|
||||||
>
|
>
|
||||||
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
<label class="gv__rate-label">{{ resource.label }}</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue(`ExcavatorResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`ExcavatorResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`ExcavatorResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="gv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue(`ExcavatorResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue(`ExcavatorResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`ExcavatorResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`ExcavatorResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="1000"
|
max="1000"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input gv__rate-num"
|
||||||
|
@input="setConfigValue(`ExcavatorResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500 w-4">x</span>
|
<span class="gv__rate-unit">x</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Survey Resource Modifiers -->
|
<!-- Survey -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Survey charge resource modifiers" subtitle="Modify resource amounts from survey charge grenades.">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Survey Charge Resource Modifiers</h3>
|
<div class="gv__rate-list">
|
||||||
<p class="text-xs text-neutral-500">Modify resource amounts from Survey Charge grenades.</p>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="resource in surveyResources"
|
v-for="resource in surveyResources"
|
||||||
:key="resource.key"
|
:key="resource.key"
|
||||||
class="flex items-center gap-4"
|
class="gv__rate-row"
|
||||||
>
|
>
|
||||||
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
<label class="gv__rate-label">{{ resource.label }}</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue(`SurveyResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`SurveyResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`SurveyResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="gv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue(`SurveyResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue(`SurveyResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`SurveyResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`SurveyResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="1000"
|
max="1000"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input gv__rate-num"
|
||||||
|
@input="setConfigValue(`SurveyResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500 w-4">x</span>
|
<span class="gv__rate-unit">x</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Create Config Modal -->
|
<!-- Create config modal -->
|
||||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
<div v-if="showCreateModal" class="gv__modal-backdrop" @click.self="showCreateModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="gv__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Gather Config</h2>
|
<h2 class="gv__modal-title">New gather config</h2>
|
||||||
<div class="space-y-4">
|
<div class="gv__modal-body">
|
||||||
<div>
|
<div class="gv__field">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
<label class="gv__field-label">Config name</label>
|
||||||
<input
|
<input
|
||||||
v-model="newConfigName"
|
v-model="newConfigName"
|
||||||
placeholder="e.g. 3x Gather Rates"
|
type="text"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
class="cc-text-input"
|
||||||
|
placeholder="e.g. 3x gather rates"
|
||||||
@keydown.enter="handleCreateConfig"
|
@keydown.enter="handleCreateConfig"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="gv__field">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
<label class="gv__field-label">Description (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newConfigDesc"
|
v-model="newConfigDesc"
|
||||||
rows="2"
|
rows="2"
|
||||||
|
class="cc-textarea"
|
||||||
placeholder="What is this config for?"
|
placeholder="What is this config for?"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
</div>
|
||||||
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
<div class="gv__modal-footer">
|
||||||
<button
|
<Button variant="ghost" @click="showCreateModal = false">Cancel</Button>
|
||||||
@click="handleCreateConfig"
|
<Button :disabled="!newConfigName.trim()" @click="handleCreateConfig">Create</Button>
|
||||||
:disabled="!newConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import from Server Modal -->
|
<!-- Import from server modal -->
|
||||||
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
<div v-if="showImportModal" class="gv__modal-backdrop" @click.self="showImportModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="gv__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
<h2 class="gv__modal-title">Import from server</h2>
|
||||||
<p class="text-sm text-neutral-400 mb-4">
|
<p class="gv__modal-desc">Import the current GatherManager config from your live server. This will create a new config profile.</p>
|
||||||
Import the current GatherManager config from your live server. This will create a new config profile.
|
<div class="gv__modal-body">
|
||||||
</p>
|
<div class="gv__field">
|
||||||
<div class="space-y-4">
|
<label class="gv__field-label">Config name</label>
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
|
||||||
<input
|
<input
|
||||||
v-model="importConfigName"
|
v-model="importConfigName"
|
||||||
placeholder="e.g. Imported Server Config"
|
type="text"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
class="cc-text-input"
|
||||||
|
placeholder="e.g. Imported server config"
|
||||||
@keydown.enter="handleImport"
|
@keydown.enter="handleImport"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
</div>
|
||||||
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
<div class="gv__modal-footer">
|
||||||
<button
|
<Button variant="ghost" @click="showImportModal = false">Cancel</Button>
|
||||||
@click="handleImport"
|
<Button :disabled="!importConfigName.trim()" @click="handleImport">Import</Button>
|
||||||
:disabled="!importConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gv { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.gv__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.gv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.gv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.gv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config action bar */
|
||||||
|
.gv__bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.gv__config-select {
|
||||||
|
appearance: none; height: var(--control-h-md); padding: 0 11px;
|
||||||
|
background: var(--surface-inset); color: var(--text-primary); border: 0;
|
||||||
|
border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
font-family: var(--font-sans); font-size: var(--text-sm); cursor: pointer;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.gv__config-select:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.gv__no-configs { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
|
.gv__bar-delete { margin-left: auto; }
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.gv__loading { display: flex; align-items: center; justify-content: center; padding: 60px; }
|
||||||
|
.gv__spinner {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--accent);
|
||||||
|
border-top-color: transparent; animation: gv-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes gv-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Presets */
|
||||||
|
.gv__presets { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.gv__presets-label { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
.gv__preset-btn {
|
||||||
|
height: 26px; padding: 0 10px; border: 0; border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-raised-2); color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs); font-weight: 600; cursor: pointer;
|
||||||
|
box-shadow: var(--ring-default); transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.gv__preset-btn:hover { background: var(--surface-active); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* Rate rows */
|
||||||
|
.gv__rate-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.gv__rate-row { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.gv__rate-label { font-size: var(--text-sm); color: var(--text-primary); width: 120px; flex: none; }
|
||||||
|
.gv__slider { flex: 1; cursor: pointer; }
|
||||||
|
.gv__rate-num {
|
||||||
|
width: 72px; height: var(--control-h-sm); flex: none;
|
||||||
|
text-align: center; font-family: var(--font-mono); font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.gv__rate-unit { font-size: var(--text-xs); color: var(--text-tertiary); width: 12px; flex: none; }
|
||||||
|
|
||||||
|
/* Shared token inputs */
|
||||||
|
.cc-textarea {
|
||||||
|
width: 100%; background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 11px; font-family: var(--font-sans); font-size: var(--text-sm);
|
||||||
|
resize: none; outline: 0; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.cc-textarea:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-text-input {
|
||||||
|
width: 100%; height: var(--control-h-md); background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 0 11px; font-family: var(--font-sans); font-size: var(--text-sm); outline: 0;
|
||||||
|
}
|
||||||
|
.cc-text-input:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-num-input {
|
||||||
|
width: 100%; height: var(--control-h-md); background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 0 11px; font-family: var(--font-sans); font-size: var(--text-sm); outline: 0;
|
||||||
|
}
|
||||||
|
.cc-num-input:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
|
||||||
|
/* Fields */
|
||||||
|
.gv__field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.gv__field-label { font-size: var(--text-xs); font-weight: 600; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.gv__modal-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 50;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||||
|
}
|
||||||
|
.gv__modal {
|
||||||
|
background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default);
|
||||||
|
width: 100%; max-width: 440px; padding: 24px; display: flex; flex-direction: column; gap: 16px;
|
||||||
|
}
|
||||||
|
.gv__modal-title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
|
||||||
|
.gv__modal-desc { font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.5; }
|
||||||
|
.gv__modal-body { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.gv__modal-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useLootStore } from '@/stores/loot'
|
import { useLootStore } from '@/stores/loot'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import LootContainerSidebar from '@/components/loot/LootContainerSidebar.vue'
|
import LootContainerSidebar from '@/components/loot/LootContainerSidebar.vue'
|
||||||
import LootItemEditor from '@/components/loot/LootItemEditor.vue'
|
import LootItemEditor from '@/components/loot/LootItemEditor.vue'
|
||||||
import LootGroupEditor from '@/components/loot/LootGroupEditor.vue'
|
import LootGroupEditor from '@/components/loot/LootGroupEditor.vue'
|
||||||
import LootItemPicker from '@/components/loot/LootItemPicker.vue'
|
import LootItemPicker from '@/components/loot/LootItemPicker.vue'
|
||||||
import { Save, Upload, Download, Play, Copy, Trash2, Plus, Layers } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
import DsSelect from '@/components/ds/forms/Select.vue'
|
||||||
|
import DsInput from '@/components/ds/forms/Input.vue'
|
||||||
|
|
||||||
const loot = useLootStore()
|
const loot = useLootStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
@@ -24,6 +31,20 @@ const activeTab = ref<'items' | 'groups'>('items')
|
|||||||
|
|
||||||
const multipliers = [1, 2, 5, 10]
|
const multipliers = [1, 2, 5, 10]
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{ value: 'items', label: 'Container items' },
|
||||||
|
{ value: 'groups', label: 'Loot groups', icon: 'layers' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Profile selector options for DS Select
|
||||||
|
const profileOptions = computed(() =>
|
||||||
|
loot.profiles.map(p => ({
|
||||||
|
value: p.id,
|
||||||
|
label: p.profile_name + (p.is_active ? ' (active)' : ''),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
const currentProfileId = computed(() => loot.currentProfile?.id ?? '')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loot.fetchProfiles()
|
await loot.fetchProfiles()
|
||||||
if (loot.profiles.length > 0 && loot.profiles[0]) {
|
if (loot.profiles.length > 0 && loot.profiles[0]) {
|
||||||
@@ -130,145 +151,139 @@ function handleAddItem(shortname: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-4">
|
<div class="lb-root">
|
||||||
<!-- Header -->
|
<!-- Page header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="lb-header">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Loot Builder</h1>
|
<div class="lb-header__left">
|
||||||
<div class="flex items-center gap-2">
|
<h1 class="lb-title">Loot builder</h1>
|
||||||
<button
|
<Badge v-if="loot.isDirty" tone="warn">Unsaved changes</Badge>
|
||||||
@click="showCreateModal = true"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
New Profile
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button size="sm" variant="secondary" icon="plus" @click="showCreateModal = true">
|
||||||
|
New profile
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Profile Bar -->
|
<!-- Profile toolbar -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel>
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="lb-toolbar">
|
||||||
<!-- Profile Selector -->
|
<!-- Profile selector -->
|
||||||
<select
|
<div class="lb-toolbar__profile">
|
||||||
v-if="loot.profiles.length > 0"
|
<DsSelect
|
||||||
:value="loot.currentProfile?.id || ''"
|
v-if="loot.profiles.length > 0"
|
||||||
@change="handleProfileChange(($event.target as HTMLSelectElement).value)"
|
:options="profileOptions"
|
||||||
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
:model-value="currentProfileId"
|
||||||
>
|
@update:model-value="(v: string | undefined) => { if (v) handleProfileChange(v) }"
|
||||||
<option v-for="p in loot.profiles" :key="p.id" :value="p.id">
|
/>
|
||||||
{{ p.profile_name }}
|
<span v-else class="lb-toolbar__empty">No profiles yet</span>
|
||||||
<template v-if="p.is_active"> (Active)</template>
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<span v-else class="text-neutral-500 text-sm">No profiles yet</span>
|
|
||||||
|
|
||||||
<!-- Save -->
|
|
||||||
<button
|
|
||||||
@click="loot.saveCurrentProfile()"
|
|
||||||
:disabled="!loot.currentProfile || !loot.isDirty || loot.isSaving"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
|
||||||
>
|
|
||||||
<Save class="w-4 h-4" />
|
|
||||||
{{ loot.isSaving ? 'Saving...' : 'Save' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Apply Dropdown -->
|
|
||||||
<div class="relative">
|
|
||||||
<button
|
|
||||||
@click="showApplyDropdown = !showApplyDropdown"
|
|
||||||
:disabled="!loot.currentProfile || loot.isApplying"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
|
||||||
>
|
|
||||||
<Play class="w-4 h-4" />
|
|
||||||
{{ loot.isApplying ? 'Applying...' : 'Apply to Server' }}
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="showApplyDropdown"
|
|
||||||
class="absolute top-full mt-1 right-0 bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl z-10 py-1 min-w-[140px]"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="m in multipliers"
|
|
||||||
:key="m"
|
|
||||||
@click="handleApply(m)"
|
|
||||||
class="w-full text-left px-4 py-2 text-sm text-neutral-300 hover:bg-neutral-700"
|
|
||||||
>
|
|
||||||
{{ m }}x Multiplier
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Duplicate -->
|
<div class="lb-toolbar__actions">
|
||||||
<button
|
<!-- Save -->
|
||||||
@click="handleDuplicate"
|
<Button
|
||||||
:disabled="!loot.currentProfile"
|
size="sm"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 disabled:opacity-50 text-sm"
|
variant="primary"
|
||||||
>
|
icon="save"
|
||||||
<Copy class="w-4 h-4" />
|
:disabled="!loot.currentProfile || !loot.isDirty || loot.isSaving"
|
||||||
Duplicate
|
:loading="loot.isSaving"
|
||||||
</button>
|
@click="loot.saveCurrentProfile()"
|
||||||
|
>
|
||||||
|
{{ loot.isSaving ? 'Saving…' : 'Save' }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<!-- Import -->
|
<!-- Apply to server with multiplier dropdown -->
|
||||||
<button
|
<div class="lb-apply-wrap">
|
||||||
@click="showImportModal = true"
|
<Button
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
size="sm"
|
||||||
>
|
variant="outline"
|
||||||
<Upload class="w-4 h-4" />
|
icon="play"
|
||||||
Import
|
:disabled="!loot.currentProfile || loot.isApplying"
|
||||||
</button>
|
:loading="loot.isApplying"
|
||||||
|
@click="showApplyDropdown = !showApplyDropdown"
|
||||||
|
>
|
||||||
|
{{ loot.isApplying ? 'Applying…' : 'Apply to server' }}
|
||||||
|
</Button>
|
||||||
|
<div v-if="showApplyDropdown" class="lb-dropdown">
|
||||||
|
<button
|
||||||
|
v-for="m in multipliers"
|
||||||
|
:key="m"
|
||||||
|
class="lb-dropdown__item"
|
||||||
|
@click="handleApply(m)"
|
||||||
|
>
|
||||||
|
{{ m }}x multiplier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Export -->
|
<!-- Duplicate -->
|
||||||
<button
|
<Button
|
||||||
@click="handleExport"
|
size="sm"
|
||||||
:disabled="!loot.currentProfile"
|
variant="ghost"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 disabled:opacity-50 text-sm"
|
icon="copy"
|
||||||
>
|
:disabled="!loot.currentProfile"
|
||||||
<Download class="w-4 h-4" />
|
@click="handleDuplicate"
|
||||||
Export
|
>
|
||||||
</button>
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
|
||||||
<!-- Delete -->
|
<!-- Import -->
|
||||||
<button
|
<Button
|
||||||
@click="handleDeleteProfile"
|
size="sm"
|
||||||
:disabled="!loot.currentProfile"
|
variant="ghost"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
icon="upload"
|
||||||
>
|
@click="showImportModal = true"
|
||||||
<Trash2 class="w-4 h-4" />
|
>
|
||||||
Delete
|
Import
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
|
<!-- Export -->
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon="download"
|
||||||
|
:disabled="!loot.currentProfile"
|
||||||
|
@click="handleExport"
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger-soft"
|
||||||
|
icon="trash-2"
|
||||||
|
:disabled="!loot.currentProfile"
|
||||||
|
@click="handleDeleteProfile"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loot.isLoading" class="lb-loading">
|
||||||
|
<Icon name="loader" :size="28" class="lb-spin" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main editor layout -->
|
||||||
<div v-if="loot.currentProfile" class="flex gap-4" style="height: calc(100vh - 250px)">
|
<div v-else-if="loot.currentProfile" class="lb-workspace">
|
||||||
<!-- Sidebar -->
|
<!-- Container sidebar -->
|
||||||
<LootContainerSidebar
|
<LootContainerSidebar
|
||||||
:loot-table="loot.currentProfile.loot_table"
|
:loot-table="loot.currentProfile.loot_table"
|
||||||
:selected="loot.selectedContainer"
|
:selected="loot.selectedContainer"
|
||||||
@select="loot.selectedContainer = $event"
|
@select="loot.selectedContainer = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Editor Area -->
|
<!-- Editor area -->
|
||||||
<div class="flex-1 flex flex-col min-w-0">
|
<div class="lb-editor">
|
||||||
<!-- Tabs -->
|
<Tabs
|
||||||
<div class="flex border-b border-neutral-800 mb-4">
|
v-model="activeTab"
|
||||||
<button
|
:items="tabItems"
|
||||||
@click="activeTab = 'items'"
|
variant="line"
|
||||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
class="lb-tabs"
|
||||||
:class="activeTab === 'items' ? 'border-oxide-500 text-oxide-400' : 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
/>
|
||||||
>
|
|
||||||
Container Items
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="activeTab = 'groups'"
|
|
||||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-2"
|
|
||||||
:class="activeTab === 'groups' ? 'border-oxide-500 text-oxide-400' : 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
|
||||||
>
|
|
||||||
<Layers class="w-4 h-4" />
|
|
||||||
Loot Groups
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="lb-editor__body">
|
||||||
<LootItemEditor
|
<LootItemEditor
|
||||||
v-if="activeTab === 'items' && loot.selectedContainer"
|
v-if="activeTab === 'items' && loot.selectedContainer"
|
||||||
:container-key="loot.selectedContainer"
|
:container-key="loot.selectedContainer"
|
||||||
@@ -276,8 +291,12 @@ function handleAddItem(shortname: string) {
|
|||||||
@dirty="loot.markDirty()"
|
@dirty="loot.markDirty()"
|
||||||
@add-item="showItemPicker = true"
|
@add-item="showItemPicker = true"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="activeTab === 'items'" class="flex items-center justify-center h-full text-neutral-500">
|
<div v-else-if="activeTab === 'items'" class="lb-editor__placeholder">
|
||||||
Select a container from the sidebar
|
<EmptyState
|
||||||
|
icon="box"
|
||||||
|
title="No container selected"
|
||||||
|
description="Select a container from the sidebar to configure its loot."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LootGroupEditor
|
<LootGroupEditor
|
||||||
@@ -289,98 +308,103 @@ function handleAddItem(shortname: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty state — no profile -->
|
||||||
<div v-else-if="!loot.isLoading" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
<div v-else class="lb-empty-wrap">
|
||||||
<Layers class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
<Panel>
|
||||||
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Loot Profile Selected</h2>
|
<EmptyState
|
||||||
<p class="text-neutral-500 mb-4">Create a new profile or select one from the dropdown above.</p>
|
icon="layers"
|
||||||
<button
|
title="No loot profile selected"
|
||||||
@click="showCreateModal = true"
|
description="Create a new profile or select one above."
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
>
|
||||||
>
|
<template #action>
|
||||||
Create First Profile
|
<Button icon="plus" @click="showCreateModal = true">Create first profile</Button>
|
||||||
</button>
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Create profile modal -->
|
||||||
<div v-if="loot.isLoading" class="flex items-center justify-center py-20">
|
<Teleport to="body">
|
||||||
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
<div v-if="showCreateModal" class="lb-overlay" @click.self="showCreateModal = false">
|
||||||
</div>
|
<div class="lb-modal">
|
||||||
|
<div class="lb-modal__head">
|
||||||
<!-- Create Modal -->
|
<span class="lb-modal__title">New loot profile</span>
|
||||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
<button class="lb-modal__close" @click="showCreateModal = false" aria-label="Close">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<Icon name="x" :size="16" />
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Loot Profile</h2>
|
</button>
|
||||||
<div class="space-y-4">
|
</div>
|
||||||
<div>
|
<div class="lb-modal__body">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Profile Name</label>
|
<DsInput
|
||||||
<input
|
|
||||||
v-model="newProfileName"
|
v-model="newProfileName"
|
||||||
|
label="Profile name"
|
||||||
placeholder="e.g. Vanilla 2x"
|
placeholder="e.g. Vanilla 2x"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
|
||||||
@keydown.enter="handleCreateProfile"
|
@keydown.enter="handleCreateProfile"
|
||||||
/>
|
/>
|
||||||
|
<div class="lb-field">
|
||||||
|
<label class="lb-field__label">Description <span class="lb-field__opt">(optional)</span></label>
|
||||||
|
<textarea
|
||||||
|
v-model="newProfileDesc"
|
||||||
|
rows="2"
|
||||||
|
placeholder="What is this profile for?"
|
||||||
|
class="cc-textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="lb-modal__foot">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
<Button variant="ghost" size="sm" @click="showCreateModal = false">Cancel</Button>
|
||||||
<textarea
|
<Button
|
||||||
v-model="newProfileDesc"
|
size="sm"
|
||||||
rows="2"
|
|
||||||
placeholder="What is this profile for?"
|
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
|
||||||
<button
|
|
||||||
@click="handleCreateProfile"
|
|
||||||
:disabled="!newProfileName.trim()"
|
:disabled="!newProfileName.trim()"
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
@click="handleCreateProfile"
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Teleport>
|
||||||
|
|
||||||
<!-- Import Modal -->
|
<!-- Import modal -->
|
||||||
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
<Teleport to="body">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-lg">
|
<div v-if="showImportModal" class="lb-overlay" @click.self="showImportModal = false">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import Loot Profile</h2>
|
<div class="lb-modal lb-modal--wide">
|
||||||
<div class="space-y-4">
|
<div class="lb-modal__head">
|
||||||
<div>
|
<span class="lb-modal__title">Import loot profile</span>
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Profile Name</label>
|
<button class="lb-modal__close" @click="showImportModal = false" aria-label="Close">
|
||||||
<input
|
<Icon name="x" :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="lb-modal__body">
|
||||||
|
<DsInput
|
||||||
v-model="importName"
|
v-model="importName"
|
||||||
|
label="Profile name"
|
||||||
placeholder="Name for imported profile"
|
placeholder="Name for imported profile"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
|
||||||
/>
|
/>
|
||||||
|
<div class="lb-field">
|
||||||
|
<label class="lb-field__label">BetterLoot JSON</label>
|
||||||
|
<textarea
|
||||||
|
v-model="importJson"
|
||||||
|
rows="10"
|
||||||
|
placeholder="Paste LootTables.json content here…"
|
||||||
|
class="cc-textarea cc-textarea--mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="lb-modal__foot">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">BetterLoot JSON</label>
|
<Button variant="ghost" size="sm" @click="showImportModal = false">Cancel</Button>
|
||||||
<textarea
|
<Button
|
||||||
v-model="importJson"
|
size="sm"
|
||||||
rows="10"
|
|
||||||
placeholder="Paste LootTables.json content here..."
|
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm font-mono resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
|
||||||
<button
|
|
||||||
@click="handleImport"
|
|
||||||
:disabled="!importName.trim() || !importJson.trim()"
|
:disabled="!importName.trim() || !importJson.trim()"
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
@click="handleImport"
|
||||||
>
|
>
|
||||||
Import
|
Import
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Teleport>
|
||||||
|
|
||||||
<!-- Item Picker Modal -->
|
<!-- Item picker modal -->
|
||||||
<LootItemPicker
|
<LootItemPicker
|
||||||
v-if="showItemPicker"
|
v-if="showItemPicker"
|
||||||
@select="handleAddItem"
|
@select="handleAddItem"
|
||||||
@@ -388,6 +412,255 @@ function handleAddItem(shortname: string) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Click-away for apply dropdown -->
|
<!-- Click-away for apply dropdown -->
|
||||||
<div v-if="showApplyDropdown" class="fixed inset-0 z-0" @click="showApplyDropdown = false" />
|
<div v-if="showApplyDropdown" class="lb-clickaway" @click="showApplyDropdown = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lb-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.lb-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.lb-header__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.lb-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar inside Panel */
|
||||||
|
.lb-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.lb-toolbar__profile {
|
||||||
|
min-width: 200px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.lb-toolbar__empty {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.lb-toolbar__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply dropdown */
|
||||||
|
.lb-apply-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.lb-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 4px;
|
||||||
|
min-width: 150px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.lb-dropdown__item {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.lb-dropdown__item:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.lb-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.lb-spin {
|
||||||
|
animation: lb-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes lb-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Workspace (sidebar + editor) */
|
||||||
|
.lb-workspace {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: calc(100vh - 260px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor column */
|
||||||
|
.lb-editor {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.lb-tabs {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.lb-editor__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.lb-editor__placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state wrapper */
|
||||||
|
.lb-empty-wrap {}
|
||||||
|
|
||||||
|
/* Click-away backdrop */
|
||||||
|
.lb-clickaway {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal overlay */
|
||||||
|
.lb-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.lb-modal {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.lb-modal--wide {
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
.lb-modal__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.lb-modal__title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.lb-modal__close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.lb-modal__close:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.lb-modal__body {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.lb-modal__foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 20px 16px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Field (textarea wrapper) */
|
||||||
|
.lb-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.lb-field__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.lb-field__opt {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bare textarea with token styling */
|
||||||
|
.cc-textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 11px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
}
|
||||||
|
.cc-textarea::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.cc-textarea:focus-within,
|
||||||
|
.cc-textarea:focus {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm);
|
||||||
|
}
|
||||||
|
.cc-textarea--mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, nextTick, computed } from 'vue'
|
import { ref, onMounted, watch, nextTick, computed } from 'vue'
|
||||||
import { Map, TrendingUp, Award, Target, Download } from 'lucide-vue-next'
|
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import type { ECharts } from 'echarts'
|
import type { ECharts } from 'echarts'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import type { MapAnalyticsSummary } from '@/types'
|
import type { MapAnalyticsSummary } from '@/types'
|
||||||
import { safeFixed } from '@/utils/formatters'
|
import { safeFixed } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
@@ -30,6 +36,10 @@ const loadMapAnalytics = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cssVar(name: string): string {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||||
|
}
|
||||||
|
|
||||||
const renderCharts = () => {
|
const renderCharts = () => {
|
||||||
if (!analytics.value || analytics.value.maps.length === 0) return
|
if (!analytics.value || analytics.value.maps.length === 0) return
|
||||||
|
|
||||||
@@ -44,20 +54,29 @@ const renderCharts = () => {
|
|||||||
const avgPlayers = analytics.value.maps.map(m => m.avg_players)
|
const avgPlayers = analytics.value.maps.map(m => m.avg_players)
|
||||||
const peakPlayers = analytics.value.maps.map(m => m.peak_players)
|
const peakPlayers = analytics.value.maps.map(m => m.peak_players)
|
||||||
|
|
||||||
|
const accent = cssVar('--accent') || '#CE422B'
|
||||||
|
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
|
||||||
|
const axisLine = cssVar('--border-default') || '#404040'
|
||||||
|
const labelColor = cssVar('--text-tertiary') || '#808080'
|
||||||
|
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
|
||||||
|
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
|
||||||
|
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
|
||||||
|
const mono = 'JetBrains Mono, monospace'
|
||||||
|
|
||||||
performanceChartInstance.setOption({
|
performanceChartInstance.setOption({
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: tooltipBg,
|
||||||
borderColor: '#2a2a2a',
|
borderColor: tooltipBorder,
|
||||||
textStyle: { color: '#e5e5e5' },
|
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
|
||||||
axisPointer: {
|
axisPointer: {
|
||||||
type: 'shadow'
|
type: 'shadow'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
data: ['Avg Players', 'Peak Players'],
|
data: ['Avg Players', 'Peak Players'],
|
||||||
textStyle: { color: '#a3a3a3' },
|
textStyle: { color: labelColor, fontFamily: mono },
|
||||||
top: 0
|
top: 0
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
@@ -70,22 +89,22 @@ const renderCharts = () => {
|
|||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: mapNames,
|
data: mapNames,
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
axisLabel: { color: '#808080', rotate: 45 }
|
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: 'Players',
|
name: 'Players',
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
splitLine: { lineStyle: { color: grid } },
|
||||||
axisLabel: { color: '#808080' }
|
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: 'Avg Players',
|
name: 'Avg Players',
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: avgPlayers,
|
data: avgPlayers,
|
||||||
itemStyle: { color: '#CE422B' },
|
itemStyle: { color: accent },
|
||||||
barGap: '10%'
|
barGap: '10%'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -138,182 +157,263 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="map-analytics-view">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="map-analytics-view__header">
|
||||||
<div class="flex items-center gap-3">
|
<h1 class="map-analytics-view__title">Map analytics</h1>
|
||||||
<Map class="w-5 h-5 text-oxide-500" />
|
<div class="map-analytics-view__controls">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Map Analytics</h1>
|
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
@click="downloadCSV"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
|
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</Button>
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
<Tabs
|
||||||
<button
|
:items="[
|
||||||
v-for="opt in (['30d', '90d', 'all'] as const)"
|
{ value: '30d', label: '30d' },
|
||||||
:key="opt"
|
{ value: '90d', label: '90d' },
|
||||||
@click="timeRange = opt"
|
{ value: 'all', label: 'All' }
|
||||||
class="px-3 py-2 text-sm font-medium transition-colors"
|
]"
|
||||||
:class="timeRange === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
v-model="timeRange"
|
||||||
>
|
variant="pill"
|
||||||
{{ opt }}
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
<div v-if="loading" class="map-analytics-view__loading">
|
||||||
<div class="text-neutral-500">Loading map analytics...</div>
|
<span class="map-analytics-view__loading-text">Loading map analytics...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="analytics">
|
<template v-else-if="analytics">
|
||||||
<!-- Summary cards -->
|
<!-- Summary cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="map-analytics-view__stats">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<StatCard
|
||||||
<div class="flex items-center gap-2 mb-2">
|
label="Best performing map"
|
||||||
<Award class="w-4 h-4 text-oxide-400" />
|
:value="analytics.best_performing_map ?? 'No data'"
|
||||||
<p class="text-sm text-neutral-400">Best Performing Map</p>
|
icon="award"
|
||||||
</div>
|
:note="analytics.maps.length > 0 ? `Avg ${safeFixed(analytics?.maps?.[0]?.avg_players, 1)} players` : undefined"
|
||||||
<p class="text-xl font-bold text-neutral-100">
|
/>
|
||||||
{{ analytics.best_performing_map ?? 'No data' }}
|
<StatCard
|
||||||
</p>
|
label="Rotation effectiveness"
|
||||||
<p class="text-xs text-neutral-600 mt-1" v-if="analytics.maps.length > 0">
|
:value="safeFixed(analytics?.rotation_effectiveness, 1)"
|
||||||
Avg {{ safeFixed(analytics?.maps?.[0]?.avg_players, 1) }} players
|
unit="%"
|
||||||
</p>
|
icon="target"
|
||||||
</div>
|
note="Overall rotation health"
|
||||||
|
/>
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<StatCard
|
||||||
<div class="flex items-center gap-2 mb-2">
|
label="Total maps tracked"
|
||||||
<Target class="w-4 h-4 text-green-400" />
|
:value="analytics.maps.length"
|
||||||
<p class="text-sm text-neutral-400">Rotation Effectiveness</p>
|
icon="trending-up"
|
||||||
</div>
|
:note="`Last ${timeRange}`"
|
||||||
<p class="text-xl font-bold text-neutral-100">
|
/>
|
||||||
{{ safeFixed(analytics?.rotation_effectiveness, 1) }}%
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-neutral-600 mt-1">Overall rotation health</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<TrendingUp class="w-4 h-4 text-blue-400" />
|
|
||||||
<p class="text-sm text-neutral-400">Total Maps Tracked</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-xl font-bold text-neutral-100">{{ analytics.maps.length }}</p>
|
|
||||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Performance chart -->
|
<!-- Performance chart -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Map performance comparison">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
<div v-if="analytics.maps.length > 0" ref="performanceChart" class="map-analytics-view__chart-area"></div>
|
||||||
Map Performance Comparison
|
<EmptyState
|
||||||
</h2>
|
v-else
|
||||||
<div v-if="analytics.maps.length > 0" ref="performanceChart" class="h-80"></div>
|
icon="map"
|
||||||
<div v-else class="h-80 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg">
|
title="No map data"
|
||||||
<p class="text-sm text-neutral-600">No map data available for this time range</p>
|
description="No map data available for this time range."
|
||||||
</div>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Map performance table -->
|
<!-- Map performance table -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Detailed map metrics" :flush-body="sortedMaps.length > 0">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
<div v-if="sortedMaps.length > 0" class="map-analytics-view__table-wrap">
|
||||||
Detailed Map Metrics
|
<table class="map-analytics-view__table">
|
||||||
</h2>
|
<thead>
|
||||||
<div v-if="sortedMaps.length > 0" class="overflow-x-auto">
|
<tr class="map-analytics-view__thead-row">
|
||||||
<table class="w-full text-sm">
|
<th class="map-analytics-view__th map-analytics-view__th--left">Map name</th>
|
||||||
<thead class="border-b border-neutral-800">
|
<th class="map-analytics-view__th map-analytics-view__th--left">Seed</th>
|
||||||
<tr>
|
<th class="map-analytics-view__th map-analytics-view__th--right">Times used</th>
|
||||||
<th class="text-left py-3 px-4 text-neutral-400 font-medium">Map Name</th>
|
<th class="map-analytics-view__th map-analytics-view__th--right">Avg players</th>
|
||||||
<th class="text-left py-3 px-4 text-neutral-400 font-medium">Seed</th>
|
<th class="map-analytics-view__th map-analytics-view__th--right">Peak players</th>
|
||||||
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Times Used</th>
|
<th class="map-analytics-view__th map-analytics-view__th--right">Effectiveness</th>
|
||||||
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Avg Players</th>
|
|
||||||
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Peak Players</th>
|
|
||||||
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Effectiveness</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="map in sortedMaps"
|
v-for="map in sortedMaps"
|
||||||
:key="map.map_id"
|
:key="map.map_id"
|
||||||
class="border-b border-neutral-800/50 hover:bg-neutral-800/30 transition-colors"
|
class="map-analytics-view__row"
|
||||||
>
|
>
|
||||||
<td class="py-3 px-4 text-neutral-200 font-medium">{{ map.map_name }}</td>
|
<td class="map-analytics-view__td map-analytics-view__td--primary">{{ map.map_name }}</td>
|
||||||
<td class="py-3 px-4 text-neutral-400">{{ map.seed ?? '—' }}</td>
|
<td class="map-analytics-view__td">{{ map.seed ?? '—' }}</td>
|
||||||
<td class="py-3 px-4 text-right text-neutral-300">{{ map.times_used }}</td>
|
<td class="map-analytics-view__td map-analytics-view__td--num">{{ map.times_used }}</td>
|
||||||
<td class="py-3 px-4 text-right text-neutral-300">{{ safeFixed(map.avg_players, 1) }}</td>
|
<td class="map-analytics-view__td map-analytics-view__td--num">{{ safeFixed(map.avg_players, 1) }}</td>
|
||||||
<td class="py-3 px-4 text-right text-neutral-300">{{ map.peak_players }}</td>
|
<td class="map-analytics-view__td map-analytics-view__td--num">{{ map.peak_players }}</td>
|
||||||
<td class="py-3 px-4 text-right">
|
<td class="map-analytics-view__td map-analytics-view__td--right">
|
||||||
<span
|
<Badge
|
||||||
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium"
|
:tone="map.effectiveness_score >= 80 ? 'online' : map.effectiveness_score >= 60 ? 'warn' : 'offline'"
|
||||||
:class="{
|
mono
|
||||||
'bg-green-500/10 text-green-400': map.effectiveness_score >= 80,
|
|
||||||
'bg-yellow-500/10 text-yellow-400': map.effectiveness_score >= 60 && map.effectiveness_score < 80,
|
|
||||||
'bg-red-500/10 text-red-400': map.effectiveness_score < 60
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
{{ safeFixed(map.effectiveness_score, 1) }}%
|
{{ safeFixed(map.effectiveness_score, 1) }}%
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="py-8 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg">
|
<EmptyState
|
||||||
<p class="text-sm text-neutral-600">No map data available</p>
|
v-else
|
||||||
</div>
|
icon="map"
|
||||||
</div>
|
title="No map data"
|
||||||
|
description="No map data available."
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Insights section -->
|
<!-- Insights section -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Actionable insights">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
<div class="map-analytics-view__insights">
|
||||||
Actionable Insights
|
<Alert
|
||||||
</h2>
|
v-if="analytics.best_performing_map"
|
||||||
<div class="space-y-3">
|
tone="accent"
|
||||||
<div v-if="analytics.best_performing_map" class="flex items-start gap-3 p-3 bg-neutral-800/50 rounded-lg">
|
:title="`Best map: ${analytics.best_performing_map}`"
|
||||||
<Award class="w-5 h-5 text-oxide-400 mt-0.5 flex-shrink-0" />
|
>
|
||||||
<div>
|
Consider featuring this map more frequently in your rotation for maximum player engagement.
|
||||||
<p class="text-sm text-neutral-200 font-medium">
|
</Alert>
|
||||||
Your best map is <span class="text-oxide-400">{{ analytics.best_performing_map }}</span>
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-neutral-500 mt-1">
|
|
||||||
Consider featuring this map more frequently in your rotation for maximum player engagement.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<Alert
|
||||||
v-if="analytics.rotation_effectiveness < 70"
|
v-if="analytics.rotation_effectiveness < 70"
|
||||||
class="flex items-start gap-3 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg"
|
tone="warn"
|
||||||
|
title="Rotation effectiveness is below optimal"
|
||||||
>
|
>
|
||||||
<Target class="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0" />
|
Consider removing low-performing maps (effectiveness < 60%) and testing new maps to improve overall rotation health.
|
||||||
<div>
|
</Alert>
|
||||||
<p class="text-sm text-neutral-200 font-medium">Rotation effectiveness is below optimal</p>
|
|
||||||
<p class="text-xs text-neutral-500 mt-1">
|
|
||||||
Consider removing low-performing maps (effectiveness < 60%) and testing new maps to improve overall rotation health.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<Alert
|
||||||
v-else-if="analytics.rotation_effectiveness >= 80"
|
v-else-if="analytics.rotation_effectiveness >= 80"
|
||||||
class="flex items-start gap-3 p-3 bg-green-500/5 border border-green-500/20 rounded-lg"
|
tone="online"
|
||||||
|
title="Excellent rotation effectiveness"
|
||||||
>
|
>
|
||||||
<TrendingUp class="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
|
Your current map rotation is driving strong player engagement. Keep monitoring for any changes.
|
||||||
<div>
|
</Alert>
|
||||||
<p class="text-sm text-neutral-200 font-medium">Excellent rotation effectiveness!</p>
|
|
||||||
<p class="text-xs text-neutral-500 mt-1">
|
|
||||||
Your current map rotation is driving strong player engagement. Keep monitoring for any changes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.map-analytics-view {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__loading-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.map-analytics-view__stats {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__chart-area {
|
||||||
|
height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__thead-row {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__th {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__th--left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__th--right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__row {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: background var(--dur-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__row:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__td--primary {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__td--num {
|
||||||
|
text-align: right;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__td--right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__insights {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import { useApi } from '@/composables/useApi'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { MapEntry } from '@/types'
|
import type { MapEntry } from '@/types'
|
||||||
import { Map, Upload, Trash2, RefreshCw, Loader2 } from 'lucide-vue-next'
|
|
||||||
import { safeFileSize } from '@/utils/formatters'
|
import { safeFileSize } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -20,8 +24,8 @@ function formatSize(bytes: number): string {
|
|||||||
return safeFileSize(bytes)
|
return safeFileSize(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
function typeBadgeClass(type: string): string {
|
function mapTypeTone(type: string): 'accent' | 'info' {
|
||||||
return type === 'custom' ? 'bg-oxide-500/15 text-oxide-400' : 'bg-blue-500/15 text-blue-400'
|
return type === 'custom' ? 'accent' : 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMaps() {
|
async function fetchMaps() {
|
||||||
@@ -92,84 +96,211 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="maps">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="page__head">
|
||||||
<div class="flex items-center gap-3">
|
<div>
|
||||||
<Map class="w-5 h-5 text-oxide-500" />
|
<div class="t-eyebrow">Operations</div>
|
||||||
<div>
|
<h1 class="page__title">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Map Library</h1>
|
Map library
|
||||||
<p class="text-sm text-neutral-500 mt-0.5">{{ maps.length }} maps</p>
|
<span v-if="maps.length > 0" class="page__count">{{ maps.length }} maps</span>
|
||||||
</div>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="page__actions">
|
||||||
<button
|
<IconButton
|
||||||
@click="fetchMaps"
|
icon="refresh-cw"
|
||||||
|
label="Refresh"
|
||||||
|
:class="isLoading && 'spin'"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
class="flex items-center gap-2 px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
@click="fetchMaps"
|
||||||
>
|
/>
|
||||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
|
||||||
</button>
|
|
||||||
<input
|
<input
|
||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".map"
|
accept=".map"
|
||||||
class="hidden"
|
class="hidden-input"
|
||||||
@change="handleFileSelected"
|
@change="handleFileSelected"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
|
icon="upload"
|
||||||
|
:loading="isUploading"
|
||||||
@click="triggerFileInput"
|
@click="triggerFileInput"
|
||||||
:disabled="isUploading"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
|
{{ isUploading ? 'Uploading…' : 'Upload map' }}
|
||||||
<Upload v-else class="w-4 h-4" />
|
</Button>
|
||||||
{{ isUploading ? 'Uploading...' : 'Upload Map' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Maps grid -->
|
<!-- Empty state -->
|
||||||
<div v-if="maps.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
<Panel v-if="maps.length === 0">
|
||||||
<Map class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
<EmptyState
|
||||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">No Maps</h3>
|
icon="map"
|
||||||
<p class="text-sm text-neutral-500">Upload custom maps or they'll appear here when procedural maps are generated.</p>
|
title="No maps"
|
||||||
</div>
|
description="Upload custom maps or they will appear here when procedural maps are generated."
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<Button icon="upload" size="sm" @click="triggerFileInput">Upload map</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
<!-- Maps grid -->
|
||||||
|
<div v-else class="maps-grid">
|
||||||
<div
|
<div
|
||||||
v-for="map in maps"
|
v-for="map in maps"
|
||||||
:key="map.id"
|
:key="map.id"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden hover:border-neutral-700 transition-colors"
|
class="map-card"
|
||||||
>
|
>
|
||||||
<!-- Thumbnail or placeholder -->
|
<!-- Thumbnail -->
|
||||||
<div class="h-32 bg-neutral-800 flex items-center justify-center">
|
<div class="map-card__thumb">
|
||||||
<img v-if="map.thumbnail_path" :src="map.thumbnail_path" :alt="map.display_name" class="w-full h-full object-cover" />
|
<img
|
||||||
<Map v-else class="w-8 h-8 text-neutral-600" />
|
v-if="map.thumbnail_path"
|
||||||
|
:src="map.thumbnail_path"
|
||||||
|
:alt="map.display_name"
|
||||||
|
class="map-card__img"
|
||||||
|
/>
|
||||||
|
<svg v-else class="map-card__placeholder" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" /><line x1="9" y1="3" x2="9" y2="18" /><line x1="15" y1="6" x2="15" y2="21" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
<!-- Card body -->
|
||||||
<h3 class="text-sm font-medium text-neutral-100 truncate">{{ map.display_name }}</h3>
|
<div class="map-card__body">
|
||||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full shrink-0 ml-2" :class="typeBadgeClass(map.map_type)">
|
<div class="map-card__row">
|
||||||
{{ map.map_type }}
|
<span class="map-card__name">{{ map.display_name }}</span>
|
||||||
</span>
|
<Badge :tone="mapTypeTone(map.map_type)" size="md">{{ map.map_type }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between text-xs text-neutral-500">
|
<div class="map-card__meta">
|
||||||
<span>{{ formatSize(map.file_size_bytes) }}</span>
|
<span>{{ formatSize(map.file_size_bytes) }}</span>
|
||||||
<span v-if="map.world_size">{{ map.world_size }}m</span>
|
<span v-if="map.world_size">{{ map.world_size }}m</span>
|
||||||
<span v-if="map.seed">Seed: {{ map.seed }}</span>
|
<span v-if="map.seed" class="mono">Seed: {{ map.seed }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end mt-3">
|
<div class="map-card__foot">
|
||||||
<button
|
<IconButton
|
||||||
|
icon="trash-2"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
label="Delete map"
|
||||||
@click="deleteMap(map)"
|
@click="deleteMap(map)"
|
||||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
/>
|
||||||
title="Delete map"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.maps {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.page__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.page__count {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.page__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.hidden-input { display: none; }
|
||||||
|
|
||||||
|
/* Spin utility */
|
||||||
|
.spin { animation: spin 0.7s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Maps grid */
|
||||||
|
.maps-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map card */
|
||||||
|
.map-card {
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
.map-card:hover {
|
||||||
|
box-shadow: var(--ring-default), 0 0 0 1px var(--border-default);
|
||||||
|
}
|
||||||
|
.map-card__thumb {
|
||||||
|
height: 128px;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.map-card__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.map-card__placeholder { color: var(--text-muted); }
|
||||||
|
.map-card__body { padding: 12px 14px; }
|
||||||
|
.map-card__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.map-card__name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.map-card__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
.map-card__foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.maps-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { Download, Upload, FileText, Loader2 } from 'lucide-vue-next'
|
|
||||||
import { safeFileSize, safeDate } from '@/utils/formatters'
|
import { safeFileSize, safeDate } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
interface ExportRecord {
|
interface ExportRecord {
|
||||||
id: string
|
id: string
|
||||||
@@ -20,6 +25,8 @@ const isExporting = ref(false)
|
|||||||
const isImporting = ref(false)
|
const isImporting = ref(false)
|
||||||
const exportType = ref<'full' | 'config_only' | 'store_only'>('full')
|
const exportType = ref<'full' | 'config_only' | 'store_only'>('full')
|
||||||
const uploadFile = ref<File | null>(null)
|
const uploadFile = ref<File | null>(null)
|
||||||
|
const importError = ref<string | null>(null)
|
||||||
|
const importSuccess = ref(false)
|
||||||
|
|
||||||
async function fetchExports() {
|
async function fetchExports() {
|
||||||
exports.value = await api.get<ExportRecord[]>('/migration/exports')
|
exports.value = await api.get<ExportRecord[]>('/migration/exports')
|
||||||
@@ -49,6 +56,8 @@ async function importData() {
|
|||||||
if (!confirm('Import data? This will overwrite existing configuration.')) return
|
if (!confirm('Import data? This will overwrite existing configuration.')) return
|
||||||
|
|
||||||
isImporting.value = true
|
isImporting.value = true
|
||||||
|
importError.value = null
|
||||||
|
importSuccess.value = false
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', uploadFile.value)
|
formData.append('file', uploadFile.value)
|
||||||
@@ -63,18 +72,20 @@ async function importData() {
|
|||||||
throw new Error('Import failed')
|
throw new Error('Import failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
alert('Import successful')
|
importSuccess.value = true
|
||||||
uploadFile.value = null
|
uploadFile.value = null
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : 'Import failed')
|
importError.value = err instanceof Error ? err.message : 'Import failed'
|
||||||
} finally {
|
} finally {
|
||||||
isImporting.value = false
|
isImporting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
const EXPORT_TYPE_OPTIONS = [
|
||||||
return safeFileSize(bytes)
|
{ value: 'full' as const, label: 'Full' },
|
||||||
}
|
{ value: 'config_only' as const, label: 'Config only' },
|
||||||
|
{ value: 'store_only' as const, label: 'Store only' },
|
||||||
|
]
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchExports()
|
fetchExports()
|
||||||
@@ -82,110 +93,251 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="mv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="mv__head">
|
||||||
<FileText class="w-5 h-5 text-oxide-500" />
|
<div class="mv__head-id">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Migration</h1>
|
<div class="mv__head-chip">
|
||||||
|
<Icon name="upload" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="t-eyebrow">Platform</div>
|
||||||
|
<h1 class="mv__title">Migration</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Export Section -->
|
<!-- Export section -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Export data" subtitle="Create a portable backup of your configuration">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Export Data</h2>
|
<div class="mv__export-row">
|
||||||
<div class="flex items-end gap-4">
|
<div class="mv__export-field">
|
||||||
<div>
|
<div class="mv__field-label">Export type</div>
|
||||||
<label class="block text-xs text-neutral-500 mb-2">Export Type</label>
|
<div class="mv__seg">
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
|
||||||
<button
|
<button
|
||||||
v-for="opt in (['full', 'config_only', 'store_only'] as const)"
|
v-for="opt in EXPORT_TYPE_OPTIONS"
|
||||||
:key="opt"
|
:key="opt.value"
|
||||||
@click="exportType = opt"
|
type="button"
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors capitalize"
|
class="mv__seg-btn"
|
||||||
:class="exportType === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
:class="exportType === opt.value && 'mv__seg-btn--active'"
|
||||||
>
|
@click="exportType = opt.value"
|
||||||
{{ opt.replace('_', ' ') }}
|
>{{ opt.label }}</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
@click="createExport"
|
icon="download"
|
||||||
|
:loading="isExporting"
|
||||||
:disabled="isExporting"
|
:disabled="isExporting"
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
@click="createExport"
|
||||||
>
|
>Export</Button>
|
||||||
<Loader2 v-if="isExporting" class="w-4 h-4 animate-spin" />
|
|
||||||
<Download v-else class="w-4 h-4" />
|
|
||||||
Export
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Export History -->
|
<!-- Export history -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel :flush-body="true" title="Export history">
|
||||||
<div class="p-5 border-b border-neutral-800">
|
<EmptyState
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Export History</h2>
|
v-if="exports.length === 0"
|
||||||
</div>
|
icon="file-text"
|
||||||
<div v-if="exports.length === 0" class="p-8 text-center text-neutral-500">
|
title="No exports yet"
|
||||||
No exports yet.
|
description="Create an export above to see it listed here."
|
||||||
</div>
|
/>
|
||||||
<table v-else class="w-full">
|
<table v-else class="cc-table">
|
||||||
<thead class="bg-neutral-800/50 border-b border-neutral-800">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Type</th>
|
<th>Type</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Created</th>
|
<th>Created</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Size</th>
|
<th>Size</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-for="exp in exports" :key="exp.id" class="hover:bg-neutral-800/30">
|
<tr v-for="exp in exports" :key="exp.id">
|
||||||
<td class="px-4 py-3 text-sm text-neutral-200 capitalize">{{ exp.export_type.replace('_', ' ') }}</td>
|
<td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(exp.created_at) }}</td>
|
<Badge tone="neutral">{{ exp.export_type.replace('_', ' ') }}</Badge>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatBytes(exp.file_size_bytes) }}</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="td-mono">{{ safeDate(exp.created_at) }}</td>
|
||||||
|
<td class="td-mono">{{ safeFileSize(exp.file_size_bytes) }}</td>
|
||||||
|
<td>
|
||||||
<a
|
<a
|
||||||
v-if="exp.download_url"
|
v-if="exp.download_url"
|
||||||
:href="exp.download_url"
|
:href="exp.download_url"
|
||||||
class="text-oxide-400 hover:text-oxide-300 text-sm transition-colors"
|
class="mv__dl-link"
|
||||||
>
|
>
|
||||||
|
<Icon name="download" :size="13" />
|
||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
<span v-else class="text-sm text-neutral-600">Preparing...</span>
|
<span v-else class="mv__preparing">Preparing…</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Import Section -->
|
<!-- Import section -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Import data" subtitle="Restore from a JSON or ZIP export file">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Import Data</h2>
|
<div class="mv__import-body">
|
||||||
<div class="space-y-4">
|
<Alert v-if="importSuccess" tone="online">Import completed successfully.</Alert>
|
||||||
<div class="border-2 border-dashed border-neutral-700 rounded-lg p-6 text-center">
|
<Alert v-if="importError" tone="danger">{{ importError }}</Alert>
|
||||||
<Upload class="w-8 h-8 text-neutral-500 mx-auto mb-2" />
|
|
||||||
|
<label for="file-upload" class="mv__dropzone">
|
||||||
|
<Icon name="upload" :size="28" class="mv__dropzone-icon" />
|
||||||
|
<span class="mv__dropzone-label">
|
||||||
|
{{ uploadFile ? uploadFile.name : 'Click to select file' }}
|
||||||
|
</span>
|
||||||
|
<span class="mv__dropzone-hint">JSON or ZIP exports</span>
|
||||||
<input
|
<input
|
||||||
|
id="file-upload"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".json,.zip"
|
accept=".json,.zip"
|
||||||
|
class="mv__file-input"
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
class="hidden"
|
|
||||||
id="file-upload"
|
|
||||||
/>
|
/>
|
||||||
<label for="file-upload" class="cursor-pointer">
|
</label>
|
||||||
<span class="text-sm text-oxide-400 hover:text-oxide-300 transition-colors">
|
|
||||||
{{ uploadFile ? uploadFile.name : 'Click to select file' }}
|
<Button
|
||||||
</span>
|
:block="true"
|
||||||
</label>
|
icon="upload"
|
||||||
<p class="text-xs text-neutral-500 mt-1">JSON or ZIP exports</p>
|
:loading="isImporting"
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="importData"
|
|
||||||
:disabled="!uploadFile || isImporting"
|
:disabled="!uploadFile || isImporting"
|
||||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
@click="importData"
|
||||||
>
|
>Import</Button>
|
||||||
<Loader2 v-if="isImporting" class="w-4 h-4 animate-spin" />
|
|
||||||
<Upload v-else class="w-4 h-4" />
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mv {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.mv__head { display: flex; align-items: center; }
|
||||||
|
.mv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.mv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.mv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Export row */
|
||||||
|
.mv__export-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mv__export-field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.mv__field-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segment control (mirrors WipesView pattern) */
|
||||||
|
.mv__seg {
|
||||||
|
display: flex;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mv__seg-btn {
|
||||||
|
height: var(--control-h-md);
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.mv__seg-btn:hover { color: var(--text-primary); background: var(--surface-hover); }
|
||||||
|
.mv__seg-btn--active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.cc-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.cc-table thead tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.cc-table th {
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cc-table tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||||
|
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||||
|
.cc-table td {
|
||||||
|
padding: 11px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Download link */
|
||||||
|
.mv__dl-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent-text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.mv__dl-link:hover { color: var(--accent); }
|
||||||
|
.mv__preparing { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Import body */
|
||||||
|
.mv__import-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropzone */
|
||||||
|
.mv__dropzone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
border: 1.5px dashed var(--border-default);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.mv__dropzone:hover { border-color: var(--accent-border); background: var(--accent-soft); }
|
||||||
|
.mv__dropzone-icon { color: var(--text-tertiary); }
|
||||||
|
.mv__dropzone:hover .mv__dropzone-icon { color: var(--accent-text); }
|
||||||
|
.mv__dropzone-label { font-size: var(--text-sm); font-weight: 500; color: var(--accent-text); }
|
||||||
|
.mv__dropzone-hint { font-size: var(--text-xs); color: var(--text-muted); }
|
||||||
|
.mv__file-input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user