Compare commits
85 Commits
feature/mo
...
bdce23f9b6
| Author | SHA1 | Date | |
|---|---|---|---|
| bdce23f9b6 | |||
| a0a70ce2ca | |||
| 526160721a | |||
| 352ae4f256 | |||
| a16092f13a | |||
| 7a08546da6 | |||
| 1bb9e3c3c3 | |||
| b8f67fb208 | |||
| e0fadbdcc2 | |||
| b3033909d3 | |||
| 6c967d9a6c | |||
| ee085496ab | |||
| 3ef1c8e438 | |||
| 1629796235 | |||
| a165c90c62 | |||
| 2976d5982f | |||
| 91c206bf76 | |||
| 55a3adea99 | |||
| 2957708bab | |||
| e9aa19a83d | |||
| 080478c4a1 | |||
| 3b333b1b49 | |||
| 0facdb2b2a | |||
| 49ad6891cf | |||
| 97d95052db | |||
| e744723db2 | |||
| fc2554e5c3 | |||
| 70122f3626 | |||
| 64da0067f1 | |||
| 5b6b63e1bc | |||
| 01b3bb52bf | |||
| c31553aeb6 | |||
| 2dc360ca48 | |||
| 34c511c8d0 | |||
| 4fe90ea368 | |||
| ba4d758eab | |||
| 696d82f114 | |||
| f4e7fc7e20 | |||
| 6d4cd91bcc | |||
| 3628268dda | |||
| 25ff5aa836 | |||
| 042a151358 | |||
| 4f24959d49 | |||
| 99649727f3 | |||
| ac858875c0 | |||
| 986a8d5d86 | |||
| 350433635b | |||
| a2590812e0 | |||
| 5ccb18e794 | |||
| bf9edde5b7 | |||
| 6c310629c7 | |||
| d72c2dadfc | |||
| c4245e3f6a | |||
| 49d0c1bd11 | |||
| 74d2ead958 | |||
| 49ce230c3a | |||
| fe6f93481b | |||
| e7bc316863 | |||
| 1c9a9d26e1 | |||
| 064f02c4b6 | |||
| 523113f013 | |||
| 9d9f5aac28 | |||
| ab647756f0 | |||
| d49008e9f2 | |||
| e7f5f24809 | |||
| 1dd7603f54 | |||
| 4d87c5f627 | |||
| 764fb0c069 | |||
| 6d31869ba2 | |||
| fcef08e9b6 | |||
| 6180e8a071 | |||
| d7fc2dccb7 | |||
| 455e850b91 | |||
| f8652c2399 | |||
| 5b3ab7f282 | |||
| 17d42cebfe | |||
| 5da27879e5 | |||
| cda0c16149 | |||
| 82807d18d9 | |||
| d72deff2cc | |||
| 387db52510 | |||
| 5daf260db9 | |||
| f8bcc7f6a3 | |||
| cdd916e199 | |||
| 4ab46410be |
65
.env.example
65
.env.example
@@ -1,5 +1,14 @@
|
|||||||
# ─── UOMysticmoon — root environment (used by docker-compose) ───
|
# ─── Runic Gateway — root environment (used by docker-compose) ───
|
||||||
# Copy to .env and fill in. NEVER commit the real .env.
|
# Copy to .env and fill in. NEVER commit the real .env.
|
||||||
|
# To run this as an existing branded instance (e.g. UOMysticmoon), see
|
||||||
|
# .env.uomysticmoon.example for the exact BRAND_*/DB pinning to copy in.
|
||||||
|
|
||||||
|
# Container image tag pulled by docker-compose (app + bot). Published by the
|
||||||
|
# Gitea Actions workflow on every merge to main as `latest` and `sha-<7>`.
|
||||||
|
# Leave as `latest` for routine deploys; pin to a specific build for a
|
||||||
|
# reproducible deploy or rollback, e.g. IMAGE_TAG=sha-042a151.
|
||||||
|
# Deploy: `docker compose pull && docker compose up -d`.
|
||||||
|
IMAGE_TAG=latest
|
||||||
|
|
||||||
# App
|
# App
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
@@ -16,12 +25,32 @@ LOG_TO_FILE=true # set false for console-only
|
|||||||
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
|
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
|
||||||
LOG_FILE=app.log
|
LOG_FILE=app.log
|
||||||
|
|
||||||
|
# ─── Branding (BRAND_*) ───────────────────────────────────────────────────
|
||||||
|
# Instance identity. Defaults render as "Runic Gateway"; set these to rebrand
|
||||||
|
# without a rebuild. Text + colors reach the SPA through the settings API at
|
||||||
|
# runtime; the server templates index.html <title>/meta/OG/favicon at boot. The
|
||||||
|
# admin-editable "site title" and "contact email" settings, if set, override
|
||||||
|
# BRAND_NAME / BRAND_CONTACT_EMAIL.
|
||||||
|
BRAND_NAME=Runic Gateway
|
||||||
|
BRAND_SHORT_NAME=Runic Gateway
|
||||||
|
BRAND_TAGLINE=an independent private Ultima Online shard
|
||||||
|
BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
|
||||||
|
BRAND_CONTACT_EMAIL=
|
||||||
|
BRAND_URL=
|
||||||
|
# Accent color — drives the web theme's --accent and the Discord embed color.
|
||||||
|
BRAND_ACCENT_COLOR=#7f99bd
|
||||||
|
# Image assets: paths under the /brand mount (see docker-compose.yml) or absolute
|
||||||
|
# URLs. Blank = built-in defaults (hero falls back to a neutral built-in image).
|
||||||
|
BRAND_LOGO=
|
||||||
|
BRAND_HERO=
|
||||||
|
BRAND_FAVICON=
|
||||||
|
|
||||||
# Database (the values here are shared by the `db`, `app`, and `bot` containers —
|
# Database (the values here are shared by the `db`, `app`, and `bot` containers —
|
||||||
# the bot only ever touches its own tables: guild_config, mod_actions, warnings)
|
# the bot only ever touches its own tables: guild_config, mod_actions, warnings)
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_NAME=uomysticmoon
|
DB_NAME=runic_gateway
|
||||||
DB_USER=uomm
|
DB_USER=runic
|
||||||
DB_PASSWORD=change-me-db-password
|
DB_PASSWORD=change-me-db-password
|
||||||
DB_ROOT_PASSWORD=change-me-root-password
|
DB_ROOT_PASSWORD=change-me-root-password
|
||||||
|
|
||||||
@@ -31,7 +60,8 @@ JWT_EXPIRES_IN=1d
|
|||||||
# auto = Secure cookie only when the request arrives over HTTPS (Pangolin).
|
# auto = Secure cookie only when the request arrives over HTTPS (Pangolin).
|
||||||
# Leave as auto so login works both via the LAN IP (HTTP) and the proxy (HTTPS).
|
# Leave as auto so login works both via the LAN IP (HTTP) and the proxy (HTTPS).
|
||||||
COOKIE_SECURE=auto
|
COOKIE_SECURE=auto
|
||||||
COOKIE_NAME=uomm_token
|
# Changing this on a live instance invalidates existing sessions (users re-login).
|
||||||
|
COOKIE_NAME=rg_token
|
||||||
|
|
||||||
# Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban).
|
# Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban).
|
||||||
# Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this
|
# Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this
|
||||||
@@ -44,8 +74,8 @@ TRUST_PROXY=1
|
|||||||
# request (to verify/refresh ptero's IP without redeploying). Noisy; keep off.
|
# request (to verify/refresh ptero's IP without redeploying). Noisy; keep off.
|
||||||
DEBUG_TRUST_PROXY=0
|
DEBUG_TRUST_PROXY=0
|
||||||
|
|
||||||
# Optional TOTP two-factor (opt-in per user).
|
# Optional TOTP two-factor (opt-in per user). Defaults to BRAND_NAME when unset.
|
||||||
TOTP_ISSUER=UOMysticmoon
|
# TOTP_ISSUER=Runic Gateway
|
||||||
TOTP_CHALLENGE_TTL=5m
|
TOTP_CHALLENGE_TTL=5m
|
||||||
|
|
||||||
# First admin bootstrap — created only if no users exist yet.
|
# First admin bootstrap — created only if no users exist yet.
|
||||||
@@ -53,13 +83,10 @@ TOTP_CHALLENGE_TTL=5m
|
|||||||
ADMIN_USERNAME=
|
ADMIN_USERNAME=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Email (optional). If SMTP_HOST is blank, the contact endpoint tells the
|
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not via
|
||||||
# client to fall back to a mailto: link instead.
|
# env. It reuses the Google auth provider's OAuth client and stores an encrypted
|
||||||
SMTP_HOST=
|
# refresh token in the DB. Until it's connected, the contact form falls back to
|
||||||
SMTP_PORT=587
|
# a mailto: link (recipient = the `contact_email` site setting).
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASS=
|
|
||||||
CONTACT_TO=UOMysticmoon@gmail.com
|
|
||||||
|
|
||||||
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
# CORS — only needed for local dev when the Vite dev server is a different origin.
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
@@ -79,3 +106,15 @@ CLIENT_ORIGIN=http://localhost:5173
|
|||||||
# longer rides the public listener, but an explicit deny rule is belt-and-braces.
|
# longer rides the public listener, but an explicit deny rule is belt-and-braces.
|
||||||
BOT_INTERNAL_URL=http://bot:4100
|
BOT_INTERNAL_URL=http://bot:4100
|
||||||
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The
|
||||||
|
# website ingests its live event feed and proxies its read queries/commands
|
||||||
|
# (shard status, online players, player-vendor sales, IDOC houses, character
|
||||||
|
# sheets, account linking, town-crier). In production the sidecar + shard run on
|
||||||
|
# a DIFFERENT host from the website, so both URLs are configurable. The
|
||||||
|
# shared-secret auth token is NOT an env var — it is entered in the admin panel
|
||||||
|
# (Shard page) and stored encrypted in the DB (same pattern as the Discord bot
|
||||||
|
# token). These URLs are just defaults; the admin can override them at runtime.
|
||||||
|
UOLINK_BASE_URL=http://127.0.0.1:8080
|
||||||
|
UOLINK_WS_URL=ws://127.0.0.1:8080/ws
|
||||||
|
UOLINK_PROTOCOL=1
|
||||||
|
|||||||
30
.env.uomysticmoon.example
Normal file
30
.env.uomysticmoon.example
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# ─── UOMysticmoon instance — BRAND_* / identity overrides ───
|
||||||
|
#
|
||||||
|
# Runic Gateway's first "tenant". Copy these into the deploy .env (on top of
|
||||||
|
# .env.example) to run RunicGateway/website as UOMysticmoon. This is the proof
|
||||||
|
# that branding is data, not code: the same image renders as UOMysticmoon purely
|
||||||
|
# from these vars.
|
||||||
|
#
|
||||||
|
# Only the values that differ from the Runic Gateway defaults are shown.
|
||||||
|
|
||||||
|
# Identity
|
||||||
|
BRAND_NAME=UOMysticmoon
|
||||||
|
BRAND_SHORT_NAME=Mysticmoon
|
||||||
|
BRAND_TAGLINE=an independent private Ultima Online shard
|
||||||
|
BRAND_DESCRIPTION=UOMysticmoon — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
|
||||||
|
BRAND_CONTACT_EMAIL=UOMysticmoon@gmail.com
|
||||||
|
# BRAND_URL=https://<your public url>
|
||||||
|
|
||||||
|
# Visual — the existing UOM accent + hero image (baked into the image already).
|
||||||
|
BRAND_ACCENT_COLOR=#7f99bd
|
||||||
|
BRAND_HERO=/assets/img/uomysticmoon-main-hero.png
|
||||||
|
|
||||||
|
# TOTP label (defaults to BRAND_NAME, so optional — shown for clarity).
|
||||||
|
TOTP_ISSUER=UOMysticmoon
|
||||||
|
|
||||||
|
# ── Infrastructure identifiers — PIN to the existing production values so the
|
||||||
|
# ── app keeps talking to the same database and existing sessions stay valid.
|
||||||
|
# ── (These are NOT branding; they must match what production already uses.)
|
||||||
|
DB_NAME=uomysticmoon
|
||||||
|
DB_USER=uomm
|
||||||
|
COOKIE_NAME=uomm_token
|
||||||
120
.gitea/workflows/build-images.yml
Normal file
120
.gitea/workflows/build-images.yml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Build the app + bot container images, publish them to Gitea's container
|
||||||
|
# registry, then roll the production stack onto the fresh images — all on every
|
||||||
|
# merge to main. Production only ever pulls prebuilt images; it never builds.
|
||||||
|
#
|
||||||
|
# Two jobs run in sequence:
|
||||||
|
# build — builds & pushes website-app / website-bot images (on ubuntu-latest)
|
||||||
|
# deploy — `needs: build`, so it starts only after a clean build+push, and
|
||||||
|
# pulls + recreates the stack on the production host (on uom-deploy-runner)
|
||||||
|
#
|
||||||
|
# Prerequisites (one-time):
|
||||||
|
# • An always-on Gitea runner with label `ubuntu-latest` whose jobs have the
|
||||||
|
# host Docker socket mounted (/var/run/docker.sock), so `docker build` talks
|
||||||
|
# to the host daemon. This also gives free layer caching between runs.
|
||||||
|
# • A second self-hosted runner labelled `uom-deploy-runner` ON the production host,
|
||||||
|
# with access to the Docker daemon and to /home/perry/website (the directory
|
||||||
|
# holding the production docker-compose.yml + .env). This is what actually
|
||||||
|
# rolls the stack; it must be able to `docker compose pull` from the registry
|
||||||
|
# (log in once on the host, or ensure the images are public-read).
|
||||||
|
# • Two repo secrets (Settings → Actions → Secrets):
|
||||||
|
# REGISTRY_USER — the Gitea username that owns the token below
|
||||||
|
# REGISTRY_TOKEN — a Gitea access token with `write:package` (+ read:package)
|
||||||
|
# See the PR description / README for step-by-step token creation.
|
||||||
|
#
|
||||||
|
# Produces, in gitea.whitlocktech.com/<owner>/ :
|
||||||
|
# website-app:latest + website-app:sha-<7>
|
||||||
|
# website-bot:latest + website-bot:sha-<7>
|
||||||
|
# then deploys the `:latest` images (docker-compose.yml defaults IMAGE_TAG=latest).
|
||||||
|
|
||||||
|
name: Build container images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: images-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: gitea.whitlocktech.com
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out the merged commit
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Derive image refs (registry owner must be lowercase for Docker)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||||
|
echo "APP_IMAGE=${REGISTRY}/${OWNER}/website-app" >> "$GITHUB_ENV"
|
||||||
|
echo "BOT_IMAGE=${REGISTRY}/${OWNER}/website-bot" >> "$GITHUB_ENV"
|
||||||
|
echo "TAG=sha-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Verify the Docker daemon is reachable
|
||||||
|
# Fails fast with a clear message if the host socket isn't mounted into
|
||||||
|
# the job container (the one hard runner prerequisite).
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "::error::Docker daemon not reachable. Mount /var/run/docker.sock into the runner's job containers."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Docker daemon OK"
|
||||||
|
|
||||||
|
- name: Log in to the Gitea container registry
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||||
|
| docker login "${REGISTRY}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build & push the app image (server + client)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker build -f Dockerfile \
|
||||||
|
-t "${APP_IMAGE}:latest" \
|
||||||
|
-t "${APP_IMAGE}:${TAG}" \
|
||||||
|
.
|
||||||
|
docker push "${APP_IMAGE}:latest"
|
||||||
|
docker push "${APP_IMAGE}:${TAG}"
|
||||||
|
|
||||||
|
- name: Build & push the bot image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker build -f bot/Dockerfile \
|
||||||
|
-t "${BOT_IMAGE}:latest" \
|
||||||
|
-t "${BOT_IMAGE}:${TAG}" \
|
||||||
|
.
|
||||||
|
docker push "${BOT_IMAGE}:latest"
|
||||||
|
docker push "${BOT_IMAGE}:${TAG}"
|
||||||
|
|
||||||
|
- name: Log out (clear cached credentials from the runner)
|
||||||
|
if: always()
|
||||||
|
run: docker logout "${REGISTRY}" || true
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
# Roll production onto the images `build` just pushed. `needs: build` makes
|
||||||
|
# this wait for a clean build+push — if the build fails, deploy never fires,
|
||||||
|
# so the running stack is left untouched rather than torn down for nothing.
|
||||||
|
needs: build
|
||||||
|
runs-on: uom-deploy-runner
|
||||||
|
# Guard against a workflow_dispatch fired from a non-main branch: only ever
|
||||||
|
# deploy the main line to production.
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
steps:
|
||||||
|
- name: Pull the fresh images and recreate the stack
|
||||||
|
# `pull` grabs the new :latest images the build job published; `down`
|
||||||
|
# then `up -d` recreates the containers on them. Compose only recreates
|
||||||
|
# services whose image digest changed, so the DB stays put.
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd /home/perry/website
|
||||||
|
docker compose pull
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
70
.gitea/workflows/pr-checks.yml
Normal file
70
.gitea/workflows/pr-checks.yml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Gate every pull request into `main` on a fast, DB-free check suite so a broken
|
||||||
|
# build or failing test can't reach the deployable branch. Complements
|
||||||
|
# build-images.yml, which runs only AFTER merge (on push to main) to publish
|
||||||
|
# images — this one runs BEFORE merge.
|
||||||
|
#
|
||||||
|
# Enforcement (one-time, in the Gitea UI):
|
||||||
|
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||||
|
# • Enable Status Check
|
||||||
|
# • Status check patterns: PR Checks / *
|
||||||
|
# Note: Gitea only lists a context in its dropdown after it has reported once,
|
||||||
|
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
|
||||||
|
# without needing the dropdown.
|
||||||
|
#
|
||||||
|
# Runner: reuses the existing self-hosted `ubuntu-latest` runner. These jobs need
|
||||||
|
# only Node (no Docker socket), and the server tests stub their models + point the
|
||||||
|
# DB pool at a dead port, so no MariaDB service is required.
|
||||||
|
|
||||||
|
name: PR Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
# A newer push to the same PR cancels the in-flight run.
|
||||||
|
concurrency:
|
||||||
|
group: pr-checks-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
server-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: server/package-lock.json
|
||||||
|
- name: Install server deps
|
||||||
|
run: npm ci --prefix server
|
||||||
|
- name: Run server tests
|
||||||
|
run: npm test --prefix server
|
||||||
|
|
||||||
|
client-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: client/package-lock.json
|
||||||
|
- name: Install client deps
|
||||||
|
run: npm ci --prefix client
|
||||||
|
- name: Build client
|
||||||
|
run: npm run build --prefix client
|
||||||
|
|
||||||
|
bot-install:
|
||||||
|
# No tests/build to run; a clean install still catches a broken or
|
||||||
|
# out-of-sync lockfile before it ships in the bot image.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: bot/package-lock.json
|
||||||
|
- name: Install bot deps
|
||||||
|
run: npm ci --prefix bot
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,5 +31,8 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# local planning docs (not part of the tracked codebase)
|
||||||
|
.plans/
|
||||||
|
|
||||||
# scratch / temp scripts
|
# scratch / temp scripts
|
||||||
_*.ps1
|
_*.ps1
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
# UOMysticmoon Website — Backend Design
|
|
||||||
|
|
||||||
> Phase 1 of 3: **backend design** → Claude Design (frontend mockup) → coding.
|
|
||||||
> This document is the contract the later phases build against.
|
|
||||||
|
|
||||||
Public contact email: **UOMysticmoon@gmail.com**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Stack & top-level decisions
|
|
||||||
|
|
||||||
| Concern | Decision | Rationale |
|
|
||||||
|---|---|---|
|
|
||||||
| Runtime | Node.js + Express | serverlinkr pattern |
|
|
||||||
| Database | MariaDB (own container) | spec; `mariadb` pool, parameterized SQL, no ORM (keeps the lightweight `model`/`db` split from serverlinkr) |
|
|
||||||
| Auth | JWT in an **httpOnly cookie** | spec says "JWT auth" + "secure cookies when HTTPS"; httpOnly keeps the token out of JS (XSS-safe), `SameSite=Strict` covers CSRF for a same-origin admin panel |
|
|
||||||
| Frontend | React + Vite, same repo, served by Express in prod | spec |
|
|
||||||
| Hashing | bcrypt (`bcryptjs`) | spec; matches serverlinkr |
|
|
||||||
| Deploy | Docker Compose (app + db) behind Pangolin | spec |
|
|
||||||
|
|
||||||
**Adapting serverlinkr → this project**
|
|
||||||
- `*.mongo.js` (mongoose) → `*.db.js` (MariaDB queries), exactly as the spec names them.
|
|
||||||
- Drop the session/passport hybrid (`express-session`, `passport`, `passport-local`, `connect-mongo`). Pure stateless JWT instead — simpler and matches "JWT auth".
|
|
||||||
- Routes grouped by **access level** (auth / public / admin) per spec, instead of serverlinkr's per-entity routers. Models stay grouped by **entity**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Folder structure
|
|
||||||
|
|
||||||
Skeleton from the spec, with a small number of justified additions marked **(+)**.
|
|
||||||
|
|
||||||
```
|
|
||||||
server/
|
|
||||||
.env.example
|
|
||||||
package.json
|
|
||||||
db/
|
|
||||||
schema.sql (+) DDL, also auto-run by the MariaDB container
|
|
||||||
seed.js (+) seed wiki pages, default settings, first admin
|
|
||||||
src/
|
|
||||||
server.js bootstrap: ensure schema, then listen on 0.0.0.0
|
|
||||||
app.js express app + middleware wiring
|
|
||||||
router/
|
|
||||||
api.router.js mounts /v1
|
|
||||||
v1/
|
|
||||||
v1.router.js mounts /auth /public /admin
|
|
||||||
auth/ auth.routes.js + auth.controller.js
|
|
||||||
public/ public.routes.js + public.controller.js
|
|
||||||
admin/ admin.routes.js + admin.controller.js
|
|
||||||
model/
|
|
||||||
users/ users.model.js + users.db.js
|
|
||||||
posts/ posts.model.js + posts.db.js (news/five-on-friday/newsletter/screenshots)
|
|
||||||
wiki/ wiki.model.js + wiki.db.js
|
|
||||||
settings/ settings.model.js + settings.db.js
|
|
||||||
activity/ activity.model.js + activity.db.js (+) admin activity log
|
|
||||||
middleware/ (+)
|
|
||||||
siteMode.js LIVE/MAINTENANCE gate for public content
|
|
||||||
noindex.js X-Robots-Tag: noindex,nofollow on admin
|
|
||||||
rateLimit.js login limiter
|
|
||||||
validate.js express-validator error handler
|
|
||||||
utils/
|
|
||||||
auth.js JWT sign/verify, isLoggedIn middleware
|
|
||||||
db.js MariaDB pool + ensureSchema()
|
|
||||||
mailer.js (+) nodemailer; mailto fallback if SMTP unset
|
|
||||||
client/ built in Phase 2/3 (React + Vite)
|
|
||||||
Dockerfile
|
|
||||||
docker-compose.yml
|
|
||||||
.env.example
|
|
||||||
.gitignore
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why the additions:** the spec's feature list requires an activity log, a maintenance-mode
|
|
||||||
gate, login rate limiting, admin `noindex`, and SMTP email — none fit cleanly in the four
|
|
||||||
listed models/two utils. They're isolated in `middleware/` + one `activity` model +
|
|
||||||
`utils/mailer.js`, and the spec explicitly says the layout is "expandable."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Database schema (MariaDB)
|
|
||||||
|
|
||||||
`utf8mb4` throughout. Created idempotently on boot (`ensureSchema()`) **and** shipped as
|
|
||||||
`db/schema.sql` for the container's `/docker-entrypoint-initdb.d`.
|
|
||||||
|
|
||||||
### users
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | INT PK AUTO_INCREMENT | |
|
|
||||||
| username | VARCHAR(32) UNIQUE NOT NULL | |
|
|
||||||
| password_hash | VARCHAR(72) NOT NULL | bcrypt; **never** returned by the API |
|
|
||||||
| role | ENUM('admin','editor') NOT NULL DEFAULT 'admin' | room to grow |
|
|
||||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
|
||||||
| last_login_at | DATETIME NULL | shown in user management |
|
|
||||||
|
|
||||||
### posts — one table, four categories
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | INT PK AUTO_INCREMENT | |
|
|
||||||
| category | ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL | |
|
|
||||||
| title | VARCHAR(200) NOT NULL | |
|
|
||||||
| slug | VARCHAR(220) NULL | optional clean URL |
|
|
||||||
| excerpt | VARCHAR(400) NULL | list teaser |
|
|
||||||
| body | MEDIUMTEXT NULL | markdown/HTML; main text for news/5oF/newsletter |
|
|
||||||
| image_url | VARCHAR(500) NULL | required for `screenshot`, optional hero elsewhere |
|
|
||||||
| published | TINYINT(1) NOT NULL DEFAULT 0 | publish/unpublish toggle |
|
|
||||||
| author_id | INT NULL FK→users(id) | ON DELETE SET NULL |
|
|
||||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
|
||||||
| updated_at | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | |
|
|
||||||
| published_at | DATETIME NULL | set when first published; list order |
|
|
||||||
|
|
||||||
Index: `(category, published, published_at DESC)`.
|
|
||||||
|
|
||||||
### wiki_pages
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | INT PK AUTO_INCREMENT | |
|
|
||||||
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `new-player-guide` |
|
|
||||||
| title | VARCHAR(200) NOT NULL | |
|
|
||||||
| body | MEDIUMTEXT NULL | markdown/HTML |
|
|
||||||
| updated_by | INT NULL FK→users(id) | |
|
|
||||||
| created_at / updated_at | DATETIME | |
|
|
||||||
|
|
||||||
Seeded with the 8 spec categories: `new-player-guide, maps-atlas, systems, items, monsters, crafting, lore, rules`.
|
|
||||||
|
|
||||||
### settings — key/value, expandable
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `key` | VARCHAR(64) PK | |
|
|
||||||
| value | TEXT NULL | |
|
|
||||||
| updated_by | INT NULL FK→users(id) | |
|
|
||||||
| updated_at | DATETIME ON UPDATE CURRENT_TIMESTAMP | |
|
|
||||||
|
|
||||||
Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
|
|
||||||
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
|
|
||||||
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.
|
|
||||||
|
|
||||||
### activity_log — append-only
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | INT PK AUTO_INCREMENT | |
|
|
||||||
| user_id | INT NULL FK→users(id) | |
|
|
||||||
| action | VARCHAR(64) NOT NULL | e.g. `auth.login`, `site_mode.change`, `post.create` |
|
|
||||||
| detail | TEXT NULL | JSON string of what changed |
|
|
||||||
| ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) |
|
|
||||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. API contract
|
|
||||||
|
|
||||||
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
|
|
||||||
accepts `Authorization: Bearer` for API testing).
|
|
||||||
|
|
||||||
### /auth (auth.routes.js → auth.controller.js)
|
|
||||||
| Method | Path | Auth | Body | Purpose |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
|
|
||||||
| POST | `/logout` | cookie | — | clear cookie |
|
|
||||||
| GET | `/me` | cookie | — | current user (no hash) or 401 — client bootstraps auth state |
|
|
||||||
|
|
||||||
No public `register`. First admin is bootstrapped by `seed.js` from env (see §6). Further
|
|
||||||
admins are created under `/admin/users`.
|
|
||||||
|
|
||||||
### /public (public.routes.js → public.controller.js) — all GET, no auth
|
|
||||||
| Method | Path | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/settings` | whitelisted public keys only (mode, maintenance_message, status_message, homepage_teaser, contact_email, site_title) |
|
|
||||||
| GET | `/status` | status message + current mode |
|
|
||||||
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
|
|
||||||
| GET | `/posts/:category/:idOrSlug` | single published post |
|
|
||||||
| GET | `/wiki` | list of pages (slug + title) |
|
|
||||||
| GET | `/wiki/:slug` | single page |
|
|
||||||
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |
|
|
||||||
|
|
||||||
Public content GETs pass through the **siteMode** gate (§5).
|
|
||||||
|
|
||||||
### /admin (admin.routes.js → admin.controller.js) — all behind `isLoggedIn` + `noindex`
|
|
||||||
| Method | Path | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/dashboard` | current mode, last change time + who, content counts, recent activity |
|
|
||||||
| PUT | `/site-mode` | `{mode}` → update settings, stamp who/when, log `site_mode.change` |
|
|
||||||
| GET | `/posts?category=` | all posts incl. unpublished |
|
|
||||||
| POST | `/posts` | create |
|
|
||||||
| GET | `/posts/:id` | one |
|
|
||||||
| PUT | `/posts/:id` | edit |
|
|
||||||
| DELETE | `/posts/:id` | delete |
|
|
||||||
| PATCH | `/posts/:id/publish` | `{published}` toggle (sets `published_at`) |
|
|
||||||
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
|
|
||||||
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
|
|
||||||
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
|
|
||||||
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
|
|
||||||
| GET | `/activity?limit=&offset=` | paginated activity log |
|
|
||||||
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
|
|
||||||
|
|
||||||
Every admin write logs to `activity_log`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Site mode (LIVE / MAINTENANCE)
|
|
||||||
|
|
||||||
State in `settings.site_mode` (`live`|`maintenance`), default **maintenance**.
|
|
||||||
|
|
||||||
`middleware/siteMode.js`, applied only to **public content** routes:
|
|
||||||
- `live` → pass through.
|
|
||||||
- `maintenance` → respond **503** with `{mode:"maintenance", message}` **unless** the request
|
|
||||||
carries a valid admin cookie (admin preview). This hides content server-side, not just in
|
|
||||||
the UI.
|
|
||||||
|
|
||||||
Always reachable regardless of mode: static assets / SPA shell, `/api/v1/auth/*`, all
|
|
||||||
`/api/v1/admin/*`. So admin login + panel + the maintenance "coming soon" page always load.
|
|
||||||
|
|
||||||
**Client behavior (Phase 3):** reads `GET /public/settings`; if `maintenance` and not an
|
|
||||||
admin previewing, render the polished dark coming-soon page (message + contact email).
|
|
||||||
Admin "preview live" simply hits the content APIs with the admin cookie, which bypass the gate.
|
|
||||||
|
|
||||||
Dashboard reads `site_mode` + `site_mode_changed_at`/`_by` for "current mode + last change +
|
|
||||||
who"; `activity_log` provides the history feed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Auth & security
|
|
||||||
|
|
||||||
- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
|
|
||||||
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`). This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
|
|
||||||
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
|
|
||||||
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
|
|
||||||
- **Validation** (`express-validator`) on all writes; centralized error handler.
|
|
||||||
- **helmet** with a CSP suited to the SPA (self + inline styles as needed; image sources for uploads/hero).
|
|
||||||
- **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`.
|
|
||||||
- **No directory browsing** (express.static doesn't list; no `serve-index`).
|
|
||||||
- **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed.
|
|
||||||
- **`app.set('trust proxy', 1)`** so secure cookies, `req.ip`, and rate-limiting work behind Pangolin.
|
|
||||||
- **CORS**: same-origin in prod (SPA served by Express). Dev only: allow `CLIENT_ORIGIN` (Vite, `http://localhost:5173`) with `credentials:true`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Email
|
|
||||||
|
|
||||||
`utils/mailer.js` (nodemailer) configured from `SMTP_HOST/PORT/USER/PASS`, sending to
|
|
||||||
`CONTACT_TO` (default UOMysticmoon@gmail.com). No Gmail password in code — env only.
|
|
||||||
If SMTP is unconfigured, `POST /public/contact` returns `{fallback:"mailto", email}` so the
|
|
||||||
client renders a `mailto:` link instead. Site mode changes / errors never leak SMTP creds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7.5 Logging & observability
|
|
||||||
|
|
||||||
`utils/logger.js` — a small dependency-free logger with **two transports, console + file**,
|
|
||||||
and four levels (`error`/`warn`/`info`/`debug`). Each line is timestamped and tagged by
|
|
||||||
subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …).
|
|
||||||
|
|
||||||
- **Console**: color on a TTY, plain in Docker; verbosity = `LOG_LEVEL` (default `info`).
|
|
||||||
- **File**: plain text appended to `LOG_DIR/LOG_FILE` (default `<server>/logs/app.log`,
|
|
||||||
`/app/logs/app.log` in Docker, bind-mounted to `./logs`); verbosity = `FILE_LOG_LEVEL`
|
|
||||||
(default `debug`, so the file keeps a complete record while the console stays readable).
|
|
||||||
Toggle with `LOG_TO_FILE`. The stream is flushed on graceful shutdown.
|
|
||||||
- **HTTP access logs** via morgan piped into the logger: real client IP (`trust proxy`),
|
|
||||||
authenticated admin username, method, URL, status, response time, size.
|
|
||||||
- **Captured events**: startup config banner, schema/seed steps, login success/failure,
|
|
||||||
rate-limit hits, site-mode changes, maintenance-gate blocks (debug), all errors with
|
|
||||||
stack traces (5xx), and SIGINT/SIGTERM shutdown. Passwords and request bodies are never
|
|
||||||
logged. `unhandledRejection`/`uncaughtException` are caught and logged.
|
|
||||||
|
|
||||||
## 8. Deployment
|
|
||||||
|
|
||||||
**docker-compose.yml** — two services on a private network:
|
|
||||||
- `db`: `mariadb:11`, env `MARIADB_DATABASE/USER/PASSWORD/ROOT_PASSWORD`, volume
|
|
||||||
`dbdata:/var/lib/mysql`, mounts `schema.sql` into `/docker-entrypoint-initdb.d`, healthcheck.
|
|
||||||
- `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express),
|
|
||||||
`env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
|
|
||||||
`ports: "3000:3000"` — **binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
|
|
||||||
- Volumes: `dbdata`, `uploads`.
|
|
||||||
|
|
||||||
Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.
|
|
||||||
|
|
||||||
**.env.example** (committed; real `.env` ignored):
|
|
||||||
```
|
|
||||||
NODE_ENV=production
|
|
||||||
PORT=3000
|
|
||||||
DB_HOST=db
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_NAME=uomysticmoon
|
|
||||||
DB_USER=uomm
|
|
||||||
DB_PASSWORD=
|
|
||||||
DB_ROOT_PASSWORD=
|
|
||||||
JWT_SECRET=
|
|
||||||
JWT_EXPIRES_IN=1d
|
|
||||||
COOKIE_SECURE=true
|
|
||||||
COOKIE_NAME=uomm_token
|
|
||||||
ADMIN_USERNAME=
|
|
||||||
ADMIN_PASSWORD=
|
|
||||||
SMTP_HOST=
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASS=
|
|
||||||
CONTACT_TO=UOMysticmoon@gmail.com
|
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Dependencies (server)
|
|
||||||
|
|
||||||
`express, cors, helmet, morgan, dotenv, mariadb, jsonwebtoken, bcryptjs, cookie-parser,
|
|
||||||
express-rate-limit, express-validator, multer, nodemailer` · dev: `nodemon`.
|
|
||||||
Removed vs serverlinkr: `mongoose, mongodb, connect-mongo, express-session, passport,
|
|
||||||
passport-local`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Spec coverage
|
|
||||||
|
|
||||||
| Spec requirement | Covered by |
|
|
||||||
|---|---|
|
|
||||||
| Public pages (`/`, `/site/*`, `/wiki/*`) | `/public/*` API + Phase-3 SPA routes; content from `posts`/`wiki`/`settings` |
|
|
||||||
| News / 5-on-Friday / Newsletter / Screenshots | `posts` table, `category` column; admin CRUD + publish |
|
|
||||||
| Wiki 8 categories, editable later | `wiki_pages` seeded with 8 slugs; admin CRUD |
|
|
||||||
| Status page | `settings.status_message` + mode via `/public/status` |
|
|
||||||
| Admin dashboard (mode, last change, who) | `/admin/dashboard` + settings stamps + activity log |
|
|
||||||
| Site mode toggle | `PUT /admin/site-mode` + `siteMode` middleware |
|
|
||||||
| Admin activity log | `activity_log` + `/admin/activity` |
|
|
||||||
| Admin user management | `/admin/users` CRUD |
|
|
||||||
| Site settings editing | `/admin/settings` |
|
|
||||||
| JWT, bcrypt, rate limit, secure cookies, noindex, no dir browsing, no hardcoded creds, .env | §6 |
|
|
||||||
| Maintenance page, admin always in, static always loads, admin preview | §5 |
|
|
||||||
| SMTP via env, mailto fallback | §7 |
|
|
||||||
| Docker Compose + MariaDB + Pangolin, 0.0.0.0 bind | §8 |
|
|
||||||
| Design tokens / hero | reused from existing `assets/css/mysticmoon.css` + hero PNG in Phase 2/3 |
|
|
||||||
| Expandable | key/value settings, role enum, modular routers/models |
|
|
||||||
```
|
|
||||||
134
HERO_EDITOR.md
134
HERO_EDITOR.md
@@ -1,134 +0,0 @@
|
|||||||
# UOMysticmoon — Hero Canvas Editor Spec
|
|
||||||
|
|
||||||
> Branch: **`hero-feature`**. Build contract for the WYSIWYG portal-hero editor.
|
|
||||||
> Derived from the design doc *Hero Canvas Editor — Design Document*, **corrected
|
|
||||||
> to match the current codebase** and with the open questions resolved.
|
|
||||||
> Same workflow as the wiki upgrade: design → phased build → verify.
|
|
||||||
|
|
||||||
## 1. Goal
|
|
||||||
|
|
||||||
Let staff compose the portal hero (background image, overlay opacity, and floating
|
|
||||||
elements — text, CTA buttons, moon, badge, image) in-browser, then preview and
|
|
||||||
publish — no source edits. Layout persists as JSON in the existing `settings` table.
|
|
||||||
|
|
||||||
## 2. Locked decisions
|
|
||||||
|
|
||||||
| # | Decision |
|
|
||||||
|---|---|
|
|
||||||
| Scope | **Full v1** — background/overlay, all element types, drag/resize/z-order, draft→preview→publish (built in phases) |
|
|
||||||
| CTA buttons | **First-class `buttons` element type** (independently positioned), not baked into a text block |
|
|
||||||
| First run | **Pre-populate** the canvas with today's hero (headline, subtitle, teaser, CTAs) as editable elements so nothing changes visually until edited |
|
|
||||||
| Drag | **Native Pointer Events** (mouse/touch/pen), zero dependencies |
|
|
||||||
| Font size | Stored in **px** (fixed reference canvas) |
|
|
||||||
| Image compression | **None** server-side; client warns when a file is > ~1 MB |
|
|
||||||
| Preview | `?preview=1` renders the **draft** by reading it through the authenticated admin settings endpoint |
|
|
||||||
| Other pages | Out of scope for v1 (design allows a per-page key later) |
|
|
||||||
|
|
||||||
## 3. Corrections to the design doc (current-code reality)
|
|
||||||
|
|
||||||
1. **Public settings is a whitelist, not `getAll()`.** `GET /api/v1/public/settings`
|
|
||||||
→ `settings.getPublic()` → `PUBLIC_KEYS` in
|
|
||||||
[settings.model.js](server/src/model/settings/settings.model.js). The doc's
|
|
||||||
"no backend changes / picked up automatically" is wrong. **Fix:** add
|
|
||||||
`hero_layout` to `PUBLIC_KEYS` (one line). `hero_layout_draft` stays out
|
|
||||||
(admin-only) — which is why preview reads the draft via `api.admin.getSettings()`.
|
|
||||||
2. **Moon is a reusable component** ([MoonDot.jsx](client/src/components/MoonDot.jsx),
|
|
||||||
props `size`/`glow`), used in logo/login/maintenance — not "only the header."
|
|
||||||
The `moon` element reuses it; it gains an optional `color`.
|
|
||||||
3. **Route vs. nav live in different files.** `/admin/hero` route →
|
|
||||||
[App.jsx](client/src/App.jsx); sidebar link/title → `NAV`/`TITLES` in
|
|
||||||
[AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx).
|
|
||||||
4. **Admin content area is `maxWidth: 1000px`** — the editor canvas renders
|
|
||||||
scaled-to-fit; percentage positions stay faithful.
|
|
||||||
|
|
||||||
Everything else in the doc matches (hardcoded `HERO_BG` + CTAs + `homepage_teaser`
|
|
||||||
in [Portal.jsx](client/src/routes/public/Portal.jsx); `updateSettings` accepts
|
|
||||||
arbitrary keys; `/admin/uploads` exists; default hero asset present; TEXT settings
|
|
||||||
columns — no schema change).
|
|
||||||
|
|
||||||
## 4. Data model — no schema change
|
|
||||||
|
|
||||||
Two `settings` keys (TEXT): `hero_layout` (live) and `hero_layout_draft` (admin).
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"background": { "image_url": null, "position_x": "left", "position_y": "center", "size": "cover" },
|
|
||||||
"overlay": { "opacity": 0.72 },
|
|
||||||
"elements": [
|
|
||||||
{ "id": "uuid", "type": "text_block|buttons|moon|badge|image",
|
|
||||||
"x": 50, "y": 42, "z": 1, "anchor": "center", "props": { /* per type */ } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Positions are **% of canvas** (reference width 1080, matching `.shell`), so the
|
|
||||||
layout adapts across viewports without breakpoint data. `version` is validated
|
|
||||||
(`=== 1`) before use; anything else falls back.
|
|
||||||
|
|
||||||
### Element props
|
|
||||||
|
|
||||||
| Type | Props |
|
|
||||||
|---|---|
|
|
||||||
| `text_block` | `lines: [{ text, tag(h1/h2/p/span), fontSize(px), color, weight }]`, `align` |
|
|
||||||
| `buttons` | `items: [{ label, to, variant(primary/ghost) }]`, `align`, `gap` |
|
|
||||||
| `moon` | `size`, `glow`, `color` |
|
|
||||||
| `badge` | `text`, `bgColor`, `textColor`, `borderRadius` |
|
|
||||||
| `image` | `src`, `width`(%), `alt` |
|
|
||||||
|
|
||||||
## 5. Backend changes
|
|
||||||
- **One line:** add `'hero_layout'` to `PUBLIC_KEYS`. No new routes/controllers —
|
|
||||||
layout saves through the existing `PUT /admin/settings`; images via `/admin/uploads`.
|
|
||||||
|
|
||||||
## 6. Frontend changes
|
|
||||||
- **New** `client/src/components/HeroElement.jsx` — renders one element by type
|
|
||||||
(shared by the live portal and the editor canvas).
|
|
||||||
- **New** `client/src/routes/admin/views/HeroEditor.jsx` — canvas + element tray +
|
|
||||||
properties panel; native-pointer drag/resize; background/overlay panel; snap grid;
|
|
||||||
auto-save draft, preview, publish, revert.
|
|
||||||
- **Edit** [Portal.jsx](client/src/routes/public/Portal.jsx) — parse `hero_layout`
|
|
||||||
(or draft when `?preview=1` + admin), render elements, fall back to a
|
|
||||||
`DEFAULT_LAYOUT` built from today's hero so the page is unchanged until edited.
|
|
||||||
- **Edit** [AdminLayout.jsx](client/src/routes/admin/AdminLayout.jsx) (nav) +
|
|
||||||
[App.jsx](client/src/App.jsx) (route `/admin/hero`).
|
|
||||||
- **Edit** [MoonDot.jsx](client/src/components/MoonDot.jsx) — optional `color`.
|
|
||||||
- **No** `client/src/api/client.js` changes needed beyond what exists
|
|
||||||
(`admin.updateSettings`, `admin.getSettings`, `admin.upload`).
|
|
||||||
|
|
||||||
## 7. Phased build (each phase: build → verify in preview → commit)
|
|
||||||
|
|
||||||
- **Phase 0 — Spec** ✅ this document.
|
|
||||||
- **Phase 1 — Data path & renderer** ✅ (verified 2026-06-28). `hero_layout`
|
|
||||||
whitelisted; `HeroElement.jsx`; Portal renders the layout with a `DEFAULT_LAYOUT`
|
|
||||||
fallback. Default render matches the old hero; publishing a layout re-renders;
|
|
||||||
draft key not exposed publicly. Shared helpers moved to `client/src/lib/heroLayout.js`.
|
|
||||||
- **Phase 2 — Editor shell + background/overlay** ✅ (verified 2026-06-28).
|
|
||||||
`/admin/hero` view + sidebar nav; canvas live-preview; background upload + 3×3
|
|
||||||
position + overlay opacity; debounced draft auto-save; publish; `?preview=1`
|
|
||||||
reads the draft (admin) with a banner; revert. Verified: overlay/position update
|
|
||||||
the canvas, auto-save writes the draft, publish writes live, preview shows the
|
|
||||||
draft while the normal portal shows live.
|
|
||||||
- **Phase 3 — Elements: select / drag / text_block / buttons** ✅ (verified
|
|
||||||
2026-06-28). Element tray (+ Text / + Buttons); click-to-select with outline;
|
|
||||||
native Pointer Events drag (% of canvas); Delete key + panel delete; z-order
|
|
||||||
(send back / bring forward); text_block line editor (text/tag/size/color/bold,
|
|
||||||
add/remove lines, align) and buttons editor (label/path/variant, add/remove).
|
|
||||||
Verified: select shows the line editor, editing a line updates the canvas live,
|
|
||||||
drag moved 50%→65%, add→3/delete→2 elements, empty-canvas click deselects.
|
|
||||||
- **Phase 4 — moon + badge + image + resize + snap grid** ✅ (verified 2026-06-28).
|
|
||||||
Tray adds moon/badge/image; property panels (moon: size/glow/color; badge:
|
|
||||||
text/colors/radius; image: upload/width/alt); corner resize handle (image→width%,
|
|
||||||
moon→size, text→box width); 8px snap-grid toggle with overlay; image placeholder
|
|
||||||
until a file is chosen. Verified: each type adds + edits, resize moved a moon
|
|
||||||
64→104px, snap grid shows, and a published moon+badge render on the live portal.
|
|
||||||
|
|
||||||
**Status: v1 feature-complete.** All phases verified end-to-end; ready for PR.
|
|
||||||
Deferred (noted in the design doc as follow-ups): 8-point resize (only a corner
|
|
||||||
handle for now), per-viewport layouts, server-side image compression.
|
|
||||||
|
|
||||||
## 8. Edge cases (from the doc, carried forward)
|
|
||||||
- `JSON.parse` wrapped in try/catch + `version` check → fall back to `DEFAULT_LAYOUT`.
|
|
||||||
- Element ids via `crypto.randomUUID()` (never array index).
|
|
||||||
- Empty `elements` → render `DEFAULT_LAYOUT` so the hero is never blank.
|
|
||||||
- Last-write-wins on concurrent admin edits (acceptable for this shard).
|
|
||||||
- Client-side warning for background files > ~1 MB (no hard block; 8 MB server cap).
|
|
||||||
159
README.md
159
README.md
@@ -1,13 +1,17 @@
|
|||||||
# UOMysticmoon Website
|
# Runic Gateway Website
|
||||||
|
|
||||||
Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online
|
Public site, wiki, and protected admin panel for a private Ultima Online shard — a
|
||||||
shard — a full-stack app in one repo:
|
full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see
|
||||||
|
[Branding](#branding)); **UOMysticmoon** is the first instance.
|
||||||
|
|
||||||
|
A full-stack app in one repo:
|
||||||
|
|
||||||
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
|
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
|
||||||
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
|
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
|
||||||
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
|
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
|
||||||
|
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link).
|
||||||
|
|
||||||
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
|
The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) (API contract, schema, security), in the [**RunicGateway/docs**](https://gitea.whitlocktech.com/RunicGateway/docs) repo — where all project documentation now lives.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,6 +28,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
|||||||
- [Pages & routes](#pages--routes)
|
- [Pages & routes](#pages--routes)
|
||||||
- [API endpoints](#api-endpoints)
|
- [API endpoints](#api-endpoints)
|
||||||
- [API documentation (Swagger)](#api-documentation-swagger)
|
- [API documentation (Swagger)](#api-documentation-swagger)
|
||||||
|
- [Shard integration (uo-link)](#shard-integration-uo-link)
|
||||||
- [Environment variables](#environment-variables)
|
- [Environment variables](#environment-variables)
|
||||||
- [Security](#security)
|
- [Security](#security)
|
||||||
- [Logging](#logging)
|
- [Logging](#logging)
|
||||||
@@ -39,7 +44,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
|||||||
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
|
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
|
||||||
| Database | MariaDB 11 (own container) |
|
| Database | MariaDB 11 (own container) |
|
||||||
| Frontend | React 18, Vite 5, React Router 6 |
|
| Frontend | React 18, Vite 5, React Router 6 |
|
||||||
| Email | Nodemailer (SMTP) with a `mailto:` fallback |
|
| Email | Nodemailer via Gmail OAuth2 (configured in admin), with a `mailto:` fallback |
|
||||||
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
|
| API docs | OpenAPI 3.0 via `swagger-autogen`, served with `swagger-ui-express` at `/api/docs` |
|
||||||
| Deploy | Docker Compose, Pangolin reverse proxy |
|
| Deploy | Docker Compose, Pangolin reverse proxy |
|
||||||
|
|
||||||
@@ -48,7 +53,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
|||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
UOMSITE/
|
website/
|
||||||
├─ server/ Express API
|
├─ server/ Express API
|
||||||
│ ├─ src/
|
│ ├─ src/
|
||||||
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
|
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
|
||||||
@@ -90,8 +95,10 @@ UOMSITE/
|
|||||||
|
|
||||||
### Option A — Docker Compose (full stack)
|
### Option A — Docker Compose (full stack)
|
||||||
|
|
||||||
The simplest way to run everything. The image installs server deps, **builds the React client**,
|
`docker-compose.yml` is **production-shaped**: it *pulls* the prebuilt `app` and `bot` images from
|
||||||
and Express serves it; MariaDB runs in its own container; tables + defaults + the first admin are
|
the Gitea container registry (published by `.gitea/workflows/build-images.yml` on every merge to
|
||||||
|
`main`) — it never builds. Each image already bundles the server deps and the built React client,
|
||||||
|
which Express serves. MariaDB runs in its own container; tables + defaults + the first admin are
|
||||||
created automatically on first boot.
|
created automatically on first boot.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -101,7 +108,9 @@ cp .env.example .env
|
|||||||
# JWT_SECRET (a long random string)
|
# JWT_SECRET (a long random string)
|
||||||
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
|
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
|
||||||
|
|
||||||
docker compose up -d --build
|
docker compose pull && docker compose up -d # IMAGE_TAG defaults to `latest`
|
||||||
|
# pin a specific build (reproducible deploy / rollback):
|
||||||
|
IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
- App: **http://localhost:3000** (binds `0.0.0.0`)
|
- App: **http://localhost:3000** (binds `0.0.0.0`)
|
||||||
@@ -109,6 +118,16 @@ docker compose up -d --build
|
|||||||
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
|
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
|
||||||
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
|
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
|
||||||
|
|
||||||
|
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
|
||||||
|
the dev file, which adds `build:` back:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Keeping `build:` out of the base file means a production host can only ever pull — it can never
|
||||||
|
accidentally build.
|
||||||
|
|
||||||
### Option B — Local development (hot reload)
|
### Option B — Local development (hot reload)
|
||||||
|
|
||||||
Run the API and the Vite dev server separately. The Vite server proxies `/api` and `/uploads`
|
Run the API and the Vite dev server separately. The Vite server proxies `/api` and `/uploads`
|
||||||
@@ -117,14 +136,14 @@ to the backend, so the SPA stays same-origin (cookies work).
|
|||||||
**1. Start a MariaDB the backend can reach** (published on `localhost:3306`):
|
**1. Start a MariaDB the backend can reach** (published on `localhost:3306`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name uomm-db -p 3306:3306 -e MARIADB_DATABASE=uomysticmoon -e MARIADB_USER=uomm -e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
|
docker run -d --name rg-db -p 3306:3306 -e MARIADB_DATABASE=runic_gateway -e MARIADB_USER=runic -e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Configure + start the backend** (terminal 1):
|
**2. Configure + start the backend** (terminal 1):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp server/.env.example server/.env
|
cp server/.env.example server/.env
|
||||||
# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=uomm, DB_PASSWORD=devpass,
|
# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=runic, DB_PASSWORD=devpass,
|
||||||
# JWT_SECRET=<anything>, ADMIN_USERNAME=admin, ADMIN_PASSWORD=<your password>
|
# JWT_SECRET=<anything>, ADMIN_USERNAME=admin, ADMIN_PASSWORD=<your password>
|
||||||
npm run install-server
|
npm run install-server
|
||||||
npm run server # nodemon → http://localhost:3000
|
npm run server # nodemon → http://localhost:3000
|
||||||
@@ -207,10 +226,13 @@ npm start # node server → serves API + SPA at http://localhost:3
|
|||||||
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
|
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
|
||||||
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
|
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
|
||||||
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
|
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
|
||||||
|
| Public · Shard | `/api/v1/public/shard` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | none |
|
||||||
|
| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) |
|
||||||
|
| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) |
|
||||||
|
|
||||||
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||||
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
||||||
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger
|
See [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) §4 for the full contract, or the interactive Swagger
|
||||||
docs below for a per-endpoint reference (parameters, request bodies, response codes).
|
docs below for a per-endpoint reference (parameters, request bodies, response codes).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -230,7 +252,7 @@ actually returns (`400` validation, `401`/`403` auth, `404`, `409` conflicts, `4
|
|||||||
|
|
||||||
**Authentication in the UI** — click **Authorize** and provide either:
|
**Authentication in the UI** — click **Authorize** and provide either:
|
||||||
|
|
||||||
- `cookieAuth` — the `uomm_token` session cookie (set automatically in the browser after
|
- `cookieAuth` — the session cookie (name `rg_token`, configurable via `COOKIE_NAME`; set automatically in the browser after
|
||||||
`POST /api/v1/auth/login`), or
|
`POST /api/v1/auth/login`), or
|
||||||
- `bearerAuth` — a mobile access token from `POST /api/v1/auth/mobile/login` (sent as
|
- `bearerAuth` — a mobile access token from `POST /api/v1/auth/mobile/login` (sent as
|
||||||
`Authorization: Bearer <token>`).
|
`Authorization: Bearer <token>`).
|
||||||
@@ -251,6 +273,74 @@ not crash).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Shard integration (uo-link)
|
||||||
|
|
||||||
|
The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that
|
||||||
|
runs next to the ServUO shard. Its source lives in a separate repo:
|
||||||
|
**[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**. uo-link speaks the shard's internals and
|
||||||
|
exposes a small, authenticated HTTP + WebSocket API; this website is a *client* of it. The shard
|
||||||
|
itself is never exposed to the internet — only the sidecar is, and only the website's backend talks
|
||||||
|
to it.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
ServUO shard ──▶ uo-link sidecar (RunicGateway/link) ──▶ website backend ──▶ browser
|
||||||
|
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Connection is admin-managed, not env.** The sidecar's base URL, WebSocket URL, shared-secret
|
||||||
|
token, and protocol version are stored in the database (`uoLinkConfig`), edited from the
|
||||||
|
**Admin → Shard** panel. The token is **encrypted at rest** (AES-256-GCM) and is **write-only** in
|
||||||
|
the API — it is never returned to any client and never sent to the browser. Every call the backend
|
||||||
|
makes carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header (a protocol
|
||||||
|
mismatch fails fast with `409` instead of being mis-parsed).
|
||||||
|
- **Live ingest (WebSocket).** When enabled, the backend opens an outbound WebSocket to the sidecar
|
||||||
|
and receives a stream of game events — `mob.login`/`logout`, `char.vitals`, `economy.supply`,
|
||||||
|
`vendor.sale`, `player.death`/`murdered`, `house.decay` (IDOC), staff `audit.*`/`cheat.*`,
|
||||||
|
`link.request`, and `server.hello`/`shutdown`. A single dispatcher (`utils/shardIngest.js`) routes
|
||||||
|
each event: state-changing kinds update `shard_online` / `shard_economy` / `shard_houses`; notable
|
||||||
|
kinds are appended to an append-only `shard_events` log; high-frequency kinds (vitals, supply
|
||||||
|
ticks) only update state and are not logged. A changed boot id on `server.hello` is detected as a
|
||||||
|
restart and stale "online" rows are cleared. On reconnect the backend backfills missed events via
|
||||||
|
the sidecar's `/history`.
|
||||||
|
- **Live round-trips (REST).** For point-in-time reads the backend calls the sidecar directly —
|
||||||
|
`/char/serial/:serial`, `/roster/:account`, `/vendors/:account`, `/economy`, `/history` — plus
|
||||||
|
commands `/link/confirm` and `/towncrier`. The REST client (`utils/uoLinkClient.js`) **never
|
||||||
|
throws**: every call returns `{ ok, data, status }`, so a shard that is down or mid-restart
|
||||||
|
degrades to a `503`/retry banner instead of a 500.
|
||||||
|
- **Fan-out to the browser.** Ingested events are pushed to browsers over **Server-Sent Events**.
|
||||||
|
Two channels exist: a **public** stream carrying only a safe allowlist of kinds, and an
|
||||||
|
**admin-only** stream that also includes sensitive kinds (staff audit, cheat detection, login
|
||||||
|
attempts, IPs). Sensitive kinds can never leak onto the public channel.
|
||||||
|
|
||||||
|
### Account linking
|
||||||
|
|
||||||
|
A player (or staff member) proves ownership of a game account without sharing any game credentials:
|
||||||
|
|
||||||
|
1. In game, the player runs **`[link`** and receives a one-time code.
|
||||||
|
2. On the website (Player portal, or Admin → Account for staff) they enter the code.
|
||||||
|
3. The backend confirms the code with the sidecar (`POST /link/confirm`), which permanently tags the
|
||||||
|
game account with the website user id, and mirrors the link locally in `shard_account_links`.
|
||||||
|
|
||||||
|
That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints
|
||||||
|
are **ownership-checked** so a user only sees accounts they linked. **Admins may view any
|
||||||
|
character**; players and editor/moderator staff are limited to their own linked accounts.
|
||||||
|
|
||||||
|
### What each audience sees
|
||||||
|
|
||||||
|
| Surface | Endpoints | Who | Data |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. |
|
||||||
|
| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. |
|
||||||
|
| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). |
|
||||||
|
|
||||||
|
The sidecar URL and token are set once in **Admin → Shard**; if uo-link is not configured (or the
|
||||||
|
shard is offline), every shard surface degrades gracefully — the public page still renders, showing
|
||||||
|
the shard as offline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.env` is git-ignored.**
|
Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.env` is git-ignored.**
|
||||||
@@ -261,26 +351,54 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
|||||||
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
|
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
|
||||||
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
|
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
|
||||||
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
|
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
|
||||||
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `uomysticmoon` / `uomm` / — | app database credentials |
|
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials |
|
||||||
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
|
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
|
||||||
| `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens |
|
| `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens |
|
||||||
| `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime |
|
| `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime |
|
||||||
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
|
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
|
||||||
| `COOKIE_NAME` | `uomm_token` | |
|
| `COOKIE_NAME` | `rg_token` | changing it on a live instance invalidates existing sessions |
|
||||||
|
| `BRAND_*` | Runic Gateway | instance branding (name, tagline, colors, logo/hero/favicon) — see [Branding](#branding) |
|
||||||
| `SECRET_ENC_KEY` | — | **required in prod** — key for AES-256-GCM encryption of stored OAuth client secrets. Dev falls back to a key derived from `JWT_SECRET` (with a warning) |
|
| `SECRET_ENC_KEY` | — | **required in prod** — key for AES-256-GCM encryption of stored OAuth client secrets. Dev falls back to a key derived from `JWT_SECRET` (with a warning) |
|
||||||
| `APP_BASE_URL` | — | public base URL, used to build the SSO OAuth `redirect_uri` (`${APP_BASE_URL}/api/v1/auth/sso/:provider/callback`). Set in prod to match what you register with Google/Discord; if unset it is derived from the request (fine for local dev) |
|
| `APP_BASE_URL` | — | public base URL, used to build the SSO OAuth `redirect_uri` (`${APP_BASE_URL}/api/v1/auth/sso/:provider/callback`). Set in prod to match what you register with Google/Discord; if unset it is derived from the request (fine for local dev) |
|
||||||
| `MOBILE_ACCESS_TTL` | `15m` | mobile bearer **access** token lifetime (short-lived) |
|
| `MOBILE_ACCESS_TTL` | `15m` | mobile bearer **access** token lifetime (short-lived) |
|
||||||
| `MOBILE_REFRESH_TTL_DAYS` | `30` | mobile **refresh** token lifetime (long-lived, rotated on use) |
|
| `MOBILE_REFRESH_TTL_DAYS` | `30` | mobile **refresh** token lifetime (long-lived, rotated on use) |
|
||||||
| `TRUST_PROXY` | `1` | reverse-proxy trust for correct `req.ip` / `req.secure` (rate limiting, backoff, bot-ban). Pin to the proxy hop's LAN IP in prod. A blanket `true` is rejected (coerced to `1`) to block `X-Forwarded-For` spoofing |
|
| `TRUST_PROXY` | `1` | reverse-proxy trust for correct `req.ip` / `req.secure` (rate limiting, backoff, bot-ban). Pin to the proxy hop's LAN IP in prod. A blanket `true` is rejected (coerced to `1`) to block `X-Forwarded-For` spoofing |
|
||||||
| `DEBUG_TRUST_PROXY` | `0` | `1` logs raw peer address + `X-Forwarded-For` + resolved `req.ip` per request (to verify/refresh the proxy IP). Noisy — leave off |
|
| `DEBUG_TRUST_PROXY` | `0` | `1` logs raw peer address + `X-Forwarded-For` + resolved `req.ip` per request (to verify/refresh the proxy IP). Noisy — leave off |
|
||||||
| `TOTP_ISSUER` | `UOMysticmoon` | label shown in authenticator apps for optional per-user 2FA |
|
| `TOTP_ISSUER` | `BRAND_NAME` | label shown in authenticator apps for optional per-user 2FA |
|
||||||
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
|
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
|
||||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
||||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | — | optional; blank → contact form uses `mailto:` |
|
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
|
||||||
| `CONTACT_TO` | `UOMysticmoon@gmail.com` | contact recipient |
|
|
||||||
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
||||||
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
||||||
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
|
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
|
||||||
|
| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for due/retry legs (town crier + Discord) |
|
||||||
|
| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branding
|
||||||
|
|
||||||
|
Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt
|
||||||
|
image can run as any shard. With none set, everything renders as **Runic Gateway**.
|
||||||
|
|
||||||
|
| Var | What |
|
||||||
|
|---|---|
|
||||||
|
| `BRAND_NAME` / `BRAND_SHORT_NAME` | display name (full / short-in-prose) |
|
||||||
|
| `BRAND_TAGLINE` / `BRAND_DESCRIPTION` | tagline + meta/OG description |
|
||||||
|
| `BRAND_CONTACT_EMAIL` / `BRAND_URL` | contact + canonical URL (for OG/absolute links) |
|
||||||
|
| `BRAND_ACCENT_COLOR` | theme `--accent` (web) + Discord embed color |
|
||||||
|
| `BRAND_LOGO` / `BRAND_HERO` / `BRAND_FAVICON` | image paths under the `/brand` mount, or absolute URLs |
|
||||||
|
|
||||||
|
**How it flows:** text/colors reach the SPA at runtime through the public settings
|
||||||
|
API (`SiteContext`), so no rebuild is needed; the server templates `index.html`
|
||||||
|
`<title>`/meta/OG/favicon at boot; emails, TOTP issuer, and the Discord bot read
|
||||||
|
`BRAND_*` directly. The admin-editable **site title** and **contact email**
|
||||||
|
settings override `BRAND_NAME` / `BRAND_CONTACT_EMAIL` when set. Image assets are
|
||||||
|
delivered from the `./brand` bind-mount (see `brand/README.md`).
|
||||||
|
|
||||||
|
**UOMysticmoon** is the first instance — [`.env.uomysticmoon.example`](.env.uomysticmoon.example)
|
||||||
|
holds the exact `BRAND_*` + infra (`DB_NAME`/`DB_USER`/`COOKIE_NAME`) pinning to
|
||||||
|
run this repo as UOMysticmoon.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -343,8 +461,9 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
|||||||
|
|
||||||
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
|
- `helmet`, admin routes `noindex` + `robots.txt` disallow, `trust proxy` for correct client IPs
|
||||||
behind Pangolin (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
|
behind Pangolin (see `TRUST_PROXY`), first admin seeded from env (no hardcoded credentials),
|
||||||
`.env` git-ignored. Passwords and request bodies are never logged. SMTP is optional — the contact
|
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
|
||||||
form falls back to a `mailto:` link when unconfigured.
|
OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the
|
||||||
|
contact form falls back to a `mailto:` link when unconfigured.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
366
WIKI_UPGRADE.md
366
WIKI_UPGRADE.md
@@ -1,366 +0,0 @@
|
|||||||
# UOMysticmoon Website — Wiki Upgrade Spec
|
|
||||||
|
|
||||||
> Branch: **`wiki-upgrade`**. This document is the contract for upgrading the CMS
|
|
||||||
> wiki from a flat single-table page store into a feature-complete wiki.
|
|
||||||
> It follows the project workflow: **design (this doc) → build in phases → verify**.
|
|
||||||
>
|
|
||||||
> Companion to [`BACKEND_DESIGN.md`](BACKEND_DESIGN.md); reuses its stack, auth,
|
|
||||||
> logging, and Docker decisions unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Goal & scope
|
|
||||||
|
|
||||||
Turn the wiki into something that behaves like a typical wiki, while staying inside
|
|
||||||
the existing Node/Express + MariaDB + React/Vite architecture and the **staff-only**
|
|
||||||
auth model (admin/editor — no new roles, no public contributions).
|
|
||||||
|
|
||||||
**In scope**
|
|
||||||
|
|
||||||
| Feature | Summary |
|
|
||||||
|---|---|
|
|
||||||
| Rich-text editing | TipTap (ProseMirror) WYSIWYG in the admin; outputs HTML |
|
|
||||||
| Sanitization | Server-side allowlist on save **and** client-side on render (fixes today's stored-XSS gap) |
|
|
||||||
| Categories / sections | First-class `wiki_categories` table; replaces hardcoded frontend blurbs |
|
|
||||||
| Drafts & publish | `published` + `published_at`, mirroring the `posts` pattern |
|
|
||||||
| Tags | Many-to-many tags with filtering |
|
|
||||||
| Internal links | `[[slug]]`-style links authored in the editor; red-link detection |
|
|
||||||
| Backlinks | "Linked from" list, maintained on save |
|
|
||||||
| Inline images | Reuse/generalize the existing multer upload for in-body images |
|
|
||||||
| Search | MariaDB `FULLTEXT` over title + body |
|
|
||||||
| Revision history | Per-save snapshots with view / diff / restore |
|
|
||||||
|
|
||||||
**Out of scope (this branch)**
|
|
||||||
|
|
||||||
- Public/player editing or suggestion workflow, moderation/review queues.
|
|
||||||
- New roles or per-page ACLs (all staff with a login can edit all pages).
|
|
||||||
- Real-time collaborative editing, comments/discussion pages, file attachments
|
|
||||||
other than images, page templates/transclusion, multilingual pages.
|
|
||||||
|
|
||||||
**Decisions locked from planning**
|
|
||||||
|
|
||||||
- Editor: **TipTap**, storing **HTML** (not Markdown, not JSON).
|
|
||||||
- Search: **MariaDB FULLTEXT** (no new infrastructure).
|
|
||||||
- Revision history and search are **included** (recommended additions beyond the
|
|
||||||
minimum requested set).
|
|
||||||
- Authoring is **admin + editor** (`isLoggedIn`); no anonymous edits.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Current state (baseline being replaced)
|
|
||||||
|
|
||||||
| Layer | Today | File |
|
|
||||||
|---|---|---|
|
|
||||||
| Schema | flat `wiki_pages(slug,title,body,updated_by,timestamps)` | [server/db/schema.sql:31](server/db/schema.sql) |
|
|
||||||
| Model | thin CRUD by slug | [server/src/model/wiki/wiki.db.js](server/src/model/wiki/wiki.db.js), [wiki.model.js](server/src/model/wiki/wiki.model.js) |
|
|
||||||
| Public API | `GET /public/wiki`, `GET /public/wiki/:slug` | [public.controller.js:53](server/src/router/v1/public/public.controller.js) |
|
|
||||||
| Admin API | `GET/POST/PUT/DELETE /admin/wiki[...]` | [admin.controller.js:163](server/src/router/v1/admin/admin.controller.js), [admin.routes.js:68](server/src/router/v1/admin/admin.routes.js) |
|
|
||||||
| Public UI | card grid (hardcoded blurbs + Roman numerals), article w/ auto-TOC | [Wiki.jsx](client/src/routes/wiki/Wiki.jsx), [WikiArticle.jsx](client/src/routes/wiki/WikiArticle.jsx) |
|
|
||||||
| Admin UI | raw-HTML `<textarea>` modal | [WikiAdmin.jsx](client/src/routes/admin/views/WikiAdmin.jsx), [WikiEditor.jsx](client/src/routes/admin/views/WikiEditor.jsx) |
|
|
||||||
| API client | `api.wiki`, `api.admin.*Wiki` | [client/src/api/client.js:52](client/src/api/client.js) |
|
|
||||||
|
|
||||||
**Known issues this upgrade resolves**
|
|
||||||
|
|
||||||
- **Stored XSS**: body is raw HTML rendered with `dangerouslySetInnerHTML` and never
|
|
||||||
sanitized ([WikiArticle.jsx:91](client/src/routes/wiki/WikiArticle.jsx)).
|
|
||||||
- Category blurbs and ordering are **faked in the component** ([Wiki.jsx:11](client/src/routes/wiki/Wiki.jsx)), not data.
|
|
||||||
- No drafts (every save is instantly public), no history, no search, no tags, no links.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Data model
|
|
||||||
|
|
||||||
`utf8mb4`, InnoDB throughout. All changes are **additive and idempotent** so
|
|
||||||
`ensureSchema()` upgrades existing databases on boot with no data loss. New columns
|
|
||||||
are nullable or have safe defaults; **existing pages default to `published = 1`** so
|
|
||||||
nothing disappears on deploy.
|
|
||||||
|
|
||||||
### 3.1 `wiki_categories` (new)
|
|
||||||
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | INT PK AI | |
|
|
||||||
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `guides` |
|
|
||||||
| title | VARCHAR(200) NOT NULL | |
|
|
||||||
| description | VARCHAR(400) NULL | card teaser on the wiki index |
|
|
||||||
| sort_order | INT NOT NULL DEFAULT 0 | manual ordering |
|
|
||||||
| created_at / updated_at | DATETIME | standard stamps |
|
|
||||||
|
|
||||||
### 3.2 `wiki_pages` (altered)
|
|
||||||
|
|
||||||
Add to the existing table:
|
|
||||||
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| category_id | INT NULL FK→wiki_categories(id) ON DELETE SET NULL | |
|
|
||||||
| excerpt | VARCHAR(400) NULL | card/search teaser (replaces hardcoded blurbs) |
|
|
||||||
| published | TINYINT(1) NOT NULL DEFAULT 1 | draft/publish toggle |
|
|
||||||
| published_at | DATETIME NULL | set on first publish |
|
|
||||||
| sort_order | INT NOT NULL DEFAULT 0 | ordering within a category |
|
|
||||||
| FULLTEXT idx_wiki_search (title, body) | | search |
|
|
||||||
|
|
||||||
### 3.3 `wiki_tags` + `wiki_page_tags` (new)
|
|
||||||
|
|
||||||
```
|
|
||||||
wiki_tags( id PK, slug VARCHAR(120) UNIQUE, label VARCHAR(120) )
|
|
||||||
wiki_page_tags( page_id FK→wiki_pages ON DELETE CASCADE,
|
|
||||||
tag_id FK→wiki_tags ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY(page_id, tag_id) )
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 `wiki_links` (new) — backlinks index
|
|
||||||
|
|
||||||
Rebuilt for a page on every save by parsing its body for internal links.
|
|
||||||
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| source_page_id | INT FK→wiki_pages ON DELETE CASCADE | |
|
|
||||||
| target_slug | VARCHAR(120) NOT NULL | may point at a not-yet-created page (red link) |
|
|
||||||
| INDEX idx_wiki_links_target (target_slug) | | backlink lookups |
|
|
||||||
|
|
||||||
Backlinks for page X = `SELECT source pages WHERE target_slug = X.slug AND source is published`.
|
|
||||||
|
|
||||||
### 3.5 `wiki_revisions` (new) — history
|
|
||||||
|
|
||||||
| col | type | notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | INT PK AI | |
|
|
||||||
| page_id | INT FK→wiki_pages ON DELETE CASCADE | |
|
|
||||||
| title / body / excerpt | snapshot of content at save time | |
|
|
||||||
| category_id | INT NULL | snapshot |
|
|
||||||
| editor_id | INT NULL FK→users(id) | who saved |
|
|
||||||
| change_note | VARCHAR(280) NULL | optional summary |
|
|
||||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
|
||||||
|
|
||||||
A revision is written **inside the same transaction** as each page create/update.
|
|
||||||
|
|
||||||
### 3.6 Seed changes
|
|
||||||
|
|
||||||
Rework [seed.js](server/db/seed.js): the current 8 hardcoded pages become **categories**
|
|
||||||
(title + the blurb currently living in the frontend), each seeded idempotently via a new
|
|
||||||
`seedDefaultCategory`. Existing seeded pages are migrated/attached where applicable.
|
|
||||||
`seedDefault` for pages stays `INSERT IGNORE` so reseeding is safe.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Backend changes
|
|
||||||
|
|
||||||
Keep the `model` (entity) / `db` (SQL) split and the route grouping by access level.
|
|
||||||
|
|
||||||
### 4.1 Models (`server/src/model/wiki/`)
|
|
||||||
|
|
||||||
- `wiki.db.js` — add SQL for: category CRUD; page list with `category`, `published`,
|
|
||||||
`q` (FULLTEXT) filters and ordering; tag upsert + attach/detach; `wiki_links` rebuild;
|
|
||||||
revision insert/list/get; backlink query.
|
|
||||||
- `wiki.model.js` — orchestration. On **create/update** (single transaction):
|
|
||||||
1. sanitize `body` with the allowlist (§6),
|
|
||||||
2. upsert the page,
|
|
||||||
3. insert a `wiki_revisions` snapshot,
|
|
||||||
4. parse body for internal links → rebuild `wiki_links` for the page,
|
|
||||||
5. sync tags.
|
|
||||||
- A small `wiki.links.js` helper: parse internal links out of the saved HTML
|
|
||||||
(anchors written by the editor as `href="/wiki/<slug>"` / a `data-wiki-slug` attr),
|
|
||||||
return the set of target slugs.
|
|
||||||
|
|
||||||
### 4.2 Public API (`/api/v1/public`)
|
|
||||||
|
|
||||||
| Method | Path | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/wiki/categories` | ordered categories with page counts |
|
|
||||||
| GET | `/wiki?category=&tag=&q=` | **published only**; list/filter/search summaries |
|
|
||||||
| GET | `/wiki/:slug` | page + category + tags + backlinks (published only) |
|
|
||||||
|
|
||||||
Still passes through the `siteMode` maintenance gate like other public content.
|
|
||||||
|
|
||||||
### 4.3 Admin API (`/api/v1/admin`, behind `isLoggedIn` + `noindex`)
|
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/wiki` | all pages incl. drafts (filters: category, tag, q, status) |
|
|
||||||
| GET | `/wiki/:slug` | one page incl. draft, tags, category |
|
|
||||||
| POST | `/wiki` | create (slug, title, body, excerpt, category_id, tags, published) |
|
|
||||||
| PUT | `/wiki/:slug` | update (allows slug rename — see §7) |
|
|
||||||
| PATCH | `/wiki/:slug/publish` | `{published}` toggle, stamps `published_at` |
|
|
||||||
| DELETE | `/wiki/:slug` | delete (cascades revisions/links/tags) |
|
|
||||||
| GET | `/wiki/:slug/revisions` | list snapshots |
|
|
||||||
| GET | `/wiki/:slug/revisions/:id` | one snapshot (for diff/preview) |
|
|
||||||
| POST | `/wiki/:slug/revisions/:id/restore` | restore (writes a new revision) |
|
|
||||||
| GET/POST/PUT/DELETE | `/wiki/categories[...]` | category CRUD + reorder |
|
|
||||||
| GET/POST | `/wiki/tags` | list/create tags |
|
|
||||||
| POST | `/uploads` | generalized image upload (see §4.4) → `{url}` |
|
|
||||||
|
|
||||||
Validation via `express-validator` (slug regex `^[a-z0-9-]+$`, title required, etc.),
|
|
||||||
centralized error handler unchanged. **Every write logs to `activity_log`**
|
|
||||||
(`wiki.create`, `wiki.update`, `wiki.publish`, `wiki.delete`, `wiki.revision.restore`,
|
|
||||||
`wiki.category.*`) following the existing convention.
|
|
||||||
|
|
||||||
### 4.4 Image uploads
|
|
||||||
|
|
||||||
Generalize the existing screenshot upload (multer config in [admin.routes.js:17](server/src/router/v1/admin/admin.routes.js))
|
|
||||||
into a shared `POST /admin/uploads` returning `{ url: "/uploads/<file>" }`, reused by both
|
|
||||||
the post editor and the wiki editor. Same size/mime limits. No new storage —
|
|
||||||
served from the existing `uploads/` volume.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Frontend changes
|
|
||||||
|
|
||||||
### 5.1 Admin
|
|
||||||
|
|
||||||
- **`WikiEditor.jsx`** — replace the raw-HTML `<textarea>` with a **TipTap** editor:
|
|
||||||
bold/italic/headings (H2 for TOC)/lists/quote/code, link tool, **image insert**
|
|
||||||
(uploads via `/admin/uploads`), and an **internal-link picker** (`[[`-triggered
|
|
||||||
autocomplete over existing slugs; flags red links). Adds: category dropdown, tag
|
|
||||||
input (create-on-type), excerpt field, **Save draft / Publish** actions, and a
|
|
||||||
**History** tab (revision list → preview → diff → restore).
|
|
||||||
- **`WikiAdmin.jsx`** — list gains status (draft/published), category column, and
|
|
||||||
filters; plus a **Categories** manager (CRUD + drag-to-reorder).
|
|
||||||
|
|
||||||
### 5.2 Public
|
|
||||||
|
|
||||||
- **`Wiki.jsx`** — fully data-driven: categories + real excerpts from the API
|
|
||||||
(delete the hardcoded `BLURBS`/`ROMAN` constants), a **search box**, optional
|
|
||||||
tag filter.
|
|
||||||
- **`WikiArticle.jsx`** — keep auto-TOC; add category breadcrumb, tag chips, a
|
|
||||||
**"Linked from"** backlinks section, "last updated by", and **render via DOMPurify**
|
|
||||||
(`dangerouslySetInnerHTML` only after sanitize).
|
|
||||||
|
|
||||||
### 5.3 API client & routes
|
|
||||||
|
|
||||||
- Extend [client/src/api/client.js](client/src/api/client.js) with the new public/admin
|
|
||||||
wiki calls (categories, search params, revisions, tags, uploads).
|
|
||||||
- Add a public search/category route if needed; admin categories view registered in
|
|
||||||
[App.jsx](client/src/App.jsx) under `/admin/wiki` (sub-tab, no new top-level route required).
|
|
||||||
|
|
||||||
### 5.4 Dependencies (new)
|
|
||||||
|
|
||||||
- **client**: `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-link`,
|
|
||||||
`@tiptap/extension-image` (+ a small diff lib for history, e.g. `diff`); `dompurify`.
|
|
||||||
- **server**: `sanitize-html`.
|
|
||||||
|
|
||||||
(The client currently ships only React + react-router, so this is the first feature
|
|
||||||
dependency addition — keep the bundle lean, import only the extensions used.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Security
|
|
||||||
|
|
||||||
- **Two-layer sanitization.** Server sanitizes on save with a strict `sanitize-html`
|
|
||||||
allowlist (headings, p, lists, blockquote, code/pre, a[href], img[src,alt],
|
|
||||||
strong/em, hr, table basics); strips scripts, event handlers, `javascript:` URLs,
|
|
||||||
styles. Client re-sanitizes with DOMPurify before render. The stored value is already
|
|
||||||
clean, so even direct DB edits or future API clients can't inject script.
|
|
||||||
- **Upload safety** unchanged from posts: mime allowlist (png/jpe/gif/webp/avif),
|
|
||||||
8 MB cap, random filenames, served as static files (no execution).
|
|
||||||
- **Authorization**: all mutating wiki/category/tag/upload routes stay behind
|
|
||||||
`isLoggedIn` (admin or editor). Public routes are read-only and published-only.
|
|
||||||
- **No secrets/logging changes**; reuse existing rate-limit, helmet/CSP, noindex.
|
|
||||||
CSP `img-src` already covers `/uploads`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Migration & backward compatibility
|
|
||||||
|
|
||||||
- Schema migration is additive; run by `ensureSchema()` on boot and shipped in
|
|
||||||
`schema.sql` for fresh containers. Use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
|
|
||||||
/ `ADD INDEX` guarded for idempotency (MariaDB 11 supports `IF NOT EXISTS`).
|
|
||||||
- Existing pages: `published` backfills to `1`, `published_at` to `updated_at`,
|
|
||||||
`category_id` left NULL (surface as "Uncategorized" until assigned).
|
|
||||||
- **Slug rename** (new capability): on `PUT` slug change, update the page slug and
|
|
||||||
best-effort rewrite known internal links pointing at the old slug; old slug is not
|
|
||||||
auto-redirected (acceptable for a staff-curated wiki) — note in release notes.
|
|
||||||
- Public API response shape is **extended, not broken**: existing fields
|
|
||||||
(`slug`, `title`, `body`, `updated_at`) remain; new fields are additive, so the
|
|
||||||
current frontend keeps working between phases.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Implementation process (phased)
|
|
||||||
|
|
||||||
Each phase is a self-contained, shippable unit: build → run locally → verify in the
|
|
||||||
browser preview → commit on `wiki-upgrade`. Open a PR into `main` at the end (or per
|
|
||||||
phase if preferred). Do not merge a phase that hasn't been verified.
|
|
||||||
|
|
||||||
### Phase 0 — Branch & scaffolding ✅ (this doc)
|
|
||||||
- `wiki-upgrade` branch created; this spec committed.
|
|
||||||
|
|
||||||
### Phase 1 — Foundation & safety (highest value) ✅
|
|
||||||
- Schema: add `wiki_categories`, alter `wiki_pages` (category_id, excerpt, published,
|
|
||||||
published_at, sort_order, FULLTEXT), update `seed.js`.
|
|
||||||
- Server: server-side sanitization on save; drafts/publish endpoints; categories CRUD;
|
|
||||||
public list filtered to published + categories endpoint.
|
|
||||||
- Client: data-driven `Wiki.jsx` (remove hardcoded blurbs); DOMPurify render in
|
|
||||||
`WikiArticle.jsx`; draft/publish + category in the (still-textarea) admin editor.
|
|
||||||
- **Exit check**: existing pages still render; XSS payload in body is neutralized;
|
|
||||||
draft pages hidden from the public list/article.
|
|
||||||
- **Verified** (2026-06-27): schema migration ran clean on MariaDB 11; XSS payload
|
|
||||||
(`<script>`, `onerror=`, `javascript:`) stripped server-side; drafts return 404 on
|
|
||||||
the public API and are absent from the public list while visible in admin; public
|
|
||||||
index is data-driven (categories + sections); article shows category breadcrumb;
|
|
||||||
client builds and server boots with no errors.
|
|
||||||
|
|
||||||
### Phase 2 — Authoring UX ✅
|
|
||||||
- TipTap editor replaces the textarea; generalized `/admin/uploads`; inline images.
|
|
||||||
- **Exit check**: create/edit a page with headings, a list, a link, and an inline
|
|
||||||
image; verify it renders sanitized on the public page.
|
|
||||||
- **Verified** (2026-06-27): `/admin/uploads` returns `{url}` and the file serves as
|
|
||||||
an image; a page authored with H2/H3, lists, a link, and an uploaded inline image
|
|
||||||
round-trips through the WYSIWYG and renders sanitized publicly (link `rel` forced,
|
|
||||||
`<script>` stripped); a toolbar edit (insert divider) saved and persisted. TipTap
|
|
||||||
is code-split into its own chunk (lazy-loaded), keeping it off the public bundle.
|
|
||||||
|
|
||||||
### Phase 3 — Connectivity ✅
|
|
||||||
- Internal `[[slug]]` links + red-link detection; `wiki_links` rebuild on save;
|
|
||||||
backlinks on the article; tags + tag/category filtering.
|
|
||||||
- **Exit check**: link page A→B, confirm B shows A under "Linked from"; tag filter works.
|
|
||||||
- **Verified** (2026-06-27): internal links authored via an in-editor page picker
|
|
||||||
(links to `/wiki/<slug>`); A→B made B list A under "Linked from"; a link to a
|
|
||||||
non-existent page renders as a red link; removing the link on save cleared the
|
|
||||||
backlink (link index rebuilt). Tags upsert on save, filter via `?tag=` (chips +
|
|
||||||
flat index view), list with published counts, and orphan tags are auto-pruned.
|
|
||||||
- Implementation note: links are plain anchors to `/wiki/<slug>` (the WYSIWYG fits
|
|
||||||
this better than `[[ ]]` syntax); the sanitizer also allows `data-wiki-slug`.
|
|
||||||
|
|
||||||
### Phase 4 — Discovery & trust ✅
|
|
||||||
- FULLTEXT search (public search box + admin filter); revision history list /
|
|
||||||
diff / restore.
|
|
||||||
- **Exit check**: search returns expected pages; edit a page twice, diff the
|
|
||||||
revisions, restore an older one, confirm a new revision is recorded.
|
|
||||||
- **Verified** (2026-06-27): `?q=` natural-language search matches on both body
|
|
||||||
(`recipes`→crafting) and title (`monsters`); the public search box and admin
|
|
||||||
filter both work. A page edited twice produced 3 revisions; the History modal
|
|
||||||
shows a word-level diff (added vs removed) of an old revision against current;
|
|
||||||
restoring reverted the page and appended a "Restored from revision #N" entry.
|
|
||||||
|
|
||||||
### Verification (every phase)
|
|
||||||
Use the preview workflow, not manual hand-off: start the dev server, exercise the
|
|
||||||
public wiki and the admin editor, check console/network for errors, and capture a
|
|
||||||
screenshot of the changed surface. Confirm `npm run` lint/build passes for the client
|
|
||||||
and the server boots cleanly with `ensureSchema()` applying the migration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. File-change map (reference)
|
|
||||||
|
|
||||||
| Area | Files |
|
|
||||||
|---|---|
|
|
||||||
| Schema/seed | `server/db/schema.sql`, `server/db/seed.js`, `server/src/utils/db.js` (ensureSchema) |
|
|
||||||
| Models | `server/src/model/wiki/wiki.db.js`, `wiki.model.js`, **new** `wiki.links.js` |
|
|
||||||
| API | `server/src/router/v1/public/public.{routes,controller}.js`, `server/src/router/v1/admin/admin.{routes,controller}.js` |
|
|
||||||
| Sanitize | **new** `server/src/utils/sanitizeHtml.js` |
|
|
||||||
| Client API | `client/src/api/client.js` |
|
|
||||||
| Public UI | `client/src/routes/wiki/Wiki.jsx`, `WikiArticle.jsx` |
|
|
||||||
| Admin UI | `client/src/routes/admin/views/WikiAdmin.jsx`, `WikiEditor.jsx`, **new** category manager + revisions view |
|
|
||||||
| Deps | `client/package.json`, `server/package.json` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Open questions / assumptions
|
|
||||||
|
|
||||||
1. **Slug redirects**: assumed not needed on rename (staff wiki). Revisit if pages get
|
|
||||||
external inbound links.
|
|
||||||
2. **Search ranking**: FULLTEXT natural-language mode assumed; can switch to BOOLEAN
|
|
||||||
mode if operators are wanted later.
|
|
||||||
3. **Diff granularity**: line/word diff of the HTML source is assumed sufficient for
|
|
||||||
revision compare; a rendered visual diff is a later nice-to-have.
|
|
||||||
4. **Editor scope**: tables and embeds beyond images are deferred unless requested.
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# ─── UOMysticmoon Discord bot — local dev environment ───
|
# ─── Runic Gateway Discord bot — local dev environment ───
|
||||||
# Copy to bot/.env for running `npm run dev` outside Docker.
|
# Copy to bot/.env for running `npm run dev` outside Docker.
|
||||||
# (In Docker, the root .env / docker-compose provides these instead.)
|
# (In Docker, the root .env / docker-compose provides these instead.)
|
||||||
#
|
#
|
||||||
@@ -40,6 +40,6 @@ SITE_PUBLIC_URL=http://localhost:3000/api/v1/public
|
|||||||
# etc.) directly. Point this at the same DB the server/ uses.
|
# etc.) directly. Point this at the same DB the server/ uses.
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_NAME=uomysticmoon
|
DB_NAME=runic_gateway
|
||||||
DB_USER=uomm
|
DB_USER=runic
|
||||||
DB_PASSWORD=change-me-db-password
|
DB_PASSWORD=change-me-db-password
|
||||||
|
|||||||
4
bot/package-lock.json
generated
4
bot/package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "uomysticmoon-bot",
|
"name": "runic-gateway-bot",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "uomysticmoon-bot",
|
"name": "runic-gateway-bot",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "uomysticmoon-bot",
|
"name": "runic-gateway-bot",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Discord bot for the UOMysticmoon community server",
|
"description": "Discord bot for the Runic Gateway community server",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
56
bot/src/bootstrap.js
vendored
56
bot/src/bootstrap.js
vendored
@@ -4,11 +4,50 @@
|
|||||||
// container restart (crash, `docker compose restart`, host reboot) self-heals
|
// container restart (crash, `docker compose restart`, host reboot) self-heals
|
||||||
// without any admin-panel interaction. Node 20's built-in fetch is used; no
|
// without any admin-panel interaction. Node 20's built-in fetch is used; no
|
||||||
// extra HTTP client dependency needed for a single startup call.
|
// extra HTTP client dependency needed for a single startup call.
|
||||||
|
//
|
||||||
|
// The fetch RETRIES with backoff: on `docker compose up`, the bot and the app
|
||||||
|
// start together and the bot's `depends_on: app` only waits for the container
|
||||||
|
// to *start*, not for the app's internal server to be listening (it still has
|
||||||
|
// to reach the DB and boot Express). Without retries the very first fetch loses
|
||||||
|
// that race, bootstrap gives up, and the bot sits disconnected while the DB
|
||||||
|
// still says enabled — the exact "enabled but disconnected until I toggle it"
|
||||||
|
// bug. Retrying until the site answers makes a cold whole-stack start heal on
|
||||||
|
// its own.
|
||||||
const discordManager = require('./discord/discordManager')
|
const discordManager = require('./discord/discordManager')
|
||||||
const createLogger = require('./utils/logger')
|
const createLogger = require('./utils/logger')
|
||||||
|
|
||||||
const log = createLogger('bootstrap')
|
const log = createLogger('bootstrap')
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 30 // ~30 tries * ~2s ≈ 1 min of patience for the app to come up
|
||||||
|
const RETRY_DELAY_MS = 2000
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
// Fetch config from the site, retrying while the site is unreachable or not yet
|
||||||
|
// ready (network error or 5xx). Returns the parsed config, or null if we gave
|
||||||
|
// up after MAX_ATTEMPTS. A 4xx (e.g. bad internal key) is a real misconfig, not
|
||||||
|
// a transient startup race, so we don't retry those.
|
||||||
|
async function fetchConfig(siteUrl, key) {
|
||||||
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
|
||||||
|
if (res.ok) return await res.json()
|
||||||
|
if (res.status >= 400 && res.status < 500) {
|
||||||
|
log.error('boot-time config fetch rejected — not retrying', { status: res.status })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
log.warn('boot-time config fetch not ready — retrying', { status: res.status, attempt })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('boot-time config fetch errored — retrying', { message: err.message, attempt })
|
||||||
|
}
|
||||||
|
if (attempt < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS)
|
||||||
|
}
|
||||||
|
log.error('boot-time config fetch gave up after retries — staying disconnected until the admin panel pushes config', {
|
||||||
|
attempts: MAX_ATTEMPTS,
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const siteUrl = process.env.SITE_INTERNAL_URL
|
const siteUrl = process.env.SITE_INTERNAL_URL
|
||||||
const key = process.env.BOT_INTERNAL_KEY
|
const key = process.env.BOT_INTERNAL_KEY
|
||||||
@@ -17,22 +56,19 @@ async function bootstrap() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const config = await fetchConfig(siteUrl, key)
|
||||||
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
|
if (!config) return
|
||||||
if (!res.ok) {
|
|
||||||
log.error('boot-time config fetch failed', { status: res.status })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const config = await res.json()
|
|
||||||
if (config.enabled) {
|
if (config.enabled) {
|
||||||
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
|
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
|
||||||
|
try {
|
||||||
await discordManager.start({ token: config.token, guildId: config.guildId })
|
await discordManager.start({ token: config.token, guildId: config.guildId })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('boot-time reconnect failed', { message: err.message })
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log.info('boot-time config says disabled — staying disconnected')
|
log.info('boot-time config says disabled — staying disconnected')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
log.error('boot-time config fetch errored', { message: err.message })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = bootstrap
|
module.exports = bootstrap
|
||||||
|
|||||||
13
bot/src/brand.js
Normal file
13
bot/src/brand.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and
|
||||||
|
// logs carry the instance identity. Kept minimal — the bot only needs the name
|
||||||
|
// and the accent color (as an int for discord.js embeds).
|
||||||
|
require('dotenv').config()
|
||||||
|
|
||||||
|
const name = process.env.BRAND_NAME || 'Runic Gateway'
|
||||||
|
const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
|
||||||
|
const accentInt = (() => {
|
||||||
|
const n = parseInt(String(accentHex).replace('#', ''), 16)
|
||||||
|
return Number.isNaN(n) ? 0x7f99bd : n
|
||||||
|
})()
|
||||||
|
|
||||||
|
module.exports = { name, accentHex, accentInt }
|
||||||
@@ -12,7 +12,7 @@ const pool = mariadb.createPool({
|
|||||||
port: Number(process.env.DB_PORT) || 3306,
|
port: Number(process.env.DB_PORT) || 3306,
|
||||||
user: process.env.DB_USER || 'root',
|
user: process.env.DB_USER || 'root',
|
||||||
password: process.env.DB_PASSWORD || '',
|
password: process.env.DB_PASSWORD || '',
|
||||||
database: process.env.DB_NAME || 'uomysticmoon',
|
database: process.env.DB_NAME || 'runic_gateway',
|
||||||
connectionLimit: 5,
|
connectionLimit: 5,
|
||||||
insertIdAsNumber: true,
|
insertIdAsNumber: true,
|
||||||
bigIntAsNumber: true,
|
bigIntAsNumber: true,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const {
|
|||||||
} = require('discord.js')
|
} = require('discord.js')
|
||||||
|
|
||||||
const roleMenus = require('../../model/roleMenus')
|
const roleMenus = require('../../model/roleMenus')
|
||||||
|
const brand = require('../../brand')
|
||||||
|
|
||||||
// Capped at 5 roles per menu — a single Discord action row holds at most 5
|
// Capped at 5 roles per menu — a single Discord action row holds at most 5
|
||||||
// buttons, and one row keeps this a single simple slash command instead of
|
// buttons, and one row keeps this a single simple slash command instead of
|
||||||
@@ -62,7 +63,7 @@ module.exports = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const embed = new EmbedBuilder().setTitle(title).setColor(0x6a8fc2)
|
const embed = new EmbedBuilder().setTitle(title).setColor(brand.accentInt)
|
||||||
if (description) embed.setDescription(description)
|
if (description) embed.setDescription(description)
|
||||||
|
|
||||||
const row = new ActionRowBuilder().addComponents(
|
const row = new ActionRowBuilder().addComponents(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
const { EmbedBuilder } = require('discord.js')
|
const { EmbedBuilder } = require('discord.js')
|
||||||
|
|
||||||
const guildConfig = require('../model/guildConfig')
|
const guildConfig = require('../model/guildConfig')
|
||||||
|
const brand = require('../brand')
|
||||||
const createLogger = require('../utils/logger')
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
const log = createLogger('news')
|
const log = createLogger('news')
|
||||||
@@ -15,7 +16,7 @@ async function postAnnounce(client, guildId, { title, excerpt, url, imageUrl })
|
|||||||
const channel = await client.channels.fetch(channelId)
|
const channel = await client.channels.fetch(channelId)
|
||||||
if (!channel || !channel.isTextBased()) throw new Error('Configured news channel is missing or not text-based.')
|
if (!channel || !channel.isTextBased()) throw new Error('Configured news channel is missing or not text-based.')
|
||||||
|
|
||||||
const embed = new EmbedBuilder().setColor(0x6a8fc2).setTitle(title).setURL(url)
|
const embed = new EmbedBuilder().setColor(brand.accentInt).setTitle(title).setURL(url)
|
||||||
if (excerpt) embed.setDescription(excerpt)
|
if (excerpt) embed.setDescription(excerpt)
|
||||||
if (imageUrl) embed.setImage(imageUrl)
|
if (imageUrl) embed.setImage(imageUrl)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Gate for the bot's /internal/* API. The only caller is the main UOMysticmoon
|
// Gate for the bot's /internal/* API. The only caller is the main Runic Gateway
|
||||||
// server, over the private compose network — never expose this route through
|
// server, over the private compose network — never expose this route through
|
||||||
// the public reverse proxy. Timing-safe compare so response time can't be used
|
// the public reverse proxy. Timing-safe compare so response time can't be used
|
||||||
// to brute-force the shared secret one byte at a time.
|
// to brute-force the shared secret one byte at a time.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const app = require('./app')
|
|||||||
const bootstrap = require('./bootstrap')
|
const bootstrap = require('./bootstrap')
|
||||||
const createLogger = require('./utils/logger')
|
const createLogger = require('./utils/logger')
|
||||||
const discordManager = require('./discord/discordManager')
|
const discordManager = require('./discord/discordManager')
|
||||||
|
const brand = require('./brand')
|
||||||
const pkg = require('../package.json')
|
const pkg = require('../package.json')
|
||||||
|
|
||||||
const log = createLogger('server')
|
const log = createLogger('server')
|
||||||
@@ -11,7 +12,7 @@ const PORT = Number(process.env.PORT) || 4100
|
|||||||
const HOST = '0.0.0.0'
|
const HOST = '0.0.0.0'
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
log.info(`starting UOMysticmoon bot v${pkg.version}`, {
|
log.info(`starting ${brand.name} bot v${pkg.version}`, {
|
||||||
node: process.version,
|
node: process.version,
|
||||||
logFile: createLogger.logFilePath || 'disabled (console only)',
|
logFile: createLogger.logFilePath || 'disabled (console only)',
|
||||||
})
|
})
|
||||||
|
|||||||
15
brand/README.md
Normal file
15
brand/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Brand assets (per-instance)
|
||||||
|
|
||||||
|
This directory is bind-mounted into the container at `/app/brand` (see
|
||||||
|
`docker-compose.yml`). Drop instance branding images here and point the matching
|
||||||
|
`BRAND_*` env vars at them, e.g.:
|
||||||
|
|
||||||
|
```
|
||||||
|
BRAND_LOGO=/brand/logo.png
|
||||||
|
BRAND_HERO=/brand/hero.png
|
||||||
|
BRAND_FAVICON=/brand/favicon.ico
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave the vars blank to use the built-in defaults (the hero falls back to a
|
||||||
|
neutral built-in image; no logo/favicon is injected). Nothing here is required
|
||||||
|
for the app to run — it renders cleanly with an empty `brand/`.
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>UOMysticmoon</title>
|
<title>Runic Gateway</title>
|
||||||
<meta name="description" content="UOMysticmoon — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
|
<meta name="description" content="Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
|||||||
18
client/package-lock.json
generated
18
client/package-lock.json
generated
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "uomysticmoon-client",
|
"name": "runic-gateway-client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "uomysticmoon-client",
|
"name": "runic-gateway-client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-image": "^2.27.2",
|
"@tiptap/extension-image": "^2.27.2",
|
||||||
"@tiptap/extension-link": "^2.27.2",
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
|
"@tiptap/extension-text-align": "^2.27.2",
|
||||||
"@tiptap/react": "^2.27.2",
|
"@tiptap/react": "^2.27.2",
|
||||||
"@tiptap/starter-kit": "^2.27.2",
|
"@tiptap/starter-kit": "^2.27.2",
|
||||||
"diff": "^5.2.2",
|
"diff": "^5.2.2",
|
||||||
@@ -1483,6 +1484,19 @@
|
|||||||
"@tiptap/core": "^2.7.0"
|
"@tiptap/core": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-text-align": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-text-style": {
|
"node_modules/@tiptap/extension-text-style": {
|
||||||
"version": "2.27.2",
|
"version": "2.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "uomysticmoon-client",
|
"name": "runic-gateway-client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-image": "^2.27.2",
|
"@tiptap/extension-image": "^2.27.2",
|
||||||
"@tiptap/extension-link": "^2.27.2",
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
|
"@tiptap/extension-text-align": "^2.27.2",
|
||||||
"@tiptap/react": "^2.27.2",
|
"@tiptap/react": "^2.27.2",
|
||||||
"@tiptap/starter-kit": "^2.27.2",
|
"@tiptap/starter-kit": "^2.27.2",
|
||||||
"diff": "^5.2.2",
|
"diff": "^5.2.2",
|
||||||
|
|||||||
BIN
client/public/assets/img/favicon.ico
Normal file
BIN
client/public/assets/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
client/public/assets/img/runic-emblem.png
Normal file
BIN
client/public/assets/img/runic-emblem.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -3,6 +3,7 @@ import { AuthProvider } from './contexts/AuthContext.jsx'
|
|||||||
import { SiteProvider } from './contexts/SiteContext.jsx'
|
import { SiteProvider } from './contexts/SiteContext.jsx'
|
||||||
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
||||||
import RequireAuth from './components/RequireAuth.jsx'
|
import RequireAuth from './components/RequireAuth.jsx'
|
||||||
|
import RequirePlayer from './components/RequirePlayer.jsx'
|
||||||
import RoleGate from './components/RoleGate.jsx'
|
import RoleGate from './components/RoleGate.jsx'
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
@@ -15,32 +16,62 @@ import Newsletter from './routes/public/Newsletter.jsx'
|
|||||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||||
import About from './routes/public/About.jsx'
|
import About from './routes/public/About.jsx'
|
||||||
import Status from './routes/public/Status.jsx'
|
import Status from './routes/public/Status.jsx'
|
||||||
|
import Shard from './routes/public/Shard.jsx'
|
||||||
|
import ShardActivity from './routes/public/ShardActivity.jsx'
|
||||||
|
import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
||||||
|
import Guilds from './routes/public/Guilds.jsx'
|
||||||
|
import Governors from './routes/public/Governors.jsx'
|
||||||
|
import Houses from './routes/public/Houses.jsx'
|
||||||
import Wiki from './routes/wiki/Wiki.jsx'
|
import Wiki from './routes/wiki/Wiki.jsx'
|
||||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||||
|
import CmsPage from './routes/public/CmsPage.jsx'
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
import AdminLogin from './routes/admin/AdminLogin.jsx'
|
import AdminLogin from './routes/admin/AdminLogin.jsx'
|
||||||
import AdminLayout from './routes/admin/AdminLayout.jsx'
|
import AdminLayout from './routes/admin/AdminLayout.jsx'
|
||||||
import Dashboard from './routes/admin/views/Dashboard.jsx'
|
import Dashboard from './routes/admin/views/Dashboard.jsx'
|
||||||
import PostsAdmin from './routes/admin/views/PostsAdmin.jsx'
|
import PostsAdmin from './routes/admin/views/PostsAdmin.jsx'
|
||||||
|
import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
|
||||||
|
import PageBuilder from './routes/admin/views/PageBuilder.jsx'
|
||||||
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
||||||
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
||||||
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||||
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
||||||
|
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
|
||||||
|
import ShardOps from './routes/admin/views/ShardOps.jsx'
|
||||||
|
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
|
||||||
|
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
|
||||||
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
||||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||||
|
import UserDetail from './routes/admin/views/UserDetail.jsx'
|
||||||
|
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
|
||||||
|
import HousesAdmin from './routes/admin/views/HousesAdmin.jsx'
|
||||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||||
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||||
|
|
||||||
|
// Player portal
|
||||||
|
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
||||||
|
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
||||||
|
import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
||||||
|
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
|
||||||
|
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||||
|
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||||
|
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SiteProvider>
|
<SiteProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public site — gated by maintenance mode (admins preview through it) */}
|
{/* Landing hero — always public, even in maintenance mode. The hero is
|
||||||
|
itself the pre-launch "coming soon" page, so it sits outside the
|
||||||
|
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
|
||||||
|
<Route path="/" element={<Portal />} />
|
||||||
|
|
||||||
|
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<MaintenanceGate>
|
<MaintenanceGate>
|
||||||
@@ -48,7 +79,6 @@ export default function App() {
|
|||||||
</MaintenanceGate>
|
</MaintenanceGate>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<Portal />} />
|
|
||||||
<Route path="/site" element={<Website />} />
|
<Route path="/site" element={<Website />} />
|
||||||
<Route path="/site/news" element={<News />} />
|
<Route path="/site/news" element={<News />} />
|
||||||
<Route path="/site/screenshots" element={<Screenshots />} />
|
<Route path="/site/screenshots" element={<Screenshots />} />
|
||||||
@@ -57,10 +87,23 @@ export default function App() {
|
|||||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||||
<Route path="/site/about" element={<About />} />
|
<Route path="/site/about" element={<About />} />
|
||||||
<Route path="/site/status" element={<Status />} />
|
<Route path="/site/status" element={<Status />} />
|
||||||
|
<Route path="/site/shard" element={<Shard />} />
|
||||||
|
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||||
|
<Route path="/site/champs" element={<ChampSpawns />} />
|
||||||
|
<Route path="/site/guilds" element={<Guilds />} />
|
||||||
|
<Route path="/site/governors" element={<Governors />} />
|
||||||
|
<Route path="/site/houses" element={<Houses />} />
|
||||||
<Route path="/wiki" element={<Wiki />} />
|
<Route path="/wiki" element={<Wiki />} />
|
||||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||||
|
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||||
|
above (React Router ranks static routes over this dynamic one). */}
|
||||||
|
<Route path="/:slug" element={<CmsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
|
||||||
|
preview link works regardless of site mode. */}
|
||||||
|
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
|
||||||
|
|
||||||
{/* Admin */}
|
{/* Admin */}
|
||||||
<Route path="/admin/login" element={<AdminLogin />} />
|
<Route path="/admin/login" element={<AdminLogin />} />
|
||||||
<Route
|
<Route
|
||||||
@@ -73,6 +116,9 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="posts" element={<PostsAdmin />} />
|
<Route path="posts" element={<PostsAdmin />} />
|
||||||
|
<Route path="pages" element={<PagesAdmin />} />
|
||||||
|
<Route path="pages/new" element={<PageBuilder />} />
|
||||||
|
<Route path="pages/:id" element={<PageBuilder />} />
|
||||||
<Route path="wiki" element={<WikiAdmin />} />
|
<Route path="wiki" element={<WikiAdmin />} />
|
||||||
<Route path="hero" element={<HeroEditor />} />
|
<Route path="hero" element={<HeroEditor />} />
|
||||||
<Route path="settings" element={<SettingsAdmin />} />
|
<Route path="settings" element={<SettingsAdmin />} />
|
||||||
@@ -90,12 +136,49 @@ export default function App() {
|
|||||||
<Route path="activity" element={<ActivityAdmin />} />
|
<Route path="activity" element={<ActivityAdmin />} />
|
||||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||||
|
<Route path="shard" element={<ShardAdmin />} />
|
||||||
|
<Route
|
||||||
|
path="shard-ops"
|
||||||
|
element={
|
||||||
|
<RoleGate roles={['admin', 'moderator']}>
|
||||||
|
<ShardOps />
|
||||||
|
</RoleGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="houses"
|
||||||
|
element={
|
||||||
|
<RoleGate roles={['admin', 'moderator']}>
|
||||||
|
<HousesAdmin />
|
||||||
|
</RoleGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="characters" element={<AdminCharacters />} />
|
||||||
|
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||||
<Route path="users" element={<UsersAdmin />} />
|
<Route path="users" element={<UsersAdmin />} />
|
||||||
|
<Route path="users/:id" element={<UserDetail />} />
|
||||||
|
<Route path="invites" element={<InvitesAdmin />} />
|
||||||
<Route path="account" element={<AccountAdmin />} />
|
<Route path="account" element={<AccountAdmin />} />
|
||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Player portal */}
|
||||||
|
<Route path="/account/login" element={<PlayerLogin />} />
|
||||||
|
<Route path="/account/register" element={<PlayerRegister />} />
|
||||||
|
<Route path="/invite/:token" element={<AcceptInvite />} />
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<RequirePlayer>
|
||||||
|
<PlayerPortalLayout />
|
||||||
|
</RequirePlayer>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/player" element={<PlayerCharacters />} />
|
||||||
|
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
|
||||||
|
<Route path="/account" element={<PlayerAccount />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</SiteProvider>
|
</SiteProvider>
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export const api = {
|
|||||||
// `extra` carries the honeypot field (and any future login fields).
|
// `extra` carries the honeypot field (and any future login fields).
|
||||||
login: (username, password, extra = {}) =>
|
login: (username, password, extra = {}) =>
|
||||||
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
|
||||||
|
// Public self-registration (player accounts). `extra` carries the honeypot +
|
||||||
|
// optional email. Returns { user } and sets the session cookie on success.
|
||||||
|
register: (username, password, extra = {}) =>
|
||||||
|
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
|
||||||
|
// Email invites (public, token-gated accept).
|
||||||
|
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
|
||||||
|
acceptInvite: (token, username, password, extra = {}) =>
|
||||||
|
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
|
||||||
loginTotp: (challenge, code) =>
|
loginTotp: (challenge, code) =>
|
||||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
||||||
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
|
||||||
@@ -69,8 +77,42 @@ export const api = {
|
|||||||
wikiCategories: () => req('/public/wiki/categories'),
|
wikiCategories: () => req('/public/wiki/categories'),
|
||||||
wikiTags: () => req('/public/wiki/tags'),
|
wikiTags: () => req('/public/wiki/tags'),
|
||||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||||
|
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||||
|
// is fetched by id + token.
|
||||||
|
page: (slug) => req(`/public/pages/${slug}`),
|
||||||
|
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
||||||
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
||||||
|
|
||||||
|
// ----- shard live data (uo-link) -----
|
||||||
|
// Token-free, same-origin reads backed by the ingested feed + a cached live
|
||||||
|
// character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed.
|
||||||
|
shard: {
|
||||||
|
status: () => req('/public/shard/status'),
|
||||||
|
feed: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.kind) qs.set('kind', opts.kind)
|
||||||
|
if (opts.limit) qs.set('limit', opts.limit)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/public/shard/feed${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
|
||||||
|
online: () => req('/public/shard/online'),
|
||||||
|
idoc: () => req('/public/shard/idoc'),
|
||||||
|
champs: () => req('/public/shard/champs'),
|
||||||
|
// Protocol 2.0 boards.
|
||||||
|
guilds: () => req('/public/shard/guilds'),
|
||||||
|
governors: () => req('/public/shard/governors'),
|
||||||
|
governorHistory: (city, limit) =>
|
||||||
|
req(`/public/shard/governors/${encodeURIComponent(city)}/history${limit ? `?limit=${limit}` : ''}`),
|
||||||
|
presence: () => req('/public/shard/presence'),
|
||||||
|
houses: () => req('/public/shard/houses'),
|
||||||
|
},
|
||||||
|
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
||||||
|
// fetch-only, so SSE subscribers build the URL from here. The admin stream
|
||||||
|
// carries every kind (incl. audit/cheat) and needs the staff session cookie.
|
||||||
|
shardStreamUrl: `${BASE}/public/shard/stream`,
|
||||||
|
adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,
|
||||||
|
|
||||||
// ----- admin -----
|
// ----- admin -----
|
||||||
admin: {
|
admin: {
|
||||||
dashboard: () => req('/admin/dashboard'),
|
dashboard: () => req('/admin/dashboard'),
|
||||||
@@ -82,6 +124,10 @@ export const api = {
|
|||||||
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
|
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
|
||||||
publishPost: (id, published) =>
|
publishPost: (id, published) =>
|
||||||
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
|
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
|
||||||
|
// News announcement pipeline (town crier + Discord) status + per-leg retry.
|
||||||
|
getAnnounce: (id) => req(`/admin/posts/${id}/announce`),
|
||||||
|
retryAnnounceLeg: (id, leg) =>
|
||||||
|
req(`/admin/posts/${id}/announce/retry`, { method: 'POST', body: { leg } }),
|
||||||
uploadImage: (file) => {
|
uploadImage: (file) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('image', file)
|
fd.append('image', file)
|
||||||
@@ -93,6 +139,15 @@ export const api = {
|
|||||||
fd.append('image', file)
|
fd.append('image', file)
|
||||||
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
|
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
|
||||||
},
|
},
|
||||||
|
// ----- CMS pages (block-based page builder) -----
|
||||||
|
listPages: () => req('/admin/pages'),
|
||||||
|
getPage: (id) => req(`/admin/pages/${id}`),
|
||||||
|
createPage: (data) => req('/admin/pages', { method: 'POST', body: data }),
|
||||||
|
updatePage: (id, data) => req(`/admin/pages/${id}`, { method: 'PATCH', body: data }),
|
||||||
|
deletePage: (id) => req(`/admin/pages/${id}`, { method: 'DELETE' }),
|
||||||
|
unprotectPage: (id, password) =>
|
||||||
|
req(`/admin/pages/${id}/unprotect`, { method: 'POST', body: { password } }),
|
||||||
|
createPagePreview: (id) => req(`/admin/pages/${id}/preview`, { method: 'POST' }),
|
||||||
listWiki: (params = '') => req(`/admin/wiki${params}`),
|
listWiki: (params = '') => req(`/admin/wiki${params}`),
|
||||||
getWiki: (slug) => req(`/admin/wiki/${slug}`),
|
getWiki: (slug) => req(`/admin/wiki/${slug}`),
|
||||||
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
|
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
|
||||||
@@ -116,9 +171,30 @@ export const api = {
|
|||||||
botActivity: () => req('/admin/bot-activity'),
|
botActivity: () => req('/admin/bot-activity'),
|
||||||
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
||||||
listUsers: () => req('/admin/users'),
|
listUsers: () => req('/admin/users'),
|
||||||
|
getUser: (id) => req(`/admin/users/${id}`),
|
||||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||||
|
// Email invites.
|
||||||
|
listInvites: () => req('/admin/invites'),
|
||||||
|
createInvite: (email, role, sendEmail = true) =>
|
||||||
|
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
|
||||||
|
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
|
||||||
|
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
|
||||||
|
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
|
||||||
|
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
|
||||||
|
// account) so the shared GameAccounts component works unchanged.
|
||||||
|
userShard: (id) => ({
|
||||||
|
accounts: () => req(`/admin/users/${id}/shard/accounts`),
|
||||||
|
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||||
|
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||||
|
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||||
|
sales: () => req(`/admin/users/${id}/shard/sales`),
|
||||||
|
houses: () => req(`/admin/users/${id}/shard/houses`),
|
||||||
|
online: () => req(`/admin/users/${id}/shard/online`),
|
||||||
|
standing: () => req(`/admin/users/${id}/shard/standing`),
|
||||||
|
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
|
||||||
|
}),
|
||||||
|
|
||||||
// ----- moderation dashboard (admin + moderator) -----
|
// ----- moderation dashboard (admin + moderator) -----
|
||||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||||
@@ -176,6 +252,19 @@ export const api = {
|
|||||||
linkedIdentities: () => req('/admin/account/identities'),
|
linkedIdentities: () => req('/admin/account/identities'),
|
||||||
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// ----- game account linking (self-service, staff) -----
|
||||||
|
shard: {
|
||||||
|
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
|
||||||
|
accounts: () => req('/admin/shard/accounts'),
|
||||||
|
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||||
|
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||||
|
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||||
|
sales: () => req('/admin/shard/sales'),
|
||||||
|
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
|
||||||
|
createAccount: (account, password) =>
|
||||||
|
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
|
||||||
|
},
|
||||||
|
|
||||||
// ----- auth providers / SSO config (admin only) -----
|
// ----- auth providers / SSO config (admin only) -----
|
||||||
listAuthProviders: () => req('/admin/auth/providers'),
|
listAuthProviders: () => req('/admin/auth/providers'),
|
||||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||||
@@ -185,6 +274,62 @@ export const api = {
|
|||||||
// ----- Discord bot control (admin only) -----
|
// ----- Discord bot control (admin only) -----
|
||||||
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||||
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
|
||||||
|
|
||||||
|
// ----- uo-link sidecar control (admin only) -----
|
||||||
|
getUoLinkConfig: () => req('/admin/uo-link/config'),
|
||||||
|
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
||||||
|
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
||||||
|
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
|
||||||
|
// `actor` is stamped server-side from the session — never sent from here.
|
||||||
|
shardOps: {
|
||||||
|
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
|
||||||
|
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
|
||||||
|
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
|
||||||
|
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
|
||||||
|
pages: () => req('/admin/shard/pages'),
|
||||||
|
respondPage: (id, data) =>
|
||||||
|
req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
|
||||||
|
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
|
||||||
|
audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----- Email delivery / Gmail OAuth2 (admin only) -----
|
||||||
|
getEmailConfig: () => req('/admin/email/config'),
|
||||||
|
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
|
||||||
|
emailConnectUrl: () => req('/admin/email/connect/start'),
|
||||||
|
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
|
||||||
|
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----- player self-service (role: 'player') -----
|
||||||
|
// Mirrors the admin account methods but self-scoped under /player. The change
|
||||||
|
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
|
||||||
|
player: {
|
||||||
|
getAccount: () => req('/player/account'),
|
||||||
|
changeUsername: (username) =>
|
||||||
|
req('/player/account/username', { method: 'PATCH', body: { username } }),
|
||||||
|
changePassword: (newPassword, currentPassword) =>
|
||||||
|
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
|
||||||
|
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
|
||||||
|
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
|
||||||
|
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
|
||||||
|
linkedIdentities: () => req('/player/account/identities'),
|
||||||
|
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// ----- game account linking (uo-link) -----
|
||||||
|
shard: {
|
||||||
|
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
|
||||||
|
accounts: () => req('/player/shard/accounts'),
|
||||||
|
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
|
||||||
|
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
||||||
|
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
|
||||||
|
sales: () => req('/player/shard/sales'),
|
||||||
|
houses: () => req('/player/shard/houses'), // the caller's own houses
|
||||||
|
createAccount: (account, password) =>
|
||||||
|
req('/player/shard/account', { method: 'POST', body: { account, password } }),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
client/src/blocks/BlockRenderer.jsx
Normal file
26
client/src/blocks/BlockRenderer.jsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Renders stored blocks via their registry component. Used by the public page
|
||||||
|
// route, the draft preview, and (recursively) the two_column block. Kept
|
||||||
|
// separate from the registry so both the renderer and the builder can import it.
|
||||||
|
// Import the lookup from the registry directly (not ./index) to avoid a cycle:
|
||||||
|
// index → types/twoColumn → BlockRenderer. The page route/builder import ./index,
|
||||||
|
// which registers every block before anything renders.
|
||||||
|
import { getBlock } from './registry.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one block. A block with `visible === false` renders nothing (admins
|
||||||
|
* hide blocks without deleting them). An unknown type also renders nothing —
|
||||||
|
* server validation prevents storing one, so this only guards a client/server
|
||||||
|
* registry skew rather than crashing the whole page.
|
||||||
|
*/
|
||||||
|
export default function BlockRenderer({ block }) {
|
||||||
|
if (!block || block.visible === false) return null
|
||||||
|
const def = getBlock(block.type)
|
||||||
|
if (!def || !def.component) return null
|
||||||
|
const Component = def.component
|
||||||
|
return <Component props={block.props || {}} block={block} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render an ordered array of blocks (array position = display order). */
|
||||||
|
export function BlockList({ blocks }) {
|
||||||
|
return (blocks || []).map((block) => <BlockRenderer key={block.id} block={block} />)
|
||||||
|
}
|
||||||
64
client/src/blocks/editorKit.jsx
Normal file
64
client/src/blocks/editorKit.jsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Shared form controls for block editors, styled with the existing admin design
|
||||||
|
// system (.field-label / .input / .select). Every block's editor is a
|
||||||
|
// ({ props, onChange }) component; these keep the seven of them consistent and
|
||||||
|
// short. onChange always receives the full next props object.
|
||||||
|
|
||||||
|
export function Field({ label, hint, children }) {
|
||||||
|
return (
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">{label}</span>
|
||||||
|
{children}
|
||||||
|
{hint && (
|
||||||
|
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 4 }}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextField({ label, hint, value, onChange, placeholder, maxLength }) {
|
||||||
|
return (
|
||||||
|
<Field label={label} hint={hint}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={value ?? ''}
|
||||||
|
placeholder={placeholder}
|
||||||
|
maxLength={maxLength}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextAreaField({ label, hint, value, onChange, placeholder, rows = 4, maxLength }) {
|
||||||
|
return (
|
||||||
|
<Field label={label} hint={hint}>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
rows={rows}
|
||||||
|
value={value ?? ''}
|
||||||
|
placeholder={placeholder}
|
||||||
|
maxLength={maxLength}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
style={{ resize: 'vertical', fontFamily: 'inherit' }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// options: array of [value, label] tuples.
|
||||||
|
export function SelectField({ label, hint, value, onChange, options }) {
|
||||||
|
return (
|
||||||
|
<Field label={label} hint={hint}>
|
||||||
|
<select className="select" value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{options.map(([v, l]) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
client/src/blocks/index.js
Normal file
19
client/src/blocks/index.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Client block registry entrypoint. Importing this module registers every
|
||||||
|
// browser-side block definition (renderer + editor + palette entry) exactly
|
||||||
|
// once, then re-exports the registry API. The page builder and the public page
|
||||||
|
// renderer should import from HERE, not ./registry, so the definitions are
|
||||||
|
// loaded before anything reads the registry.
|
||||||
|
//
|
||||||
|
// Wave 1 definitions are registered below as each block is built (spec build
|
||||||
|
// order step 3), one import per block.
|
||||||
|
|
||||||
|
export * from './registry'
|
||||||
|
|
||||||
|
// ── Wave 1 block definitions (self-register on import) ─────────────────
|
||||||
|
import './types/heading.jsx'
|
||||||
|
import './types/richText.jsx'
|
||||||
|
import './types/image.jsx'
|
||||||
|
import './types/twoColumn.jsx'
|
||||||
|
import './types/cta.jsx'
|
||||||
|
import './types/divider.jsx'
|
||||||
|
import './types/quote.jsx'
|
||||||
84
client/src/blocks/registry.js
Normal file
84
client/src/blocks/registry.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// Block registry (client side) — mirrors the server registry
|
||||||
|
// (server/src/blocks/registry.js) but carries the browser-only concerns: the
|
||||||
|
// React renderer, the admin edit form, and the palette icon/label. The page
|
||||||
|
// builder's palette, drag-reorder canvas, per-block edit panel, and the public
|
||||||
|
// page renderer all read from this registry, so adding a block later is one
|
||||||
|
// entry here (plus its server-side schema entry) rather than edits scattered
|
||||||
|
// across the builder and renderer.
|
||||||
|
//
|
||||||
|
// A registered definition looks like:
|
||||||
|
// {
|
||||||
|
// type: 'heading', // must match the server registry type
|
||||||
|
// version: 1, // must match the server schema version
|
||||||
|
// label: 'Heading', // palette display name
|
||||||
|
// icon: 'heading', // palette icon key
|
||||||
|
// component: HeadingBlock, // renderer: (props) => JSX
|
||||||
|
// editor: HeadingEditor, // admin edit form: ({ props, onChange }) => JSX
|
||||||
|
// defaults: () => ({ ... }), // starting props when a block is added
|
||||||
|
// container: false, // true only for two_column
|
||||||
|
// containerSlots: [], // ['left','right'] for two_column
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// This module only defines the pattern; Wave 1 definitions register via
|
||||||
|
// ./index.js as each block is built (spec build order step 3).
|
||||||
|
|
||||||
|
const registry = new Map()
|
||||||
|
|
||||||
|
// Kept in sync with the server's RESERVED_KEYS — the only top-level keys on a
|
||||||
|
// stored block object. Exported so the builder can construct envelopes without
|
||||||
|
// hard-coding the shape.
|
||||||
|
export const RESERVED_KEYS = ['id', 'type', 'version', 'visible', 'props']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a block definition. Throws on a duplicate type — a programmer error
|
||||||
|
* caught at module load, not runtime.
|
||||||
|
* @param {object} def
|
||||||
|
* @returns {object} the stored definition
|
||||||
|
*/
|
||||||
|
export function registerBlock(def) {
|
||||||
|
if (!def || typeof def.type !== 'string' || def.type.length === 0) {
|
||||||
|
throw new Error('registerBlock: a block definition needs a string `type`')
|
||||||
|
}
|
||||||
|
if (registry.has(def.type)) {
|
||||||
|
throw new Error(`registerBlock: block type already registered: ${def.type}`)
|
||||||
|
}
|
||||||
|
const entry = {
|
||||||
|
type: def.type,
|
||||||
|
version: Number.isInteger(def.version) ? def.version : 1,
|
||||||
|
label: def.label || def.type,
|
||||||
|
icon: def.icon || null,
|
||||||
|
component: def.component || null,
|
||||||
|
editor: def.editor || null,
|
||||||
|
defaults: typeof def.defaults === 'function' ? def.defaults : () => ({}),
|
||||||
|
container: Boolean(def.container),
|
||||||
|
containerSlots: def.containerSlots ? [...def.containerSlots] : [],
|
||||||
|
}
|
||||||
|
registry.set(entry.type, entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {object|null} the definition for `type`, or null if unknown. */
|
||||||
|
export function getBlock(type) {
|
||||||
|
return registry.get(type) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} whether `type` is a registered block. */
|
||||||
|
export function hasBlock(type) {
|
||||||
|
return registry.has(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {object[]} all registered definitions (registration order). */
|
||||||
|
export function listBlocks() {
|
||||||
|
return [...registry.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a stable block id. Called once when a block is added to the canvas;
|
||||||
|
* never derived from array position, so a reorder keeps ids intact (they are the
|
||||||
|
* React key and the future revision-history join point).
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function makeBlockId() {
|
||||||
|
const rand = Math.random().toString(36).slice(2, 8).toUpperCase()
|
||||||
|
return `b_${rand}`
|
||||||
|
}
|
||||||
63
client/src/blocks/types/cta.jsx
Normal file
63
client/src/blocks/types/cta.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// cta block — a call-to-action button/link. Renders as an anchor styled with the
|
||||||
|
// existing button system (primary / secondary).
|
||||||
|
import { registerBlock } from '../registry'
|
||||||
|
import { SelectField, TextField } from '../editorKit.jsx'
|
||||||
|
|
||||||
|
const STYLES = [
|
||||||
|
['primary', 'Primary'],
|
||||||
|
['secondary', 'Secondary'],
|
||||||
|
]
|
||||||
|
|
||||||
|
function CtaBlock({ props }) {
|
||||||
|
if (!props.url || !props.text) return null
|
||||||
|
const style = props.style === 'secondary' ? 'secondary' : 'primary'
|
||||||
|
// External links get a safe rel; same-origin relative links don't need it.
|
||||||
|
const external = /^https?:\/\//i.test(props.url)
|
||||||
|
return (
|
||||||
|
<div className="page-cta-wrap">
|
||||||
|
<a
|
||||||
|
className={`btn btn-sq page-cta page-cta--${style}`}
|
||||||
|
href={props.url}
|
||||||
|
{...(external ? { rel: 'noopener noreferrer nofollow' } : {})}
|
||||||
|
>
|
||||||
|
{props.text}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CtaEditor({ props, onChange }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<TextField
|
||||||
|
label="Button text"
|
||||||
|
value={props.text}
|
||||||
|
maxLength={100}
|
||||||
|
onChange={(text) => onChange({ ...props, text })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="URL"
|
||||||
|
hint="A full https:// link or a same-site path like /wiki/getting-started."
|
||||||
|
value={props.url}
|
||||||
|
placeholder="https://…"
|
||||||
|
onChange={(url) => onChange({ ...props, url })}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Style"
|
||||||
|
value={props.style || 'primary'}
|
||||||
|
onChange={(style) => onChange({ ...props, style })}
|
||||||
|
options={STYLES}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'cta',
|
||||||
|
version: 1,
|
||||||
|
label: 'Button',
|
||||||
|
icon: '⇥',
|
||||||
|
component: CtaBlock,
|
||||||
|
editor: CtaEditor,
|
||||||
|
defaults: () => ({ text: '', url: '', style: 'primary' }),
|
||||||
|
})
|
||||||
25
client/src/blocks/types/divider.jsx
Normal file
25
client/src/blocks/types/divider.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// divider block — a pure spacer / horizontal rule. No props, so its editor is
|
||||||
|
// just a note.
|
||||||
|
import { registerBlock } from '../registry'
|
||||||
|
|
||||||
|
function DividerBlock() {
|
||||||
|
return <hr className="page-divider" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DividerEditor() {
|
||||||
|
return (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
|
||||||
|
A divider has no options — it adds a horizontal rule and spacing.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'divider',
|
||||||
|
version: 1,
|
||||||
|
label: 'Divider',
|
||||||
|
icon: '—',
|
||||||
|
component: DividerBlock,
|
||||||
|
editor: DividerEditor,
|
||||||
|
defaults: () => ({}),
|
||||||
|
})
|
||||||
46
client/src/blocks/types/heading.jsx
Normal file
46
client/src/blocks/types/heading.jsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// heading block — plain-text section heading (h1–h4). Text is rendered as text
|
||||||
|
// (React escapes it); use rich_text for inline markup.
|
||||||
|
import { registerBlock } from '../registry'
|
||||||
|
import { SelectField, TextField } from '../editorKit.jsx'
|
||||||
|
|
||||||
|
const LEVELS = [
|
||||||
|
['h1', 'Heading 1'],
|
||||||
|
['h2', 'Heading 2'],
|
||||||
|
['h3', 'Heading 3'],
|
||||||
|
['h4', 'Heading 4'],
|
||||||
|
]
|
||||||
|
const VALID = ['h1', 'h2', 'h3', 'h4']
|
||||||
|
|
||||||
|
function HeadingBlock({ props }) {
|
||||||
|
const Tag = VALID.includes(props.level) ? props.level : 'h2'
|
||||||
|
return <Tag className="page-heading">{props.text}</Tag>
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeadingEditor({ props, onChange }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<SelectField
|
||||||
|
label="Level"
|
||||||
|
value={props.level || 'h2'}
|
||||||
|
onChange={(level) => onChange({ ...props, level })}
|
||||||
|
options={LEVELS}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Text"
|
||||||
|
value={props.text}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(text) => onChange({ ...props, text })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'heading',
|
||||||
|
version: 1,
|
||||||
|
label: 'Heading',
|
||||||
|
icon: 'H',
|
||||||
|
component: HeadingBlock,
|
||||||
|
editor: HeadingEditor,
|
||||||
|
defaults: () => ({ level: 'h2', text: '' }),
|
||||||
|
})
|
||||||
100
client/src/blocks/types/image.jsx
Normal file
100
client/src/blocks/types/image.jsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// image block — a single image with optional caption and alignment. Upload
|
||||||
|
// reuses the shared admin uploader (returns { url }); the block stays URL-based
|
||||||
|
// until the Wave 3 asset picker lands.
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { registerBlock } from '../registry'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
import { SelectField, TextField } from '../editorKit.jsx'
|
||||||
|
|
||||||
|
const ALIGN = [
|
||||||
|
['left', 'Left'],
|
||||||
|
['center', 'Center'],
|
||||||
|
['right', 'Right'],
|
||||||
|
['full', 'Full width'],
|
||||||
|
]
|
||||||
|
const VALID = ['left', 'center', 'right', 'full']
|
||||||
|
|
||||||
|
function ImageBlock({ props }) {
|
||||||
|
if (!props.src) return null
|
||||||
|
const align = VALID.includes(props.alignment) ? props.alignment : 'center'
|
||||||
|
return (
|
||||||
|
<figure className={`page-image page-image--${align}`}>
|
||||||
|
<img src={props.src} alt={props.alt || ''} />
|
||||||
|
{props.caption && <figcaption>{props.caption}</figcaption>}
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageEditor({ props, onChange }) {
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function onUpload(e) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = ''
|
||||||
|
if (!file) return
|
||||||
|
setUploading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const { url } = await api.admin.upload(file)
|
||||||
|
onChange({ ...props, src: url })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Upload failed')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<span className="field-label">Image</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={onUpload}
|
||||||
|
className="sans"
|
||||||
|
style={{ color: 'var(--muted)', fontSize: '0.85rem', display: 'block' }}
|
||||||
|
/>
|
||||||
|
{uploading && <span className="sans dim" style={{ fontSize: '0.8rem' }}> uploading…</span>}
|
||||||
|
{error && <span className="sans" style={{ fontSize: '0.8rem', color: '#d98b84' }}>{error}</span>}
|
||||||
|
{props.src && (
|
||||||
|
<img
|
||||||
|
src={props.src}
|
||||||
|
alt=""
|
||||||
|
style={{ display: 'block', marginTop: 10, maxWidth: '100%', borderRadius: 8, border: '1px solid var(--line)' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
label="Alt text"
|
||||||
|
hint="Describes the image for screen readers and when it fails to load."
|
||||||
|
value={props.alt}
|
||||||
|
maxLength={300}
|
||||||
|
onChange={(alt) => onChange({ ...props, alt })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Caption (optional)"
|
||||||
|
value={props.caption}
|
||||||
|
maxLength={500}
|
||||||
|
onChange={(caption) => onChange({ ...props, caption })}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Alignment"
|
||||||
|
value={props.alignment || 'center'}
|
||||||
|
onChange={(alignment) => onChange({ ...props, alignment })}
|
||||||
|
options={ALIGN}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'image',
|
||||||
|
version: 1,
|
||||||
|
label: 'Image',
|
||||||
|
icon: '🖼',
|
||||||
|
component: ImageBlock,
|
||||||
|
editor: ImageEditor,
|
||||||
|
defaults: () => ({ src: '', alt: '', caption: '', alignment: 'center' }),
|
||||||
|
})
|
||||||
43
client/src/blocks/types/quote.jsx
Normal file
43
client/src/blocks/types/quote.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// quote block — a pull quote with optional attribution.
|
||||||
|
import { registerBlock } from '../registry'
|
||||||
|
import { TextAreaField, TextField } from '../editorKit.jsx'
|
||||||
|
|
||||||
|
function QuoteBlock({ props }) {
|
||||||
|
if (!props.text) return null
|
||||||
|
return (
|
||||||
|
<figure className="page-quote">
|
||||||
|
<blockquote>{props.text}</blockquote>
|
||||||
|
{props.attribution && <figcaption>— {props.attribution}</figcaption>}
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuoteEditor({ props, onChange }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<TextAreaField
|
||||||
|
label="Quote"
|
||||||
|
value={props.text}
|
||||||
|
rows={3}
|
||||||
|
maxLength={1000}
|
||||||
|
onChange={(text) => onChange({ ...props, text })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Attribution (optional)"
|
||||||
|
value={props.attribution}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(attribution) => onChange({ ...props, attribution })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'quote',
|
||||||
|
version: 1,
|
||||||
|
label: 'Quote',
|
||||||
|
icon: '❝',
|
||||||
|
component: QuoteBlock,
|
||||||
|
editor: QuoteEditor,
|
||||||
|
defaults: () => ({ text: '', attribution: '' }),
|
||||||
|
})
|
||||||
39
client/src/blocks/types/richText.jsx
Normal file
39
client/src/blocks/types/richText.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// rich_text block — HTML from the shared rich-text editor. Rendered inside the
|
||||||
|
// same `.prose` styling as wiki/news bodies, sanitized on render as defense in
|
||||||
|
// depth (the server also sanitizes on save).
|
||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import { registerBlock } from '../registry'
|
||||||
|
|
||||||
|
const RichTextEditor = lazy(() => import('../../components/RichTextEditor.jsx'))
|
||||||
|
|
||||||
|
function RichTextBlock({ props }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="prose page-rich-text"
|
||||||
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(props.html || '') }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RichTextEditorForm({ props, onChange }) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<span className="spin" />}>
|
||||||
|
<RichTextEditor
|
||||||
|
value={props.html || ''}
|
||||||
|
onChange={(html) => onChange({ ...props, html })}
|
||||||
|
variant="post"
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'rich_text',
|
||||||
|
version: 1,
|
||||||
|
label: 'Rich text',
|
||||||
|
icon: '¶',
|
||||||
|
component: RichTextBlock,
|
||||||
|
editor: RichTextEditorForm,
|
||||||
|
defaults: () => ({ html: '' }),
|
||||||
|
})
|
||||||
120
client/src/blocks/types/twoColumn.jsx
Normal file
120
client/src/blocks/types/twoColumn.jsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// two_column block — the only container. Holds two ordered arrays of sub-blocks
|
||||||
|
// (`left`, `right`). Sub-blocks are leaf blocks only (no nested containers — the
|
||||||
|
// one-level cap the server also enforces), so the column editor's palette is the
|
||||||
|
// set of non-container registered blocks.
|
||||||
|
import { registerBlock, getBlock, listBlocks, makeBlockId } from '../registry'
|
||||||
|
import BlockRenderer from '../BlockRenderer.jsx'
|
||||||
|
|
||||||
|
// ── Renderer ──────────────────────────────────────────────────────────
|
||||||
|
function TwoColumnBlock({ props }) {
|
||||||
|
const left = Array.isArray(props.left) ? props.left : []
|
||||||
|
const right = Array.isArray(props.right) ? props.right : []
|
||||||
|
return (
|
||||||
|
<div className="page-two-column">
|
||||||
|
<div className="page-column">
|
||||||
|
{left.map((b) => (
|
||||||
|
<BlockRenderer key={b.id} block={b} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="page-column">
|
||||||
|
{right.map((b) => (
|
||||||
|
<BlockRenderer key={b.id} block={b} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Column editor ─────────────────────────────────────────────────────
|
||||||
|
// Manages one side's array: add (from the leaf palette), edit each via its own
|
||||||
|
// registry editor, reorder, remove.
|
||||||
|
function ColumnEditor({ title, items, onChange }) {
|
||||||
|
const list = Array.isArray(items) ? items : []
|
||||||
|
const palette = listBlocks().filter((b) => !b.container)
|
||||||
|
|
||||||
|
function addBlock(type) {
|
||||||
|
const def = getBlock(type)
|
||||||
|
if (!def) return
|
||||||
|
const block = { id: makeBlockId(), type, version: def.version, visible: true, props: def.defaults() }
|
||||||
|
onChange([...list, block])
|
||||||
|
}
|
||||||
|
function updateAt(i, nextProps) {
|
||||||
|
onChange(list.map((b, j) => (j === i ? { ...b, props: nextProps } : b)))
|
||||||
|
}
|
||||||
|
function removeAt(i) {
|
||||||
|
onChange(list.filter((_, j) => j !== i))
|
||||||
|
}
|
||||||
|
function move(i, dir) {
|
||||||
|
const j = i + dir
|
||||||
|
if (j < 0 || j >= list.length) return
|
||||||
|
const next = [...list]
|
||||||
|
;[next[i], next[j]] = [next[j], next[i]]
|
||||||
|
onChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-column-editor">
|
||||||
|
<div className="pb-column-head">
|
||||||
|
<span className="field-label" style={{ margin: 0 }}>{title}</span>
|
||||||
|
<select
|
||||||
|
className="select pb-add-select"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) addBlock(e.target.value)
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">+ Add block…</option>
|
||||||
|
{palette.map((b) => (
|
||||||
|
<option key={b.type} value={b.type}>
|
||||||
|
{b.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{list.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>Empty column.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{list.map((block, i) => {
|
||||||
|
const def = getBlock(block.type)
|
||||||
|
const Editor = def?.editor
|
||||||
|
return (
|
||||||
|
<div key={block.id} className="pb-subblock">
|
||||||
|
<div className="pb-subblock-head">
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.78rem' }}>{def?.label || block.type}</span>
|
||||||
|
<div className="pb-subblock-actions">
|
||||||
|
<button type="button" className="pill pb-mini" disabled={i === 0} onClick={() => move(i, -1)} title="Move up">↑</button>
|
||||||
|
<button type="button" className="pill pb-mini" disabled={i === list.length - 1} onClick={() => move(i, 1)} title="Move down">↓</button>
|
||||||
|
<button type="button" className="pill pb-mini" onClick={() => removeAt(i)} title="Remove">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{Editor && <Editor props={block.props || {}} onChange={(p) => updateAt(i, p)} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TwoColumnEditor({ props, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="pb-two-column-editor">
|
||||||
|
<ColumnEditor title="Left column" items={props.left} onChange={(left) => onChange({ ...props, left })} />
|
||||||
|
<ColumnEditor title="Right column" items={props.right} onChange={(right) => onChange({ ...props, right })} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBlock({
|
||||||
|
type: 'two_column',
|
||||||
|
version: 1,
|
||||||
|
label: 'Two columns',
|
||||||
|
icon: '▥',
|
||||||
|
component: TwoColumnBlock,
|
||||||
|
editor: TwoColumnEditor,
|
||||||
|
defaults: () => ({ left: [], right: [] }),
|
||||||
|
container: true,
|
||||||
|
containerSlots: ['left', 'right'],
|
||||||
|
})
|
||||||
202
client/src/components/CharacterSheet.jsx
Normal file
202
client/src/components/CharacterSheet.jsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
// Reusable character-sheet renderer for the char.profile shape returned by
|
||||||
|
// /public/shard/char/:serial. Presentational only — the parent handles loading
|
||||||
|
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
|
||||||
|
//
|
||||||
|
// `moderation` opts in the in-game kick/ban controls for the character's account;
|
||||||
|
// they self-gate to staff (ShardAccountActions), so passing it from a page a
|
||||||
|
// player can reach is safe.
|
||||||
|
|
||||||
|
import ShardAccountActions from './ShardAccountActions.jsx'
|
||||||
|
|
||||||
|
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||||
|
|
||||||
|
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
|
||||||
|
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
|
||||||
|
// literal string. Without a cliloc table on the site we can only show literals, so
|
||||||
|
// numeric reward entries are skipped rather than shown as a raw number. Returns a
|
||||||
|
// de-duped list of human-readable title chips.
|
||||||
|
function displayTitles(titles) {
|
||||||
|
if (!titles) return []
|
||||||
|
const out = []
|
||||||
|
if (titles.fameKarma) out.push(titles.fameKarma)
|
||||||
|
if (titles.skill) out.push(titles.skill)
|
||||||
|
const reward = Array.isArray(titles.reward) ? titles.reward : []
|
||||||
|
const sel = typeof titles.selected === 'number' ? titles.selected : -1
|
||||||
|
// Prefer the selected reward title; fall back to the first literal one.
|
||||||
|
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
|
||||||
|
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
|
||||||
|
return [...new Set(out.filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleChip({ children, tone = 'var(--muted)' }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
|
||||||
|
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({ value, label }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
|
||||||
|
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Vital({ label, cur, max }) {
|
||||||
|
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '12px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||||
|
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
|
||||||
|
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? '—'}</span></span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CharacterSheet({ char, moderation = false }) {
|
||||||
|
if (!char) return null
|
||||||
|
const stats = char.stats || {}
|
||||||
|
const resist = stats.resist || {}
|
||||||
|
// Skills the character actually has, best first.
|
||||||
|
const skills = (char.skills || [])
|
||||||
|
.filter((s) => (s.value || s.base || 0) > 0)
|
||||||
|
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
||||||
|
const equipment = char.equipment || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||||
|
{/* Identity */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
|
||||||
|
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
|
||||||
|
border: '1px solid var(--line)', fontSize: '0.74rem',
|
||||||
|
color: char.online ? '#7fd0a4' : 'var(--muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{char.online ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Titles + standing (guild led / governorship) — all optional */}
|
||||||
|
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
|
||||||
|
{char.governorOf && char.governorOf.map((city) => (
|
||||||
|
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
|
||||||
|
))}
|
||||||
|
{char.guild && (
|
||||||
|
<TitleChip tone="var(--accent)">
|
||||||
|
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
|
||||||
|
</TitleChip>
|
||||||
|
)}
|
||||||
|
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Staff moderation for this character's account (self-gates to staff). */}
|
||||||
|
{moderation && char.acct && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
|
||||||
|
<ShardAccountActions account={char.acct} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Core stats */}
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
|
||||||
|
<div className="grid-3" style={{ gap: 12 }}>
|
||||||
|
<StatTile value={stats.str ?? '—'} label="Strength" />
|
||||||
|
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
|
||||||
|
<StatTile value={stats.int ?? '—'} label="Intelligence" />
|
||||||
|
</div>
|
||||||
|
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
|
||||||
|
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
|
||||||
|
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
|
||||||
|
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Resistances */}
|
||||||
|
{Object.keys(resist).length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
|
||||||
|
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
|
||||||
|
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills */}
|
||||||
|
{skills.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
|
||||||
|
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||||
|
{skills.map((s) => {
|
||||||
|
const cap = s.cap || 100
|
||||||
|
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
|
||||||
|
return (
|
||||||
|
<div key={s.n}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
|
||||||
|
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
|
||||||
|
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Equipment */}
|
||||||
|
{equipment.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{equipment.map((it) => (
|
||||||
|
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||||
|
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{it.layer || 'Item'}</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
{it.mods && Object.keys(it.mods).length > 0 && (
|
||||||
|
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
|
||||||
|
{Object.entries(it.mods).map(([k, v]) => (
|
||||||
|
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
client/src/components/CharacterStats.jsx
Normal file
72
client/src/components/CharacterStats.jsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
// A small stat-tile row for a "My Characters" page: total characters, how many
|
||||||
|
// are online right now, and how many game accounts are linked. `scope` is the
|
||||||
|
// shard api object (admin or player self-service). Renders nothing until an
|
||||||
|
// account is linked, so the empty/link-prompt state below it stands alone.
|
||||||
|
//
|
||||||
|
// It fetches the same rosters GameAccounts loads; for a personal page that's at
|
||||||
|
// most a couple of extra live round-trips, and keeps this presentational bit
|
||||||
|
// decoupled from GameAccounts' per-account roster loading.
|
||||||
|
|
||||||
|
function Tile({ value, label }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||||
|
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: 8 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CharacterStats({ scope }) {
|
||||||
|
const [stats, setStats] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const accounts = await scope.accounts()
|
||||||
|
const linked = accounts.length
|
||||||
|
if (linked === 0) {
|
||||||
|
if (!cancelled) setStats({ linked: 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Roster is a live round-trip and can be unavailable (503); tolerate a
|
||||||
|
// partial result so a restarting shard doesn't blank the whole row.
|
||||||
|
const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
|
||||||
|
let chars = 0
|
||||||
|
let online = 0
|
||||||
|
let complete = true
|
||||||
|
for (const r of rosters) {
|
||||||
|
if (r.status === 'fulfilled') {
|
||||||
|
const cs = r.value.chars || []
|
||||||
|
chars += cs.length
|
||||||
|
online += cs.filter((c) => c.online).length
|
||||||
|
} else {
|
||||||
|
complete = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled) setStats({ linked, chars, online, complete })
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setStats({ error: true })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [scope])
|
||||||
|
|
||||||
|
// Hidden until we know an account is linked (or while first loading).
|
||||||
|
if (!stats || stats.error || stats.linked === 0) return null
|
||||||
|
|
||||||
|
// Counts depend on live rosters; show a dash if none came back.
|
||||||
|
const count = (n) => (stats.complete || stats.chars > 0 ? n : '—')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="grid-3" style={{ gap: 14, marginBottom: 26 }}>
|
||||||
|
<Tile value={count(stats.chars)} label="Characters" />
|
||||||
|
<Tile value={count(stats.online)} label="Online now" />
|
||||||
|
<Tile value={stats.linked} label={stats.linked === 1 ? 'Linked account' : 'Linked accounts'} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
client/src/components/CreateGameAccountForm.jsx
Normal file
69
client/src/components/CreateGameAccountForm.jsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// Reusable "create a game account" form (its own username + password — the game
|
||||||
|
// client credentials, distinct from the website login). Calls `submit(account,
|
||||||
|
// password)` which should POST /player/shard/account; on success calls onCreated.
|
||||||
|
// Used by the player portal (self-serve) and the invite-accept page alike.
|
||||||
|
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
|
||||||
|
const [account, setAccount] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setMsg(''); setError('')
|
||||||
|
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
|
||||||
|
return setError('Account name must be 3–30 letters, numbers, . _ or -.')
|
||||||
|
}
|
||||||
|
if (password.length < 8) return setError('Password must be at least 8 characters.')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await submit(account, password)
|
||||||
|
setMsg(`Game account “${account}” created and linked.`)
|
||||||
|
setAccount(''); setPassword('')
|
||||||
|
if (onCreated) await onCreated()
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 409) setError('That account name is already taken.')
|
||||||
|
else if (err.status === 429) setError('The account limit for your network has been reached.')
|
||||||
|
else if (err.status === 403) setError('Game-account signup is not available right now.')
|
||||||
|
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
|
||||||
|
else setError(err.message || 'Could not create the account right now.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
{!compact && (
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Choose the username and password you’ll type into the game client. These are your
|
||||||
|
<strong style={{ color: 'var(--head)' }}> game</strong> credentials — separate from your website login.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||||
|
<span className="field-label">Game account name</span>
|
||||||
|
<input
|
||||||
|
type="text" autoComplete="off" value={account}
|
||||||
|
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
<span className="field-label">Game password</span>
|
||||||
|
<input
|
||||||
|
type="password" autoComplete="new-password" value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)} className="input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||||
|
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Creating…' : 'Create game account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
230
client/src/components/GameAccounts.jsx
Normal file
230
client/src/components/GameAccounts.jsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from './PageState.jsx'
|
||||||
|
import ShardAccountActions from './ShardAccountActions.jsx'
|
||||||
|
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
|
||||||
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
|
// Shared game-account linking + character roster, used by both the player portal
|
||||||
|
// (/player) and the staff account page (/admin/account). `scope` is the api
|
||||||
|
// object with { link, accounts, roster } (player or admin self-service); `charTo`
|
||||||
|
// maps a serial to the route for that character's sheet. `readOnly` drops the
|
||||||
|
// link forms and self-voice copy for the admin case where staff view *another*
|
||||||
|
// user's accounts (no `scope.link`) at /admin/users/:id.
|
||||||
|
|
||||||
|
function LinkForm({ scope, onLinked, compact }) {
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setMsg(''); setError('')
|
||||||
|
if (!code.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const { account } = await scope.link(code.trim())
|
||||||
|
setMsg(`Linked ${account}.`)
|
||||||
|
setCode('')
|
||||||
|
await onLinked()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not link that code.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
{!compact && <span className="field-label">Link code</span>}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
|
className="input"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="AB12CD"
|
||||||
|
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Linking…' : 'Link account'}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountRoster({ scope, account, charTo }) {
|
||||||
|
const [roster, setRoster] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [unavailable, setUnavailable] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(''); setUnavailable(false)
|
||||||
|
try {
|
||||||
|
setRoster(await scope.roster(account))
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 503) setUnavailable(true)
|
||||||
|
else setError(err.message || 'Could not load this account.')
|
||||||
|
}
|
||||||
|
}, [scope, account])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
if (unavailable) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
|
||||||
|
<button className="pill" onClick={load}>Retry</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||||
|
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
|
||||||
|
|
||||||
|
const chars = roster.chars || []
|
||||||
|
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid-2" style={{ gap: 12 }}>
|
||||||
|
{chars.map((c) => (
|
||||||
|
<Link
|
||||||
|
key={c.serial}
|
||||||
|
to={charTo(c.serial)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
|
||||||
|
{(c.name || '?').charAt(0)}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
|
||||||
|
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
|
||||||
|
</div>
|
||||||
|
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
|
||||||
|
// then calls onUnlink(account) and reloads. Errors surface inline.
|
||||||
|
function UnlinkButton({ account, onUnlink }) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
async function go() {
|
||||||
|
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
await onUnlink(account)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.status === 403 ? 'Protected account — refused.' : err.status === 404 ? 'Not linked.' : (err.message || 'Could not unlink.'))
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
|
||||||
|
{busy ? 'Unlinking…' : 'Unlink'}
|
||||||
|
</button>
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
|
||||||
|
const [accounts, setAccounts] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
// Whether the site currently offers game-account creation (public flag). Only
|
||||||
|
// relevant for the self-service (non-readOnly) view with a createAccount scope.
|
||||||
|
const [signupOk, setSignupOk] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setAccounts(await scope.accounts())
|
||||||
|
} catch {
|
||||||
|
setError(readOnly ? 'Could not load this user’s game accounts.' : 'Could not load your game accounts.')
|
||||||
|
}
|
||||||
|
}, [scope, readOnly])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (readOnly || !scope.createAccount) return
|
||||||
|
let active = true
|
||||||
|
api.publicSettings()
|
||||||
|
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
|
||||||
|
.catch(() => {})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [readOnly, scope])
|
||||||
|
|
||||||
|
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!accounts) return <Loading />
|
||||||
|
|
||||||
|
// No linked accounts. In read-only (admin viewing another user) this is just an
|
||||||
|
// empty state; otherwise it's the link-your-account prompt.
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||||
|
This user has not linked a game account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
|
||||||
|
one-time code, then enter it below to see your characters, stats, skills and vendors here.
|
||||||
|
</p>
|
||||||
|
<LinkForm scope={scope} onLinked={load} />
|
||||||
|
</div>
|
||||||
|
{canCreate && (
|
||||||
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
|
||||||
|
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linked — characters grouped by account.
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<section key={a.account}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||||
|
{a.account}
|
||||||
|
</div>
|
||||||
|
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
|
||||||
|
</div>
|
||||||
|
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
|
||||||
|
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{!readOnly && (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||||||
|
<LinkForm scope={scope} onLinked={load} compact />
|
||||||
|
{canCreate && (
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
|
||||||
|
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
const MOON_IMAGE = '/assets/img/hero-moon.png'
|
const MOON_IMAGE = '/assets/img/hero-moon.png'
|
||||||
|
|
||||||
@@ -34,7 +35,19 @@ function TextBlock({ props }) {
|
|||||||
return (
|
return (
|
||||||
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
||||||
{(props.lines || []).map((line, i) => {
|
{(props.lines || []).map((line, i) => {
|
||||||
const Tag = /^(h1|h2|h3|p|span)$/.test(line.tag) ? line.tag : 'p'
|
const Tag = /^(h1|h2|h3|p|span|div)$/.test(line.tag) ? line.tag : 'p'
|
||||||
|
// A rich-text line (e.g. the homepage teaser) carries sanitized HTML;
|
||||||
|
// sanitize again on render as defense in depth. Others render as text.
|
||||||
|
if (line.html) {
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={i}
|
||||||
|
className="hero-rich"
|
||||||
|
style={lineStyle(line)}
|
||||||
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(line.text || '') }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Tag key={i} style={lineStyle(line)}>
|
<Tag key={i} style={lineStyle(line)}>
|
||||||
{line.text}
|
{line.text}
|
||||||
|
|||||||
84
client/src/components/PlayersOnline.jsx
Normal file
84
client/src/components/PlayersOnline.jsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useAsync } from '../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../lib/useShardFeed.js'
|
||||||
|
import { bucketize } from '../data/regionBuckets.js'
|
||||||
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
|
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
|
||||||
|
// then keeps the total + region breakdown current from the presence.online SSE
|
||||||
|
// kind. The raw byRegion map is rolled up into display buckets (see
|
||||||
|
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
|
||||||
|
const PRESENCE_KINDS = new Set(['presence.online'])
|
||||||
|
|
||||||
|
export default function PlayersOnline() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.presence())
|
||||||
|
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
|
||||||
|
|
||||||
|
// The freshest snapshot wins: the newest buffered presence.online event, else
|
||||||
|
// the initial fetch.
|
||||||
|
const snapshot = events[0] || data
|
||||||
|
|
||||||
|
const { total, rows } = useMemo(() => {
|
||||||
|
const count = Number(snapshot?.count) || 0
|
||||||
|
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
|
||||||
|
return { total: count, rows: bucketRows }
|
||||||
|
}, [snapshot])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 20 }}>
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
letterSpacing: '0.12em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Players online
|
||||||
|
</span>
|
||||||
|
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
|
||||||
|
{loading ? '—' : total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
|
||||||
|
Population is unavailable right now.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
|
||||||
|
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
rows.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{r.label}</span>
|
||||||
|
{/* tabular figures keep the right-aligned counts in a clean column */}
|
||||||
|
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom'
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
// Gate for /admin/* — redirects to the login screen when not authenticated.
|
// Gate for /admin/* — redirects to the login screen when not authenticated, and
|
||||||
|
// bounces a signed-in player to their own portal (the admin API 403s them anyway;
|
||||||
|
// this keeps the UI honest and mirrors RequirePlayer).
|
||||||
export default function RequireAuth({ children }) {
|
export default function RequireAuth({ children }) {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -16,5 +18,8 @@ export default function RequireAuth({ children }) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/admin/login" state={{ from: location }} replace />
|
return <Navigate to="/admin/login" state={{ from: location }} replace />
|
||||||
}
|
}
|
||||||
|
if (user.role === 'player') {
|
||||||
|
return <Navigate to="/account" replace />
|
||||||
|
}
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|||||||
23
client/src/components/RequirePlayer.jsx
Normal file
23
client/src/components/RequirePlayer.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
|
// Gate for the /account player portal. Redirects to the player login when there
|
||||||
|
// is no session, or when the signed-in user is not a player (staff manage their
|
||||||
|
// own account under /admin/account). Server-side requireRole('player') is the
|
||||||
|
// real enforcement; this just keeps the UI honest.
|
||||||
|
export default function RequirePlayer({ children }) {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: 'var(--bg-deep)' }}>
|
||||||
|
<span className="spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!user || user.role !== 'player') {
|
||||||
|
return <Navigate to="/account/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useEditor, EditorContent } from '@tiptap/react'
|
|||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
import Image from '@tiptap/extension-image'
|
import Image from '@tiptap/extension-image'
|
||||||
|
import TextAlign from '@tiptap/extension-text-align'
|
||||||
import { api } from '../api/client.js'
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
// Toolbar button.
|
// Toolbar button.
|
||||||
@@ -25,6 +26,22 @@ function escapeHtml(s) {
|
|||||||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alignment glyph: three lines justified to the given side.
|
||||||
|
function AlignIcon({ align }) {
|
||||||
|
const rows = {
|
||||||
|
left: [[2, 14], [2, 10], [2, 12]],
|
||||||
|
center: [[2, 14], [4, 12], [3, 13]],
|
||||||
|
right: [[2, 14], [6, 14], [4, 14]],
|
||||||
|
}[align]
|
||||||
|
return (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
|
||||||
|
{rows.map(([x1, x2], i) => (
|
||||||
|
<line key={i} x1={x1} y1={4 + i * 4} x2={x2} y2={4 + i * 4} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Toolbar variants:
|
// Toolbar variants:
|
||||||
// 'full' — every control, incl. the internal wiki-page link picker (wiki use).
|
// 'full' — every control, incl. the internal wiki-page link picker (wiki use).
|
||||||
// 'post' — full minus the wiki-page picker (no page-list context in posts).
|
// 'post' — full minus the wiki-page picker (no page-list context in posts).
|
||||||
@@ -42,6 +59,11 @@ export default function RichTextEditor({ value, onChange, pages = [], variant =
|
|||||||
StarterKit.configure({ heading: { levels: [2, 3] } }),
|
StarterKit.configure({ heading: { levels: [2, 3] } }),
|
||||||
Link.configure({ openOnClick: false, autolink: true }),
|
Link.configure({ openOnClick: false, autolink: true }),
|
||||||
Image.configure({ inline: false }),
|
Image.configure({ inline: false }),
|
||||||
|
// Alignment stored as `text-align` on the block node (heading/paragraph),
|
||||||
|
// so it round-trips through save/reload as inline style. Shared here means
|
||||||
|
// every consumer — post editor, and the future rich_text / two_column
|
||||||
|
// blocks — gets it for free.
|
||||||
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||||
],
|
],
|
||||||
content: value || '',
|
content: value || '',
|
||||||
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
||||||
@@ -131,6 +153,16 @@ export default function RichTextEditor({ value, onChange, pages = [], variant =
|
|||||||
—
|
—
|
||||||
</Btn>
|
</Btn>
|
||||||
<span className="rte-sep" />
|
<span className="rte-sep" />
|
||||||
|
<Btn title="Align left" active={editor.isActive({ textAlign: 'left' })} onClick={() => editor.chain().focus().setTextAlign('left').run()}>
|
||||||
|
<AlignIcon align="left" />
|
||||||
|
</Btn>
|
||||||
|
<Btn title="Align center" active={editor.isActive({ textAlign: 'center' })} onClick={() => editor.chain().focus().setTextAlign('center').run()}>
|
||||||
|
<AlignIcon align="center" />
|
||||||
|
</Btn>
|
||||||
|
<Btn title="Align right" active={editor.isActive({ textAlign: 'right' })} onClick={() => editor.chain().focus().setTextAlign('right').run()}>
|
||||||
|
<AlignIcon align="right" />
|
||||||
|
</Btn>
|
||||||
|
<span className="rte-sep" />
|
||||||
<Btn title="Link" active={editor.isActive('link')} onClick={setLink}>
|
<Btn title="Link" active={editor.isActive('link')} onClick={setLink}>
|
||||||
🔗
|
🔗
|
||||||
</Btn>
|
</Btn>
|
||||||
|
|||||||
84
client/src/components/ShardAccountActions.jsx
Normal file
84
client/src/components/ShardAccountActions.jsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
|
// Compact in-game moderation controls (kick / ban / unban) scoped to a single
|
||||||
|
// game account. Reused wherever a linked account or character is shown to staff:
|
||||||
|
// the admin user-detail account list and the character sheet. Self-gates on role
|
||||||
|
// (admin/moderator) so it is safe to render inside components that players also
|
||||||
|
// see — a player never gets the controls, and the API enforces the same gate.
|
||||||
|
//
|
||||||
|
// `actor` is stamped server-side from the session; nothing here sends it. Kick is
|
||||||
|
// reversible (they reconnect) so it acts immediately; Ban reveals an inline
|
||||||
|
// confirm with an optional duration + reason before it fires.
|
||||||
|
export default function ShardAccountActions({ account, style }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
const [ok, setOk] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [banOpen, setBanOpen] = useState(false)
|
||||||
|
const [durationSec, setDurationSec] = useState('')
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
|
||||||
|
// Only staff who can actually use the write plane see the controls.
|
||||||
|
if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null
|
||||||
|
|
||||||
|
async function run(label, fn, done) {
|
||||||
|
setBusy(label); setOk(''); setErr('')
|
||||||
|
try {
|
||||||
|
const r = await fn()
|
||||||
|
setOk(done(r))
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Action failed.')
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kick = () =>
|
||||||
|
run('kick', () => api.admin.shardOps.kick({ account }), (r) =>
|
||||||
|
`Kicked${r && r.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`,
|
||||||
|
)
|
||||||
|
const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
|
||||||
|
const ban = () =>
|
||||||
|
run('ban', () =>
|
||||||
|
api.admin.shardOps.ban({
|
||||||
|
account,
|
||||||
|
durationSec: durationSec === '' ? undefined : Number(durationSec),
|
||||||
|
reason: reason.trim() || undefined,
|
||||||
|
}),
|
||||||
|
() => {
|
||||||
|
setBanOpen(false)
|
||||||
|
return `Banned${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`
|
||||||
|
})
|
||||||
|
|
||||||
|
const btn = { fontSize: '0.72rem', padding: '4px 10px' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sans" style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button onClick={kick} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'kick' ? '…' : 'Kick'}</button>
|
||||||
|
<button onClick={() => { setBanOpen((v) => !v); setOk(''); setErr('') }} disabled={!!busy} className="btn btn-sq" style={{ ...btn, borderColor: '#d98b84', color: '#d98b84' }}>Ban…</button>
|
||||||
|
<button onClick={unban} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'unban' ? '…' : 'Unban'}</button>
|
||||||
|
{ok && <span style={{ color: '#7fd0a4', fontSize: '0.8rem' }}>{ok}</span>}
|
||||||
|
{err && <span style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{banOpen && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 8, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8, background: 'rgba(217,139,132,0.06)' }}>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Duration (sec, blank = permanent)</span>
|
||||||
|
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" style={{ maxWidth: 150 }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', flex: 1, minWidth: 160 }}>
|
||||||
|
<span className="field-label">Reason (optional)</span>
|
||||||
|
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
||||||
|
</label>
|
||||||
|
<button onClick={ban} disabled={busy === 'ban'} className="btn btn-primary btn-sq" style={{ borderColor: '#d98b84', background: '#d98b84', ...btn }}>
|
||||||
|
{busy === 'ban' ? 'Banning…' : `Confirm ban ${account}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,21 +2,29 @@ import { Link } from 'react-router-dom'
|
|||||||
import { useSite } from '../contexts/SiteContext.jsx'
|
import { useSite } from '../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
export default function SiteFooter() {
|
export default function SiteFooter() {
|
||||||
const { contactEmail } = useSite()
|
const { contactEmail, siteTitle } = useSite()
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
className="sans"
|
className="sans site-footer"
|
||||||
style={{
|
style={{
|
||||||
borderTop: '1px solid var(--line)',
|
borderTop: '1px solid var(--line)',
|
||||||
padding: '28px 16px',
|
padding: '28px 16px',
|
||||||
color: 'var(--muted)',
|
color: 'var(--muted)',
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
background: 'rgba(9,13,18,0.6)',
|
background: 'rgba(9,13,18,0.6)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
<div className="site-footer-inner">
|
||||||
<span>UOMysticmoon is an independent private shard project.</span>
|
<div className="site-footer-badge">
|
||||||
|
<img src="/assets/img/runic-emblem.png" alt="" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
Powered by
|
||||||
|
<br />
|
||||||
|
<strong>Runic Gateway</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="site-footer-info">
|
||||||
|
<span>{siteTitle} is an independent private shard project.</span>
|
||||||
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
|
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||||
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
{contactEmail}
|
{contactEmail}
|
||||||
@@ -31,6 +39,7 @@ export default function SiteFooter() {
|
|||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,43 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link, NavLink } from 'react-router-dom'
|
||||||
import MoonDot from './MoonDot.jsx'
|
import MoonDot from './MoonDot.jsx'
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
|
import { useSite } from '../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
const NAV = {
|
// One consistent top nav for the whole public site. Every page gets the same
|
||||||
website: [
|
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
|
||||||
|
const NAV = [
|
||||||
|
{ label: 'Home', to: '/', end: true },
|
||||||
{ label: 'News', to: '/site/news' },
|
{ label: 'News', to: '/site/news' },
|
||||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||||
{ label: 'About', to: '/site/about' },
|
|
||||||
{ label: 'Wiki', to: '/wiki' },
|
{ label: 'Wiki', to: '/wiki' },
|
||||||
],
|
{ label: 'Shard', to: '/site/shard' },
|
||||||
wiki: [
|
{ label: 'Champions', to: '/site/champs' },
|
||||||
{ label: 'Website', to: '/site' },
|
{ label: 'Guilds', to: '/site/guilds' },
|
||||||
{ label: 'New Player Guide', to: '/wiki/new-player-guide' },
|
{ label: 'Governors', to: '/site/governors' },
|
||||||
{ label: 'Maps & Atlas', to: '/wiki/maps-atlas' },
|
{ label: 'Houses', to: '/site/houses' },
|
||||||
{ label: 'Systems', to: '/wiki/systems' },
|
{ label: 'About', to: '/site/about' },
|
||||||
{ label: 'Rules', to: '/wiki/rules' },
|
]
|
||||||
],
|
|
||||||
}
|
const linkStyle = ({ isActive }) => ({
|
||||||
|
background: isActive ? 'var(--accent)' : undefined,
|
||||||
|
color: isActive ? 'var(--bg-deep)' : undefined,
|
||||||
|
borderColor: isActive ? 'var(--accent)' : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function SiteHeader() {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
const { siteTitle } = useSite()
|
||||||
|
|
||||||
|
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||||
|
const account =
|
||||||
|
user && user.role && user.role !== 'player'
|
||||||
|
? { label: 'Admin', to: '/admin' }
|
||||||
|
: user
|
||||||
|
? { label: 'My Account', to: '/player' }
|
||||||
|
: { label: 'Sign in', to: '/account/login' }
|
||||||
|
|
||||||
export default function SiteHeader({ section = 'website' }) {
|
|
||||||
const links = NAV[section] || NAV.website
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
@@ -34,38 +51,31 @@ export default function SiteHeader({ section = 'website' }) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="shell"
|
className="shell"
|
||||||
style={{
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 20, padding: '14px 0', flexWrap: 'wrap' }}
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 20,
|
|
||||||
padding: '14px 0',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="display"
|
className="display"
|
||||||
style={{
|
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
color: 'var(--accent-bright)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MoonDot />
|
<MoonDot />
|
||||||
UOMysticmoon
|
{siteTitle}
|
||||||
</Link>
|
</Link>
|
||||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||||
{links.map((l) => (
|
{NAV.map((l) => (
|
||||||
<Link key={l.to + l.label} to={l.to} className="pill">
|
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||||
{l.label}
|
{l.label}
|
||||||
</Link>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
{!loading && (
|
||||||
|
<NavLink
|
||||||
|
to={account.to}
|
||||||
|
className="pill"
|
||||||
|
style={{ marginLeft: 6, borderColor: 'var(--accent)', color: 'var(--accent-bright)' }}
|
||||||
|
>
|
||||||
|
{account.label}
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
41
client/src/components/VendorSales.jsx
Normal file
41
client/src/components/VendorSales.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ago } from '../lib/format.js'
|
||||||
|
|
||||||
|
// Owner-private recent player-vendor sales. `fetchSales` is the scope method
|
||||||
|
// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
|
||||||
|
// sales for accounts linked to the caller.
|
||||||
|
export default function VendorSales({ fetchSales }) {
|
||||||
|
const [sales, setSales] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
fetchSales()
|
||||||
|
.then((rows) => active && setSales(rows))
|
||||||
|
.catch(() => active && setError('Could not load your vendor sales.'))
|
||||||
|
return () => { active = false }
|
||||||
|
}, [fetchSales])
|
||||||
|
|
||||||
|
if (error) return null
|
||||||
|
if (!sales) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 12 }}>Recent vendor sales</div>
|
||||||
|
{sales.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{sales.map((s, i) => (
|
||||||
|
<li key={`${s.t}-${i}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp
|
||||||
|
</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(s.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -30,6 +30,14 @@ export function AuthProvider({ children }) {
|
|||||||
return data
|
return data
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Public self-registration (player). Creates the account, sets the session
|
||||||
|
// cookie, and returns { user }. `extra` carries the honeypot + optional email.
|
||||||
|
const register = useCallback(async (username, password, extra) => {
|
||||||
|
const data = await api.register(username, password, extra)
|
||||||
|
if (data.user) setUser(data.user)
|
||||||
|
return data
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Step 2 for TOTP users: exchange the challenge + code for a real session.
|
// Step 2 for TOTP users: exchange the challenge + code for a real session.
|
||||||
const loginTotp = useCallback(async (challenge, code) => {
|
const loginTotp = useCallback(async (challenge, code) => {
|
||||||
const data = await api.loginTotp(challenge, code)
|
const data = await api.loginTotp(challenge, code)
|
||||||
@@ -54,7 +62,7 @@ export function AuthProvider({ children }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, loginTotp, ssoLoginTotp, logout, refresh }}>
|
<AuthContext.Provider value={{ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,13 +23,24 @@ export function SiteProvider({ children }) {
|
|||||||
refresh()
|
refresh()
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
|
const brand = settings.brand || {}
|
||||||
|
|
||||||
|
// Apply the instance accent color to the CSS variable the theme is built on,
|
||||||
|
// so branding flows to every `var(--accent)` at runtime (no rebuild).
|
||||||
|
useEffect(() => {
|
||||||
|
if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent)
|
||||||
|
}, [brand.accent])
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
settings,
|
settings,
|
||||||
loading,
|
loading,
|
||||||
refresh,
|
refresh,
|
||||||
|
brand,
|
||||||
mode: settings.site_mode || 'live',
|
mode: settings.site_mode || 'live',
|
||||||
siteTitle: settings.site_title || 'UOMysticmoon',
|
siteTitle: brand.name || settings.site_title || 'Runic Gateway',
|
||||||
contactEmail: settings.contact_email || 'UOMysticmoon@gmail.com',
|
siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway',
|
||||||
|
contactEmail: brand.contactEmail || settings.contact_email || '',
|
||||||
|
heroImage: brand.hero || '/assets/img/runic-emblem.png',
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SiteContext.Provider value={value}>{children}</SiteContext.Provider>
|
return <SiteContext.Provider value={value}>{children}</SiteContext.Provider>
|
||||||
|
|||||||
31
client/src/data/cityCrests.js
Normal file
31
client/src/data/cityCrests.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple
|
||||||
|
// emoji sigil + a ring colour — enough to make the Governors board and the
|
||||||
|
// governor badge read as distinct "crests" today, swappable for real artwork
|
||||||
|
// later WITHOUT touching any component: drop an `img` (an imported asset URL or a
|
||||||
|
// public path) onto an entry and update CityCrest to prefer it.
|
||||||
|
//
|
||||||
|
// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4:
|
||||||
|
// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia).
|
||||||
|
|
||||||
|
export const CITY_CRESTS = {
|
||||||
|
Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' },
|
||||||
|
Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' },
|
||||||
|
Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' },
|
||||||
|
Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' },
|
||||||
|
Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' },
|
||||||
|
Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' },
|
||||||
|
SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' },
|
||||||
|
NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' }
|
||||||
|
|
||||||
|
// Look up a crest by the raw city key, tolerating spacing variants
|
||||||
|
// ("Skara Brae" / "New Magincia"). `label` falls back to the given name.
|
||||||
|
export function crestFor(city) {
|
||||||
|
if (!city) return FALLBACK
|
||||||
|
const key = String(city).replace(/\s+/g, '')
|
||||||
|
const crest = CITY_CRESTS[city] || CITY_CRESTS[key]
|
||||||
|
if (crest) return crest
|
||||||
|
return { ...FALLBACK, label: String(city) }
|
||||||
|
}
|
||||||
62
client/src/data/regionBuckets.js
Normal file
62
client/src/data/regionBuckets.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO
|
||||||
|
// regions) up into a handful of labelled display buckets for the "Players Online"
|
||||||
|
// widget. This is the ONE place to retune the grouping — edit BUCKETS (order +
|
||||||
|
// membership) and the widget follows. Anything not matched lands in "Wilderness"
|
||||||
|
// so the bucket counts always reconcile to the true total.
|
||||||
|
|
||||||
|
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
|
||||||
|
// membership. First matching bucket wins; the last bucket is the catch-all.
|
||||||
|
export const BUCKETS = [
|
||||||
|
{
|
||||||
|
id: 'britain',
|
||||||
|
label: 'Britain',
|
||||||
|
// Passthrough for the capital + its immediate surrounds.
|
||||||
|
match: (r) => /^britain/i.test(r),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'towns',
|
||||||
|
label: 'Towns',
|
||||||
|
// The other named cities/towns.
|
||||||
|
match: (r) =>
|
||||||
|
/^(moonglow|minoc|trinsic|jhelom|yew|skara ?brae|magincia|new ?magincia|vesper|nujelm|cove|ocllo|serpent'?s? hold|wind|delucia|papua)/i.test(
|
||||||
|
r,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dungeons',
|
||||||
|
label: 'Dungeons',
|
||||||
|
match: (r) =>
|
||||||
|
/(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test(
|
||||||
|
r,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'housing',
|
||||||
|
label: 'Housing',
|
||||||
|
// House regions expose themselves as named house/townhouse regions.
|
||||||
|
match: (r) => /(house|townhouse|homestead|tent)/i.test(r),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wilderness',
|
||||||
|
label: 'Wilderness',
|
||||||
|
// Catch-all: the unnamed "Wilderness" region + anything unmatched above.
|
||||||
|
match: () => true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS
|
||||||
|
// order, dropping empty buckets, with the summed total also returned.
|
||||||
|
export function bucketize(byRegion = {}) {
|
||||||
|
const totals = new Map(BUCKETS.map((b) => [b.id, 0]))
|
||||||
|
let total = 0
|
||||||
|
for (const [region, n] of Object.entries(byRegion || {})) {
|
||||||
|
const count = Number(n) || 0
|
||||||
|
total += count
|
||||||
|
const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1]
|
||||||
|
totals.set(bucket.id, totals.get(bucket.id) + count)
|
||||||
|
}
|
||||||
|
const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter(
|
||||||
|
(r) => r.count > 0,
|
||||||
|
)
|
||||||
|
return { rows, total }
|
||||||
|
}
|
||||||
@@ -1,13 +1,28 @@
|
|||||||
// Shared hero-layout helpers used by the public portal and the admin editor.
|
// Shared hero-layout helpers used by the public portal and the admin editor.
|
||||||
|
|
||||||
export const DEFAULT_HERO_IMAGE = '/assets/img/uomysticmoon-main-hero.png'
|
// Runic Gateway default hero emblem; the instance hero image (BRAND_HERO)
|
||||||
|
// overrides it at runtime, threaded in as `defaultImage` by the portal.
|
||||||
|
export const DEFAULT_HERO_IMAGE = '/assets/img/runic-emblem.png'
|
||||||
|
|
||||||
// The original hand-tuned multi-gradient hero background (used only for the
|
// Default hero background: the emblem centered behind the text as a medallion,
|
||||||
// untouched default so the live page is byte-for-byte unchanged until edited).
|
// under a symmetric dark overlay tuned to keep centered hero copy legible.
|
||||||
export const HERO_BG =
|
// Two layers (overlay gradient + image) so the per-layer background-size in
|
||||||
"linear-gradient(90deg,rgba(11,15,20,0.34) 0%,rgba(11,15,20,0.5) 36%,rgba(11,15,20,0.78) 62%,rgba(11,15,20,0.66) 100%),linear-gradient(180deg,rgba(11,15,20,0.08) 0%,rgba(11,15,20,0.72) 100%),url('" +
|
// `heroBackground` can contain the square emblem while the overlay stays full-bleed.
|
||||||
DEFAULT_HERO_IMAGE +
|
export function heroBgStack(image) {
|
||||||
|
return (
|
||||||
|
'linear-gradient(180deg,rgba(11,15,20,0.62) 0%,rgba(11,15,20,0.48) 38%,rgba(11,15,20,0.52) 58%,rgba(11,15,20,0.86) 100%),' +
|
||||||
|
"url('" +
|
||||||
|
(image || DEFAULT_HERO_IMAGE) +
|
||||||
"')"
|
"')"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the emblem fully visible and centered, capped so it never overflows a
|
||||||
|
// narrow viewport; the overlay layer covers.
|
||||||
|
export const HERO_DEFAULT_SIZE = 'cover, min(74vh, 640px, 86vw)'
|
||||||
|
export const HERO_DEFAULT_POSITION = 'center, center'
|
||||||
|
|
||||||
|
export const HERO_BG = heroBgStack(DEFAULT_HERO_IMAGE)
|
||||||
|
|
||||||
// Single-stop dark overlay driven by the editor's opacity slider.
|
// Single-stop dark overlay driven by the editor's opacity slider.
|
||||||
export function buildOverlay(opacity) {
|
export function buildOverlay(opacity) {
|
||||||
@@ -16,15 +31,23 @@ export function buildOverlay(opacity) {
|
|||||||
|
|
||||||
// Background style for a layout. When `isDefault` and no custom image is set, use
|
// Background style for a layout. When `isDefault` and no custom image is set, use
|
||||||
// the exact original gradient stack; otherwise compose the overlay over the image.
|
// the exact original gradient stack; otherwise compose the overlay over the image.
|
||||||
export function heroBackground(layout, { isDefault = false } = {}) {
|
export function heroBackground(layout, { isDefault = false, defaultImage } = {}) {
|
||||||
const bg = layout.background || {}
|
const bg = layout.background || {}
|
||||||
const backgroundImage =
|
const fallback = defaultImage || DEFAULT_HERO_IMAGE
|
||||||
isDefault && !bg.image_url
|
// Untouched default: emblem contained + centered behind the text (per-layer
|
||||||
? HERO_BG
|
// size/position so the overlay stays full-bleed while the square emblem fits).
|
||||||
: `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || DEFAULT_HERO_IMAGE}')`
|
if (isDefault && !bg.image_url) {
|
||||||
return {
|
return {
|
||||||
backgroundColor: 'var(--bg-deep)',
|
backgroundColor: 'var(--bg-deep)',
|
||||||
backgroundImage,
|
backgroundImage: heroBgStack(fallback),
|
||||||
|
backgroundPosition: HERO_DEFAULT_POSITION,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: HERO_DEFAULT_SIZE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
backgroundColor: 'var(--bg-deep)',
|
||||||
|
backgroundImage: `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || fallback}')`,
|
||||||
backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`,
|
backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`,
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
backgroundSize: bg.size || 'cover',
|
backgroundSize: bg.size || 'cover',
|
||||||
@@ -44,7 +67,7 @@ export function parseLayout(str) {
|
|||||||
// The current hardcoded hero as a HeroLayout, so the page is unchanged until
|
// The current hardcoded hero as a HeroLayout, so the page is unchanged until
|
||||||
// staff publish their own. Font sizes use the existing clamp() strings so the
|
// staff publish their own. Font sizes use the existing clamp() strings so the
|
||||||
// default stays responsive (editor-created text uses px).
|
// default stays responsive (editor-created text uses px).
|
||||||
export function defaultLayout(teaser) {
|
export function defaultLayout(teaser, name = 'Runic Gateway') {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
background: { image_url: null, position_x: 'left', position_y: 'center', size: 'cover' },
|
background: { image_url: null, position_x: 'left', position_y: 'center', size: 'cover' },
|
||||||
@@ -62,9 +85,9 @@ export function defaultLayout(teaser) {
|
|||||||
width: 760,
|
width: 760,
|
||||||
lines: [
|
lines: [
|
||||||
{ text: 'Private shard project', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' },
|
{ text: 'Private shard project', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' },
|
||||||
{ text: 'UOMysticmoon', tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 },
|
{ text: name, tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 },
|
||||||
{ text: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
|
{ text: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
|
||||||
{ text: teaser, tag: 'p', fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
|
{ text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
106
client/src/lib/shardEvents.js
Normal file
106
client/src/lib/shardEvents.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Shared formatting for shard events — used by the public Shard page, the
|
||||||
|
// Activity feed, and the admin live feed. One place decides how each kind reads
|
||||||
|
// and which category/badge it belongs to.
|
||||||
|
|
||||||
|
function nameOf(who) {
|
||||||
|
if (!who) return 'Someone'
|
||||||
|
if (typeof who === 'string') return who
|
||||||
|
return who.name || who.acct || 'Someone'
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = (v) => Number(v || 0).toLocaleString()
|
||||||
|
|
||||||
|
// A one-line human description of an event. Accepts either a stored event
|
||||||
|
// (with .payload) or a raw live frame (fields at top level).
|
||||||
|
export function describe(ev) {
|
||||||
|
const p = ev.payload || ev
|
||||||
|
switch (ev.kind) {
|
||||||
|
case 'vendor.sale':
|
||||||
|
return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${n(p.price)}gp`
|
||||||
|
case 'player.death':
|
||||||
|
return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}`
|
||||||
|
case 'player.murdered':
|
||||||
|
return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}`
|
||||||
|
case 'mob.killed':
|
||||||
|
return `${nameOf(p.killer)} killed ${nameOf(p.killed)}`
|
||||||
|
case 'skill.gain':
|
||||||
|
return `${nameOf(p.who)} gained ${p.skill}${p.base != null ? ` (${p.base})` : ''}`
|
||||||
|
case 'fame.change':
|
||||||
|
return `${nameOf(p.who)}’s fame changed to ${n(p.new)}`
|
||||||
|
case 'karma.change':
|
||||||
|
return `${nameOf(p.who)}’s karma changed to ${n(p.new)}`
|
||||||
|
case 'quest.complete':
|
||||||
|
return `${nameOf(p.who)} completed “${p.quest}”`
|
||||||
|
case 'house.decay':
|
||||||
|
return `${p.name || 'A house'} is now ${p.to || p.stage}${p.region ? ` — ${p.region}` : ''}`
|
||||||
|
case 'mob.login':
|
||||||
|
return `${nameOf(p.who)} entered the world`
|
||||||
|
case 'mob.logout':
|
||||||
|
return `${nameOf(p.who)} left the world`
|
||||||
|
case 'economy.supply':
|
||||||
|
return `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`
|
||||||
|
case 'server.hello':
|
||||||
|
return `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`
|
||||||
|
case 'server.shutdown':
|
||||||
|
return 'Shard shut down'
|
||||||
|
case 'server.crashed':
|
||||||
|
return `Shard crashed${p.error ? `: ${p.error}` : ''}`
|
||||||
|
case 'champ.update': {
|
||||||
|
const where = p.name || p.type || 'A champion spawn'
|
||||||
|
if (p.status === 'active' && p.bossUp) return `${where}: boss is up${p.boss ? ` (${p.boss})` : ''}`
|
||||||
|
if (p.status === 'active') return `${where} is active${p.level != null ? ` — level ${p.level}` : ''}`
|
||||||
|
if (p.status === 'cooldown') return `${where} is on cooldown`
|
||||||
|
return `${where} is ${p.status || 'idle'}`
|
||||||
|
}
|
||||||
|
case 'champ.remove':
|
||||||
|
return `A champion spawn ended`
|
||||||
|
// Support (help-page) queue + in-game moderation (admin channel only)
|
||||||
|
case 'page.new':
|
||||||
|
return `New ${p.type || 'help'} page from ${nameOf(p.sender)}`
|
||||||
|
case 'page.updated':
|
||||||
|
return `Help page from ${nameOf(p.sender)} updated${p.handled ? ' (claimed)' : ''}`
|
||||||
|
case 'page.closed':
|
||||||
|
return `Help page ${p.pageId || ''} closed`
|
||||||
|
case 'admin.audit':
|
||||||
|
return `${p.actor || 'Staff'} ${p.action || 'acted'}${p.target ? ` on ${p.target}` : ''}${p.origin ? ` [${p.origin}]` : ''}`
|
||||||
|
// Staff / sensitive (admin channel only)
|
||||||
|
case 'audit.set':
|
||||||
|
return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`
|
||||||
|
case 'audit.command':
|
||||||
|
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${p.args ? ` ${p.args}` : ''}`
|
||||||
|
case 'cheat.fastwalk':
|
||||||
|
return `Fast-walk flagged: ${nameOf(p.who)}${p.ip ? ` (${p.ip})` : ''}`
|
||||||
|
case 'account.login.attempt':
|
||||||
|
return `Login attempt: ${p.acct}${p.ip ? ` from ${p.ip}` : ''}`
|
||||||
|
case 'gold.change':
|
||||||
|
return `${p.acct}: gold ${p.delta >= 0 ? '+' : ''}${n(p.delta)} → ${n(p.new)}`
|
||||||
|
default:
|
||||||
|
return ev.kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category grouping for the filter tabs.
|
||||||
|
// Vendor sales are intentionally NOT a public category — they are owner-private
|
||||||
|
// (a linked player sees their own under the portal). The admin live feed still
|
||||||
|
// describes vendor.sale via describe() below.
|
||||||
|
export const CATEGORIES = [
|
||||||
|
{ id: 'all', label: 'All', kinds: null },
|
||||||
|
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
|
||||||
|
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
|
||||||
|
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CATEGORY_OF = (() => {
|
||||||
|
const m = {}
|
||||||
|
for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id
|
||||||
|
return m
|
||||||
|
})()
|
||||||
|
|
||||||
|
export function categoryOf(kind) {
|
||||||
|
return CATEGORY_OF[kind] || 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short badge label for a kind (the part after the dot, title-cased-ish).
|
||||||
|
export function kindLabel(kind) {
|
||||||
|
return String(kind || '').replace(/[._]/g, ' ')
|
||||||
|
}
|
||||||
54
client/src/lib/useShardFeed.js
Normal file
54
client/src/lib/useShardFeed.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
|
// Subscribe to the public shard live-event SSE stream and keep a rolling buffer
|
||||||
|
// of the most recent events. The browser talks to our own /public/shard/stream
|
||||||
|
// route (plain HTTP EventSource) — never the sidecar's WebSocket — so the token
|
||||||
|
// stays server-side and it works through any reverse proxy.
|
||||||
|
//
|
||||||
|
// EventSource auto-reconnects on drop, so there is no manual retry loop here; a
|
||||||
|
// `connected` flag is exposed for a small live/offline indicator. `filter` (a
|
||||||
|
// Set of kinds, optional) limits which events are buffered. `max` caps the
|
||||||
|
// buffer length.
|
||||||
|
export function useShardFeed({ url, filter, max = 40 } = {}) {
|
||||||
|
const [events, setEvents] = useState([])
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
// Keep the latest filter in a ref so re-renders don't tear down the stream.
|
||||||
|
const filterRef = useRef(filter)
|
||||||
|
filterRef.current = filter
|
||||||
|
const streamUrl = url || api.shardStreamUrl
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// EventSource isn't available during SSR / very old browsers — degrade to
|
||||||
|
// "no live feed" rather than throwing.
|
||||||
|
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined
|
||||||
|
|
||||||
|
const es = new EventSource(streamUrl, { withCredentials: true })
|
||||||
|
|
||||||
|
es.onopen = () => setConnected(true)
|
||||||
|
es.onerror = () => setConnected(false) // EventSource will retry on its own
|
||||||
|
|
||||||
|
es.onmessage = (msg) => {
|
||||||
|
let event
|
||||||
|
try {
|
||||||
|
event = JSON.parse(msg.data)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!event || !event.kind) return
|
||||||
|
const f = filterRef.current
|
||||||
|
if (f && !f.has(event.kind)) return
|
||||||
|
setEvents((prev) => {
|
||||||
|
// Tag with a stable-ish local id for React keys (events carry t but can
|
||||||
|
// collide within a ms) and cap the buffer.
|
||||||
|
const next = [{ ...event, _id: `${event.kind}-${event.t}-${prev.length}` }, ...prev]
|
||||||
|
return next.slice(0, max)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => es.close()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [max, streamUrl])
|
||||||
|
|
||||||
|
return { events, connected }
|
||||||
|
}
|
||||||
@@ -1,39 +1,113 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import MoonDot from '../../components/MoonDot.jsx'
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
// `roles` (when present) restricts which roles see a nav item. Items without it
|
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
|
||||||
// are shown to admin/editor as before. Moderators are further confined to just
|
// One shared frame keeps them terse; each item just supplies its path(s).
|
||||||
// their own section + account security (see the redirect effect below).
|
function Icon({ children, size = 16 }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const IconHome = () => <Icon><path d="M3 10.5 12 3l9 7.5" /><path d="M5 9.5V21h14V9.5" /></Icon>
|
||||||
|
const IconPosts = () => <Icon><path d="M5 3h14v18H5z" /><path d="M8 8h8M8 12h8M8 16h5" /></Icon>
|
||||||
|
const IconWiki = () => <Icon><path d="M4 4h9a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H4z" /><path d="M20 4h-2a2 2 0 0 0-2 2v14a2 2 0 0 1 2-2h2z" /></Icon>
|
||||||
|
const IconPages = () => <Icon><path d="M5 3h9l5 5v13H5z" /><path d="M14 3v5h5" /><path d="M8 13h8M8 17h8" /></Icon>
|
||||||
|
const IconActivity = () => <Icon><path d="M3 12h4l3 8 4-16 3 8h4" /></Icon>
|
||||||
|
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
|
||||||
|
const IconUsers = () => <Icon><circle cx="9" cy="8" r="3" /><path d="M3 20a6 6 0 0 1 12 0" /><path d="M16 6a3 3 0 0 1 0 6M17 20a6 6 0 0 0-3-5" /></Icon>
|
||||||
|
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||||
|
const IconHero = () => <Icon><path d="M3 5h18v14H3z" /><circle cx="8" cy="10" r="1.6" /><path d="M4 18l5-5 3 3 3-4 5 6" /></Icon>
|
||||||
|
const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18 12v3M15 12v2" /></Icon>
|
||||||
|
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
|
||||||
|
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
||||||
|
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||||
|
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
|
||||||
|
|
||||||
|
// Nav is grouped into collapsible categories. A group with no `title` renders
|
||||||
|
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||||
|
// (when present) matches server-side enforcement so the sidebar never shows a
|
||||||
|
// link that would 403; an item without `roles` is visible to everyone.
|
||||||
|
// Moderators are further confined to just their section + account (see below).
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/admin', label: 'Dashboard', end: true },
|
{
|
||||||
{ to: '/admin/posts', label: 'Posts' },
|
items: [
|
||||||
{ to: '/admin/wiki', label: 'Wiki' },
|
{ to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
|
||||||
{ to: '/admin/hero', label: 'Hero Editor' },
|
],
|
||||||
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
|
},
|
||||||
{ to: '/admin/settings', label: 'Settings' },
|
{
|
||||||
{ to: '/admin/activity', label: 'Activity' },
|
title: 'Content',
|
||||||
{ to: '/admin/bot-activity', label: 'Bot Activity' },
|
items: [
|
||||||
{ to: '/admin/discord-bot', label: 'Discord Bot' },
|
{ to: '/admin/posts', label: 'Posts', icon: IconPosts, roles: ['admin', 'editor'] },
|
||||||
{ to: '/admin/auth-providers', label: 'Authentication' },
|
{ to: '/admin/pages', label: 'Pages', icon: IconPages, roles: ['admin', 'editor'] },
|
||||||
{ to: '/admin/users', label: 'Users' },
|
{ to: '/admin/wiki', label: 'Wiki', icon: IconWiki, roles: ['admin', 'editor'] },
|
||||||
{ to: '/admin/account', label: 'Account' },
|
{ to: '/admin/activity', label: 'Activity', icon: IconActivity, roles: ['admin', 'editor'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Moderation',
|
||||||
|
items: [
|
||||||
|
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||||
|
{ to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
|
||||||
|
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'System',
|
||||||
|
items: [
|
||||||
|
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
||||||
|
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
|
||||||
|
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
|
||||||
|
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
||||||
|
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
||||||
|
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
|
||||||
|
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
|
||||||
|
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{ to: '/admin/characters', label: 'My Characters', icon: IconShard },
|
||||||
|
{ to: '/admin/account', label: 'Account', icon: IconUser },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const COLLAPSE_KEY = 'admin.nav.collapsed'
|
||||||
|
|
||||||
const TITLES = {
|
const TITLES = {
|
||||||
'/admin': 'Dashboard',
|
'/admin': 'Dashboard',
|
||||||
'/admin/posts': 'Posts',
|
'/admin/posts': 'Posts',
|
||||||
|
'/admin/pages': 'Pages',
|
||||||
'/admin/wiki': 'Wiki Pages',
|
'/admin/wiki': 'Wiki Pages',
|
||||||
'/admin/hero': 'Hero Editor',
|
'/admin/hero': 'Hero Editor',
|
||||||
'/admin/moderation': 'Moderation',
|
'/admin/moderation': 'Moderation',
|
||||||
|
'/admin/shard-ops': 'In-Game Ops',
|
||||||
|
'/admin/houses': 'House Registry',
|
||||||
'/admin/settings': 'Site Settings',
|
'/admin/settings': 'Site Settings',
|
||||||
'/admin/activity': 'Activity Log',
|
'/admin/activity': 'Activity Log',
|
||||||
'/admin/bot-activity': 'Bot Activity',
|
'/admin/bot-activity': 'Web Bot Activity',
|
||||||
'/admin/discord-bot': 'Discord Bot',
|
'/admin/discord-bot': 'Discord Bot',
|
||||||
|
'/admin/shard': 'Shard (uo-link)',
|
||||||
|
'/admin/characters': 'My Characters',
|
||||||
'/admin/auth-providers': 'Authentication',
|
'/admin/auth-providers': 'Authentication',
|
||||||
'/admin/users': 'Users',
|
'/admin/users': 'Users',
|
||||||
|
'/admin/invites': 'Invites',
|
||||||
'/admin/account': 'Account Security',
|
'/admin/account': 'Account Security',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,36 +118,77 @@ const navBtnBase = {
|
|||||||
fontFamily: 'var(--sans)',
|
fontFamily: 'var(--sans)',
|
||||||
fontSize: '0.92rem',
|
fontSize: '0.92rem',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
display: 'block',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
transition: 'background .15s,color .15s',
|
transition: 'background .15s,color .15s',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const { mode } = useSite()
|
const { mode, siteTitle } = useSite()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const title =
|
const title =
|
||||||
TITLES[location.pathname] ||
|
TITLES[location.pathname] ||
|
||||||
(location.pathname.startsWith('/admin/moderation') ? 'Moderation' : 'Admin')
|
(location.pathname.startsWith('/admin/moderation')
|
||||||
|
? 'Moderation'
|
||||||
|
: location.pathname.startsWith('/admin/characters')
|
||||||
|
? 'My Characters'
|
||||||
|
: location.pathname.startsWith('/admin/users/')
|
||||||
|
? 'User'
|
||||||
|
: 'Admin')
|
||||||
// The hero canvas editor needs room — let it use the full content width.
|
// The hero canvas editor needs room — let it use the full content width.
|
||||||
const wide = location.pathname === '/admin/hero'
|
const wide = location.pathname === '/admin/hero'
|
||||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||||
|
|
||||||
// Moderators only get the moderation section + their own account security.
|
// Moderators only get the moderation section (Discord + in-game ops) + their
|
||||||
|
// own account security.
|
||||||
const isModerator = user?.role === 'moderator'
|
const isModerator = user?.role === 'moderator'
|
||||||
const navItems = NAV.filter((n) => {
|
const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
||||||
if (n.roles && !n.roles.includes(user?.role)) return false
|
const visible = (item) => {
|
||||||
if (isModerator) return n.to === '/admin/moderation' || n.to === '/admin/account'
|
if (item.roles && !item.roles.includes(user?.role)) return false
|
||||||
|
if (isModerator) return MOD_PATHS.includes(item.to)
|
||||||
return true
|
return true
|
||||||
|
}
|
||||||
|
// Drop items the current role can't see, then drop any now-empty group so an
|
||||||
|
// empty category header never renders.
|
||||||
|
const navGroups = NAV
|
||||||
|
.map((g) => ({ ...g, items: g.items.filter(visible) }))
|
||||||
|
.filter((g) => g.items.length > 0)
|
||||||
|
|
||||||
|
// Accordion: track which titled categories are collapsed. Persist across
|
||||||
|
// reloads; default all-open. The group holding the active route auto-opens.
|
||||||
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
const toggleGroup = (title) => {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = { ...prev, [title]: !prev[title] }
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next))
|
||||||
|
} catch {
|
||||||
|
/* private mode / quota — collapse is non-essential */
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const activeGroupTitle = navGroups.find((g) =>
|
||||||
|
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
|
||||||
|
)?.title
|
||||||
|
|
||||||
// Confine a moderator who deep-links (or is redirected to the index) to a page
|
// Confine a moderator who deep-links (or is redirected to the index) to a page
|
||||||
// outside their remit — the API would 403 anyway, so send them to their home.
|
// outside their remit — the API would 403 anyway, so send them to their home.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isModerator) return
|
if (!isModerator) return
|
||||||
const p = location.pathname
|
const p = location.pathname
|
||||||
if (!p.startsWith('/admin/moderation') && p !== '/admin/account') {
|
const allowed =
|
||||||
|
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
|
||||||
|
if (!allowed) {
|
||||||
navigate('/admin/moderation', { replace: true })
|
navigate('/admin/moderation', { replace: true })
|
||||||
}
|
}
|
||||||
}, [isModerator, location.pathname, navigate])
|
}, [isModerator, location.pathname, navigate])
|
||||||
@@ -110,7 +225,7 @@ export default function AdminLayout() {
|
|||||||
<MoonDot />
|
<MoonDot />
|
||||||
<div>
|
<div>
|
||||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||||
UOMysticmoon
|
{siteTitle}
|
||||||
</div>
|
</div>
|
||||||
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
|
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
|
||||||
Admin
|
Admin
|
||||||
@@ -118,12 +233,14 @@ export default function AdminLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
||||||
{navItems.map((n) => (
|
{navGroups.map((group, gi) => {
|
||||||
|
const links = group.items.map((n) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={n.to}
|
key={n.to}
|
||||||
to={n.to}
|
to={n.to}
|
||||||
end={n.end}
|
end={n.end}
|
||||||
|
className="admin-nav-link"
|
||||||
style={({ isActive }) => ({
|
style={({ isActive }) => ({
|
||||||
...navBtnBase,
|
...navBtnBase,
|
||||||
background: isActive ? 'var(--blue)' : 'transparent',
|
background: isActive ? 'var(--blue)' : 'transparent',
|
||||||
@@ -131,9 +248,56 @@ export default function AdminLayout() {
|
|||||||
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{n.label}
|
{n.icon && <n.icon />}
|
||||||
|
<span>{n.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))
|
||||||
|
|
||||||
|
// Untitled groups (Dashboard, Account) render their links directly.
|
||||||
|
if (!group.title) {
|
||||||
|
return (
|
||||||
|
<div key={`g${gi}`} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{links}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titled groups get a collapsible header. The group with the active
|
||||||
|
// route stays open regardless of the stored collapse preference.
|
||||||
|
const isOpen = group.title === activeGroupTitle || !collapsed[group.title]
|
||||||
|
return (
|
||||||
|
<div key={group.title} className="admin-nav-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-nav-head sans"
|
||||||
|
onClick={() => toggleGroup(group.title)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<span>{group.title}</span>
|
||||||
|
<svg
|
||||||
|
className="admin-nav-chev"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
style={{ transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="admin-nav-items" style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{links}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div style={{ padding: '14px 16px', borderTop: '1px solid var(--line-soft)' }}>
|
<div style={{ padding: '14px 16px', borderTop: '1px solid var(--line-soft)' }}>
|
||||||
|
|||||||
@@ -3,20 +3,19 @@ import { Link, useNavigate, useLocation } from 'react-router-dom'
|
|||||||
import MoonDot from '../../components/MoonDot.jsx'
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
import { api } from '../../api/client.js'
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
// Friendly copy for the ?sso_error codes the SSO callback can redirect back with.
|
// Friendly copy for the ?sso_error codes the SSO callback can redirect back with.
|
||||||
const SSO_ERRORS = {
|
const SSO_ERRORS = {
|
||||||
not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.',
|
not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.',
|
||||||
|
disabled: 'This account is not active. Contact an administrator.',
|
||||||
denied: 'Sign-in was cancelled.',
|
denied: 'Sign-in was cancelled.',
|
||||||
unavailable: 'That sign-in method is not available right now.',
|
unavailable: 'That sign-in method is not available right now.',
|
||||||
bad_state: 'Your sign-in session expired. Please try again.',
|
bad_state: 'Your sign-in session expired. Please try again.',
|
||||||
error: 'Could not complete sign-in. Please try again.',
|
error: 'Could not complete sign-in. Please try again.',
|
||||||
}
|
}
|
||||||
|
|
||||||
const BG =
|
|
||||||
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
|
||||||
|
|
||||||
// Hidden anti-bot field. Off-screen via CSS (NOT display:none/hidden, which bots
|
// Hidden anti-bot field. Off-screen via CSS (NOT display:none/hidden, which bots
|
||||||
// skip) so real users never fill it but naive scripted bots do. Name must match
|
// skip) so real users never fill it but naive scripted bots do. Name must match
|
||||||
// the server's HONEYPOT_FIELD ('company').
|
// the server's HONEYPOT_FIELD ('company').
|
||||||
@@ -32,9 +31,14 @@ const honeypotStyle = {
|
|||||||
|
|
||||||
export default function AdminLogin() {
|
export default function AdminLogin() {
|
||||||
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||||
|
const { siteTitle, heroImage } = useSite()
|
||||||
|
const BG = `linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('${heroImage}')`
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const dest = location.state?.from?.pathname || '/admin'
|
const dest = location.state?.from?.pathname || '/admin'
|
||||||
|
// A player who signs in here belongs in the player portal, not the admin shell
|
||||||
|
// (the admin API 403s them anyway). Staff go to their intended admin dest.
|
||||||
|
const destFor = (u) => (u && u.role === 'player' ? '/account' : dest)
|
||||||
|
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
@@ -54,9 +58,10 @@ export default function AdminLogin() {
|
|||||||
const [providers, setProviders] = useState([])
|
const [providers, setProviders] = useState([])
|
||||||
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
||||||
|
|
||||||
// Already signed in → go straight to the panel.
|
// Already signed in → go straight to the right home for the role.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) navigate(dest, { replace: true })
|
if (user) navigate(destFor(user), { replace: true })
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, dest, navigate])
|
}, [user, dest, navigate])
|
||||||
|
|
||||||
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
|
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
|
||||||
@@ -101,7 +106,7 @@ export default function AdminLogin() {
|
|||||||
setBusy(false)
|
setBusy(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
navigate(dest, { replace: true })
|
navigate(destFor(data.user), { replace: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
@@ -117,8 +122,8 @@ export default function AdminLogin() {
|
|||||||
const { returnTo } = await ssoLoginTotp(code)
|
const { returnTo } = await ssoLoginTotp(code)
|
||||||
navigate(returnTo || '/admin', { replace: true })
|
navigate(returnTo || '/admin', { replace: true })
|
||||||
} else {
|
} else {
|
||||||
await loginTotp(challenge, code)
|
const u = await loginTotp(challenge, code)
|
||||||
navigate(dest, { replace: true })
|
navigate(destFor(u), { replace: true })
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const expired = err.status === 401 && /expired/i.test(err.message)
|
const expired = err.status === 401 && /expired/i.test(err.message)
|
||||||
@@ -146,7 +151,8 @@ export default function AdminLogin() {
|
|||||||
backgroundColor: 'var(--bg-deep)',
|
backgroundColor: 'var(--bg-deep)',
|
||||||
backgroundImage: BG,
|
backgroundImage: BG,
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
backgroundSize: 'cover',
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'min(60vh, 520px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||||
@@ -155,7 +161,7 @@ export default function AdminLogin() {
|
|||||||
<MoonDot size={15} glow={0.55} />
|
<MoonDot size={15} glow={0.55} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||||
UOMysticmoon
|
{siteTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
|
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
|
||||||
Admin Panel
|
Admin Panel
|
||||||
|
|||||||
29
client/src/routes/admin/views/AdminCharacter.jsx
Normal file
29
client/src/routes/admin/views/AdminCharacter.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import CharacterSheet from '../../../components/CharacterSheet.jsx'
|
||||||
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// A staff member's own character sheet inside the admin shell. Owner-checked —
|
||||||
|
// the endpoint only returns a sheet for a character on the caller's linked account.
|
||||||
|
export default function AdminCharacter() {
|
||||||
|
const { serial } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
|
||||||
|
const restarting = error && error.status === 503
|
||||||
|
const forbidden = error && error.status === 403
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 760 }}>
|
||||||
|
<p style={{ margin: '0 0 18px' }}>
|
||||||
|
<Link to="/admin/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||||
|
← Back to my characters
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{loading && <Loading />}
|
||||||
|
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||||
|
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
|
||||||
|
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
|
||||||
|
{!loading && !error && data && <CharacterSheet char={data} moderation />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
client/src/routes/admin/views/AdminCharacters.jsx
Normal file
18
client/src/routes/admin/views/AdminCharacters.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import CharacterStats from '../../../components/CharacterStats.jsx'
|
||||||
|
import GameAccounts from '../../../components/GameAccounts.jsx'
|
||||||
|
import VendorSales from '../../../components/VendorSales.jsx'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Staff link their OWN in-game account and view their characters — the same
|
||||||
|
// shared component players use, pointed at the staff self-service endpoints.
|
||||||
|
// Sits inside the Admin shell, which supplies the "My Characters" page header;
|
||||||
|
// stat tiles bring it to parity with the Player Portal's Characters page.
|
||||||
|
export default function AdminCharacters() {
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 760 }}>
|
||||||
|
<CharacterStats scope={api.admin.shard} />
|
||||||
|
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/characters/${serial}`} />
|
||||||
|
<VendorSales fetchSales={api.admin.shard.sales} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
245
client/src/routes/admin/views/EmailDelivery.jsx
Normal file
245
client/src/routes/admin/views/EmailDelivery.jsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
|
// Email delivery panel (Gmail over OAuth2), rendered as a section on the Settings
|
||||||
|
// page. Sending is authorized by an in-app "Connect Gmail" consent flow that
|
||||||
|
// captures a refresh token server-side — the token is write-only over the API
|
||||||
|
// (stored encrypted, never returned). Reuses the Google SSO OAuth client, so it
|
||||||
|
// requires the Google provider to be configured on the Authentication page first.
|
||||||
|
|
||||||
|
const STATUS_COLOR = {
|
||||||
|
connected: '#7fd0a4',
|
||||||
|
error: '#d98b84',
|
||||||
|
unconfigured: 'var(--muted)',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-friendly text for the ?email_error=<code> the callback may redirect with.
|
||||||
|
const ERROR_TEXT = {
|
||||||
|
denied: 'Google sign-in was cancelled or denied.',
|
||||||
|
bad_state: 'The connect session expired. Please try again.',
|
||||||
|
no_client: 'The Google OAuth client is not configured.',
|
||||||
|
no_refresh_token:
|
||||||
|
'Google did not return a refresh token. Remove this app under your Google Account → Security → Third-party access, then reconnect.',
|
||||||
|
no_email: 'Could not read the Gmail address from Google.',
|
||||||
|
error: 'Could not connect the Gmail account. Please try again.',
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPanel({ config }) {
|
||||||
|
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
|
||||||
|
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
|
||||||
|
{config.status || 'unconfigured'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{config.senderEmail && (
|
||||||
|
<p className="sans" style={{ margin: 0, fontSize: '0.85rem', color: 'var(--ink)' }}>
|
||||||
|
Sending as <strong>{config.senderEmail}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{config.statusDetail && (
|
||||||
|
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
|
||||||
|
)}
|
||||||
|
{config.lastVerifiedAt && (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||||
|
Last verified: {new Date(config.lastVerifiedAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmailDelivery() {
|
||||||
|
const { siteTitle } = useSite()
|
||||||
|
const [config, setConfig] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [senderName, setSenderName] = useState('')
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [actionError, setActionError] = useState('')
|
||||||
|
const [banner, setBanner] = useState(null) // { kind: 'ok'|'err', text }
|
||||||
|
|
||||||
|
const load = useCallback(async (seedForm = false) => {
|
||||||
|
try {
|
||||||
|
const c = await api.admin.getEmailConfig()
|
||||||
|
setConfig(c)
|
||||||
|
if (seedForm) {
|
||||||
|
setSenderName(c.senderName || '')
|
||||||
|
setEnabled(c.enabled)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
} catch {
|
||||||
|
setError('Could not load email settings.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// On mount, surface the outcome of a just-completed connect redirect, strip the
|
||||||
|
// query params so a refresh doesn't replay the banner, then load config.
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
if (params.has('email_connected')) {
|
||||||
|
setBanner({ kind: 'ok', text: 'Gmail account connected.' })
|
||||||
|
} else if (params.has('email_error')) {
|
||||||
|
setBanner({ kind: 'err', text: ERROR_TEXT[params.get('email_error')] || 'Could not connect email.' })
|
||||||
|
}
|
||||||
|
if (params.has('email_connected') || params.has('email_error')) {
|
||||||
|
params.delete('email_connected')
|
||||||
|
params.delete('email_error')
|
||||||
|
const qs = params.toString()
|
||||||
|
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
||||||
|
}
|
||||||
|
load(true)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
setBusy('connect')
|
||||||
|
setActionError('')
|
||||||
|
try {
|
||||||
|
const { url } = await api.admin.emailConnectUrl()
|
||||||
|
window.location.href = url
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err.message || 'Could not start the connect flow.')
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setBusy('save')
|
||||||
|
setMsg('')
|
||||||
|
setActionError('')
|
||||||
|
try {
|
||||||
|
const saved = await api.admin.saveEmailConfig({ senderName, enabled })
|
||||||
|
setConfig(saved)
|
||||||
|
setMsg('Saved.')
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err.message || 'Could not save.')
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTest() {
|
||||||
|
setBusy('test')
|
||||||
|
setMsg('')
|
||||||
|
setActionError('')
|
||||||
|
try {
|
||||||
|
const r = await api.admin.testEmail()
|
||||||
|
setMsg(`Test email sent to ${r.to}.`)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err.message || 'Could not send the test email.')
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnect() {
|
||||||
|
setBusy('disconnect')
|
||||||
|
setMsg('')
|
||||||
|
setActionError('')
|
||||||
|
try {
|
||||||
|
const c = await api.admin.disconnectEmail()
|
||||||
|
setConfig(c)
|
||||||
|
setEnabled(false)
|
||||||
|
setMsg('Disconnected.')
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err.message || 'Could not disconnect.')
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <p className="sans" style={{ color: '#d98b84' }}>{error}</p>
|
||||||
|
if (!config) return null
|
||||||
|
|
||||||
|
const connected = config.hasRefreshToken
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 16, marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 30 }}>
|
||||||
|
<div>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Email delivery</h2>
|
||||||
|
<p className="sans dim" style={{ margin: '6px 0 0', fontSize: '0.82rem' }}>
|
||||||
|
Sends the contact form through Gmail over OAuth2, delivered to the
|
||||||
|
<strong> Contact email</strong> above. Reuses the Google authentication
|
||||||
|
client — configure that on the Authentication page first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{banner && (
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: `1px solid ${banner.kind === 'ok' ? '#3f6b52' : '#7a4440'}`,
|
||||||
|
color: banner.kind === 'ok' ? '#7fd0a4' : '#d98b84',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{banner.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<StatusPanel config={config} />
|
||||||
|
|
||||||
|
{!config.googleConfigured && (
|
||||||
|
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: '#e0b070' }}>
|
||||||
|
The Google authentication provider needs a client ID and secret before
|
||||||
|
you can connect a Gmail account.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!connected ? (
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<button onClick={connect} disabled={busy === 'connect' || !config.googleConfigured} className="btn btn-primary btn-sq">
|
||||||
|
{busy === 'connect' ? 'Redirecting…' : 'Connect Gmail'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||||
|
Enable email sending
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">From display name (optional)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={senderName}
|
||||||
|
onChange={(e) => setSenderName(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={siteTitle}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={save} disabled={busy === 'save'} className="btn btn-primary btn-sq">
|
||||||
|
{busy === 'save' ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
<button onClick={sendTest} disabled={busy === 'test'} className="pill">
|
||||||
|
{busy === 'test' ? 'Sending…' : 'Send test'}
|
||||||
|
</button>
|
||||||
|
<button onClick={connect} disabled={busy === 'connect'} className="pill">
|
||||||
|
Reconnect
|
||||||
|
</button>
|
||||||
|
<button onClick={disconnect} disabled={busy === 'disconnect'} className="pill">
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ minHeight: 18 }}>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{actionError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{actionError}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
client/src/routes/admin/views/HousesAdmin.jsx
Normal file
119
client/src/routes/admin/views/HousesAdmin.jsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../../../lib/useShardFeed.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and
|
||||||
|
// decay — everything the public board hides. Loaded from /admin/shard/houses, kept
|
||||||
|
// live from the admin SSE channel (house.update / house.remove).
|
||||||
|
const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay'])
|
||||||
|
|
||||||
|
const DECAY_TONE = {
|
||||||
|
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
|
||||||
|
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecayBadge({ decay, isIdoc }) {
|
||||||
|
const label = isIdoc ? 'IDOC' : decay
|
||||||
|
if (!label) return null
|
||||||
|
const tone = DECAY_TONE[label] || 'var(--muted)'
|
||||||
|
return (
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerLabel(h) {
|
||||||
|
return h.ownerName || h.ownerAcct || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function HouseRow({ h }) {
|
||||||
|
const owner = ownerLabel(h)
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
|
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{h.name || 'An unnamed house'}
|
||||||
|
</strong>
|
||||||
|
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
|
||||||
|
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
|
||||||
|
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||||
|
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{h.price != null && (
|
||||||
|
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
|
||||||
|
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HousesAdmin() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.admin.shard.houses())
|
||||||
|
// Full registry deltas ride the admin SSE channel (never the public one).
|
||||||
|
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 })
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
|
||||||
|
const board = useMemo(() => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (!ev.serial) continue
|
||||||
|
if (ev.kind === 'house.update') {
|
||||||
|
map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct })
|
||||||
|
} else if (ev.kind === 'house.remove') {
|
||||||
|
map.delete(ev.serial)
|
||||||
|
} else if (ev.kind === 'house.decay') {
|
||||||
|
const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y }
|
||||||
|
map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()]
|
||||||
|
}, [data, events])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const needle = q.trim().toLowerCase()
|
||||||
|
const rows = needle
|
||||||
|
? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)))
|
||||||
|
: board
|
||||||
|
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||||
|
}, [board, q])
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load the house registry." />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
||||||
|
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
|
||||||
|
{board.length.toLocaleString()} houses
|
||||||
|
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
|
||||||
|
</p>
|
||||||
|
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
|
||||||
|
</div>
|
||||||
|
{board.length === 0 ? (
|
||||||
|
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{board.length > 0 && filtered.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match “{q}”.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
178
client/src/routes/admin/views/InvitesAdmin.jsx
Normal file
178
client/src/routes/admin/views/InvitesAdmin.jsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { dateTime } from '../../../lib/format.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Admin email invites: send an invite at a chosen access level, see recent
|
||||||
|
// invites and their status, revoke pending ones. When email delivery isn't
|
||||||
|
// configured the create response hands back the accept link to copy manually.
|
||||||
|
|
||||||
|
const ROLES = ['player', 'moderator', 'editor', 'admin']
|
||||||
|
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator', player: 'badge-player' }
|
||||||
|
const STATUS_COLOR = { pending: 'var(--accent)', accepted: '#7fd0a4', revoked: 'var(--muted)' }
|
||||||
|
|
||||||
|
function CopyLink({ url }) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
async function copy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1800)
|
||||||
|
} catch {
|
||||||
|
/* clipboard blocked — the link is selectable in the box regardless */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
|
||||||
|
<code
|
||||||
|
onClick={(e) => { const r = document.createRange(); r.selectNodeContents(e.currentTarget); const s = window.getSelection(); s.removeAllRanges(); s.addRange(r) }}
|
||||||
|
style={{ flex: 1, wordBreak: 'break-all', color: 'var(--head)', background: 'var(--panel-flat)', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--line)', cursor: 'text', fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</code>
|
||||||
|
<button type="button" onClick={copy} className="btn btn-sq" style={{ flex: 'none' }}>
|
||||||
|
{copied ? 'Copied ✓' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateInvite({ onCreated }) {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [role, setRole] = useState('player')
|
||||||
|
const [sendEmail, setSendEmail] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [result, setResult] = useState(null) // { emailed, acceptUrl, emailError }
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(''); setResult(null)
|
||||||
|
if (!email.trim()) return setError('Enter an email address.')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await api.admin.createInvite(email.trim(), role, sendEmail)
|
||||||
|
setResult(res)
|
||||||
|
setEmail('')
|
||||||
|
await onCreated()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not create the invite.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 22, marginBottom: 22 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 10 }}>Invite someone</div>
|
||||||
|
<form onSubmit={submit} style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ flex: '1 1 240px' }}>
|
||||||
|
<span className="field-label">Email</span>
|
||||||
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="person@example.com" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Access level</span>
|
||||||
|
<select value={role} onChange={(e) => setRole(e.target.value)} className="select">
|
||||||
|
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Creating…' : (sendEmail ? 'Create & email' : 'Create link')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: '0.85rem', color: 'var(--ink)', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={sendEmail} onChange={(e) => setSendEmail(e.target.checked)} />
|
||||||
|
Email the invitation (otherwise just generate a link to share)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="sans" style={{ margin: '12px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||||
|
{result && (
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.84rem', color: result.emailed ? '#7fd0a4' : 'var(--muted)' }}>
|
||||||
|
{result.emailed
|
||||||
|
? 'Invitation emailed. You can also share this single-use link:'
|
||||||
|
: `Invite created${result.emailError ? ` (email not sent: ${result.emailError})` : ''}. Share this single-use link:`}
|
||||||
|
</p>
|
||||||
|
<CopyLink url={result.acceptUrl} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvitesAdmin() {
|
||||||
|
const [invites, setInvites] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setInvites(await api.admin.listInvites())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load invites.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
async function revoke(id) {
|
||||||
|
if (!window.confirm('Revoke this pending invitation?')) return
|
||||||
|
try {
|
||||||
|
await api.admin.revokeInvite(id)
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
/* surfaced by the row staying; keep it simple */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<CreateInvite onCreated={load} />
|
||||||
|
|
||||||
|
{!invites ? (
|
||||||
|
<Loading />
|
||||||
|
) : (
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Email</th>
|
||||||
|
<th className="adm-th">Role</th>
|
||||||
|
<th className="adm-th">Status</th>
|
||||||
|
<th className="adm-th">Expires</th>
|
||||||
|
<th className="adm-th">Created</th>
|
||||||
|
<th className="adm-th" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invites.length === 0 && (
|
||||||
|
<tr><td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>No invites yet.</td></tr>
|
||||||
|
)}
|
||||||
|
{invites.map((iv) => {
|
||||||
|
const status = iv.status === 'pending' && iv.expired ? 'expired' : iv.status
|
||||||
|
return (
|
||||||
|
<tr key={iv.id}>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--text)' }}>{iv.email}</td>
|
||||||
|
<td className="adm-td"><span className={`badge ${ROLE_BADGE[iv.role] || 'badge-editor'}`}>{iv.role}</span></td>
|
||||||
|
<td className="adm-td" style={{ color: STATUS_COLOR[iv.status] || 'var(--muted)', textTransform: 'capitalize' }}>{status}</td>
|
||||||
|
<td className="adm-td dim">{dateTime(iv.expiresAt)}</td>
|
||||||
|
<td className="adm-td dim">{dateTime(iv.createdAt)}</td>
|
||||||
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||||
|
{iv.status === 'pending' && (
|
||||||
|
<button type="button" className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }} onClick={() => revoke(iv.id)}>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
453
client/src/routes/admin/views/PageBuilder.jsx
Normal file
453
client/src/routes/admin/views/PageBuilder.jsx
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import Modal from '../../../components/Modal.jsx'
|
||||||
|
import { Loading } from '../../../components/PageState.jsx'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
import '../../../blocks/index.js' // registers all block types
|
||||||
|
import { listBlocks, getBlock, makeBlockId } from '../../../blocks/registry.js'
|
||||||
|
import { SelectField, TextField, TextAreaField } from '../../../blocks/editorKit.jsx'
|
||||||
|
|
||||||
|
const LAYOUTS = [
|
||||||
|
['default', 'Default'],
|
||||||
|
['full_width', 'Full width'],
|
||||||
|
['landing', 'Landing'],
|
||||||
|
]
|
||||||
|
const NAV_GROUPS = [
|
||||||
|
['', 'None'],
|
||||||
|
['main', 'Main nav'],
|
||||||
|
['footer', 'Footer'],
|
||||||
|
['account', 'Account'],
|
||||||
|
['hidden', 'Hidden'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const EMPTY = {
|
||||||
|
title: '',
|
||||||
|
slug: '',
|
||||||
|
status: 'draft',
|
||||||
|
blocks: [],
|
||||||
|
metadata: { seoTitle: '', metaDescription: '', ogImage: '', canonicalUrl: '', robots: '' },
|
||||||
|
settings: { layout: 'default', showInNav: false, navGroup: '', navOrder: null, protected: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map an API page (grouped shape) into local editable form state.
|
||||||
|
function toForm(page) {
|
||||||
|
return {
|
||||||
|
title: page.title || '',
|
||||||
|
slug: page.slug || '',
|
||||||
|
status: page.status || 'draft',
|
||||||
|
blocks: Array.isArray(page.blocks) ? page.blocks : [],
|
||||||
|
metadata: { ...EMPTY.metadata, ...cleanNulls(page.metadata) },
|
||||||
|
settings: {
|
||||||
|
layout: page.settings?.layout || 'default',
|
||||||
|
showInNav: Boolean(page.settings?.showInNav),
|
||||||
|
navGroup: page.settings?.navGroup || '',
|
||||||
|
navOrder: page.settings?.navOrder ?? null,
|
||||||
|
protected: Boolean(page.settings?.protected),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanNulls(obj) {
|
||||||
|
const out = {}
|
||||||
|
for (const [k, v] of Object.entries(obj || {})) out[k] = v == null ? '' : v
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageBuilder() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const isEdit = Boolean(id)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [form, setForm] = useState(EMPTY)
|
||||||
|
const [protectedNow, setProtectedNow] = useState(false) // server truth, edit mode
|
||||||
|
const [loading, setLoading] = useState(isEdit)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [details, setDetails] = useState([]) // block validation errors
|
||||||
|
const [notice, setNotice] = useState('')
|
||||||
|
const [tab, setTab] = useState('content')
|
||||||
|
const [pwModal, setPwModal] = useState(false)
|
||||||
|
const [dragIndex, setDragIndex] = useState(null)
|
||||||
|
|
||||||
|
const palette = useMemo(() => listBlocks(), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEdit) return
|
||||||
|
let active = true
|
||||||
|
setLoading(true)
|
||||||
|
api.admin
|
||||||
|
.getPage(id)
|
||||||
|
.then((page) => {
|
||||||
|
if (!active) return
|
||||||
|
setForm(toForm(page))
|
||||||
|
setProtectedNow(Boolean(page.settings?.protected))
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!active) return
|
||||||
|
setError(err.message || 'Could not load the page.')
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [id, isEdit])
|
||||||
|
|
||||||
|
// ── Block operations ────────────────────────────────────────────────
|
||||||
|
const addBlock = useCallback((type) => {
|
||||||
|
const def = getBlock(type)
|
||||||
|
if (!def) return
|
||||||
|
const block = { id: makeBlockId(), type, version: def.version, visible: true, props: def.defaults() }
|
||||||
|
setForm((f) => ({ ...f, blocks: [...f.blocks, block] }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateBlock = useCallback((blockId, nextProps) => {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
blocks: f.blocks.map((b) => (b.id === blockId ? { ...b, props: nextProps } : b)),
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleVisible = useCallback((blockId) => {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
blocks: f.blocks.map((b) => (b.id === blockId ? { ...b, visible: b.visible === false } : b)),
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeBlock = useCallback((blockId) => {
|
||||||
|
setForm((f) => ({ ...f, blocks: f.blocks.filter((b) => b.id !== blockId) }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const moveBlock = useCallback((from, to) => {
|
||||||
|
setForm((f) => {
|
||||||
|
if (to < 0 || to >= f.blocks.length) return f
|
||||||
|
const next = [...f.blocks]
|
||||||
|
const [moved] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, moved)
|
||||||
|
return { ...f, blocks: next }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function onDrop(index) {
|
||||||
|
if (dragIndex === null || dragIndex === index) return setDragIndex(null)
|
||||||
|
moveBlock(dragIndex, index)
|
||||||
|
setDragIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form field setters ──────────────────────────────────────────────
|
||||||
|
const setField = (k) => (v) => setForm((f) => ({ ...f, [k]: v }))
|
||||||
|
const setMeta = (k) => (v) => setForm((f) => ({ ...f, metadata: { ...f.metadata, [k]: v } }))
|
||||||
|
const setSetting = (k) => (v) => setForm((f) => ({ ...f, settings: { ...f.settings, [k]: v } }))
|
||||||
|
|
||||||
|
// Serialize local state into an API payload. Empty metadata strings become
|
||||||
|
// null; navGroup '' becomes null.
|
||||||
|
function payload() {
|
||||||
|
const metadata = {}
|
||||||
|
for (const [k, v] of Object.entries(form.metadata)) metadata[k] = v === '' ? null : v
|
||||||
|
const settings = {
|
||||||
|
layout: form.settings.layout,
|
||||||
|
showInNav: Boolean(form.settings.showInNav),
|
||||||
|
navGroup: form.settings.navGroup === '' ? null : form.settings.navGroup,
|
||||||
|
navOrder: form.settings.navOrder === '' || form.settings.navOrder == null ? null : Number(form.settings.navOrder),
|
||||||
|
}
|
||||||
|
return { title: form.title.trim(), status: form.status, blocks: form.blocks, metadata, settings }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save({ silent } = {}) {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
setDetails([])
|
||||||
|
setNotice('')
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await api.admin.updatePage(id, payload())
|
||||||
|
if (!silent) setNotice('Saved.')
|
||||||
|
} else {
|
||||||
|
if (!form.slug.trim()) throw new Error('A slug is required.')
|
||||||
|
const created = await api.admin.createPage({ slug: form.slug.trim(), ...payload() })
|
||||||
|
navigate(`/admin/pages/${created.id}`, { replace: true })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not save the page.')
|
||||||
|
if (err.body?.details) setDetails(err.body.details)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePublish() {
|
||||||
|
const next = form.status === 'published' ? 'draft' : 'published'
|
||||||
|
setForm((f) => ({ ...f, status: next }))
|
||||||
|
// Persist immediately (edit mode) so the status change isn't lost.
|
||||||
|
if (isEdit) {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.admin.updatePage(id, { ...payload(), status: next })
|
||||||
|
setNotice(next === 'published' ? 'Published.' : 'Unpublished.')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not change status.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function protectPage() {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.admin.updatePage(id, { settings: { protected: true } })
|
||||||
|
setProtectedNow(true)
|
||||||
|
setForm((f) => ({ ...f, settings: { ...f.settings, protected: true } }))
|
||||||
|
setNotice('Page protected.')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not protect the page.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unprotectPage(password) {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.admin.unprotectPage(id, password)
|
||||||
|
setProtectedNow(false)
|
||||||
|
setForm((f) => ({ ...f, settings: { ...f.settings, protected: false } }))
|
||||||
|
setPwModal(false)
|
||||||
|
setNotice('Protection removed.')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not unprotect the page.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preview() {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const { token } = await api.admin.createPagePreview(id)
|
||||||
|
window.open(`/preview/${id}/${token}`, '_blank', 'noopener')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not create a preview link.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!confirm('Delete this page? This cannot be undone.')) return
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.admin.deletePage(id)
|
||||||
|
navigate('/admin/pages')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not delete the page.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
|
||||||
|
const published = form.status === 'published'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="pb-toolbar">
|
||||||
|
<button className="pill" onClick={() => navigate('/admin/pages')}>← Pages</button>
|
||||||
|
<span className={`badge ${published ? 'badge-pub' : 'badge-draft'}`}>{published ? 'Published' : 'Draft'}</span>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{isEdit && (
|
||||||
|
<button className="pill" onClick={preview} disabled={busy}>Preview</button>
|
||||||
|
)}
|
||||||
|
{isEdit && (
|
||||||
|
<button className="pill" onClick={togglePublish} disabled={busy}>
|
||||||
|
{published ? 'Unpublish' : 'Publish'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-primary btn-sq" onClick={() => save()} disabled={busy}>
|
||||||
|
{busy ? 'Saving…' : isEdit ? 'Save' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="pb-error sans">
|
||||||
|
{error}
|
||||||
|
{details.length > 0 && (
|
||||||
|
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
|
||||||
|
{details.map((d, i) => <li key={i}>{d}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{notice && <div className="pb-notice sans">{notice}</div>}
|
||||||
|
|
||||||
|
{/* Title + slug */}
|
||||||
|
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', margin: '16px 0' }}>
|
||||||
|
<label style={{ flex: '2 1 320px' }}>
|
||||||
|
<span className="field-label">Title</span>
|
||||||
|
<input className="input" value={form.title} onChange={(e) => setField('title')(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label style={{ flex: '1 1 220px' }}>
|
||||||
|
<span className="field-label">Slug {isEdit && '(fixed)'}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.slug}
|
||||||
|
disabled={isEdit}
|
||||||
|
placeholder="my-page"
|
||||||
|
onChange={(e) => setField('slug')(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="pb-tabs">
|
||||||
|
<button className={`pb-tab ${tab === 'content' ? 'is-active' : ''}`} onClick={() => setTab('content')}>Content</button>
|
||||||
|
<button className={`pb-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Settings & SEO</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'content' && (
|
||||||
|
<>
|
||||||
|
<div className="pb-palette">
|
||||||
|
<span className="field-label" style={{ margin: '0 6px 0 0' }}>Add block</span>
|
||||||
|
{palette.map((b) => (
|
||||||
|
<button key={b.type} className="pill" onClick={() => addBlock(b.type)} disabled={busy}>
|
||||||
|
<span aria-hidden style={{ marginRight: 6 }}>{b.icon}</span>{b.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pb-canvas">
|
||||||
|
{form.blocks.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ textAlign: 'center', padding: 30 }}>
|
||||||
|
No blocks yet — add one from the palette above.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{form.blocks.map((block, i) => {
|
||||||
|
const def = getBlock(block.type)
|
||||||
|
const Editor = def?.editor
|
||||||
|
const hidden = block.visible === false
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={block.id}
|
||||||
|
className={`pb-block-card ${hidden ? 'is-hidden' : ''} ${dragIndex === i ? 'is-dragging' : ''}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragIndex(i)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => onDrop(i)}
|
||||||
|
onDragEnd={() => setDragIndex(null)}
|
||||||
|
>
|
||||||
|
<div className="pb-block-head">
|
||||||
|
<span className="pb-drag" title="Drag to reorder">⠿</span>
|
||||||
|
<strong className="sans">{def?.label || block.type}</strong>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<button className="pill pb-mini" title={hidden ? 'Show' : 'Hide'} onClick={() => toggleVisible(block.id)}>
|
||||||
|
{hidden ? '🙈' : '👁'}
|
||||||
|
</button>
|
||||||
|
<button className="pill pb-mini" disabled={i === 0} onClick={() => moveBlock(i, i - 1)} title="Move up">↑</button>
|
||||||
|
<button className="pill pb-mini" disabled={i === form.blocks.length - 1} onClick={() => moveBlock(i, i + 1)} title="Move down">↓</button>
|
||||||
|
<button className="pill pb-mini" onClick={() => removeBlock(block.id)} title="Remove">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="pb-block-body">
|
||||||
|
{Editor ? (
|
||||||
|
<Editor props={block.props || {}} onChange={(p) => updateBlock(block.id, p)} />
|
||||||
|
) : (
|
||||||
|
<p className="sans dim">Unknown block type: {block.type}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'settings' && (
|
||||||
|
<div className="pb-settings">
|
||||||
|
<div className="card" style={{ padding: 18 }}>
|
||||||
|
<p className="card-kicker">SEO & metadata</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
|
||||||
|
<TextField label="SEO title" value={form.metadata.seoTitle} maxLength={200} onChange={setMeta('seoTitle')} hint="Overrides the page title in the browser tab / search results." />
|
||||||
|
<TextAreaField label="Meta description" value={form.metadata.metaDescription} rows={2} maxLength={400} onChange={setMeta('metaDescription')} />
|
||||||
|
<TextField label="OG image URL" value={form.metadata.ogImage} maxLength={500} onChange={setMeta('ogImage')} />
|
||||||
|
<TextField label="Canonical URL" value={form.metadata.canonicalUrl} maxLength={500} onChange={setMeta('canonicalUrl')} />
|
||||||
|
<TextField label="Robots" value={form.metadata.robots} maxLength={100} onChange={setMeta('robots')} placeholder="index,follow" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 18 }}>
|
||||||
|
<p className="card-kicker">Layout & navigation</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
|
||||||
|
<SelectField label="Layout" value={form.settings.layout} onChange={setSetting('layout')} options={LAYOUTS} />
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input type="checkbox" checked={form.settings.showInNav} onChange={(e) => setSetting('showInNav')(e.target.checked)} />
|
||||||
|
<span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>Show in navigation</span>
|
||||||
|
</label>
|
||||||
|
<SelectField label="Nav group" value={form.settings.navGroup} onChange={setSetting('navGroup')} options={NAV_GROUPS} />
|
||||||
|
<TextField label="Nav order" value={form.settings.navOrder ?? ''} onChange={(v) => setSetting('navOrder')(v === '' ? null : v.replace(/[^0-9]/g, ''))} hint="Lower numbers appear first." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 18 }}>
|
||||||
|
<p className="card-kicker">Protection & danger zone</p>
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.85rem', marginTop: 8 }}>
|
||||||
|
A protected page can’t be deleted and its protection can only be removed by re-entering your password.
|
||||||
|
</p>
|
||||||
|
{!isEdit && <p className="sans dim" style={{ fontSize: '0.82rem' }}>Save the page first to manage protection.</p>}
|
||||||
|
{isEdit && (
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 10 }}>
|
||||||
|
{protectedNow ? (
|
||||||
|
<button className="pill" onClick={() => setPwModal(true)} disabled={busy}>🔓 Remove protection…</button>
|
||||||
|
) : (
|
||||||
|
<button className="pill" onClick={protectPage} disabled={busy}>🔒 Protect page</button>
|
||||||
|
)}
|
||||||
|
<button className="pill pb-danger" onClick={remove} disabled={busy || protectedNow} title={protectedNow ? 'Unprotect first' : 'Delete'}>
|
||||||
|
Delete page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pwModal && (
|
||||||
|
<UnprotectModal onCancel={() => setPwModal(false)} onConfirm={unprotectPage} busy={busy} error={error} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnprotectModal({ onCancel, onConfirm, busy, error }) {
|
||||||
|
const [pw, setPw] = useState('')
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Confirm your password"
|
||||||
|
onClose={onCancel}
|
||||||
|
width={420}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="pill" onClick={onCancel} disabled={busy}>Cancel</button>
|
||||||
|
<button className="btn btn-primary btn-sq" onClick={() => onConfirm(pw)} disabled={busy || !pw}>
|
||||||
|
{busy ? 'Verifying…' : 'Remove protection'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="sans dim" style={{ marginTop: 0, fontSize: '0.88rem' }}>
|
||||||
|
Removing protection is a sensitive change — re-enter your account password to continue.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
autoFocus
|
||||||
|
value={pw}
|
||||||
|
onChange={(e) => setPw(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && pw && onConfirm(pw)}
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.85rem', marginBottom: 0 }}>{error}</p>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
client/src/routes/admin/views/PagesAdmin.jsx
Normal file
91
client/src/routes/admin/views/PagesAdmin.jsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
|
import { shortDate } from '../../../lib/format.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// List of CMS pages. Create/edit open the full-page block builder; the builder
|
||||||
|
// owns save/delete/publish so this view is read-only navigation.
|
||||||
|
export default function PagesAdmin() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [tick] = useState(0)
|
||||||
|
const { loading, error, data } = useAsync(() => api.admin.listPages(), [tick])
|
||||||
|
const pages = data || []
|
||||||
|
|
||||||
|
const openNew = useCallback(() => navigate('/admin/pages/new'), [navigate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 14, marginBottom: 18 }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
|
||||||
|
Compose pages from blocks. A published page is live at <code>/its-slug</code>.
|
||||||
|
</p>
|
||||||
|
<button onClick={openNew} className="btn btn-primary btn-sq">
|
||||||
|
+ New page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load pages." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Title</th>
|
||||||
|
<th className="adm-th">Slug</th>
|
||||||
|
<th className="adm-th">Status</th>
|
||||||
|
<th className="adm-th">Updated</th>
|
||||||
|
<th className="adm-th" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pages.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
|
||||||
|
No pages yet — create your first one.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{pages.map((p) => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
||||||
|
{p.title}
|
||||||
|
{p.protected && (
|
||||||
|
<span title="Protected" style={{ marginLeft: 8 }}>🔒</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td dim">/{p.slug}</td>
|
||||||
|
<td className="adm-td">
|
||||||
|
<span className={`badge ${p.status === 'published' ? 'badge-pub' : 'badge-draft'}`}>
|
||||||
|
{p.status === 'published' ? 'Published' : 'Draft'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="adm-td dim">{shortDate(p.updatedAt)}</td>
|
||||||
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||||
|
{p.status === 'published' && (
|
||||||
|
<a
|
||||||
|
className="link-accent"
|
||||||
|
href={`/${p.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ marginRight: 14 }}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="link-accent" onClick={() => navigate(`/admin/pages/${p.id}`)}>
|
||||||
|
Edit
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, Suspense, useState } from 'react'
|
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||||
import Modal from '../../../components/Modal.jsx'
|
import Modal from '../../../components/Modal.jsx'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
@@ -105,6 +105,8 @@ export default function PostEditor({ post, onClose, onSaved }) {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||||
|
|
||||||
|
{isEdit && post.category === 'news' && <AnnouncePanel postId={post.id} />}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
||||||
<label style={{ flex: '1 1 200px' }}>
|
<label style={{ flex: '1 1 200px' }}>
|
||||||
<span className="field-label">Category</span>
|
<span className="field-label">Category</span>
|
||||||
@@ -173,3 +175,94 @@ const delStyle = {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
marginRight: 'auto',
|
marginRight: 'auto',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Announcement status panel ────────────────────────────────────────────────
|
||||||
|
// Shows the town-crier + Discord delivery state for a published news post and
|
||||||
|
// offers a per-leg retry (useful after fixing the sidecar / news channel without
|
||||||
|
// re-publishing). Only rendered for news posts in edit mode; renders nothing
|
||||||
|
// until the post has actually been announced (no job row yet → nothing to show).
|
||||||
|
const LEG_META = {
|
||||||
|
towncrier: { label: 'In-game town crier' },
|
||||||
|
discord: { label: 'Discord #news' },
|
||||||
|
}
|
||||||
|
const STATUS_STYLE = {
|
||||||
|
done: { color: '#7bbf8f', label: 'delivered' },
|
||||||
|
pending: { color: '#d9b84a', label: 'pending' },
|
||||||
|
failed: { color: '#d98b84', label: 'failed' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnnouncePanel({ postId }) {
|
||||||
|
const [job, setJob] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [retrying, setRetrying] = useState('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setJob(await api.admin.getAnnounce(postId))
|
||||||
|
} catch {
|
||||||
|
setJob(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [postId])
|
||||||
|
|
||||||
|
async function retry(leg) {
|
||||||
|
setRetrying(leg)
|
||||||
|
try {
|
||||||
|
setJob(await api.admin.retryAnnounceLeg(postId, leg))
|
||||||
|
} catch {
|
||||||
|
// leave the current state; the row simply didn't change
|
||||||
|
} finally {
|
||||||
|
setRetrying('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !job) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={panelStyle}>
|
||||||
|
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
|
||||||
|
{['towncrier', 'discord'].map((leg) => {
|
||||||
|
const status = job[`${leg}_status`]
|
||||||
|
const err = job[`${leg}_last_error`]
|
||||||
|
const s = STATUS_STYLE[status] || STATUS_STYLE.pending
|
||||||
|
return (
|
||||||
|
<div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{LEG_META[leg].label}</span>
|
||||||
|
<span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}>● {s.label}</span>
|
||||||
|
{status !== 'done' && (
|
||||||
|
<button
|
||||||
|
onClick={() => retry(leg)}
|
||||||
|
disabled={Boolean(retrying)}
|
||||||
|
className="pill"
|
||||||
|
style={{ marginLeft: 'auto', fontSize: '0.75rem', padding: '3px 12px' }}
|
||||||
|
>
|
||||||
|
{retrying === leg ? 'Retrying…' : 'Retry'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{status === 'failed' && err && (
|
||||||
|
<span className="sans" style={{ fontSize: '0.75rem', color: '#d98b84', paddingLeft: 148 }}>{err}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
background: 'rgba(255,255,255,0.02)',
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,52 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||||
|
import EmailDelivery from './EmailDelivery.jsx'
|
||||||
|
|
||||||
|
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
|
||||||
|
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
|
||||||
|
|
||||||
// Editable settings shown on this screen (key -> label + control type).
|
// Editable settings shown on this screen (key -> label + control type).
|
||||||
const FIELDS = [
|
const FIELDS = [
|
||||||
{ key: 'site_title', label: 'Site title' },
|
{ key: 'site_title', label: 'Site title' },
|
||||||
{ key: 'homepage_teaser', label: 'Homepage teaser', long: true },
|
{
|
||||||
|
key: 'homepage_teaser',
|
||||||
|
label: 'Homepage teaser',
|
||||||
|
rich: true,
|
||||||
|
help: 'Rich text shown under the hero heading on the portal (when no custom hero layout is published).',
|
||||||
|
},
|
||||||
{ key: 'maintenance_message', label: 'Maintenance message', long: true },
|
{ key: 'maintenance_message', label: 'Maintenance message', long: true },
|
||||||
{ key: 'status_message', label: 'Status message' },
|
{ key: 'status_message', label: 'Status message' },
|
||||||
{ key: 'contact_email', label: 'Contact email' },
|
{
|
||||||
|
key: 'contact_email',
|
||||||
|
label: 'Contact email',
|
||||||
|
help: 'Where contact-form messages (and test emails) are delivered. Also the address shown when email delivery is unconfigured and the form falls back to a mailto: link.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'player_registration',
|
||||||
|
label: 'Player registration',
|
||||||
|
help: 'Who can create a player account, and how. Off by default.',
|
||||||
|
options: [
|
||||||
|
{ value: 'disabled', label: 'Disabled — no self-registration' },
|
||||||
|
{ value: 'password', label: 'Password — username + password sign-up' },
|
||||||
|
{ value: 'sso', label: 'SSO — sign up with a linked provider' },
|
||||||
|
{ value: 'both', label: 'Both — password and SSO' },
|
||||||
|
],
|
||||||
|
fallback: 'disabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'game_account_signup',
|
||||||
|
label: 'Game-account creation',
|
||||||
|
help: 'Whether players can create a GAME account (for the game client) from the site. The game server’s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
|
||||||
|
options: [
|
||||||
|
{ value: 'disabled', label: 'Disabled — link an existing account only' },
|
||||||
|
{ value: 'website', label: 'Website — the site creates game accounts' },
|
||||||
|
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
|
||||||
|
{ value: 'game', label: 'Game only — created in the game client, not the site' },
|
||||||
|
],
|
||||||
|
fallback: 'disabled',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function SettingsAdmin() {
|
export default function SettingsAdmin() {
|
||||||
@@ -28,7 +65,7 @@ export default function SettingsAdmin() {
|
|||||||
.then((all) => {
|
.then((all) => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const v = {}
|
const v = {}
|
||||||
FIELDS.forEach((f) => (v[f.key] = all[f.key] ?? ''))
|
FIELDS.forEach((f) => (v[f.key] = all[f.key] ?? f.fallback ?? ''))
|
||||||
setValues(v)
|
setValues(v)
|
||||||
setInitial(v)
|
setInitial(v)
|
||||||
})
|
})
|
||||||
@@ -42,10 +79,13 @@ export default function SettingsAdmin() {
|
|||||||
if (loading) return <Loading />
|
if (loading) return <Loading />
|
||||||
if (error) return <ErrorState message={error} />
|
if (error) return <ErrorState message={error} />
|
||||||
|
|
||||||
const set = (k) => (e) => {
|
// setRaw takes the next value directly (rich editor onChange), set adapts a
|
||||||
setValues((v) => ({ ...v, [k]: e.target.value }))
|
// DOM change event onto it.
|
||||||
|
const setRaw = (k) => (val) => {
|
||||||
|
setValues((v) => ({ ...v, [k]: val }))
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
}
|
}
|
||||||
|
const set = (k) => (e) => setRaw(k)(e.target.value)
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
@@ -65,16 +105,38 @@ export default function SettingsAdmin() {
|
|||||||
return (
|
return (
|
||||||
<section style={{ maxWidth: 620 }}>
|
<section style={{ maxWidth: 620 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||||
{FIELDS.map((f) => (
|
{FIELDS.map((f) => {
|
||||||
<label key={f.key} style={{ display: 'block' }}>
|
// A rich field can't live inside a <label> (nested toolbar buttons +
|
||||||
|
// contenteditable), so it uses a plain <div> wrapper instead.
|
||||||
|
const Wrap = f.rich ? 'div' : 'label'
|
||||||
|
return (
|
||||||
|
<Wrap key={f.key} style={{ display: 'block' }}>
|
||||||
<span className="field-label">{f.label}</span>
|
<span className="field-label">{f.label}</span>
|
||||||
{f.long ? (
|
{f.rich ? (
|
||||||
|
<Suspense fallback={<span className="spin" />}>
|
||||||
|
<RichTextEditor value={values[f.key]} onChange={setRaw(f.key)} variant="post" />
|
||||||
|
</Suspense>
|
||||||
|
) : f.options ? (
|
||||||
|
<select value={values[f.key]} onChange={set(f.key)} className="select">
|
||||||
|
{f.options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : f.long ? (
|
||||||
<textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} />
|
<textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} />
|
||||||
) : (
|
) : (
|
||||||
<input type="text" value={values[f.key]} onChange={set(f.key)} className="input" />
|
<input type="text" value={values[f.key]} onChange={set(f.key)} className="input" />
|
||||||
)}
|
)}
|
||||||
</label>
|
{f.help && (
|
||||||
))}
|
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||||
|
{f.help}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Wrap>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<div style={{ display: 'flex', gap: 10, marginTop: 6, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 10, marginTop: 6, alignItems: 'center' }}>
|
||||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||||
{busy ? 'Saving…' : 'Save changes'}
|
{busy ? 'Saving…' : 'Save changes'}
|
||||||
@@ -86,6 +148,8 @@ export default function SettingsAdmin() {
|
|||||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EmailDelivery />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
248
client/src/routes/admin/views/ShardAdmin.jsx
Normal file
248
client/src/routes/admin/views/ShardAdmin.jsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { useShardFeed } from '../../../lib/useShardFeed.js'
|
||||||
|
import { describe, kindLabel } from '../../../lib/shardEvents.js'
|
||||||
|
import { ago } from '../../../lib/format.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Full live feed from the admin SSE channel — every kind, incl. staff audit,
|
||||||
|
// cheat detection and login attempts that the public channel never carries.
|
||||||
|
function AdminLiveFeed() {
|
||||||
|
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Live feed (all events)</h3>
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Waiting for shard events…</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 360, overflowY: 'auto' }}>
|
||||||
|
{events.map((e) => (
|
||||||
|
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.6rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>{kindLabel(e.kind)}</span>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
||||||
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// uo-link sidecar control panel. The auth token is write-only over this API —
|
||||||
|
// stored encrypted, never returned — same convention as the Discord bot token.
|
||||||
|
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
|
||||||
|
// effect immediately with no redeploy.
|
||||||
|
|
||||||
|
function Toggle({ checked, onChange, label }) {
|
||||||
|
return (
|
||||||
|
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR = {
|
||||||
|
connected: '#7fd0a4',
|
||||||
|
reconnecting: '#e0b070',
|
||||||
|
error: '#d98b84',
|
||||||
|
disconnected: 'var(--muted)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPanel({ config }) {
|
||||||
|
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
||||||
|
const ingest = config.ingest || {}
|
||||||
|
const health = config.health || {}
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
|
||||||
|
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
|
||||||
|
{config.status || 'disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{config.statusDetail && (
|
||||||
|
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
|
||||||
|
)}
|
||||||
|
<div className="sans dim" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 16px', fontSize: '0.78rem', marginTop: 2 }}>
|
||||||
|
<span>Shard link: <strong style={{ color: 'var(--ink)' }}>{config.pluginConnected ? 'up' : 'down'}</strong></span>
|
||||||
|
<span>WS ingest: <strong style={{ color: 'var(--ink)' }}>{ingest.connected ? 'connected' : 'offline'}</strong></span>
|
||||||
|
<span>Reconnects: <strong style={{ color: 'var(--ink)' }}>{ingest.reconnects ?? 0}</strong></span>
|
||||||
|
<span>SSE clients: <strong style={{ color: 'var(--ink)' }}>{(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}</strong></span>
|
||||||
|
{config.lastEventAt && <span style={{ gridColumn: '1 / -1' }}>Last event: {new Date(config.lastEventAt).toLocaleString()}</span>}
|
||||||
|
{health.uptime && <span style={{ gridColumn: '1 / -1' }}>Sidecar uptime: {health.uptime}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Town crier ──────────────────────────────────────────────────────────────
|
||||||
|
function TownCrier() {
|
||||||
|
const [id, setId] = useState('')
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [durationSec, setDurationSec] = useState(3600)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function post() {
|
||||||
|
setBusy(true); setMsg(''); setError('')
|
||||||
|
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||||
|
if (!id.trim() || lines.length === 0) {
|
||||||
|
setBusy(false)
|
||||||
|
return setError('An id and at least one line are required.')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined })
|
||||||
|
setMsg(`Posted “${id.trim()}”.`)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not post.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function remove() {
|
||||||
|
if (!id.trim()) return setError('Enter the id to remove.')
|
||||||
|
setBusy(true); setMsg(''); setError('')
|
||||||
|
try {
|
||||||
|
await api.admin.deleteTownCrier(id.trim())
|
||||||
|
setMsg(`Removed “${id.trim()}”.`)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not remove.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Town crier</h3>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||||
|
Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Message id</span>
|
||||||
|
<input type="text" value={id} onChange={(e) => setId(e.target.value)} className="input" placeholder="news-42" autoComplete="off" style={{ maxWidth: 220 }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Lines (one per line)</span>
|
||||||
|
<textarea value={text} onChange={(e) => setText(e.target.value)} className="input" rows={3} placeholder={'Hear ye!\nMarket tax is now 5%.'} style={{ resize: 'vertical' }} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Duration (seconds)</span>
|
||||||
|
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={1} max={86400} style={{ maxWidth: 160 }} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<button onClick={post} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Working…' : 'Post message'}</button>
|
||||||
|
<button onClick={remove} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>Remove by id</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShardAdmin() {
|
||||||
|
const [config, setConfig] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
|
const [wsUrl, setWsUrl] = useState('')
|
||||||
|
const [token, setToken] = useState('')
|
||||||
|
const [protocol, setProtocol] = useState(1)
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [saveError, setSaveError] = useState('')
|
||||||
|
const pollRef = useRef(null)
|
||||||
|
const initializedRef = useRef(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const c = await api.admin.getUoLinkConfig()
|
||||||
|
setConfig(c)
|
||||||
|
// Seed the editable fields once; later polls only refresh the status panel
|
||||||
|
// so they never clobber what the admin is mid-typing.
|
||||||
|
if (!initializedRef.current) {
|
||||||
|
setBaseUrl(c.baseUrl || '')
|
||||||
|
setWsUrl(c.wsUrl || '')
|
||||||
|
setProtocol(c.protocol || 1)
|
||||||
|
setEnabled(c.enabled)
|
||||||
|
initializedRef.current = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Could not load uo-link config.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
pollRef.current = setInterval(load, 5000)
|
||||||
|
return () => clearInterval(pollRef.current)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setBusy(true); setMsg(''); setSaveError('')
|
||||||
|
try {
|
||||||
|
const body = { baseUrl, wsUrl, protocol: Number(protocol), enabled }
|
||||||
|
if (token) body.token = token
|
||||||
|
const saved = await api.admin.saveUoLinkConfig(body)
|
||||||
|
setConfig(saved)
|
||||||
|
setToken('')
|
||||||
|
setMsg('Saved.')
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err.message || 'Could not save.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!config) return <Loading />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Shard (uo-link)</h2>
|
||||||
|
|
||||||
|
<StatusPanel config={config} />
|
||||||
|
|
||||||
|
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Base URL (REST)</span>
|
||||||
|
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} className="input" autoComplete="off" placeholder="http://127.0.0.1:8080" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">WebSocket URL (feed)</span>
|
||||||
|
<input type="text" value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} className="input" autoComplete="off" placeholder="ws://127.0.0.1:8080/ws" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Auth token</span>
|
||||||
|
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="input" autoComplete="new-password" placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Shared secret from sidecar.toml'} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', maxWidth: 140 }}>
|
||||||
|
<span className="field-label">Protocol</span>
|
||||||
|
<input type="number" value={protocol} onChange={(e) => setProtocol(e.target.value)} className="input" min={1} max={99} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
|
||||||
|
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Saving…' : 'Save changes'}</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TownCrier />
|
||||||
|
|
||||||
|
<AdminLiveFeed />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
269
client/src/routes/admin/views/ShardOps.jsx
Normal file
269
client/src/routes/admin/views/ShardOps.jsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useShardFeed } from '../../../lib/useShardFeed.js'
|
||||||
|
import { describe } from '../../../lib/shardEvents.js'
|
||||||
|
import { ago } from '../../../lib/format.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
|
||||||
|
// unban) and the help-page support queue, plus a live audit log. Open to admins
|
||||||
|
// and moderators. The acting staff member (`actor`) is attached server-side from
|
||||||
|
// the session — nothing here sends it — so every action is attributable.
|
||||||
|
|
||||||
|
function Flash({ ok, err }) {
|
||||||
|
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
|
||||||
|
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Broadcast ────────────────────────────────────────────────────────────────
|
||||||
|
function Broadcast() {
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [hue, setHue] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [ok, setOk] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
if (!text.trim()) return setErr('Enter a message.')
|
||||||
|
setBusy(true); setOk(''); setErr('')
|
||||||
|
try {
|
||||||
|
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
|
||||||
|
setOk('Broadcast sent.')
|
||||||
|
setText('')
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Could not broadcast.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||||
|
A system message shown to everyone online right now.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Message</span>
|
||||||
|
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', maxWidth: 140 }}>
|
||||||
|
<span className="field-label">Hue (optional)</span>
|
||||||
|
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
|
||||||
|
<Flash ok={ok} err={err} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
|
||||||
|
function AccountActions() {
|
||||||
|
const [account, setAccount] = useState('')
|
||||||
|
const [durationSec, setDurationSec] = useState('')
|
||||||
|
const [reason, setReason] = useState('')
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
const [ok, setOk] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const acct = account.trim()
|
||||||
|
function guard() {
|
||||||
|
if (!acct) {
|
||||||
|
setErr('Enter an account name.')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(label, fn, done) {
|
||||||
|
if (!guard()) return
|
||||||
|
setBusy(label); setOk(''); setErr('')
|
||||||
|
try {
|
||||||
|
const r = await fn()
|
||||||
|
setOk(done(r))
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Action failed.')
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kick = () =>
|
||||||
|
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => `Kicked ${acct}${r?.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`)
|
||||||
|
const ban = () =>
|
||||||
|
run('ban', () => api.admin.shardOps.ban({ account: acct, durationSec: durationSec === '' ? undefined : Number(durationSec), reason: reason.trim() || undefined }), () => `Banned ${acct}${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`)
|
||||||
|
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Account actions</h3>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||||
|
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Account</span>
|
||||||
|
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ display: 'block', maxWidth: 200 }}>
|
||||||
|
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
|
||||||
|
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
|
||||||
|
<span className="field-label">Ban reason (optional)</span>
|
||||||
|
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
|
||||||
|
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
|
||||||
|
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
|
||||||
|
<Flash ok={ok} err={err} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Support (help-page) queue ────────────────────────────────────────────────
|
||||||
|
function PageRow({ page, onDone }) {
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
async function respond(close) {
|
||||||
|
if (!message.trim()) return setErr('Enter a reply first.')
|
||||||
|
setBusy(close ? 'respond-close' : 'respond'); setErr('')
|
||||||
|
try {
|
||||||
|
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
|
||||||
|
onDone()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Could not send.')
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function close() {
|
||||||
|
setBusy('close'); setErr('')
|
||||||
|
try {
|
||||||
|
await api.admin.shardOps.closePage(page.pageId)
|
||||||
|
onDone()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Could not close.')
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||||||
|
{page.sender?.name || page.pageId}
|
||||||
|
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
|
||||||
|
</div>
|
||||||
|
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||||
|
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
|
||||||
|
</div>
|
||||||
|
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
|
||||||
|
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
|
||||||
|
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
|
||||||
|
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SupportQueue() {
|
||||||
|
const [pages, setPages] = useState(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const pollRef = useRef(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setPages(await api.admin.shardOps.pages())
|
||||||
|
} catch {
|
||||||
|
setErr('Could not load the support queue.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
pollRef.current = setInterval(load, 7000)
|
||||||
|
return () => clearInterval(pollRef.current)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||||
|
Open help pages from players. A reply reaches them in game (or on their next login).
|
||||||
|
</p>
|
||||||
|
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
|
||||||
|
{pages == null ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading…</p>
|
||||||
|
) : pages.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audit log ────────────────────────────────────────────────────────────────
|
||||||
|
// Seeded from the stored admin.audit history, then kept live from the admin SSE
|
||||||
|
// channel (which carries every kind — we filter to admin.audit here).
|
||||||
|
function AuditLog() {
|
||||||
|
const [seed, setSeed] = useState([])
|
||||||
|
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.admin.shardOps
|
||||||
|
.audit(50)
|
||||||
|
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
|
||||||
|
.catch(() => setSeed([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Live events on top; fall back to the seed for anything older than the live tail.
|
||||||
|
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
|
||||||
|
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
|
||||||
|
{rows.map((e) => (
|
||||||
|
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
||||||
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShardOps() {
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||||
|
<Broadcast />
|
||||||
|
<AccountActions />
|
||||||
|
<SupportQueue />
|
||||||
|
<AuditLog />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
182
client/src/routes/admin/views/UserDetail.jsx
Normal file
182
client/src/routes/admin/views/UserDetail.jsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
|
import { dateTime, ago } from '../../../lib/format.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
import CharacterStats from '../../../components/CharacterStats.jsx'
|
||||||
|
import GameAccounts from '../../../components/GameAccounts.jsx'
|
||||||
|
import VendorSales from '../../../components/VendorSales.jsx'
|
||||||
|
|
||||||
|
// Admin read-only view of one user's shard (uo-link) footprint: linked game
|
||||||
|
// accounts + character rosters, currently-online characters, houses (incl.
|
||||||
|
// IDOC) and recent vendor sales — everything scoped to that user's accounts.
|
||||||
|
// Reached from the Users table's "View" action; Edit stays a separate modal.
|
||||||
|
|
||||||
|
const ROLE_BADGE = {
|
||||||
|
admin: 'badge-admin',
|
||||||
|
editor: 'badge-editor',
|
||||||
|
moderator: 'badge-moderator',
|
||||||
|
player: 'badge-player',
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ children }) {
|
||||||
|
return (
|
||||||
|
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently-online characters on the user's accounts, with where they are. The
|
||||||
|
// per-character Online/Offline badge lives in the roster; this adds location.
|
||||||
|
function OnlineNow({ scope }) {
|
||||||
|
const { data } = useAsync(() => scope.online(), [scope])
|
||||||
|
if (!data) return null
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<SectionTitle>Online now</SectionTitle>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{data.map((c) => (
|
||||||
|
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
|
||||||
|
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
||||||
|
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shard "standing": city governorships held and guilds led by this user's
|
||||||
|
// accounts (both reliable current-state lookups). Renders nothing when empty.
|
||||||
|
function Standing({ scope }) {
|
||||||
|
const { data } = useAsync(() => scope.standing(), [scope])
|
||||||
|
if (!data) return null
|
||||||
|
const govs = data.governorOf || []
|
||||||
|
const guilds = data.guildsLed || []
|
||||||
|
if (govs.length === 0 && guilds.length === 0) return null
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<SectionTitle>Standing</SectionTitle>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{govs.map((g) => (
|
||||||
|
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
|
||||||
|
Governor of {g.city}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{guilds.map((g) => (
|
||||||
|
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
|
||||||
|
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Houses owned by the user's accounts, IDOC first (flagged).
|
||||||
|
function Houses({ scope }) {
|
||||||
|
const { data } = useAsync(() => scope.houses(), [scope])
|
||||||
|
if (!data) return null
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<SectionTitle>Houses</SectionTitle>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{data.map((h) => (
|
||||||
|
<li
|
||||||
|
key={h.serial}
|
||||||
|
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||||||
|
{h.name || 'Unnamed house'}
|
||||||
|
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
|
||||||
|
{h.region || (h.map != null ? `map ${h.map}` : 'unknown')}
|
||||||
|
{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||||
|
{h.ownerAcct ? ` · ${h.ownerAcct}` : ''}
|
||||||
|
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
||||||
|
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
|
||||||
|
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
|
||||||
|
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShardSections({ scope }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CharacterStats scope={scope} />
|
||||||
|
<SectionTitle>Linked accounts & characters</SectionTitle>
|
||||||
|
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
|
||||||
|
<Standing scope={scope} />
|
||||||
|
<OnlineNow scope={scope} />
|
||||||
|
<Houses scope={scope} />
|
||||||
|
<VendorSales fetchSales={scope.sales} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserDetail() {
|
||||||
|
const { id } = useParams()
|
||||||
|
// Memoize so the child components' effects (keyed on `scope`) don't refetch
|
||||||
|
// on every render.
|
||||||
|
const scope = useMemo(() => api.admin.userShard(id), [id])
|
||||||
|
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load this user." />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<Link to="/admin/users" className="link-accent" style={{ fontSize: '0.85rem' }}>
|
||||||
|
← Back to users
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 24px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
|
||||||
|
{user.username}
|
||||||
|
</span>
|
||||||
|
<span className={`badge ${ROLE_BADGE[user.role] || 'badge-editor'}`}>{user.role}</span>
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{ fontSize: '0.82rem', color: user.status && user.status !== 'active' ? '#d98b84' : 'var(--muted)' }}
|
||||||
|
>
|
||||||
|
{user.status || 'active'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ display: 'flex', gap: 18, marginTop: 10, flexWrap: 'wrap', fontSize: '0.8rem' }}>
|
||||||
|
{user.email && <span>{user.email}</span>}
|
||||||
|
<span>Last login: {user.last_login_at ? dateTime(user.last_login_at) : 'never'}</span>
|
||||||
|
{user.created_at && <span>Joined: {dateTime(user.created_at)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ShardSections scope={scope} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ export default function UserEditor({ user, onClose, onSaved }) {
|
|||||||
username: user?.username || '',
|
username: user?.username || '',
|
||||||
password: '',
|
password: '',
|
||||||
role: user?.role || 'admin',
|
role: user?.role || 'admin',
|
||||||
|
status: user?.status || 'active',
|
||||||
|
email: user?.email || '',
|
||||||
})
|
})
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -21,12 +23,19 @@ export default function UserEditor({ user, onClose, onSaved }) {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
|
const email = form.email.trim() || null
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
const payload = { username: form.username.trim(), role: form.role }
|
const payload = { username: form.username.trim(), role: form.role, status: form.status, email }
|
||||||
if (form.password) payload.password = form.password
|
if (form.password) payload.password = form.password
|
||||||
await api.admin.updateUser(user.id, payload)
|
await api.admin.updateUser(user.id, payload)
|
||||||
} else {
|
} else {
|
||||||
await api.admin.createUser({ username: form.username.trim(), password: form.password, role: form.role })
|
await api.admin.createUser({
|
||||||
|
username: form.username.trim(),
|
||||||
|
password: form.password,
|
||||||
|
role: form.role,
|
||||||
|
status: form.status,
|
||||||
|
email,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -75,17 +84,41 @@ export default function UserEditor({ user, onClose, onSaved }) {
|
|||||||
<input type="text" value={form.username} onChange={set('username')} className="input" autoComplete="off" />
|
<input type="text" value={form.username} onChange={set('username')} className="input" autoComplete="off" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span className="field-label">{isEdit ? 'New password (leave blank to keep)' : 'Password'}</span>
|
<span className="field-label">
|
||||||
|
{isEdit ? 'Reset password (leave blank to keep)' : 'Password'}
|
||||||
|
</span>
|
||||||
<input type="password" value={form.password} onChange={set('password')} className="input" autoComplete="new-password" />
|
<input type="password" value={form.password} onChange={set('password')} className="input" autoComplete="new-password" />
|
||||||
|
{isEdit && (
|
||||||
|
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||||
|
Setting a new password here is the supported reset for a player who is locked out. It logs
|
||||||
|
their other sessions out.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
|
<span className="field-label">Email (optional)</span>
|
||||||
|
<input type="email" value={form.email} onChange={set('email')} className="input" autoComplete="off" placeholder="player@example.com" />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<label style={{ flex: 1 }}>
|
||||||
<span className="field-label">Role</span>
|
<span className="field-label">Role</span>
|
||||||
<select value={form.role} onChange={set('role')} className="select">
|
<select value={form.role} onChange={set('role')} className="select">
|
||||||
<option value="admin">admin</option>
|
<option value="admin">admin</option>
|
||||||
<option value="editor">editor</option>
|
<option value="editor">editor</option>
|
||||||
<option value="moderator">moderator</option>
|
<option value="moderator">moderator</option>
|
||||||
|
<option value="player">player</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label style={{ flex: 1 }}>
|
||||||
|
<span className="field-label">Status</span>
|
||||||
|
<select value={form.status} onChange={set('status')} className="select">
|
||||||
|
<option value="active">active</option>
|
||||||
|
<option value="disabled">disabled</option>
|
||||||
|
<option value="banned">banned</option>
|
||||||
|
<option value="pending">pending</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
import { useAsync } from '../../../lib/useAsync.js'
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
import { dateTime } from '../../../lib/format.js'
|
import { dateTime } from '../../../lib/format.js'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
import UserEditor from './UserEditor.jsx'
|
import UserEditor from './UserEditor.jsx'
|
||||||
|
|
||||||
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator' }
|
const ROLE_BADGE = {
|
||||||
|
admin: 'badge-admin',
|
||||||
|
editor: 'badge-editor',
|
||||||
|
moderator: 'badge-moderator',
|
||||||
|
player: 'badge-player',
|
||||||
|
}
|
||||||
|
|
||||||
export default function UsersAdmin() {
|
export default function UsersAdmin() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||||
const { loading, error, data } = useAsync(() => api.admin.listUsers(), [tick])
|
const { loading, error, data } = useAsync(() => api.admin.listUsers(), [tick])
|
||||||
@@ -18,7 +25,7 @@ export default function UsersAdmin() {
|
|||||||
<section>
|
<section>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||||
Manage admin, editor, and moderator accounts
|
Manage admin, editor, moderator, and player accounts
|
||||||
</p>
|
</p>
|
||||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
||||||
+ Add user
|
+ Add user
|
||||||
@@ -35,6 +42,7 @@ export default function UsersAdmin() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="adm-th">Username</th>
|
<th className="adm-th">Username</th>
|
||||||
<th className="adm-th">Role</th>
|
<th className="adm-th">Role</th>
|
||||||
|
<th className="adm-th">Status</th>
|
||||||
<th className="adm-th">Last login</th>
|
<th className="adm-th">Last login</th>
|
||||||
<th className="adm-th" />
|
<th className="adm-th" />
|
||||||
</tr>
|
</tr>
|
||||||
@@ -48,11 +56,24 @@ export default function UsersAdmin() {
|
|||||||
<td className="adm-td">
|
<td className="adm-td">
|
||||||
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
|
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="adm-td">
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{ fontSize: '0.82rem', color: u.status && u.status !== 'active' ? '#d98b84' : 'var(--muted)' }}
|
||||||
|
>
|
||||||
|
{u.status || 'active'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
||||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||||
|
<span style={{ display: 'inline-flex', gap: 16, justifyContent: 'flex-end' }}>
|
||||||
|
<span className="link-accent" onClick={() => navigate(`/admin/users/${u.id}`)}>
|
||||||
|
View
|
||||||
|
</span>
|
||||||
<span className="link-accent" onClick={() => setEditing(u)}>
|
<span className="link-accent" onClick={() => setEditing(u)}>
|
||||||
Edit
|
Edit
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
132
client/src/routes/player/AcceptInvite.jsx
Normal file
132
client/src/routes/player/AcceptInvite.jsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||||||
|
import CreateGameAccountForm from '../../components/CreateGameAccountForm.jsx'
|
||||||
|
|
||||||
|
// Public, token-gated invite acceptance (/invite/:token). Validates the invite,
|
||||||
|
// lets the invitee set a username + password (their email + role are pre-assigned),
|
||||||
|
// creates the account at that role and logs them in. For a player invite it then
|
||||||
|
// offers the built-in "create game account" step before sending them to the portal.
|
||||||
|
export default function AcceptInvite() {
|
||||||
|
const { token } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { refresh } = useAuth()
|
||||||
|
|
||||||
|
const [invite, setInvite] = useState(null) // { email, role }
|
||||||
|
const [loadErr, setLoadErr] = useState('')
|
||||||
|
const [signupOk, setSignupOk] = useState(false)
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [company, setCompany] = useState('') // honeypot
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [accepted, setAccepted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
api.getInvite(token)
|
||||||
|
.then((iv) => active && setInvite(iv))
|
||||||
|
.catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.'))
|
||||||
|
api.publicSettings()
|
||||||
|
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
|
||||||
|
.catch(() => {})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
const dest = invite && invite.role === 'player' ? '/player' : '/admin'
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
|
||||||
|
if (password.length < 8) return setError('Password must be at least 8 characters.')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.acceptInvite(token, username.trim(), password, { company })
|
||||||
|
await refresh() // pull the freshly-issued session into context
|
||||||
|
setAccepted(true)
|
||||||
|
// Staff invites are web-only — no game step; go straight in.
|
||||||
|
if (!(invite.role === 'player' && signupOk)) navigate(dest, { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 409) setError('That username is already taken, or the invite was already used.')
|
||||||
|
else if (err.status === 404) setError('This invitation is invalid or has expired.')
|
||||||
|
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
|
||||||
|
else setError('Could not accept the invitation right now.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading / invalid ─────────────────────────────────────────────────────
|
||||||
|
if (loadErr) {
|
||||||
|
return (
|
||||||
|
<PlayerShell subtitle="Invitation">
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>{loadErr}</p>
|
||||||
|
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
|
||||||
|
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Go to sign in</Link>
|
||||||
|
</p>
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!invite) {
|
||||||
|
return (
|
||||||
|
<PlayerShell subtitle="Invitation">
|
||||||
|
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}><span className="spin" /></div>
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accepted: optional game-account step (player invites) ──────────────────
|
||||||
|
if (accepted) {
|
||||||
|
return (
|
||||||
|
<PlayerShell subtitle="Set up your game account">
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||||
|
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
|
||||||
|
</p>
|
||||||
|
<CreateGameAccountForm
|
||||||
|
submit={api.player.shard.createAccount}
|
||||||
|
onCreated={() => navigate('/player', { replace: true })}
|
||||||
|
/>
|
||||||
|
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
|
||||||
|
<button type="button" onClick={() => navigate('/player', { replace: true })} className="btn" style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer' }}>
|
||||||
|
Skip for now →
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accept form ────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<PlayerShell subtitle="Accept your invitation">
|
||||||
|
<p className="sans" style={{ marginTop: 0, marginBottom: 18, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
You’ve been invited as <strong style={{ color: 'var(--head)' }}>{invite.role}</strong>
|
||||||
|
{invite.email ? <> for <strong style={{ color: 'var(--head)' }}>{invite.email}</strong></> : null}. Choose a username and password to finish.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
<span className="field-label">Username</span>
|
||||||
|
<input type="text" autoComplete="username" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||||
|
<span className="field-label">Password</span>
|
||||||
|
<input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<div style={honeypotStyle} aria-hidden="true">
|
||||||
|
<label>
|
||||||
|
Company
|
||||||
|
<input type="text" name="company" tabIndex={-1} autoComplete="off" value={company} onChange={(e) => setCompany(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||||||
|
{busy ? 'Creating…' : 'Accept & create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
354
client/src/routes/player/PlayerAccount.jsx
Normal file
354
client/src/routes/player/PlayerAccount.jsx
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// ── Change username ────────────────────────────────────────────────────────
|
||||||
|
function ChangeUsername({ account, onChanged }) {
|
||||||
|
const [username, setUsername] = useState(account.username)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function save(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const { username: next } = await api.player.changeUsername(username.trim())
|
||||||
|
setMsg('Username updated.')
|
||||||
|
await onChanged(next)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 409) setError('That username is already taken.')
|
||||||
|
else setError(err.message || 'Could not change your username.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Username">
|
||||||
|
<form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320 }}>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Username</span>
|
||||||
|
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="input" autoComplete="username" />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button type="submit" disabled={busy || username.trim() === account.username} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Saving…' : 'Change username'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Note msg={msg} error={error} />
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Change / set password ──────────────────────────────────────────────────
|
||||||
|
function ChangePassword({ account }) {
|
||||||
|
const hasPassword = account.has_password
|
||||||
|
const [current, setCurrent] = useState('')
|
||||||
|
const [next, setNext] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function save(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
if (next.length < 8) return setError('New password must be at least 8 characters.')
|
||||||
|
if (hasPassword && !current) return setError('Enter your current password.')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.player.changePassword(next, hasPassword ? current : undefined)
|
||||||
|
setMsg(hasPassword ? 'Password changed.' : 'Password set. You can now sign in with it.')
|
||||||
|
setCurrent('')
|
||||||
|
setNext('')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not change your password.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title={hasPassword ? 'Password' : 'Set a password'}>
|
||||||
|
{!hasPassword && (
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Your account was created through a linked provider and has no password yet. Set one to also be
|
||||||
|
able to sign in with a username and password.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320 }}>
|
||||||
|
{hasPassword && (
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Current password</span>
|
||||||
|
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)} className="input" autoComplete="current-password" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
<span className="field-label">New password</span>
|
||||||
|
<input type="password" value={next} onChange={(e) => setNext(e.target.value)} className="input" autoComplete="new-password" />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Saving…' : hasPassword ? 'Change password' : 'Set password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Note msg={msg} error={error} />
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Two-factor (TOTP) ──────────────────────────────────────────────────────
|
||||||
|
function TwoFactor({ account, reload }) {
|
||||||
|
const enabled = account.totp_enabled
|
||||||
|
const [setup, setSetup] = useState(null)
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function begin() {
|
||||||
|
setBusy(true); setMsg(''); setError('')
|
||||||
|
try {
|
||||||
|
setSetup(await api.player.totpSetup())
|
||||||
|
setCode('')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not start setup.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function confirm() {
|
||||||
|
setBusy(true); setMsg(''); setError('')
|
||||||
|
try {
|
||||||
|
await api.player.totpEnable(code.trim())
|
||||||
|
setSetup(null); setCode(''); setMsg('Two-factor is now enabled.')
|
||||||
|
await reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not enable two-factor.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function disable() {
|
||||||
|
setBusy(true); setMsg(''); setError('')
|
||||||
|
try {
|
||||||
|
await api.player.totpDisable(code.trim())
|
||||||
|
setCode(''); setMsg('Two-factor has been disabled.')
|
||||||
|
await reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not disable two-factor.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Two-factor authentication">
|
||||||
|
<div className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '6px 12px', borderRadius: 999, border: '1px solid var(--line)', fontSize: '0.82rem', color: enabled ? '#7fd0a4' : 'var(--muted)', marginBottom: 18 }}>
|
||||||
|
<span style={{ width: 9, height: 9, borderRadius: '50%', background: enabled ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{enabled ? 'Enabled' : 'Not enabled'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!enabled && !setup && (
|
||||||
|
<div>
|
||||||
|
<button onClick={begin} disabled={busy} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Preparing…' : 'Set up two-factor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!enabled && setup && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||||
|
Scan this QR code with your authenticator app, then enter the current 6-digit code.
|
||||||
|
</p>
|
||||||
|
<img src={setup.qr} alt="TOTP QR code" width={180} height={180} style={{ borderRadius: 8, background: '#fff', padding: 8, alignSelf: 'flex-start' }} />
|
||||||
|
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||||
|
<span className="field-label">Verification code</span>
|
||||||
|
<input type="text" inputMode="numeric" autoComplete="one-time-code" placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<button onClick={confirm} disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Enabling…' : 'Confirm & enable'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setSetup(null)} disabled={busy} className="pill">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{enabled && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||||
|
Enter a current code from your authenticator to turn two-factor off.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||||
|
<span className="field-label">Verification code</span>
|
||||||
|
<input type="text" inputMode="numeric" autoComplete="one-time-code" placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button onClick={disable} disabled={busy || !code.trim()} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
|
||||||
|
{busy ? 'Disabling…' : 'Disable two-factor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Note msg={msg} error={error} />
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Linked SSO identities ──────────────────────────────────────────────────
|
||||||
|
function LinkedAccounts() {
|
||||||
|
const [linked, setLinked] = useState(null)
|
||||||
|
const [available, setAvailable] = useState([])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const banner = (() => {
|
||||||
|
const q = new URLSearchParams(window.location.search)
|
||||||
|
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||||
|
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||||
|
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||||
|
return null
|
||||||
|
})()
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [ids, avail] = await Promise.all([
|
||||||
|
api.player.linkedIdentities(),
|
||||||
|
api.authProviders().catch(() => []),
|
||||||
|
])
|
||||||
|
setLinked(ids)
|
||||||
|
setAvailable(Array.isArray(avail) ? avail : [])
|
||||||
|
} catch {
|
||||||
|
setError('Could not load linked accounts.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||||
|
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||||
|
|
||||||
|
async function unlink(provider) {
|
||||||
|
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||||
|
try {
|
||||||
|
await api.player.unlinkIdentity(provider)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not unlink.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!linked) return null
|
||||||
|
|
||||||
|
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||||
|
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Linked accounts">
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Link a Google, Discord, or other provider so you can sign in with it.
|
||||||
|
</p>
|
||||||
|
{banner && (
|
||||||
|
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>{banner.text}</p>
|
||||||
|
)}
|
||||||
|
{linked.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||||
|
{linked.map((i) => (
|
||||||
|
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||||
|
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||||
|
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||||
|
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{linkable.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 6 }}>
|
||||||
|
{linkable.map((p) => (
|
||||||
|
<button key={p.id} onClick={() => window.location.assign(`/api/v1/auth/sso/${p.id}/link?returnTo=${encodeURIComponent('/account')}`)} className="btn" style={{ display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'center', width: '100%', maxWidth: 320, borderRadius: 8, padding: 10, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.04)', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||||
|
<ProviderIcon icon={p.icon} size={18} />
|
||||||
|
</span>
|
||||||
|
Link {p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{linked.length === 0 && linkable.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.86rem' }}>No SSO providers are enabled.</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared bits ────────────────────────────────────────────────────────────
|
||||||
|
function Section({ title, children }) {
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
|
||||||
|
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function Note({ msg, error }) {
|
||||||
|
if (!msg && !error) return null
|
||||||
|
return <p className="sans" style={{ margin: '4px 0 0', color: error ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}>{error || msg}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
export default function PlayerAccount() {
|
||||||
|
const { refresh } = useAuth()
|
||||||
|
const [account, setAccount] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setAccount(await api.player.getAccount())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load your account.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
// After a username change: reload local account + refresh the auth context so
|
||||||
|
// the header reflects the new name.
|
||||||
|
const onUsernameChanged = useCallback(async () => {
|
||||||
|
await Promise.all([load(), refresh()])
|
||||||
|
}, [load, refresh])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message={error} />}
|
||||||
|
{!loading && !error && account && (
|
||||||
|
<>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||||
|
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
|
||||||
|
{account.email ? ` · ${account.email}` : ''}
|
||||||
|
</p>
|
||||||
|
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||||
|
<ChangePassword account={account} />
|
||||||
|
<TwoFactor account={account} reload={load} />
|
||||||
|
<LinkedAccounts />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
client/src/routes/player/PlayerCharacter.jsx
Normal file
29
client/src/routes/player/PlayerCharacter.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// A player's character sheet inside the portal. Owner-checked: the endpoint only
|
||||||
|
// returns a sheet for a character on an account linked to the caller.
|
||||||
|
export default function PlayerCharacter() {
|
||||||
|
const { serial } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
|
||||||
|
const restarting = error && error.status === 503
|
||||||
|
const forbidden = error && error.status === 403
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ margin: '0 0 18px' }}>
|
||||||
|
<Link to="/player" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||||
|
← Back to characters
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{loading && <Loading />}
|
||||||
|
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||||
|
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
|
||||||
|
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
|
||||||
|
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
client/src/routes/player/PlayerCharacters.jsx
Normal file
58
client/src/routes/player/PlayerCharacters.jsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||||
|
import VendorSales from '../../components/VendorSales.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// The logged-in player's characters. Shows the link prompt when no game account
|
||||||
|
// is linked, otherwise their characters grouped by account (shared component),
|
||||||
|
// plus their own home status and recent vendor sales.
|
||||||
|
|
||||||
|
const DECAY_TONE = {
|
||||||
|
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
|
||||||
|
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
|
||||||
|
}
|
||||||
|
|
||||||
|
// The caller's own houses (home status). Only their own — never anyone else's.
|
||||||
|
function MyHouses() {
|
||||||
|
const { data } = useAsync(() => api.player.shard.houses(), [])
|
||||||
|
if (!data || data.length === 0) return null
|
||||||
|
return (
|
||||||
|
<section style={{ marginTop: 30 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 12 }}>My houses</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{data.map((h) => {
|
||||||
|
const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage)
|
||||||
|
const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)')
|
||||||
|
return (
|
||||||
|
<div key={h.serial} className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{h.name || 'An unnamed house'}</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||||
|
{h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 9px' }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.76rem' }}>
|
||||||
|
Keep an eye on the decay status — refresh a house in game before it reaches IDOC.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerCharacters() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
|
||||||
|
<MyHouses />
|
||||||
|
<VendorSales fetchSales={api.player.shard.sales} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
208
client/src/routes/player/PlayerLogin.jsx
Normal file
208
client/src/routes/player/PlayerLogin.jsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||||||
|
|
||||||
|
// Friendly copy for the ?sso_error codes the SSO callback can bounce back with.
|
||||||
|
const SSO_ERRORS = {
|
||||||
|
not_linked:
|
||||||
|
'That account is not linked to a player. Enable SSO sign-up, or sign in with a password and link it under your account.',
|
||||||
|
disabled: 'This account is not active. Contact an administrator.',
|
||||||
|
denied: 'Sign-in was cancelled.',
|
||||||
|
unavailable: 'That sign-in method is not available right now.',
|
||||||
|
bad_state: 'Your sign-in session expired. Please try again.',
|
||||||
|
error: 'Could not complete sign-in. Please try again.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerLogin() {
|
||||||
|
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const dest = location.state?.from?.pathname || '/player'
|
||||||
|
// A staff member who signs in here belongs in the admin shell, not the portal.
|
||||||
|
const destFor = (u) => (u && u.role !== 'player' ? '/admin' : dest)
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [company, setCompany] = useState('') // honeypot — must stay empty
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
|
||||||
|
const [challenge, setChallenge] = useState('')
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [ssoTotp, setSsoTotp] = useState(false)
|
||||||
|
|
||||||
|
const [providers, setProviders] = useState([])
|
||||||
|
const [canRegister, setCanRegister] = useState(false)
|
||||||
|
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
||||||
|
|
||||||
|
// Already signed in → go straight to the right home for the role.
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) navigate(destFor(user), { replace: true })
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [user, dest, navigate])
|
||||||
|
|
||||||
|
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1.
|
||||||
|
useEffect(() => {
|
||||||
|
if (new URLSearchParams(location.search).get('sso_totp')) {
|
||||||
|
setStage('totp')
|
||||||
|
setSsoTotp(true)
|
||||||
|
}
|
||||||
|
}, [location.search])
|
||||||
|
|
||||||
|
// SSO providers (for buttons) + whether password registration is open.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
api
|
||||||
|
.authProviders()
|
||||||
|
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
|
||||||
|
.catch(() => active && setProviders([]))
|
||||||
|
api
|
||||||
|
.publicSettings()
|
||||||
|
.then((s) => active && setCanRegister(Boolean(s?.registration?.password)))
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function startSso(provider) {
|
||||||
|
// Always return into the player portal so the callback lands on /account*.
|
||||||
|
const q = `?returnTo=${encodeURIComponent(dest.startsWith('/account') ? dest : '/account')}`
|
||||||
|
window.location.assign(provider.loginUrl + q)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const data = await login(username, password, { company })
|
||||||
|
if (data.totpRequired) {
|
||||||
|
setChallenge(data.challenge)
|
||||||
|
setStage('totp')
|
||||||
|
setBusy(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(destFor(data.user), { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 403) setError('This account is not active. Contact an administrator.')
|
||||||
|
else setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmitTotp(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
if (ssoTotp) {
|
||||||
|
const { returnTo } = await ssoLoginTotp(code)
|
||||||
|
navigate(returnTo || '/account', { replace: true })
|
||||||
|
} else {
|
||||||
|
const u = await loginTotp(challenge, code)
|
||||||
|
navigate(destFor(u), { replace: true })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const expired = err.status === 401 && /expired/i.test(err.message)
|
||||||
|
setError(expired ? 'Your verification session expired. Please sign in again.' : 'Invalid verification code.')
|
||||||
|
setBusy(false)
|
||||||
|
if (expired) {
|
||||||
|
setStage('creds')
|
||||||
|
setSsoTotp(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerShell
|
||||||
|
subtitle="Player sign-in"
|
||||||
|
footer={
|
||||||
|
canRegister && (
|
||||||
|
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||||
|
New here?{' '}
|
||||||
|
<Link to="/account/register" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
|
Create an account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}>
|
||||||
|
{stage === 'creds' ? (
|
||||||
|
<>
|
||||||
|
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
<span className="field-label">Username</span>
|
||||||
|
<input type="text" autoComplete="username" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||||
|
<span className="field-label">Password</span>
|
||||||
|
<input type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<div style={honeypotStyle} aria-hidden="true">
|
||||||
|
<label>
|
||||||
|
Company
|
||||||
|
<input type="text" name="company" tabIndex={-1} autoComplete="off" value={company} onChange={(e) => setCompany(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||||
|
<span className="field-label">Authentication code</span>
|
||||||
|
<input type="text" inputMode="numeric" autoComplete="one-time-code" autoFocus placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} className="input" />
|
||||||
|
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||||
|
Enter the code from your authenticator app.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(error || (stage === 'creds' && ssoError)) && (
|
||||||
|
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center', lineHeight: 1.5 }}>
|
||||||
|
{error || ssoError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||||||
|
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{stage === 'creds' && providers.length > 0 && (
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||||
|
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<button key={p.id} type="button" onClick={() => startSso(p)} className="btn" style={ssoBtnStyle}>
|
||||||
|
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||||
|
<ProviderIcon icon={p.icon} size={18} />
|
||||||
|
</span>
|
||||||
|
Continue with {p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssoBtnStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 10,
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 11,
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
}
|
||||||
162
client/src/routes/player/PlayerPortalLayout.jsx
Normal file
162
client/src/routes/player/PlayerPortalLayout.jsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
|
// Shared shell for the logged-in player portal. Uses the same sidebar shell as
|
||||||
|
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
|
||||||
|
// experiences read as one app — the portal just carries fewer nav rows.
|
||||||
|
|
||||||
|
// Small inline stroke icons (16px, currentColor) — same frame as AdminLayout.
|
||||||
|
function Icon({ children, size = 16 }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||||
|
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ to: '/player', label: 'Characters', end: true, icon: IconUser },
|
||||||
|
{ to: '/account', label: 'Account', icon: IconGear },
|
||||||
|
]
|
||||||
|
|
||||||
|
// The sticky content header mirrors the active page. Character sheets live under
|
||||||
|
// /player/char/:serial and keep their own in-page back link.
|
||||||
|
const TITLES = {
|
||||||
|
'/player': 'Characters',
|
||||||
|
'/account': 'Account',
|
||||||
|
}
|
||||||
|
|
||||||
|
const navBtnBase = {
|
||||||
|
textAlign: 'left',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 14px',
|
||||||
|
fontFamily: 'var(--sans)',
|
||||||
|
fontSize: '0.92rem',
|
||||||
|
textDecoration: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
transition: 'background .15s,color .15s',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerPortalLayout() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const { siteTitle } = useSite()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const title =
|
||||||
|
TITLES[location.pathname] ||
|
||||||
|
(location.pathname.startsWith('/player/char/') ? 'Character' : 'Player Portal')
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await logout()
|
||||||
|
navigate('/account/login', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-grid">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
borderRight: '1px solid var(--line)',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
height: '100vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<MoonDot />
|
||||||
|
<div>
|
||||||
|
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||||
|
{siteTitle}
|
||||||
|
</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
|
||||||
|
Player Portal
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
||||||
|
{NAV.map((n) => (
|
||||||
|
<NavLink
|
||||||
|
key={n.to}
|
||||||
|
to={n.to}
|
||||||
|
end={n.end}
|
||||||
|
className="admin-nav-link"
|
||||||
|
style={({ isActive }) => ({
|
||||||
|
...navBtnBase,
|
||||||
|
background: isActive ? 'var(--blue)' : 'transparent',
|
||||||
|
color: isActive ? 'var(--ink)' : 'var(--muted)',
|
||||||
|
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<n.icon />
|
||||||
|
<span>{n.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div style={{ padding: '14px 16px', borderTop: '1px solid var(--line-soft)' }}>
|
||||||
|
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: '0.78rem', color: 'var(--muted)' }}>
|
||||||
|
<span style={{ width: 9, height: 9, borderRadius: '50%', background: 'var(--mode-live)', boxShadow: '0 0 8px var(--mode-live)' }} />
|
||||||
|
Signed in as <strong style={{ color: 'var(--ink)' }}>{user?.username}</strong>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={signOut}
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'block', width: '100%', textAlign: 'center', border: '1px solid var(--line)', borderRadius: 8, padding: 9, color: 'var(--muted)', background: 'transparent', fontSize: '0.84rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main */}
|
||||||
|
<main style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 16,
|
||||||
|
padding: '20px 32px',
|
||||||
|
borderBottom: '1px solid var(--line-soft)',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 className="display" style={{ margin: 0, fontSize: '1.5rem', color: 'var(--head)' }}>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
|
||||||
|
← Site
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 900, width: '100%' }}>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
client/src/routes/player/PlayerRegister.jsx
Normal file
163
client/src/routes/player/PlayerRegister.jsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||||||
|
|
||||||
|
export default function PlayerRegister() {
|
||||||
|
const { user, register } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [company, setCompany] = useState('') // honeypot — must stay empty
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
// Which methods are enabled (derived, from /public/settings). null = loading.
|
||||||
|
const [avail, setAvail] = useState(null)
|
||||||
|
const [providers, setProviders] = useState([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && user.role === 'player') navigate('/player', { replace: true })
|
||||||
|
}, [user, navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
api
|
||||||
|
.publicSettings()
|
||||||
|
.then((s) => active && setAvail(s?.registration || { password: false, sso: false }))
|
||||||
|
.catch(() => active && setAvail({ password: false, sso: false }))
|
||||||
|
api
|
||||||
|
.authProviders()
|
||||||
|
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
|
||||||
|
.catch(() => active && setProviders([]))
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function startSso(provider) {
|
||||||
|
window.location.assign(provider.loginUrl + `?returnTo=${encodeURIComponent('/account')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
|
||||||
|
if (password.length < 8) return setError('Password must be at least 8 characters.')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await register(username.trim(), password, { email: email.trim() || undefined, company })
|
||||||
|
navigate('/player', { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 409) setError('That username is already taken.')
|
||||||
|
else if (err.status === 403) setError('Registration is not open right now.')
|
||||||
|
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
|
||||||
|
else setError('Could not create your account right now.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closed = avail && !avail.password && !avail.sso
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerShell
|
||||||
|
subtitle="Create a player account"
|
||||||
|
footer={
|
||||||
|
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{avail === null ? (
|
||||||
|
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
|
||||||
|
<span className="spin" />
|
||||||
|
</div>
|
||||||
|
) : closed ? (
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem', textAlign: 'center', lineHeight: 1.6 }}>
|
||||||
|
Self-registration is currently closed. Please check back later.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{avail.password && (
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
<span className="field-label">Username</span>
|
||||||
|
<input type="text" autoComplete="username" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
<span className="field-label">Password</span>
|
||||||
|
<input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||||
|
<span className="field-label">Email (optional)</span>
|
||||||
|
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="player@example.com" />
|
||||||
|
<span className="sans" style={{ display: 'block', marginTop: 6, color: 'var(--dim)', fontSize: '0.74rem' }}>
|
||||||
|
Used only for account recovery help. No password-reset emails yet — a forgotten password
|
||||||
|
is reset by an administrator.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div style={honeypotStyle} aria-hidden="true">
|
||||||
|
<label>
|
||||||
|
Company
|
||||||
|
<input type="text" name="company" tabIndex={-1} autoComplete="off" value={company} onChange={(e) => setCompany(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||||||
|
{busy ? 'Creating…' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{avail.sso && providers.length > 0 && (
|
||||||
|
<div style={{ marginTop: avail.password ? 20 : 0 }}>
|
||||||
|
{avail.password && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||||
|
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<button key={p.id} type="button" onClick={() => startSso(p)} className="btn" style={ssoBtnStyle}>
|
||||||
|
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||||
|
<ProviderIcon icon={p.icon} size={18} />
|
||||||
|
</span>
|
||||||
|
Sign up with {p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PlayerShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssoBtnStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 10,
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 11,
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
}
|
||||||
73
client/src/routes/player/PlayerShell.jsx
Normal file
73
client/src/routes/player/PlayerShell.jsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
|
// Centered card layout shared by the player login / register pages. `subtitle`
|
||||||
|
// labels the card; `footer` is optional content under the card (e.g. cross-links).
|
||||||
|
export default function PlayerShell({ subtitle, children, footer }) {
|
||||||
|
const { siteTitle, heroImage } = useSite()
|
||||||
|
const bg = `linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('${heroImage}')`
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
padding: '40px 18px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'var(--bg-deep)',
|
||||||
|
backgroundImage: bg,
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'min(60vh, 520px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<MoonDot size={15} glow={0.55} />
|
||||||
|
</div>
|
||||||
|
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||||
|
{siteTitle}
|
||||||
|
</h1>
|
||||||
|
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 28,
|
||||||
|
background: 'linear-gradient(180deg,rgba(25,34,49,0.92),rgba(20,26,33,0.92))',
|
||||||
|
backdropFilter: 'blur(6px)',
|
||||||
|
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footer}
|
||||||
|
|
||||||
|
<p style={{ textAlign: 'center', margin: '20px 0 0' }}>
|
||||||
|
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
|
||||||
|
← Back to site
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off-screen honeypot styling (matches the admin login): present for bots, never
|
||||||
|
// seen or filled by real users. Name must equal the server HONEYPOT_FIELD.
|
||||||
|
export const honeypotStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '-9999px',
|
||||||
|
top: 'auto',
|
||||||
|
width: '1px',
|
||||||
|
height: '1px',
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}
|
||||||
@@ -3,14 +3,14 @@ import PageHeader from '../../components/PageHeader.jsx'
|
|||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
const { contactEmail } = useSite()
|
const { contactEmail, siteShortName } = useSite()
|
||||||
return (
|
return (
|
||||||
<PublicLayout section="website">
|
<PublicLayout section="website">
|
||||||
<div className="shell-narrow page-body">
|
<div className="shell-narrow page-body">
|
||||||
<PageHeader eyebrow="About" title="About Mysticmoon" />
|
<PageHeader eyebrow="About" title={`About ${siteShortName}`} />
|
||||||
<div className="prose">
|
<div className="prose">
|
||||||
<p>
|
<p>
|
||||||
Mysticmoon is an independent, privately-run Ultima Online shard built by a small group of long-time players.
|
{siteShortName} is an independent, privately-run Ultima Online shard built by a small group of long-time players.
|
||||||
It is not affiliated with or endorsed by the owners of Ultima Online — it is a labor of love for the old
|
It is not affiliated with or endorsed by the owners of Ultima Online — it is a labor of love for the old
|
||||||
worlds and the friendships made in them.
|
worlds and the friendships made in them.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
203
client/src/routes/public/ChampSpawns.jsx
Normal file
203
client/src/routes/public/ChampSpawns.jsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// The champion-spawn board. Loaded once from /public/shard/champs, then kept live
|
||||||
|
// by merging champ.update / champ.remove deltas from the public SSE feed. Three
|
||||||
|
// families share the board, split by category into their own sections.
|
||||||
|
const CHAMP_KINDS = new Set(['champ.update', 'champ.remove'])
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: 'champion', title: 'Champion altars', blurb: 'Felucca-style altar spawns.' },
|
||||||
|
{ id: 'mini', title: 'Mini champs', blurb: 'TerMur controllers — they re-arm on their own.' },
|
||||||
|
{ id: 'sea', title: 'Sea bosses', blurb: 'High Seas world bosses, alive only while summoned.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_STYLE = {
|
||||||
|
active: { bg: 'rgba(95,185,138,0.16)', fg: '#8fdcae', border: 'rgba(95,185,138,0.45)', label: 'Active' },
|
||||||
|
cooldown: { bg: 'rgba(230,194,106,0.14)', fg: '#e6c26a', border: 'rgba(230,194,106,0.4)', label: 'Cooldown' },
|
||||||
|
dormant: { bg: 'rgba(140,150,165,0.14)', fg: '#aab3c0', border: 'rgba(140,150,165,0.35)', label: 'Dormant' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short "in 4m" / "in 2h" for a future ISO timestamp (restartAt / expireAt).
|
||||||
|
function until(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const ms = new Date(iso).getTime() - Date.now()
|
||||||
|
if (!Number.isFinite(ms)) return ''
|
||||||
|
if (ms <= 0) return 'due'
|
||||||
|
const mins = Math.round(ms / 60000)
|
||||||
|
if (mins < 60) return `in ${mins}m`
|
||||||
|
const hrs = Math.round(mins / 60)
|
||||||
|
return `in ${hrs}h`
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const s = STATUS_STYLE[status] || STATUS_STYLE.dormant
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
flex: 'none',
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
padding: '3px 9px',
|
||||||
|
borderRadius: 999,
|
||||||
|
color: s.fg,
|
||||||
|
background: s.bg,
|
||||||
|
border: `1px solid ${s.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slim progress bar (kills toward the next level, or a sea boss's hit points).
|
||||||
|
function Meter({ value, max, tone = 'var(--accent)' }) {
|
||||||
|
if (!max) return null
|
||||||
|
const pct = Math.max(0, Math.min(100, (Number(value) / Number(max)) * 100))
|
||||||
|
return (
|
||||||
|
<div style={{ height: 6, borderRadius: 4, background: 'rgba(255,255,255,0.07)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: tone, borderRadius: 4 }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category-specific middle line + meter for one spawn.
|
||||||
|
function ChampDetail({ s }) {
|
||||||
|
const line = { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.8rem', color: 'var(--muted)', marginTop: 8 }
|
||||||
|
if (s.category === 'sea') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="sans" style={line}>
|
||||||
|
<span>{s.boss || s.type}</span>
|
||||||
|
{s.hitsMax != null && <span>{Number(s.hits).toLocaleString()} / {Number(s.hitsMax).toLocaleString()} hp</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6 }}><Meter value={s.hits} max={s.hitsMax} tone="#d9736f" /></div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (s.category === 'mini') {
|
||||||
|
return (
|
||||||
|
<div className="sans" style={line}>
|
||||||
|
<span>Level {s.level ?? 0}{s.maxLevel != null ? ` / ${s.maxLevel}` : ''}</span>
|
||||||
|
<span>{s.status === 'active' ? 'Running' : 'Re-arming'}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// champion
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="sans" style={line}>
|
||||||
|
<span>
|
||||||
|
Level {s.level ?? 0}
|
||||||
|
{s.bossUp && s.boss ? ` — ${s.boss}` : ''}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{s.status === 'cooldown'
|
||||||
|
? until(s.restartAt) || 'restarting'
|
||||||
|
: s.status === 'active'
|
||||||
|
? `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.status === 'active' && (
|
||||||
|
<div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChampCard({ s }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<strong className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{s.name || s.type || 'Spawn'}
|
||||||
|
</strong>
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
</div>
|
||||||
|
<ChampDetail s={s} />
|
||||||
|
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.74rem' }}>
|
||||||
|
{s.map || '—'}{s.x != null ? ` (${s.x}, ${s.y})` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChampSpawns() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.champs())
|
||||||
|
const { events, connected } = useShardFeed({ filter: CHAMP_KINDS, max: 60 })
|
||||||
|
|
||||||
|
// Merge the initial snapshot with live deltas: seed a map by serial, then apply
|
||||||
|
// buffered events oldest → newest (the buffer is newest-first) so live wins.
|
||||||
|
const board = useMemo(() => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const s of data || []) if (s && s.serial) map.set(s.serial, s)
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (!ev || !ev.serial) continue
|
||||||
|
if (ev.kind === 'champ.update') map.set(ev.serial, ev)
|
||||||
|
else if (ev.kind === 'champ.remove') map.delete(ev.serial)
|
||||||
|
}
|
||||||
|
return [...map.values()]
|
||||||
|
}, [data, events])
|
||||||
|
|
||||||
|
const byCategory = (id) =>
|
||||||
|
board.filter((s) => (s.category || 'champion') === id).sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||||
|
|
||||||
|
const activeCount = board.filter((s) => s.status === 'active').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<PageHeader eyebrow="Live" title="Champion spawns" lead="Every altar, mini-champ and sea boss across the shard, updating in real time." />
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the champion board right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{board.length === 0 ? (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>No champion spawns are being tracked right now.</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', marginTop: -12, marginBottom: 24 }}>
|
||||||
|
{activeCount} active · {board.length} tracked
|
||||||
|
</p>
|
||||||
|
{SECTIONS.map((sec) => {
|
||||||
|
const rows = byCategory(sec.id)
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return (
|
||||||
|
<section key={sec.id} style={{ marginBottom: 28 }}>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.1rem', color: 'var(--head)' }}>{sec.title}</h2>
|
||||||
|
<p className="sans dim" style={{ margin: '2px 0 0', fontSize: '0.8rem' }}>{sec.blurb}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid-2" style={{ gap: 12 }}>
|
||||||
|
{rows.map((s) => <ChampCard key={s.serial} s={s} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
client/src/routes/public/CmsPage.jsx
Normal file
56
client/src/routes/public/CmsPage.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
import '../../blocks/index.js' // registers all block types
|
||||||
|
import { BlockList } from '../../blocks/BlockRenderer.jsx'
|
||||||
|
|
||||||
|
// Renders a CMS page composed of blocks. Two modes:
|
||||||
|
// - live: /:slug → fetches the published page (staff see drafts)
|
||||||
|
// - preview: /preview/:id/:token → fetches the current state via a token,
|
||||||
|
// regardless of publish status (draft-preview links).
|
||||||
|
export default function CmsPage({ preview = false }) {
|
||||||
|
const params = useParams()
|
||||||
|
const { loading, error, data: page } = useAsync(
|
||||||
|
() => (preview ? api.pagePreview(params.id, params.token) : api.page(params.slug)),
|
||||||
|
[preview, params.id, params.token, params.slug],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reflect the page's title + meta description while it's mounted, then restore.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!page) return
|
||||||
|
const prevTitle = document.title
|
||||||
|
document.title = page.metadata?.seoTitle || page.title || prevTitle
|
||||||
|
return () => {
|
||||||
|
document.title = prevTitle
|
||||||
|
}
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
const layout = page?.settings?.layout || 'default'
|
||||||
|
const widthClass = layout === 'full_width' || layout === 'landing' ? 'shell-wide' : 'shell'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className={`${widthClass} page-body`} style={{ paddingTop: 40 }}>
|
||||||
|
{preview && page && (
|
||||||
|
<div className="page-preview-banner sans">
|
||||||
|
Preview — this is the current draft state and isn’t publicly visible.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && (
|
||||||
|
<ErrorState
|
||||||
|
message={error.status === 404 ? 'That page could not be found.' : 'Could not load this page.'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{page && (
|
||||||
|
<article className={`page-blocks page-layout--${layout}`}>
|
||||||
|
<BlockList blocks={page.blocks} />
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
186
client/src/routes/public/Governors.jsx
Normal file
186
client/src/routes/public/Governors.jsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { crestFor } from '../../data/cityCrests.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// The town-governor board (City Loyalty). Loaded from /public/shard/governors,
|
||||||
|
// kept live by merging city.update deltas by city. Empty on shards without the
|
||||||
|
// City Loyalty system. Each city card links to its term history (look-back).
|
||||||
|
const GOV_KINDS = new Set(['city.update'])
|
||||||
|
|
||||||
|
const PHASE = {
|
||||||
|
none: null,
|
||||||
|
nominate: { label: 'Nominations open', color: '#7f8fd0' },
|
||||||
|
vote: { label: 'Voting', color: '#e6c26a' },
|
||||||
|
pending: { label: 'Result pending', color: '#c9a24b' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt).
|
||||||
|
function until(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const ms = new Date(iso).getTime() - Date.now()
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||||
|
const mins = Math.round(ms / 60000)
|
||||||
|
if (mins < 60) return `in ${mins}m`
|
||||||
|
const hrs = Math.round(mins / 60)
|
||||||
|
if (hrs < 24) return `in ${hrs}h`
|
||||||
|
return `in ${Math.round(hrs / 24)}d`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(ms) {
|
||||||
|
if (ms == null) return ''
|
||||||
|
return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function CityCrest({ city, size = 44 }) {
|
||||||
|
const c = crestFor(city)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
flex: 'none', width: size, height: size, borderRadius: '50%',
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: size * 0.5, background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: `2px solid ${c.color}`, boxShadow: `0 0 10px ${c.color}22`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.sigil}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsible term history for one city, fetched on demand from the ledger.
|
||||||
|
function TermHistory({ city }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const { loading, error, data } = useAsync(
|
||||||
|
() => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)),
|
||||||
|
[open, city],
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sans"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0, fontSize: '0.76rem' }}
|
||||||
|
>
|
||||||
|
{open ? 'Hide past governors' : 'Past governors →'}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{loading && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Loading…</p>}
|
||||||
|
{error && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Could not load history.</p>}
|
||||||
|
{data && data.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>No recorded terms yet.</p>
|
||||||
|
)}
|
||||||
|
{data && data.length > 0 && (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||||
|
{data.map((t, i) => (
|
||||||
|
<li key={i} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{t.governor?.name || 'Vacant'}
|
||||||
|
</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.72rem' }}>
|
||||||
|
{fmtDate(t.startedAt)}{t.endedAt ? ` – ${fmtDate(t.endedAt)}` : ' – present'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CityCard({ c }) {
|
||||||
|
const phase = PHASE[c.electionPhase] || null
|
||||||
|
const gov = c.governor
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 18 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<CityCrest city={c.city} />
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
|
<strong className="display" style={{ fontSize: '1.05rem', color: 'var(--head)' }}>
|
||||||
|
{crestFor(c.city).label || c.city}
|
||||||
|
</strong>
|
||||||
|
{phase && (
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.66rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: phase.color, border: `1px solid ${phase.color}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||||
|
{phase.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sans" style={{ marginTop: 3, fontSize: '0.9rem', color: gov ? 'var(--ink)' : 'var(--muted)' }}>
|
||||||
|
{gov ? (
|
||||||
|
<>Governor <strong style={{ color: 'var(--head)' }}>{gov.name}</strong></>
|
||||||
|
) : (
|
||||||
|
'Seat vacant'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{c.electionPhase && c.electionPhase !== 'none' && (
|
||||||
|
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
|
||||||
|
{c.candidates ? `${c.candidates} candidate${c.candidates === 1 ? '' : 's'}` : 'No candidates yet'}
|
||||||
|
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TermHistory city={c.city} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Governors() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.governors())
|
||||||
|
const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 })
|
||||||
|
|
||||||
|
const board = useMemo(() => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const c of data || []) if (c && c.city) map.set(c.city, c)
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev)
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || ''))
|
||||||
|
}, [data, events])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<PageHeader eyebrow="Live" title="Governors of Britannia" lead="Who rules each city, and where the next election stands." />
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the governor board right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{board.length === 0 ? (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>
|
||||||
|
City Loyalty governance is not enabled on this shard.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<div className="grid-2" style={{ gap: 12 }}>
|
||||||
|
{board.map((c) => <CityCard key={c.city} c={c} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
client/src/routes/public/Guilds.jsx
Normal file
169
client/src/routes/public/Guilds.jsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// The guild board. Loaded once from /public/shard/guilds, then kept live by
|
||||||
|
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
|
||||||
|
// joined" strip on top of the board.
|
||||||
|
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
|
||||||
|
|
||||||
|
function Leader({ leader }) {
|
||||||
|
if (!leader || !leader.name) return <span className="dim">—</span>
|
||||||
|
return <span>{leader.name}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuildRow({ g }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="panel"
|
||||||
|
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
|
||||||
|
{g.abbr && (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
flex: 'none',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
border: '1px solid rgba(201,162,75,0.4)',
|
||||||
|
borderRadius: 5,
|
||||||
|
padding: '1px 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.abbr}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<strong
|
||||||
|
className="display"
|
||||||
|
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{g.name || 'A guild'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
{g.alliance && (
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||||
|
{g.alliance}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
|
||||||
|
<span className="dim"> / {g.members ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||||
|
<Leader leader={g.leader} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Guilds() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.guilds())
|
||||||
|
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
|
||||||
|
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
|
||||||
|
const board = useMemo(() => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
|
||||||
|
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
|
||||||
|
}
|
||||||
|
return [...map.values()]
|
||||||
|
}, [data, events])
|
||||||
|
|
||||||
|
// Recent joins strip (newest first, deduped, capped).
|
||||||
|
const joins = useMemo(
|
||||||
|
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
|
||||||
|
[events],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const needle = q.trim().toLowerCase()
|
||||||
|
const rows = needle
|
||||||
|
? board.filter((g) =>
|
||||||
|
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
|
||||||
|
)
|
||||||
|
: board
|
||||||
|
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||||
|
}, [board, q])
|
||||||
|
|
||||||
|
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<PageHeader eyebrow="Live" title="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, updating in real time." />
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the guild board right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{board.length === 0 ? (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{joins.length > 0 && (
|
||||||
|
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||||
|
Recently joined
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||||
|
{joins.map((j) => (
|
||||||
|
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||||
|
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
|
||||||
|
<span className="dim"> joined </span>
|
||||||
|
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
||||||
|
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
|
||||||
|
{board.length} guilds · {totalMembers.toLocaleString()} members
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="input sans"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Search guilds…"
|
||||||
|
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match “{q}”.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
client/src/routes/public/Houses.jsx
Normal file
91
client/src/routes/public/Houses.jsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner,
|
||||||
|
// price, decay detail and the full registry are staff-only (admin Houses view).
|
||||||
|
// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a
|
||||||
|
// house entering IDOC appears, one leaving it drops off.
|
||||||
|
const HOUSE_KINDS = new Set(['house.decay'])
|
||||||
|
|
||||||
|
function HouseRow({ h }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#e05a5a', boxShadow: '0 0 8px rgba(224,90,90,0.7)' }}
|
||||||
|
/>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{h.region || 'The wilderness'}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||||
|
{h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.06em', color: '#e05a5a', border: '1px solid #e05a5a66', borderRadius: 999, padding: '2px 9px' }}>
|
||||||
|
IDOC
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Houses() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.houses())
|
||||||
|
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
|
||||||
|
|
||||||
|
// Merge the IDOC snapshot with live house.decay deltas by serial: entering IDOC
|
||||||
|
// adds/updates the row; anything else (refreshed, collapsed) drops it.
|
||||||
|
const board = useMemo(() => {
|
||||||
|
const map = new Map()
|
||||||
|
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||||
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||||
|
const ev = events[i]
|
||||||
|
if (ev.kind !== 'house.decay' || !ev.serial) continue
|
||||||
|
if (String(ev.to).toUpperCase() === 'IDOC') {
|
||||||
|
map.set(ev.serial, { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y, z: ev.z, isIdoc: true })
|
||||||
|
} else {
|
||||||
|
map.delete(ev.serial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => (a.region || '').localeCompare(b.region || ''))
|
||||||
|
}, [data, events])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<PageHeader eyebrow="Live" title="Houses in danger" lead="Homes that have fallen into IDOC — where to find them before they collapse." />
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the houses board right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
board.length === 0 ? (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>No houses are collapsing right now.</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="sans" style={{ color: '#e0928a', fontSize: '0.8rem', marginTop: -12, marginBottom: 20 }}>
|
||||||
|
{board.length} in danger
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{board.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,14 +2,12 @@ import { Link } from 'react-router-dom'
|
|||||||
import MoonDot from '../../components/MoonDot.jsx'
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
const HERO_BG =
|
|
||||||
"linear-gradient(180deg,rgba(11,15,20,0.55) 0%,rgba(11,15,20,0.74) 60%,rgba(11,15,20,0.92) 100%),url('/assets/img/uomysticmoon-main-hero.png')"
|
|
||||||
|
|
||||||
export default function Maintenance() {
|
export default function Maintenance() {
|
||||||
const { settings, contactEmail } = useSite()
|
const { settings, contactEmail, siteShortName, heroImage } = useSite()
|
||||||
|
const heroBg = `linear-gradient(180deg,rgba(11,15,20,0.55) 0%,rgba(11,15,20,0.74) 60%,rgba(11,15,20,0.92) 100%),url('${heroImage}')`
|
||||||
const message =
|
const message =
|
||||||
settings.maintenance_message ||
|
settings.maintenance_message ||
|
||||||
'Mysticmoon is in maintenance while we shape its towns, roads, and dungeons. The gates will open soon. Until then, follow along as the world wakes.'
|
`${siteShortName} is in maintenance while we shape its towns, roads, and dungeons. The gates will open soon. Until then, follow along as the world wakes.`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
@@ -22,10 +20,10 @@ export default function Maintenance() {
|
|||||||
padding: '80px max(18px,calc((100% - 760px)/2))',
|
padding: '80px max(18px,calc((100% - 760px)/2))',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'var(--bg-deep)',
|
backgroundColor: 'var(--bg-deep)',
|
||||||
backgroundImage: HERO_BG,
|
backgroundImage: heroBg,
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'min(60vh, 520px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}>
|
<div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}>
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
|||||||
import { useAsync } from '../../lib/useAsync.js'
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
import { longDate } from '../../lib/format.js'
|
import { longDate } from '../../lib/format.js'
|
||||||
import { api } from '../../api/client.js'
|
import { api } from '../../api/client.js'
|
||||||
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
export default function News() {
|
export default function News() {
|
||||||
const { loading, error, data } = useAsync(() => api.posts('news'))
|
const { loading, error, data } = useAsync(() => api.posts('news'))
|
||||||
|
const { siteShortName } = useSite()
|
||||||
const posts = data || []
|
const posts = data || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -15,7 +17,7 @@ export default function News() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Development"
|
eyebrow="Development"
|
||||||
title="News & Updates"
|
title="News & Updates"
|
||||||
lead="Progress notes and announcements as Mysticmoon takes shape."
|
lead={`Progress notes and announcements as ${siteShortName} takes shape.`}
|
||||||
/>
|
/>
|
||||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
<section style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
{loading && <Loading />}
|
{loading && <Loading />}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import { defaultLayout, parseLayout, heroBackground } from '../../lib/heroLayout
|
|||||||
const PREVIEW = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('preview') === '1'
|
const PREVIEW = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('preview') === '1'
|
||||||
|
|
||||||
export default function Portal() {
|
export default function Portal() {
|
||||||
const { settings } = useSite()
|
const { settings, siteShortName, siteTitle, heroImage } = useSite()
|
||||||
const teaser =
|
const teaser =
|
||||||
settings.homepage_teaser ||
|
settings.homepage_teaser ||
|
||||||
'Mysticmoon is still being shaped beneath a midnight sky — a quiet preview for the news, screenshots, guides, and community notes to come as the world wakes.'
|
`${siteShortName} is still being shaped beneath a midnight sky — a quiet preview for the news, screenshots, guides, and community notes to come as the world wakes.`
|
||||||
|
|
||||||
// Published layout (public). Falls back to the pre-populated default if missing,
|
// Published layout (public). Falls back to the pre-populated default if missing,
|
||||||
// malformed, the wrong version, or empty — so the hero is never blank.
|
// malformed, the wrong version, or empty — so the hero is never blank.
|
||||||
@@ -34,9 +34,9 @@ export default function Portal() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const active = (PREVIEW && draft) || (published && published.elements.length ? published : null)
|
const active = (PREVIEW && draft) || (published && published.elements.length ? published : null)
|
||||||
const layout = active || defaultLayout(teaser)
|
const layout = active || defaultLayout(teaser, siteTitle)
|
||||||
const isDefault = !active
|
const isDefault = !active
|
||||||
const bgStyle = heroBackground(layout, { isDefault })
|
const bgStyle = heroBackground(layout, { isDefault, defaultImage: heroImage })
|
||||||
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
|
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
227
client/src/routes/public/Shard.jsx
Normal file
227
client/src/routes/public/Shard.jsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { describe } from '../../lib/shardEvents.js'
|
||||||
|
import { ago } from '../../lib/format.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
||||||
|
|
||||||
|
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
||||||
|
function Sparkline({ series }) {
|
||||||
|
if (!series || series.length < 2) return null
|
||||||
|
const w = 320
|
||||||
|
const h = 56
|
||||||
|
const golds = series.map((s) => Number(s.gold) || 0)
|
||||||
|
const min = Math.min(...golds)
|
||||||
|
const max = Math.max(...golds)
|
||||||
|
const span = max - min || 1
|
||||||
|
const pts = series
|
||||||
|
.map((s, i) => {
|
||||||
|
const x = (i / (series.length - 1)) * w
|
||||||
|
const y = h - ((Number(s.gold) || 0) - min) / span * h
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
|
||||||
|
function Stat({ value, label }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||||
|
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Shard() {
|
||||||
|
const { loading, error, data } = useAsync(() =>
|
||||||
|
Promise.all([
|
||||||
|
api.shard.status(),
|
||||||
|
api.shard.idoc(),
|
||||||
|
api.shard.economy(60),
|
||||||
|
api.shard.online(),
|
||||||
|
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
|
||||||
|
)
|
||||||
|
const { events, connected } = useShardFeed({ max: 30 })
|
||||||
|
|
||||||
|
const status = data?.status
|
||||||
|
const online = status?.pluginConnected
|
||||||
|
const gold = status?.economy?.gold
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader eyebrow="Live" title="Shard" />
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load shard data right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
{/* Connection banner */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
padding: '24px 26px',
|
||||||
|
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: online
|
||||||
|
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
|
||||||
|
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 'none',
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
|
||||||
|
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
|
||||||
|
{online ? 'The shard is online' : 'The shard is offline'}
|
||||||
|
</strong>
|
||||||
|
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
||||||
|
{online
|
||||||
|
? 'The gate to Britannia stands open.'
|
||||||
|
: status?.enabled
|
||||||
|
? 'The link to the game world is down — checking back automatically.'
|
||||||
|
: 'Live shard data is not configured yet.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Stat tiles */}
|
||||||
|
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
|
||||||
|
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
||||||
|
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Live players-online breakdown (total + region buckets) */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<PlayersOnline />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Staff online — linked staff accounts only, with location */}
|
||||||
|
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
|
Staff online
|
||||||
|
</div>
|
||||||
|
{(!data.online || data.online.length === 0) ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{data.online.map((p) => (
|
||||||
|
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
|
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
||||||
|
{p.name || p.serial}
|
||||||
|
</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
||||||
|
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Economy sparkline */}
|
||||||
|
{data.economy && data.economy.length > 1 && (
|
||||||
|
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||||
|
Gold supply over time
|
||||||
|
</div>
|
||||||
|
<Sparkline series={data.economy} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
{/* Latest IDOC */}
|
||||||
|
<FeedList
|
||||||
|
title="Houses in danger (IDOC)"
|
||||||
|
empty="No houses are collapsing right now."
|
||||||
|
items={data.idoc.map((h) => ({
|
||||||
|
id: h.serial,
|
||||||
|
text: `${h.name || 'A house'}${h.region ? ` — ${h.region}` : ''}`,
|
||||||
|
when: h.updatedAt,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live ticker */}
|
||||||
|
<section className="panel" style={{ padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||||
|
Live feed
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<Link to="/site/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
|
||||||
|
View all activity →
|
||||||
|
</Link>
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||||
|
Waiting for something to happen in the world…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{events.map((ev) => (
|
||||||
|
<li key={ev._id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(ev)}</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(ev.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedList({ title, items, empty }) {
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 20 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>{empty}</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{items.map((it) => (
|
||||||
|
<li key={it.id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.text}</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(it.when)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
client/src/routes/public/ShardActivity.jsx
Normal file
81
client/src/routes/public/ShardActivity.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { describe, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js'
|
||||||
|
import { ago } from '../../lib/format.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// Public activity feed: the full shard event log, filterable by category, with a
|
||||||
|
// live tail that prepends new events as they happen.
|
||||||
|
export default function ShardActivity() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 }))
|
||||||
|
const { events: live } = useShardFeed({ max: 60 })
|
||||||
|
const [cat, setCat] = useState('all')
|
||||||
|
|
||||||
|
// Merge the live tail with the loaded history, de-duped by kind+t, newest first.
|
||||||
|
const merged = useMemo(() => {
|
||||||
|
const seen = new Set()
|
||||||
|
const out = []
|
||||||
|
for (const e of [...live, ...(data || [])]) {
|
||||||
|
const key = `${e.kind}-${e.t}`
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
out.push(e)
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => (b.t || 0) - (a.t || 0))
|
||||||
|
}, [live, data])
|
||||||
|
|
||||||
|
const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader eyebrow="Live" title="Shard Activity" />
|
||||||
|
<p style={{ marginTop: -8, marginBottom: 18 }}>
|
||||||
|
<Link to="/site/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>← Back to shard</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Category tabs */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => setCat(c.id)}
|
||||||
|
className="pill"
|
||||||
|
style={cat === c.id ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : undefined}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the activity feed right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
filtered.length === 0 ? (
|
||||||
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.9rem' }}>Nothing here yet — events will appear as they happen in the world.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{filtered.map((e) => (
|
||||||
|
<li key={e._id || `${e.kind}-${e.t}`} className="panel" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>
|
||||||
|
{kindLabel(e.kind)}
|
||||||
|
</span>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', fontSize: '0.92rem' }}>{describe(e)}</span>
|
||||||
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.76rem' }}>{ago(e.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,24 +1,26 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
import PageHeader from '../../components/PageHeader.jsx'
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
const CARDS = [
|
const CARDS = [
|
||||||
{ kicker: 'Gallery', title: 'Gameplay Pictures', body: 'Screenshots from towns, dungeons, events, and daily life on the shard.', to: '/site/screenshots' },
|
{ kicker: 'Gallery', title: 'Gameplay Pictures', body: 'Screenshots from towns, dungeons, events, and daily life on the shard.', to: '/site/screenshots' },
|
||||||
{ kicker: 'Updates', title: 'Development News', body: 'Progress notes, shard milestones, and public announcements.', to: '/site/news' },
|
{ kicker: 'Updates', title: 'Development News', body: 'Progress notes, shard milestones, and public announcements.', to: '/site/news' },
|
||||||
{ kicker: 'Community', title: 'Five on Friday', body: 'Weekly questions, small previews, and notes from the team.', to: '/site/five-on-friday' },
|
{ kicker: 'Community', title: 'Five on Friday', body: 'Weekly questions, small previews, and notes from the team.', to: '/site/five-on-friday' },
|
||||||
{ kicker: 'Long-form', title: 'Monthly Newsletter', body: 'Fuller summaries for players who want the whole picture.', to: '/site/newsletter' },
|
{ kicker: 'Long-form', title: 'Monthly Newsletter', body: 'Fuller summaries for players who want the whole picture.', to: '/site/newsletter' },
|
||||||
{ kicker: 'Reference', title: 'Wiki', body: 'Guides and reference pages for the Mysticmoon world.', to: '/wiki' },
|
{ kicker: 'Reference', title: 'Wiki', body: 'Guides and reference pages for the game world.', to: '/wiki' },
|
||||||
{ kicker: 'Live', title: 'Shard Status', body: 'Launch state, test windows, and known issues.', to: '/site/status' },
|
{ kicker: 'Live', title: 'Shard Status', body: 'Launch state, test windows, and known issues.', to: '/site/status' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Website() {
|
export default function Website() {
|
||||||
|
const { siteShortName } = useSite()
|
||||||
return (
|
return (
|
||||||
<PublicLayout section="website">
|
<PublicLayout section="website">
|
||||||
<div className="shell page-body">
|
<div className="shell page-body">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
center
|
center
|
||||||
eyebrow="Public portal"
|
eyebrow="Public portal"
|
||||||
title="Mysticmoon Website"
|
title={`${siteShortName} Website`}
|
||||||
lead="A home for gameplay pictures, development updates, community posts, monthly newsletters, and weekly Five on Friday notes."
|
lead="A home for gameplay pictures, development updates, community posts, monthly newsletters, and weekly Five on Friday notes."
|
||||||
/>
|
/>
|
||||||
<section className="grid-3">
|
<section className="grid-3">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import PageHeader from '../../components/PageHeader.jsx'
|
|||||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||||
import { useAsync } from '../../lib/useAsync.js'
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
import { api } from '../../api/client.js'
|
import { api } from '../../api/client.js'
|
||||||
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
function SearchBox({ initial, onSubmit }) {
|
function SearchBox({ initial, onSubmit }) {
|
||||||
const [term, setTerm] = useState(initial || '')
|
const [term, setTerm] = useState(initial || '')
|
||||||
@@ -61,6 +62,7 @@ function PageCard({ page }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Wiki() {
|
export default function Wiki() {
|
||||||
|
const { siteShortName } = useSite()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const activeCategory = searchParams.get('category')
|
const activeCategory = searchParams.get('category')
|
||||||
const activeTag = searchParams.get('tag')
|
const activeTag = searchParams.get('tag')
|
||||||
@@ -92,7 +94,7 @@ export default function Wiki() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
center
|
center
|
||||||
eyebrow="Knowledge base"
|
eyebrow="Knowledge base"
|
||||||
title="Mysticmoon Wiki"
|
title={`${siteShortName} Wiki`}
|
||||||
lead="A calm starting point for shard guides, the world and its lore, gameplay systems, and community rules."
|
lead="A calm starting point for shard guides, the world and its lore, gameplay systems, and community rules."
|
||||||
/>
|
/>
|
||||||
<SearchBox initial={activeQ || ''} onSubmit={runSearch} />
|
<SearchBox initial={activeQ || ''} onSubmit={runSearch} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* ===== UOMysticmoon design tokens (from the Claude Design handoff) ===== */
|
/* ===== Runic Gateway design tokens (from the Claude Design handoff) ===== */
|
||||||
:root {
|
:root {
|
||||||
--bg: #0e1318;
|
--bg: #0e1318;
|
||||||
--bg-deep: #0b0f14;
|
--bg-deep: #0b0f14;
|
||||||
@@ -79,6 +79,10 @@ a {
|
|||||||
width: min(760px, calc(100% - 32px));
|
width: min(760px, calc(100% - 32px));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
.shell-wide {
|
||||||
|
width: min(1280px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -542,6 +546,20 @@ button[disabled] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Hero canvas editor ===== */
|
/* ===== Hero canvas editor ===== */
|
||||||
|
/* Rich-text hero line (e.g. the homepage teaser). Inherits the line's font/color
|
||||||
|
from its inline style; collapse the editor's outer block margins so spacing is
|
||||||
|
driven by the line's own marginTop rather than a nested <p>. */
|
||||||
|
.hero-rich > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.hero-rich > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.hero-rich a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-el-editable {
|
.hero-el-editable {
|
||||||
outline: 1px dashed rgba(127, 153, 189, 0.45);
|
outline: 1px dashed rgba(127, 153, 189, 0.45);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
@@ -618,6 +636,11 @@ button[disabled] {
|
|||||||
color: #e0b070;
|
color: #e0b070;
|
||||||
border: 1px solid rgba(224, 176, 112, 0.4);
|
border: 1px solid rgba(224, 176, 112, 0.4);
|
||||||
}
|
}
|
||||||
|
.badge-player {
|
||||||
|
background: rgba(126, 196, 156, 0.12);
|
||||||
|
color: #7ec49c;
|
||||||
|
border: 1px solid rgba(126, 196, 156, 0.4);
|
||||||
|
}
|
||||||
/* Action-type badges for the moderation dashboard. */
|
/* Action-type badges for the moderation dashboard. */
|
||||||
.badge-ban {
|
.badge-ban {
|
||||||
background: rgba(217, 139, 132, 0.16);
|
background: rgba(217, 139, 132, 0.16);
|
||||||
@@ -683,6 +706,47 @@ button[disabled] {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Admin sidebar — collapsible category sections */
|
||||||
|
.admin-nav-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.admin-nav-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 14px 4px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.admin-nav-head:hover {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.admin-nav-chev {
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.admin-nav-items {
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
|
.admin-nav-link > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.admin-nav-link svg {
|
||||||
|
flex: 0 0 16px;
|
||||||
|
}
|
||||||
.wiki-grid {
|
.wiki-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 230px 1fr;
|
grid-template-columns: 230px 1fr;
|
||||||
@@ -694,3 +758,326 @@ button[disabled] {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== CMS page builder — public block rendering ===== */
|
||||||
|
/* Vertical rhythm between top-level blocks on a rendered page. */
|
||||||
|
.page-blocks > * + * {
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
.page-heading {
|
||||||
|
font-family: var(--serif, Georgia, serif);
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.page-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
/* image block */
|
||||||
|
.page-image {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.page-image img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.page-image figcaption {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.page-image--center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.page-image--center img,
|
||||||
|
.page-image--center figcaption {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.page-image--right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.page-image--right img,
|
||||||
|
.page-image--right figcaption {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.page-image--full img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
/* cta block */
|
||||||
|
.page-cta-wrap {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.page-cta--secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
/* quote block */
|
||||||
|
.page-quote {
|
||||||
|
margin: 0;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding: 4px 0 4px 20px;
|
||||||
|
}
|
||||||
|
.page-quote blockquote {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.page-quote figcaption {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
/* two-column block */
|
||||||
|
.page-two-column {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
.page-column > * + * {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.page-two-column {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CMS page builder — column sub-block editor ===== */
|
||||||
|
.pb-two-column-editor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.pb-two-column-editor {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pb-column-editor {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--panel-flat, transparent);
|
||||||
|
}
|
||||||
|
.pb-column-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.pb-add-select {
|
||||||
|
width: auto;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.pb-subblock {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.pb-subblock-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.pb-subblock-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.pb-mini {
|
||||||
|
min-width: 28px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CMS page builder — admin canvas ===== */
|
||||||
|
.pb-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
padding: 10px 0;
|
||||||
|
background: var(--bg);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.pb-error {
|
||||||
|
border: 1px solid #6e3b38;
|
||||||
|
background: rgba(110, 59, 56, 0.16);
|
||||||
|
color: #e6a9a3;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
.pb-notice {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--blue);
|
||||||
|
color: var(--accent-bright);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
.pb-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.pb-tab {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pb-tab.is-active {
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
.pb-palette {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.pb-canvas {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.pb-block-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel-flat, transparent);
|
||||||
|
}
|
||||||
|
.pb-block-card.is-dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.pb-block-card.is-hidden {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.pb-block-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.pb-drag {
|
||||||
|
cursor: grab;
|
||||||
|
color: var(--muted);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.pb-block-body {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.pb-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
.pb-danger {
|
||||||
|
border-color: #6e3b38;
|
||||||
|
color: #d98b84;
|
||||||
|
}
|
||||||
|
.pb-danger:hover:not([disabled]) {
|
||||||
|
background: rgba(110, 59, 56, 0.18);
|
||||||
|
border-color: #8a4b47;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Site footer: powered-by badge left-justified, info centered. */
|
||||||
|
.site-footer-inner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.site-footer-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.site-footer-badge img {
|
||||||
|
height: 48px;
|
||||||
|
width: 48px;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 0 6px rgba(127, 153, 189, 0.35));
|
||||||
|
}
|
||||||
|
.site-footer-badge span {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--dim);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.site-footer-badge strong {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.site-footer-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
/* Pin the badge to the left while the info column stays visually centered. */
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
.site-footer-badge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.site-footer-inner {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.site-footer-badge img {
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Draft-preview banner on the public renderer. */
|
||||||
|
.page-preview-banner {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--blue);
|
||||||
|
color: var(--accent-bright);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|||||||
24
docker-compose.dev.yml
Normal file
24
docker-compose.dev.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Development overlay — build the app + bot images locally instead of pulling the
|
||||||
|
# prebuilt ones from the Gitea registry.
|
||||||
|
#
|
||||||
|
# The base docker-compose.yml is production-shaped (image: only, no build:), so a
|
||||||
|
# production host can never accidentally build — it only pulls. Use this overlay
|
||||||
|
# EXPLICITLY for local work (it is not auto-loaded like docker-compose.override.yml
|
||||||
|
# would be):
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
|
#
|
||||||
|
# Production stays:
|
||||||
|
#
|
||||||
|
# docker compose pull && docker compose up -d
|
||||||
|
#
|
||||||
|
# The `image:` tags inherited from the base file double as the local build tags,
|
||||||
|
# so a built image and a pulled one are interchangeable.
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
|
||||||
|
bot:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: bot/Dockerfile
|
||||||
@@ -21,7 +21,14 @@ services:
|
|||||||
# - "3306:3306"
|
# - "3306:3306"
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
# Prebuilt image from the Gitea registry (published by
|
||||||
|
# .gitea/workflows/build-images.yml on every merge to main). This file is
|
||||||
|
# production-shaped — image only, NO build: — so a production host can only
|
||||||
|
# ever pull, never accidentally build. IMAGE_TAG defaults to `latest`; pin a
|
||||||
|
# specific build for a reproducible deploy / rollback, e.g.
|
||||||
|
# IMAGE_TAG=sha-042a151 (see .env / .env.example). To build locally instead,
|
||||||
|
# overlay docker-compose.dev.yml (see README).
|
||||||
|
image: gitea.whitlocktech.com/runicgateway/website-app:${IMAGE_TAG:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
@@ -35,6 +42,11 @@ services:
|
|||||||
- uploads:/app/uploads
|
- uploads:/app/uploads
|
||||||
# Bind-mount logs to the host so app.log is directly readable at ./logs/
|
# Bind-mount logs to the host so app.log is directly readable at ./logs/
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
|
# Instance branding assets (logo/hero/favicon), served at /brand when
|
||||||
|
# BRAND_LOGO/HERO/FAVICON point there. Optional — defaults are baked into
|
||||||
|
# the image, so this mount only matters for custom brand images. Create
|
||||||
|
# ./brand/ on the host and drop assets in; read-only in the container.
|
||||||
|
- ./brand:/app/brand:ro
|
||||||
# Only the PUBLIC API port (3000) is published. The internal server<->bot
|
# Only the PUBLIC API port (3000) is published. The internal server<->bot
|
||||||
# port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it
|
# port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it
|
||||||
# stays reachable only over the private compose network — Pangolin/the public
|
# stays reachable only over the private compose network — Pangolin/the public
|
||||||
@@ -44,9 +56,9 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
build:
|
# Same as app: prebuilt bot image, pulled in production. Build locally via
|
||||||
context: .
|
# docker-compose.dev.yml.
|
||||||
dockerfile: bot/Dockerfile
|
image: gitea.whitlocktech.com/runicgateway/website-bot:${IMAGE_TAG:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "uomysticmoon-website",
|
"name": "runic-gateway-website",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "UOMysticmoon — public site, wiki, and admin panel for a private Ultima Online shard",
|
"description": "Runic Gateway — public site, wiki, and admin panel for a private Ultima Online shard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"install-server": "npm install --prefix server",
|
"install-server": "npm install --prefix server",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ─── UOMysticmoon server — local dev environment ───
|
# ─── Runic Gateway server — local dev environment ───
|
||||||
# Copy to server/.env for running `npm run dev` outside Docker.
|
# Copy to server/.env for running `npm run dev` outside Docker.
|
||||||
# (In Docker, the root .env / docker-compose provides these instead.)
|
# (In Docker, the root .env / docker-compose provides these instead.)
|
||||||
|
|
||||||
@@ -18,14 +18,14 @@ LOG_TO_FILE=true # set false for console-only
|
|||||||
# Point at a local or Dockerized MariaDB
|
# Point at a local or Dockerized MariaDB
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_NAME=uomysticmoon
|
DB_NAME=runic_gateway
|
||||||
DB_USER=uomm
|
DB_USER=runic
|
||||||
DB_PASSWORD=change-me-db-password
|
DB_PASSWORD=change-me-db-password
|
||||||
|
|
||||||
JWT_SECRET=dev-only-change-me
|
JWT_SECRET=dev-only-change-me
|
||||||
JWT_EXPIRES_IN=1d
|
JWT_EXPIRES_IN=1d
|
||||||
COOKIE_SECURE=auto
|
COOKIE_SECURE=auto
|
||||||
COOKIE_NAME=uomm_token
|
COOKIE_NAME=rg_token
|
||||||
|
|
||||||
# Encryption key for secrets stored at rest (OAuth client secrets in auth_providers).
|
# Encryption key for secrets stored at rest (OAuth client secrets in auth_providers).
|
||||||
# Any string — hashed to a 256-bit AES-GCM key. REQUIRED in production; in dev an
|
# Any string — hashed to a 256-bit AES-GCM key. REQUIRED in production; in dev an
|
||||||
@@ -62,7 +62,8 @@ TRUST_PROXY=1
|
|||||||
DEBUG_TRUST_PROXY=0
|
DEBUG_TRUST_PROXY=0
|
||||||
|
|
||||||
# Optional TOTP two-factor (opt-in per user).
|
# Optional TOTP two-factor (opt-in per user).
|
||||||
TOTP_ISSUER=UOMysticmoon
|
# TOTP_ISSUER defaults to BRAND_NAME; BRAND_* live in the root .env (see root .env.example)
|
||||||
|
TOTP_ISSUER=Runic Gateway
|
||||||
# How long the "password verified, awaiting code" step stays valid.
|
# How long the "password verified, awaiting code" step stays valid.
|
||||||
TOTP_CHALLENGE_TTL=5m
|
TOTP_CHALLENGE_TTL=5m
|
||||||
|
|
||||||
@@ -70,11 +71,10 @@ TOTP_CHALLENGE_TTL=5m
|
|||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=change-me-admin-password
|
ADMIN_PASSWORD=change-me-admin-password
|
||||||
|
|
||||||
SMTP_HOST=
|
# Email is configured in Admin → Settings → Email (Gmail over OAuth2), not here.
|
||||||
SMTP_PORT=587
|
# It reuses the Google auth provider's OAuth client and stores an encrypted
|
||||||
SMTP_USER=
|
# refresh token in the DB. The contact recipient is the `contact_email` site
|
||||||
SMTP_PASS=
|
# setting; while email is unconfigured the contact form falls back to a mailto: link.
|
||||||
CONTACT_TO=UOMysticmoon@gmail.com
|
|
||||||
|
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
@@ -89,3 +89,11 @@ CLIENT_ORIGIN=http://localhost:5173
|
|||||||
# encrypted in the DB (see the bot_config table / SECRET_ENC_KEY above).
|
# encrypted in the DB (see the bot_config table / SECRET_ENC_KEY above).
|
||||||
BOT_INTERNAL_URL=http://localhost:4100
|
BOT_INTERNAL_URL=http://localhost:4100
|
||||||
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
|
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
|
||||||
|
|
||||||
|
# News announcement pipeline (published news post -> in-game town crier + Discord
|
||||||
|
# #news). The dispatcher is an in-process poller; these tune it. Links in the
|
||||||
|
# announcements use APP_BASE_URL (set above), so set that in production too.
|
||||||
|
# ANNOUNCE_POLL_MS how often the dispatcher sweeps for due/retry legs
|
||||||
|
# TOWNCRIER_DURATION_SEC how long the in-game town-crier message stays up (<= 86400)
|
||||||
|
ANNOUNCE_POLL_MS=15000
|
||||||
|
TOWNCRIER_DURATION_SEC=3600
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user