Compare commits
112 Commits
18c4b50ec3
...
ci/gitea-a
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 2b4c4c5235 | |||
| 3027bb0400 | |||
| b0c0d1fe9b | |||
| f2691959ff | |||
| 60d2121b83 | |||
| 20d3fbf594 | |||
| f8db61025b | |||
| 2067028070 | |||
| 03e62b56ad | |||
| 15cf8ea286 | |||
| 5f62eccdd8 | |||
| e8a54d9ff7 | |||
| 933206a1b8 | |||
| 1cfb79f5ae | |||
| 3ef84b41ef | |||
| 5df943095d | |||
| bb5cc68c54 | |||
| 17c1eb07e8 | |||
| 7a21cc636c | |||
| ad7aebb3ba | |||
| 0318d6fe9f | |||
| 433e02d3ef | |||
| a1f0675577 | |||
| d7fb274bad | |||
| 6af85c30b6 | |||
| 86e44a94a2 | |||
| 31b31c3a17 | |||
| 8fa34ca68e | |||
| 870971fc12 | |||
| 58852a5078 | |||
| cd678e75ce | |||
| a82f839c61 | |||
| 05933f8d94 | |||
| 073c010d72 | |||
| f305019c54 | |||
| 7e8ffeee6f | |||
| 6ab3e47d38 | |||
| ea46b5d346 | |||
| d38c98ad9e | |||
| ad9c556c9a | |||
| e84835a0fb | |||
| d89cc7e691 | |||
| 43db509293 | |||
| 6b04aa72c1 | |||
| 81318ae264 | |||
| 853224b578 | |||
| 8ad18140d0 | |||
| 20d7150ab4 | |||
| 52c74db825 | |||
| 4c16040373 | |||
| 103007e49a | |||
| 438d252c05 | |||
| a5a8c1930c | |||
| fecd28238d | |||
| 73eac2a138 | |||
| f69c86f737 | |||
| 785090eb97 | |||
| ccad727ec3 | |||
| 578bffc51f | |||
| 30c2a30c80 | |||
| 75b13159d7 | |||
| 7bb992f58d | |||
| 7c081ae749 | |||
| 4a7dbf0085 | |||
| b925114923 | |||
| dd1f61222d | |||
| 673c0400c5 | |||
| 8fdcb28cea | |||
| 6f55c516c7 |
@@ -6,6 +6,18 @@
|
|||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev", "--prefix", "client"],
|
"runtimeArgs": ["run", "dev", "--prefix", "client"],
|
||||||
"port": 5173
|
"port": 5173
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--prefix", "server"],
|
||||||
|
"port": 3000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bot",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--prefix", "bot"],
|
||||||
|
"port": 4100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
61
.env.example
61
.env.example
@@ -4,6 +4,10 @@
|
|||||||
# App
|
# App
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
# Separate, UNPUBLISHED port for server<->bot internal traffic (the decrypted
|
||||||
|
# bot-token route). Must match the port in the bot's SITE_INTERNAL_URL
|
||||||
|
# (docker-compose.yml) and must NEVER be published/proxied. See issue #33.
|
||||||
|
INTERNAL_PORT=3001
|
||||||
UPLOAD_DIR=/app/uploads
|
UPLOAD_DIR=/app/uploads
|
||||||
# Logging — written to BOTH the console and a log file.
|
# Logging — written to BOTH the console and a log file.
|
||||||
LOG_LEVEL=info # console verbosity: error | warn | info | debug
|
LOG_LEVEL=info # console verbosity: error | warn | info | debug
|
||||||
@@ -12,7 +16,8 @@ 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
|
||||||
|
|
||||||
# Database (the values here are shared by the `db` and `app` 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)
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_NAME=uomysticmoon
|
DB_NAME=uomysticmoon
|
||||||
@@ -28,18 +33,58 @@ JWT_EXPIRES_IN=1d
|
|||||||
COOKIE_SECURE=auto
|
COOKIE_SECURE=auto
|
||||||
COOKIE_NAME=uomm_token
|
COOKIE_NAME=uomm_token
|
||||||
|
|
||||||
|
# Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban).
|
||||||
|
# Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this
|
||||||
|
# to ptero's LAN IP (e.g. 10.0.0.42) so XFF is only trusted from ptero. Requires
|
||||||
|
# a static DHCP reservation for ptero in Omada, else a lease change breaks it.
|
||||||
|
# Integer hop count or "false" also accepted; a blanket "true" is rejected
|
||||||
|
# (coerced to 1) to prevent X-Forwarded-For spoofing.
|
||||||
|
TRUST_PROXY=1
|
||||||
|
# Set to 1 to log raw peer address + X-Forwarded-For + resolved req.ip per
|
||||||
|
# request (to verify/refresh ptero's IP without redeploying). Noisy; keep off.
|
||||||
|
DEBUG_TRUST_PROXY=0
|
||||||
|
|
||||||
|
# Optional TOTP two-factor (opt-in per user).
|
||||||
|
TOTP_ISSUER=UOMysticmoon
|
||||||
|
TOTP_CHALLENGE_TTL=5m
|
||||||
|
|
||||||
# First admin bootstrap — created only if no users exist yet.
|
# First admin bootstrap — created only if no users exist yet.
|
||||||
# Set, run once, then you can blank these out.
|
# Set, run once, then you can blank these out.
|
||||||
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
|
||||||
|
|
||||||
|
# Discord bot — internal API (server <-> bot/, see docker-compose.yml's `bot`
|
||||||
|
# service). BOT_INTERNAL_KEY MUST be byte-for-byte identical to the same
|
||||||
|
# variable in bot/.env.example — it is the only auth on both sides' /internal/*
|
||||||
|
# routes, so a mismatch silently breaks every server<->bot call with 401s.
|
||||||
|
# It also guards the server's /internal/bot-config route, which returns the
|
||||||
|
# DECRYPTED Discord token; with NODE_ENV=production the app REFUSES TO START if
|
||||||
|
# this is left blank, at this placeholder, or shorter than 16 chars. Generate a
|
||||||
|
# long random string. The Discord bot TOKEN itself is not an env var — it's
|
||||||
|
# entered in the admin panel (Discord Bot page) and stored encrypted in the DB.
|
||||||
|
#
|
||||||
|
# Defense in depth: even with a strong key, configure Pangolin/your reverse
|
||||||
|
# proxy to DENY /api/v1/internal (and never forward INTERNAL_PORT). The route no
|
||||||
|
# longer rides the public listener, but an explicit deny rule is belt-and-braces.
|
||||||
|
BOT_INTERNAL_URL=http://bot:4100
|
||||||
|
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
|
||||||
|
|||||||
87
.gitea/workflows/build-images.yml
Normal file
87
.gitea/workflows/build-images.yml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Build and publish the app + bot container images to Gitea's container registry
|
||||||
|
# on every merge to main. Production then pulls prebuilt images instead of
|
||||||
|
# building on the host.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
# • 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>
|
||||||
|
|
||||||
|
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
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -30,3 +30,9 @@ Thumbs.db
|
|||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# local planning docs (not part of the tracked codebase)
|
||||||
|
.plans/
|
||||||
|
|
||||||
|
# scratch / temp scripts
|
||||||
|
_*.ps1
|
||||||
|
|||||||
@@ -234,10 +234,13 @@ who"; `activity_log` provides the history feed.
|
|||||||
|
|
||||||
## 7. Email
|
## 7. Email
|
||||||
|
|
||||||
`utils/mailer.js` (nodemailer) configured from `SMTP_HOST/PORT/USER/PASS`, sending to
|
`utils/mailer.js` (nodemailer) sends through **Gmail over OAuth2 (SMTP XOAUTH2)**, configured in
|
||||||
`CONTACT_TO` (default UOMysticmoon@gmail.com). No Gmail password in code — env only.
|
Admin → Settings → Email — not env. The mailbox is authorized by an in-app "Connect Gmail" consent
|
||||||
If SMTP is unconfigured, `POST /public/contact` returns `{fallback:"mailto", email}` so the
|
flow (`/admin/email/*`) that captures a refresh token, stored AES-GCM-encrypted in the `email_config`
|
||||||
client renders a `mailto:` link instead. Site mode changes / errors never leak SMTP creds.
|
singleton (never returned over the API). The OAuth client id/secret are reused from the `google`
|
||||||
|
auth-providers row. Recipient is the `contact_email` site setting. If email is unconfigured/disabled,
|
||||||
|
`POST /public/contact` returns `{fallback:"mailto", email}` so the client renders a `mailto:` link
|
||||||
|
instead. Errors never leak credentials.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -287,11 +290,7 @@ COOKIE_SECURE=true
|
|||||||
COOKIE_NAME=uomm_token
|
COOKIE_NAME=uomm_token
|
||||||
ADMIN_USERNAME=
|
ADMIN_USERNAME=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
SMTP_HOST=
|
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASS=
|
|
||||||
CONTACT_TO=UOMysticmoon@gmail.com
|
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
134
HERO_EDITOR.md
Normal file
134
HERO_EDITOR.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# 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).
|
||||||
460
README.md
460
README.md
@@ -1,63 +1,427 @@
|
|||||||
# UOMysticmoon Website
|
# UOMysticmoon Website
|
||||||
|
|
||||||
Public site, wiki, and protected admin panel for the UOMysticmoon private Ultima Online
|
Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online
|
||||||
shard. Built on the `serverlinkr` layered pattern: **Express + MariaDB + JWT** backend and a
|
shard — a full-stack app in one repo:
|
||||||
**React + Vite** frontend in the same repo, deployed with **Docker Compose** behind a
|
|
||||||
**Pangolin** reverse proxy.
|
|
||||||
|
|
||||||
> Build order: **(1) backend** (this phase) → (2) frontend design (Claude Design) →
|
- **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).
|
||||||
> (3) frontend coding. See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) for the full design.
|
- **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.
|
||||||
|
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([UOM/link](https://gitea.whitlocktech.com/UOM/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).
|
||||||
|
|
||||||
## Layout
|
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Tech stack](#tech-stack)
|
||||||
|
- [Project structure](#project-structure)
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Setup & run](#setup--run)
|
||||||
|
- [Option A — Docker Compose (full stack)](#option-a--docker-compose-full-stack)
|
||||||
|
- [Option B — Local development (hot reload)](#option-b--local-development-hot-reload)
|
||||||
|
- [Option C — Production build without Docker](#option-c--production-build-without-docker)
|
||||||
|
- [First admin & site mode](#first-admin--site-mode)
|
||||||
|
- [Pages & routes](#pages--routes)
|
||||||
|
- [API endpoints](#api-endpoints)
|
||||||
|
- [API documentation (Swagger)](#api-documentation-swagger)
|
||||||
|
- [Shard integration (uo-link)](#shard-integration-uo-link)
|
||||||
|
- [Environment variables](#environment-variables)
|
||||||
|
- [Security](#security)
|
||||||
|
- [Logging](#logging)
|
||||||
|
- [Deployment behind Pangolin](#deployment-behind-pangolin)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
| Layer | Tech |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Node.js 20+, Express 4, `mariadb` driver (parameterized SQL, no ORM) |
|
||||||
|
| 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) |
|
||||||
|
| Frontend | React 18, Vite 5, React Router 6 |
|
||||||
|
| 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` |
|
||||||
|
| Deploy | Docker Compose, Pangolin reverse proxy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
server/ Express API (router → controller → model → db), MariaDB schema + seed
|
UOMSITE/
|
||||||
client/ React + Vite SPA (added in the frontend phase)
|
├─ server/ Express API
|
||||||
Dockerfile, docker-compose.yml, .env.example
|
│ ├─ src/
|
||||||
|
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
|
||||||
|
│ │ ├─ app.js middleware + static SPA + routes
|
||||||
|
│ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry)
|
||||||
|
│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups
|
||||||
|
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
|
||||||
|
│ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
|
||||||
|
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger
|
||||||
|
│ ├─ db/ schema.sql + seed.js
|
||||||
|
│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec)
|
||||||
|
│ └─ .env.example
|
||||||
|
├─ client/ React + Vite SPA
|
||||||
|
│ ├─ src/
|
||||||
|
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
|
||||||
|
│ │ ├─ routes/wiki/ Wiki landing + WikiArticle
|
||||||
|
│ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors
|
||||||
|
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), …
|
||||||
|
│ │ ├─ contexts/ AuthContext, SiteContext
|
||||||
|
│ │ ├─ api/client.js fetch wrapper (sends cookies)
|
||||||
|
│ │ └─ styles/theme.css design tokens
|
||||||
|
│ └─ public/assets/img/ hero image
|
||||||
|
├─ Dockerfile builds client → serves via Express
|
||||||
|
├─ docker-compose.yml app + MariaDB
|
||||||
|
├─ .env.example root env (used by Compose)
|
||||||
|
└─ package.json workspace scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick start (local dev)
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Node.js 20+** and npm (Node 22/24 are fine).
|
||||||
|
- **Docker Desktop** (for MariaDB, and for the full Compose deploy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup & run
|
||||||
|
|
||||||
|
### Option A — Docker Compose (full stack)
|
||||||
|
|
||||||
|
The simplest way to run everything. The image installs server deps, **builds the React client**,
|
||||||
|
and Express serves it; MariaDB runs in its own container; tables + defaults + the first admin are
|
||||||
|
created automatically on first boot.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Start a MariaDB (or use your own and set DB_* in server/.env)
|
cp .env.example .env
|
||||||
docker compose up -d db
|
# Edit .env and set at least:
|
||||||
|
# DB_PASSWORD, DB_ROOT_PASSWORD (any strong values)
|
||||||
|
# JWT_SECRET (a long random string)
|
||||||
|
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
|
||||||
|
|
||||||
# 2. Configure + install
|
docker compose up -d --build
|
||||||
cp server/.env.example server/.env # edit DB_*, JWT_SECRET, ADMIN_USERNAME/PASSWORD
|
```
|
||||||
|
|
||||||
|
- App: **http://localhost:3000** (binds `0.0.0.0`)
|
||||||
|
- Health check: `GET http://localhost:3000/api/health` → `{ "status": "ok" }`
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
### Option B — Local development (hot reload)
|
||||||
|
|
||||||
|
Run the API and the Vite dev server separately. The Vite server proxies `/api` and `/uploads`
|
||||||
|
to the backend, so the SPA stays same-origin (cookies work).
|
||||||
|
|
||||||
|
**1. Start a MariaDB the backend can reach** (published on `localhost:3306`):
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Configure + start the backend** (terminal 1):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp server/.env.example server/.env
|
||||||
|
# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=uomm, DB_PASSWORD=devpass,
|
||||||
|
# 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
|
||||||
# 3. Run the API (creates tables, seeds defaults + first admin on boot)
|
|
||||||
npm run server # http://localhost:3000 (API at /api/v1)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /api/health` → `{ "status": "ok" }` confirms it's up.
|
**3. Start the frontend** (terminal 2):
|
||||||
|
|
||||||
## Deploy (Docker Compose)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # fill in DB creds, JWT_SECRET, admin, SMTP
|
npm run install-client
|
||||||
docker compose up -d --build # app on 0.0.0.0:3000, MariaDB on the internal network
|
npm run client # Vite → http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
Point Pangolin at the `app` container on port 3000. The auth cookie auto-detects HTTPS, so
|
Develop at **http://localhost:5173** (hot reload). On Windows, the Vite proxy targets
|
||||||
the admin panel works both via the LAN IP (HTTP) and through the proxy (HTTPS). The full app
|
`127.0.0.1:3000` to avoid the IPv6-`localhost` pitfall.
|
||||||
image build requires `client/` (frontend phase); until then the server runs API-only.
|
|
||||||
|
|
||||||
## Key endpoints
|
> Tip: `npm run install-all` installs both server and client deps in one go.
|
||||||
|
|
||||||
|
### Option C — Production build without Docker
|
||||||
|
|
||||||
|
Build the SPA and let Express serve it on a single port (still needs a MariaDB + `server/.env`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run install-all
|
||||||
|
npm run build # → client/dist
|
||||||
|
npm start # node server → serves API + SPA at http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First admin & site mode
|
||||||
|
|
||||||
|
- On first boot, if the `users` table is empty and `ADMIN_USERNAME` / `ADMIN_PASSWORD` are set,
|
||||||
|
the first admin is created automatically. You can also run `npm run seed`. After it exists you
|
||||||
|
may blank those env vars.
|
||||||
|
- The site **starts in `maintenance` mode**: public visitors see the polished "coming soon" page;
|
||||||
|
the admin login and panel are always reachable.
|
||||||
|
- Sign in at **`/admin/login`**, then flip **Maintenance → Live** from the Dashboard. A logged-in
|
||||||
|
admin can preview the live site even while it's in maintenance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pages & routes
|
||||||
|
|
||||||
|
**Public** (gated by site mode):
|
||||||
|
|
||||||
|
| Route | Page |
|
||||||
|
|---|---|
|
||||||
|
| `/` | Portal landing (hero + destinations) |
|
||||||
|
| `/site` | Website index (section cards) |
|
||||||
|
| `/site/news` | News feed |
|
||||||
|
| `/site/screenshots` | Screenshot gallery |
|
||||||
|
| `/site/five-on-friday` | Five on Friday |
|
||||||
|
| `/site/newsletter` · `/site/newsletter/:id` | Newsletter list + issue |
|
||||||
|
| `/site/about` · `/site/status` | About · Shard status |
|
||||||
|
| `/wiki` · `/wiki/:slug` | Wiki landing + article (auto table-of-contents) |
|
||||||
|
|
||||||
|
**Admin** (cookie auth, `noindex`):
|
||||||
|
|
||||||
|
| Route | View |
|
||||||
|
|---|---|
|
||||||
|
| `/admin/login` | Sign in |
|
||||||
|
| `/admin` | Dashboard (mode toggle, stats, recent activity) |
|
||||||
|
| `/admin/posts` | Posts CRUD + publish + image upload |
|
||||||
|
| `/admin/wiki` | Wiki pages CRUD |
|
||||||
|
| `/admin/settings` | Site settings |
|
||||||
|
| `/admin/activity` | Activity log |
|
||||||
|
| `/admin/bot-activity` | Bot activity — banned IPs + recent scoring events, emergency unban (admin only) |
|
||||||
|
| `/admin/auth-providers` | Authentication — enable/configure SSO providers: built-in Google & Discord + custom OIDC/OAuth2 (admin only) |
|
||||||
|
| `/admin/users` | User management |
|
||||||
|
| `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API endpoints
|
||||||
|
|
||||||
| Group | Base | Auth |
|
| Group | Base | Auth |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Auth | `/api/v1/auth` (`login`, `logout`, `me`) | cookie |
|
| Auth (web) | `/api/v1/auth` (`login`, `login/totp`, `logout`, `me`) | cookie |
|
||||||
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `wiki`, `contact`) | none |
|
| Auth (mobile) | `/api/v1/auth/mobile` (`login`, `refresh`, `logout`) | bearer (access + refresh tokens) |
|
||||||
| Admin | `/api/v1/admin` (dashboard, site-mode, posts, wiki, settings, activity, users) | cookie (admin) |
|
| 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 |
|
||||||
|
| 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) |
|
||||||
|
|
||||||
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the complete contract.
|
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||||
|
`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
|
||||||
|
docs below for a per-endpoint reference (parameters, request bodies, response codes).
|
||||||
|
|
||||||
## Security notes
|
---
|
||||||
|
|
||||||
JWT in an httpOnly cookie · bcrypt hashing · login rate limiting · admin routes `noindex` ·
|
## API documentation (Swagger)
|
||||||
first admin seeded from env (no hardcoded credentials) · `.env` is git-ignored. SMTP is
|
|
||||||
optional — the contact form falls back to a `mailto:` link when SMTP is not configured.
|
The full API is documented as an **OpenAPI 3.0** spec and served with **Swagger UI**:
|
||||||
|
|
||||||
|
| URL | What |
|
||||||
|
|---|---|
|
||||||
|
| `http://localhost:3000/api/docs` | Interactive Swagger UI (try-it-out, auth) |
|
||||||
|
| `http://localhost:3000/api/docs.json` | Raw OpenAPI 3.0 spec (JSON) |
|
||||||
|
|
||||||
|
Every endpoint is tagged and grouped (Auth, Auth · Mobile, Auth · SSO, Public, and the Admin
|
||||||
|
groups) with its summary, parameters, request body, security requirement, and the response codes it
|
||||||
|
actually returns (`400` validation, `401`/`403` auth, `404`, `409` conflicts, `429` rate limits, …).
|
||||||
|
|
||||||
|
**Authentication in the UI** — click **Authorize** and provide either:
|
||||||
|
|
||||||
|
- `cookieAuth` — the `uomm_token` session cookie (set automatically in the browser after
|
||||||
|
`POST /api/v1/auth/login`), or
|
||||||
|
- `bearerAuth` — a mobile access token from `POST /api/v1/auth/mobile/login` (sent as
|
||||||
|
`Authorization: Bearer <token>`).
|
||||||
|
|
||||||
|
**Regenerating the spec** — the spec is generated from `#swagger.*` annotations next to each route
|
||||||
|
(`server/src/router/**`) plus the shared definitions in `server/swagger/swagger.js`
|
||||||
|
([swagger-autogen](https://github.com/davibaltar/swagger-autogen)). The output
|
||||||
|
`server/swagger/swagger-output.json` is committed so the docs work with no build step. After adding
|
||||||
|
or changing a route, regenerate it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm run swagger # → server/swagger/swagger-output.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
|
||||||
|
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:
|
||||||
|
**[UOM/link](https://gitea.whitlocktech.com/UOM/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 (UOM/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
|
||||||
|
|
||||||
|
Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.env` is git-ignored.**
|
||||||
|
|
||||||
|
| Var | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `NODE_ENV` | `production` | |
|
||||||
|
| `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) |
|
||||||
|
| `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_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
|
||||||
|
| `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens |
|
||||||
|
| `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime |
|
||||||
|
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
|
||||||
|
| `COOKIE_NAME` | `uomm_token` | |
|
||||||
|
| `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) |
|
||||||
|
| `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) |
|
||||||
|
| `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 |
|
||||||
|
| `TOTP_ISSUER` | `UOMysticmoon` | label shown in authenticator apps for optional per-user 2FA |
|
||||||
|
| `TOTP_CHALLENGE_TTL` | `5m` | lifetime of the short-lived post-password "awaiting code" step |
|
||||||
|
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
||||||
|
| _Email_ | — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = `contact_email` setting |
|
||||||
|
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
||||||
|
| `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) |
|
||||||
|
| `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`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
**Session & authorization**
|
||||||
|
|
||||||
|
- All auth flows go through one **session service** (`server/src/auth/`): controllers call
|
||||||
|
`sessionService.createSession(user, authMethod)` and middleware calls `validateSession()`, so web
|
||||||
|
cookies, mobile bearer tokens, and SSO all produce the *same* authenticated session model.
|
||||||
|
`utils/auth.js` remains a thin backward-compat facade.
|
||||||
|
- JWT in an httpOnly, `SameSite=Lax` cookie (`Secure` auto-detected), bcrypt password hashing.
|
||||||
|
- Admin routes are **re-validated against the database on every request**, so a demoted or deleted
|
||||||
|
user loses access immediately instead of keeping their old role until the token expires.
|
||||||
|
- **Role-based authorization** — admin-only endpoints (users, site mode, settings, auth providers)
|
||||||
|
are gated by a `requireRole` check, so a lower-privilege editor can't reach them.
|
||||||
|
|
||||||
|
**Mobile bearer auth**
|
||||||
|
|
||||||
|
- Native clients use `/api/v1/auth/mobile/*`: a short-lived **access token** (bearer JWT, validated
|
||||||
|
by the same middleware as the cookie) plus a long-lived, **server-stored, revocable refresh
|
||||||
|
token** that is **rotated on every refresh** (a replayed refresh token is single-use). Refresh
|
||||||
|
tokens are stored **hashed** (never in the clear); logout revokes one or all. Mobile login reuses
|
||||||
|
the same bot-scoring + backoff defenses as web, with single-request TOTP.
|
||||||
|
|
||||||
|
**Single sign-on (OAuth2 / OIDC)**
|
||||||
|
|
||||||
|
- Pluggable providers — built-in **Google** and **Discord** (endpoints fixed in code; admins supply
|
||||||
|
only client id/secret) plus fully-configurable **custom OIDC/OAuth2** providers, managed from the
|
||||||
|
**Authentication** admin panel. Only `enabled` + fully-configured providers are shown to users.
|
||||||
|
- **Link-only** by policy: an SSO login succeeds *only* if the external identity is already linked to
|
||||||
|
an existing account (linked by the user from **Account**). External identities are **never
|
||||||
|
auto-provisioned** — no one gains access without an account you created.
|
||||||
|
- The redirect flow is CSRF-protected with a signed, httpOnly, short-lived transaction cookie plus
|
||||||
|
**PKCE**; OAuth client secrets are **encrypted at rest** (AES-256-GCM) and never returned to any
|
||||||
|
client. SSO logins go through the same `sessionService`, so login/activity logging, RBAC, and bot
|
||||||
|
protection are identical to a local login.
|
||||||
|
|
||||||
|
**Login hardening**
|
||||||
|
|
||||||
|
- **Optional per-user TOTP two-factor** (opt-in, self-service on `/admin/account`). When enabled,
|
||||||
|
the password step issues only a short-lived, non-session `stage:'totp'` challenge; a session
|
||||||
|
cookie is granted only after the second factor verifies.
|
||||||
|
- **Login throttling** — `express-slow-down` + a hard rate cap + a separate per-IP exponential
|
||||||
|
backoff, with generic error messages that don't reveal whether the username exists.
|
||||||
|
- **Honeypot** field on the login form; submissions that fill it are treated as bots.
|
||||||
|
- **Bot-scoring + automatic IP ban** — weighted scoring of CMS-scanner paths and junk 404s (with a
|
||||||
|
periodic sweep of stale entries) bans hostile scanners; failed logins and honeypot hits feed the
|
||||||
|
score. Admins get visibility into this on the **Bot Activity** panel: currently banned IPs and a
|
||||||
|
recent-events feed (in-memory, most-recent-first), plus a logged emergency **unban** for false
|
||||||
|
positives — read + unban only, not a scoring-config surface.
|
||||||
|
|
||||||
|
**Uploads & input**
|
||||||
|
|
||||||
|
- Uploaded file extensions are derived from the **validated mimetype**, not the client-supplied
|
||||||
|
filename (prevents a disguised-extension upload).
|
||||||
|
- `express-validator` on all writes; usernames are validated **and** uniqueness-checked on update.
|
||||||
|
|
||||||
|
**Platform**
|
||||||
|
|
||||||
|
- `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),
|
||||||
|
`.env` git-ignored. Passwords and request bodies are never logged. Email sends through Gmail
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
@@ -71,18 +435,18 @@ Every log line goes to **both the console and a log file**, timestamped and leve
|
|||||||
2026-06-26T18:55:20.110Z ERROR [error] GET /api/v1/public/wiki -> 500 ... {"stack":"..."}
|
2026-06-26T18:55:20.110Z ERROR [error] GET /api/v1/public/wiki -> 500 ... {"stack":"..."}
|
||||||
```
|
```
|
||||||
|
|
||||||
What's captured: startup config banner, schema/seed steps, **HTTP access logs** (real client
|
Captured: startup config banner, schema/seed steps, **HTTP access logs** (real client IP via
|
||||||
IP via `trust proxy`, the authenticated admin, method/URL/status/time/size), login
|
`trust proxy`, the authenticated admin, method/URL/status/time/size), login success/failure,
|
||||||
success/failure, rate-limit hits, site-mode changes, all errors with stack traces, and
|
rate-limit hits, site-mode changes, all errors with stack traces, and graceful shutdown. Console
|
||||||
graceful shutdown. Passwords and request bodies are never logged.
|
verbosity is `LOG_LEVEL`; the file keeps the fuller `FILE_LOG_LEVEL` record. In Docker the file is
|
||||||
|
bind-mounted to `./logs/app.log` and `docker compose logs -f app` shows the console stream.
|
||||||
|
|
||||||
| Env | Default | Meaning |
|
---
|
||||||
|---|---|---|
|
|
||||||
| `LOG_LEVEL` | `info` | console verbosity |
|
|
||||||
| `FILE_LOG_LEVEL` | `debug` | file verbosity (keeps a full record) |
|
|
||||||
| `LOG_TO_FILE` | `true` | set `false` for console-only |
|
|
||||||
| `LOG_DIR` | `<server>/logs` (`/app/logs` in Docker) | log directory |
|
|
||||||
| `LOG_FILE` | `app.log` | log file name |
|
|
||||||
|
|
||||||
In Docker the log file is bind-mounted to `./logs/app.log` on the host; `docker compose logs -f app`
|
## Deployment behind Pangolin
|
||||||
also shows the console stream.
|
|
||||||
|
`docker compose up -d --build` exposes the `app` container on `0.0.0.0:3000` (no `127.0.0.1`
|
||||||
|
binding) so Pangolin can reach it. Point a Pangolin resource at `app:3000`. Because `COOKIE_SECURE`
|
||||||
|
defaults to `auto`, the admin login works both directly via the LAN IP over HTTP **and** through
|
||||||
|
Pangolin over HTTPS — no config change needed. MariaDB stays on the private Compose network
|
||||||
|
(no published port by default); data persists in the `dbdata` volume, uploads in `uploads`.
|
||||||
|
|||||||
366
WIKI_UPGRADE.md
Normal file
366
WIKI_UPGRADE.md
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
# 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.
|
||||||
45
bot/.env.example
Normal file
45
bot/.env.example
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# ─── UOMysticmoon Discord bot — local dev environment ───
|
||||||
|
# Copy to bot/.env for running `npm run dev` outside Docker.
|
||||||
|
# (In Docker, the root .env / docker-compose provides these instead.)
|
||||||
|
#
|
||||||
|
# NOTE: there is no Discord bot token here on purpose. The token is entered
|
||||||
|
# in the admin panel (Discord Bot page), stored encrypted in the main site's
|
||||||
|
# DB, and pushed to this process in-memory over the internal API. It is
|
||||||
|
# never read from an env var and never written to this process's disk.
|
||||||
|
|
||||||
|
PORT=4100
|
||||||
|
|
||||||
|
# Logging — written to BOTH the console and a log file (default <bot>/logs/bot.log).
|
||||||
|
LOG_LEVEL=debug # console verbosity: error | warn | info | debug
|
||||||
|
FILE_LOG_LEVEL=debug # file verbosity
|
||||||
|
LOG_TO_FILE=true # set false for console-only
|
||||||
|
# LOG_DIR= # defaults to bot/logs
|
||||||
|
# LOG_FILE=bot.log
|
||||||
|
|
||||||
|
# Shared secret for the internal API between this bot and the main site
|
||||||
|
# (server/). MUST be byte-for-byte identical to BOT_INTERNAL_KEY in
|
||||||
|
# server/.env.example / the root .env.example — it is the only auth on both
|
||||||
|
# sides' /internal/* routes, so a mismatch silently breaks every server<->bot
|
||||||
|
# call with 401s. Generate one long random string and copy it to both places.
|
||||||
|
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
|
||||||
|
|
||||||
|
# Where this bot calls back to the main site to fetch its config on boot
|
||||||
|
# (GET .../internal/bot-config), so a restart self-reconnects without needing
|
||||||
|
# the admin panel to push config again. This targets the site's UNPUBLISHED
|
||||||
|
# internal port (INTERNAL_PORT, default 3001) — NOT the public 3000. See #33.
|
||||||
|
SITE_INTERNAL_URL=http://localhost:3001/internal/bot-config
|
||||||
|
|
||||||
|
# Read-only PUBLIC API base (Phase 7) — no shared secret, same data any
|
||||||
|
# visitor's browser can fetch. Used by /wiki (search) and /announce
|
||||||
|
# (re-post an existing news item).
|
||||||
|
SITE_PUBLIC_URL=http://localhost:3000/api/v1/public
|
||||||
|
|
||||||
|
# Database (Phase 2+) — same physical DB as the main site, but the bot only
|
||||||
|
# ever reads/writes its OWN tables (guild_config, mod_actions, warnings, and
|
||||||
|
# more in later phases). It never touches site tables (users, bot_config,
|
||||||
|
# etc.) directly. Point this at the same DB the server/ uses.
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=uomysticmoon
|
||||||
|
DB_USER=uomm
|
||||||
|
DB_PASSWORD=change-me-db-password
|
||||||
3
bot/.gitignore
vendored
Normal file
3
bot/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
logs/
|
||||||
16
bot/Dockerfile
Normal file
16
bot/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app/bot
|
||||||
|
|
||||||
|
COPY bot/package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
COPY bot/ .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/bot/logs && chown -R node:node /app/bot/logs
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 4100
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
1609
bot/package-lock.json
generated
Normal file
1609
bot/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
bot/package.json
Normal file
24
bot/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "uomysticmoon-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Discord bot for the UOMysticmoon community server",
|
||||||
|
"private": true,
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "nodemon src/server.js"
|
||||||
|
},
|
||||||
|
"keywords": ["discord", "discord.js"],
|
||||||
|
"author": "whitlocktech",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"discord.js": "^14.16.3",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"mariadb": "^3.3.1",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
bot/src/app.js
Normal file
12
bot/src/app.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
const internalRouter = require('./internal/internal.routes')
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => res.json({ status: 'ok' }))
|
||||||
|
app.use('/internal', internalRouter)
|
||||||
|
|
||||||
|
module.exports = app
|
||||||
38
bot/src/bootstrap.js
vendored
Normal file
38
bot/src/bootstrap.js
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Runs once at process start, before the internal Express server is
|
||||||
|
// considered ready. Fetches current config from the main site (token,
|
||||||
|
// guildId, enabled) and reconnects immediately if enabled — so a bot
|
||||||
|
// container restart (crash, `docker compose restart`, host reboot) self-heals
|
||||||
|
// without any admin-panel interaction. Node 20's built-in fetch is used; no
|
||||||
|
// extra HTTP client dependency needed for a single startup call.
|
||||||
|
const discordManager = require('./discord/discordManager')
|
||||||
|
const createLogger = require('./utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('bootstrap')
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const siteUrl = process.env.SITE_INTERNAL_URL
|
||||||
|
const key = process.env.BOT_INTERNAL_KEY
|
||||||
|
if (!siteUrl || !key) {
|
||||||
|
log.warn('SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set — skipping boot-time config fetch, staying disconnected until the admin panel pushes config')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } })
|
||||||
|
if (!res.ok) {
|
||||||
|
log.error('boot-time config fetch failed', { status: res.status })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const config = await res.json()
|
||||||
|
if (config.enabled) {
|
||||||
|
log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId })
|
||||||
|
await discordManager.start({ token: config.token, guildId: config.guildId })
|
||||||
|
} else {
|
||||||
|
log.info('boot-time config says disabled — staying disconnected')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('boot-time config fetch errored', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = bootstrap
|
||||||
41
bot/src/db.js
Normal file
41
bot/src/db.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// DB pool for the bot's OWN tables (guild_config, mod_actions, warnings) —
|
||||||
|
// mirrors server/src/utils/db.js. The bot never reads/writes any table it
|
||||||
|
// doesn't own; site-owned tables (users, bot_config, etc.) are reached only
|
||||||
|
// through the internal API, never directly. Schema for these tables lives in
|
||||||
|
// server/db/schema.sql (same physical database, ensured by the main server on
|
||||||
|
// boot) — there's no separate migration tool to justify a second database for
|
||||||
|
// a single-guild v1 bot.
|
||||||
|
const mariadb = require('mariadb')
|
||||||
|
|
||||||
|
const pool = mariadb.createPool({
|
||||||
|
host: process.env.DB_HOST || '127.0.0.1',
|
||||||
|
port: Number(process.env.DB_PORT) || 3306,
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || 'uomysticmoon',
|
||||||
|
connectionLimit: 5,
|
||||||
|
insertIdAsNumber: true,
|
||||||
|
bigIntAsNumber: true,
|
||||||
|
decimalAsNumber: true,
|
||||||
|
// The driver defaults to 'local' — silently serializing bound JS Date
|
||||||
|
// params using the HOST MACHINE's local offset instead of the DB session's
|
||||||
|
// timezone (discovered via temp_roles.expires_at coming back hours off in
|
||||||
|
// dev, CDT vs the container's UTC). 'auto' negotiates the actual session
|
||||||
|
// timezone so Date round-trips correctly regardless of host TZ.
|
||||||
|
timezone: 'auto',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function query(sql, params) {
|
||||||
|
const conn = await pool.getConnection()
|
||||||
|
try {
|
||||||
|
return await conn.query(sql, params)
|
||||||
|
} finally {
|
||||||
|
conn.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close() {
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { query, close }
|
||||||
49
bot/src/discord/commands/announce.command.js
Normal file
49
bot/src/discord/commands/announce.command.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const siteApiClient = require('../../site/siteApiClient')
|
||||||
|
const newsAnnounce = require('../newsAnnounce')
|
||||||
|
|
||||||
|
function siteOrigin() {
|
||||||
|
const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public'
|
||||||
|
return new URL(base).origin
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'announce',
|
||||||
|
description: 'Re-post or boost an existing news item.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{ name: 'post', description: 'News post id or slug', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const idOrSlug = interaction.options.getString('post', true)
|
||||||
|
await interaction.deferReply({ ephemeral: true })
|
||||||
|
|
||||||
|
const result = await siteApiClient.getNewsPost(idOrSlug)
|
||||||
|
if (result.maintenance) {
|
||||||
|
await interaction.editReply({ content: `Can't reach the site right now: ${result.message || 'maintenance mode'}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
await interaction.editReply({ content: `Couldn't find that news post ("${idOrSlug}").` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = result.data
|
||||||
|
const origin = siteOrigin()
|
||||||
|
try {
|
||||||
|
await newsAnnounce.postAnnounce(interaction.client, interaction.guildId, {
|
||||||
|
title: post.title,
|
||||||
|
excerpt: post.excerpt,
|
||||||
|
url: `${origin}/site/news`,
|
||||||
|
// image_url is stored relative — Discord embeds require an absolute URL.
|
||||||
|
imageUrl: post.image_url ? new URL(post.image_url, origin).toString() : null,
|
||||||
|
})
|
||||||
|
await interaction.editReply({ content: `Posted "${post.title}" to the news channel.` })
|
||||||
|
} catch (err) {
|
||||||
|
await interaction.editReply({ content: `Couldn't post: ${err.message}` })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
30
bot/src/discord/commands/autorole.command.js
Normal file
30
bot/src/discord/commands/autorole.command.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const guildConfig = require('../../model/guildConfig')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'autorole',
|
||||||
|
description: 'View or set the role automatically assigned to new members on join.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'role',
|
||||||
|
description: 'Role to auto-assign on join. Omit to view the current setting.',
|
||||||
|
type: ApplicationCommandOptionType.Role,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const role = interaction.options.getRole('role')
|
||||||
|
if (!role) {
|
||||||
|
const currentId = await guildConfig.getAutoRoleId(interaction.guildId)
|
||||||
|
const content = currentId ? `Auto-role is set to <@&${currentId}>.` : 'No auto-role is set yet.'
|
||||||
|
await interaction.reply({ content, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await guildConfig.setAutoRoleId(interaction.guildId, role.id)
|
||||||
|
await interaction.reply({ content: `Auto-role set to ${role}. New members will get this automatically.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
34
bot/src/discord/commands/ban.command.js
Normal file
34
bot/src/discord/commands/ban.command.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const modLog = require('../modLog')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'ban',
|
||||||
|
description: 'Ban a member from the server.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.BanMembers.toString(),
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member to ban', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
{ name: 'reason', description: 'Reason for the ban', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const user = interaction.options.getUser('user', true)
|
||||||
|
const reason = interaction.options.getString('reason', true)
|
||||||
|
|
||||||
|
if (user.id === interaction.user.id) {
|
||||||
|
await interaction.reply({ content: "You can't ban yourself.", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = interaction.guild.members.cache.get(user.id)
|
||||||
|
if (member && !member.bannable) {
|
||||||
|
await interaction.reply({ content: "I don't have permission to ban that member (role hierarchy).", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.guild.members.ban(user, { reason })
|
||||||
|
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'ban', target: user, staffUser: interaction.user, reason })
|
||||||
|
await interaction.reply({ content: `Banned ${user.tag}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
73
bot/src/discord/commands/filter.command.js
Normal file
73
bot/src/discord/commands/filter.command.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const filterWords = require('../../model/filterWords')
|
||||||
|
const filterCache = require('../../filter/filterCache')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'filter',
|
||||||
|
description: 'Manage the banned-word filter.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'add',
|
||||||
|
description: 'Add a word to the filter.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'word', description: 'Word or phrase to ban', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
{
|
||||||
|
name: 'severity',
|
||||||
|
description: 'Auto-action when triggered (default: delete)',
|
||||||
|
type: ApplicationCommandOptionType.String,
|
||||||
|
required: false,
|
||||||
|
choices: [
|
||||||
|
{ name: 'Delete only', value: 'delete' },
|
||||||
|
{ name: 'Delete + warn', value: 'warn' },
|
||||||
|
{ name: 'Delete + mute (10m)', value: 'mute' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'remove',
|
||||||
|
description: 'Remove a word from the filter.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'word', description: 'Word or phrase to remove', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list',
|
||||||
|
description: 'List all filtered words.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const sub = interaction.options.getSubcommand()
|
||||||
|
|
||||||
|
if (sub === 'add') {
|
||||||
|
const word = interaction.options.getString('word', true)
|
||||||
|
const severity = interaction.options.getString('severity') || 'delete'
|
||||||
|
await filterWords.add({ guildId: interaction.guildId, word, severity, addedBy: interaction.user.id, addedByTag: interaction.user.tag })
|
||||||
|
await filterCache.refresh(interaction.guildId)
|
||||||
|
await interaction.reply({ content: `Added "${word}" to the filter (${severity}).`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'remove') {
|
||||||
|
const word = interaction.options.getString('word', true)
|
||||||
|
const removed = await filterWords.remove(interaction.guildId, word)
|
||||||
|
await filterCache.refresh(interaction.guildId)
|
||||||
|
await interaction.reply({ content: removed ? `Removed "${word}" from the filter.` : `"${word}" wasn't in the filter.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const words = await filterWords.list(interaction.guildId)
|
||||||
|
const content = words.length === 0 ? 'The filter list is empty.' : words.map((w) => `${w.word} (${w.severity})`).join('\n')
|
||||||
|
await interaction.reply({ content, ephemeral: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
61
bot/src/discord/commands/filterallow.command.js
Normal file
61
bot/src/discord/commands/filterallow.command.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const filterAllowlist = require('../../model/filterAllowlist')
|
||||||
|
const filterCache = require('../../filter/filterCache')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'filterallow',
|
||||||
|
description: 'Manage roles/channels that bypass the filter entirely.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'role',
|
||||||
|
description: 'Toggle a role in/out of the filter bypass list.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [{ name: 'role', description: 'Role to toggle', type: ApplicationCommandOptionType.Role, required: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'channel',
|
||||||
|
description: 'Toggle a channel in/out of the filter bypass list.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [{ name: 'channel', description: 'Channel to toggle', type: ApplicationCommandOptionType.Channel, required: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list',
|
||||||
|
description: 'Show current filter bypass roles/channels.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const sub = interaction.options.getSubcommand()
|
||||||
|
|
||||||
|
if (sub === 'role') {
|
||||||
|
const role = interaction.options.getRole('role', true)
|
||||||
|
const nowAllowed = await filterAllowlist.toggleRole(interaction.guildId, role.id)
|
||||||
|
await filterCache.refresh(interaction.guildId)
|
||||||
|
await interaction.reply({ content: `${role} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'channel') {
|
||||||
|
const channel = interaction.options.getChannel('channel', true)
|
||||||
|
const nowAllowed = await filterAllowlist.toggleChannel(interaction.guildId, channel.id)
|
||||||
|
await filterCache.refresh(interaction.guildId)
|
||||||
|
await interaction.reply({ content: `${channel} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const [roles, channels] = await Promise.all([
|
||||||
|
filterAllowlist.getRoles(interaction.guildId),
|
||||||
|
filterAllowlist.getChannels(interaction.guildId),
|
||||||
|
])
|
||||||
|
const roleText = roles.length ? roles.map((id) => `<@&${id}>`).join(', ') : 'none'
|
||||||
|
const channelText = channels.length ? channels.map((id) => `<#${id}>`).join(', ') : 'none'
|
||||||
|
await interaction.reply({ content: `Bypass roles: ${roleText}\nBypass channels: ${channelText}`, ephemeral: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
31
bot/src/discord/commands/index.js
Normal file
31
bot/src/discord/commands/index.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Command registry. Each module exports { data, execute } — `data` is the
|
||||||
|
// slash-command definition pushed to Discord (registerCommands), `execute` is
|
||||||
|
// the interactionCreate handler (dispatch). Adding a new command is just
|
||||||
|
// adding a file here — discordManager.js never needs to change.
|
||||||
|
const commands = [
|
||||||
|
require('./ping.command'),
|
||||||
|
require('./modlog.command'),
|
||||||
|
require('./ban.command'),
|
||||||
|
require('./kick.command'),
|
||||||
|
require('./mute.command'),
|
||||||
|
require('./warn.command'),
|
||||||
|
require('./warnings.command'),
|
||||||
|
require('./filter.command'),
|
||||||
|
require('./filterallow.command'),
|
||||||
|
require('./schedule.command'),
|
||||||
|
require('./rolemenu.command'),
|
||||||
|
require('./autorole.command'),
|
||||||
|
require('./role.command'),
|
||||||
|
require('./roles.command'),
|
||||||
|
require('./invite.command'),
|
||||||
|
require('./news.command'),
|
||||||
|
require('./announce.command'),
|
||||||
|
require('./wiki.command'),
|
||||||
|
]
|
||||||
|
|
||||||
|
const byName = new Map(commands.map((c) => [c.data.name, c]))
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
all: commands,
|
||||||
|
get: (name) => byName.get(name),
|
||||||
|
}
|
||||||
85
bot/src/discord/commands/invite.command.js
Normal file
85
bot/src/discord/commands/invite.command.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||||
|
|
||||||
|
const guildConfig = require('../../model/guildConfig')
|
||||||
|
const inviteLog = require('../../model/inviteLog')
|
||||||
|
const inviteRotator = require('../../invites/inviteRotator')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'invite',
|
||||||
|
description: 'Manage the auto-rotating primary server invite.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'channel',
|
||||||
|
description: 'View or set the channel new invites are created in.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'channel',
|
||||||
|
description: 'Channel to create invites in. Omit to view the current setting.',
|
||||||
|
type: ApplicationCommandOptionType.Channel,
|
||||||
|
channel_types: [ChannelType.GuildText],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rotate',
|
||||||
|
description: 'Revoke the current invite and generate a new one now.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'log',
|
||||||
|
description: 'Show recent invite rotation history.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const sub = interaction.options.getSubcommand()
|
||||||
|
|
||||||
|
if (sub === 'channel') {
|
||||||
|
const channel = interaction.options.getChannel('channel')
|
||||||
|
if (!channel) {
|
||||||
|
const currentId = await guildConfig.getInviteChannelId(interaction.guildId)
|
||||||
|
const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.'
|
||||||
|
await interaction.reply({ content, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await guildConfig.setInviteChannelId(interaction.guildId, channel.id)
|
||||||
|
await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'rotate') {
|
||||||
|
await interaction.deferReply({ ephemeral: true })
|
||||||
|
try {
|
||||||
|
const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, {
|
||||||
|
triggeredBy: interaction.user.id,
|
||||||
|
triggeredByTag: interaction.user.tag,
|
||||||
|
})
|
||||||
|
await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` })
|
||||||
|
} catch (err) {
|
||||||
|
await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'log') {
|
||||||
|
const rows = await inviteLog.list(interaction.guildId, 10)
|
||||||
|
if (rows.length === 0) {
|
||||||
|
await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lines = rows.map((r) => {
|
||||||
|
const who = r.triggered_by_tag || 'automatic (scheduled)'
|
||||||
|
const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active'
|
||||||
|
return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})`
|
||||||
|
})
|
||||||
|
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
38
bot/src/discord/commands/kick.command.js
Normal file
38
bot/src/discord/commands/kick.command.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const modLog = require('../modLog')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'kick',
|
||||||
|
description: 'Kick a member from the server.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.KickMembers.toString(),
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member to kick', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
{ name: 'reason', description: 'Reason for the kick', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const user = interaction.options.getUser('user', true)
|
||||||
|
const reason = interaction.options.getString('reason', true)
|
||||||
|
|
||||||
|
if (user.id === interaction.user.id) {
|
||||||
|
await interaction.reply({ content: "You can't kick yourself.", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = interaction.guild.members.cache.get(user.id)
|
||||||
|
if (!member) {
|
||||||
|
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!member.kickable) {
|
||||||
|
await interaction.reply({ content: "I don't have permission to kick that member (role hierarchy).", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await member.kick(reason)
|
||||||
|
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'kick', target: user, staffUser: interaction.user, reason })
|
||||||
|
await interaction.reply({ content: `Kicked ${user.tag}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
33
bot/src/discord/commands/modlog.command.js
Normal file
33
bot/src/discord/commands/modlog.command.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||||
|
|
||||||
|
const guildConfig = require('../../model/guildConfig')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'modlog',
|
||||||
|
description: 'View or set the mod-log channel (ban/kick/mute/warn actions post here).',
|
||||||
|
// Configuration, not a moderation action — gated to Manage Server rather
|
||||||
|
// than the ModerateMembers bit the action commands use.
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'channel',
|
||||||
|
description: 'Channel to post mod-log entries to. Omit to view the current setting.',
|
||||||
|
type: ApplicationCommandOptionType.Channel,
|
||||||
|
channel_types: [ChannelType.GuildText],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const channel = interaction.options.getChannel('channel')
|
||||||
|
if (!channel) {
|
||||||
|
const currentId = await guildConfig.getModLogChannelId(interaction.guildId)
|
||||||
|
const content = currentId ? `Mod-log channel is set to <#${currentId}>.` : 'No mod-log channel is set yet.'
|
||||||
|
await interaction.reply({ content, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await guildConfig.setModLogChannelId(interaction.guildId, channel.id)
|
||||||
|
await interaction.reply({ content: `Mod-log channel set to ${channel}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
49
bot/src/discord/commands/mute.command.js
Normal file
49
bot/src/discord/commands/mute.command.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const modLog = require('../modLog')
|
||||||
|
const { parseDuration, MAX_TIMEOUT_MS } = require('../../utils/duration')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'mute',
|
||||||
|
description: 'Timeout a member for a duration (e.g. 10m, 2h, 1d).',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member to mute', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
{ name: 'duration', description: 'e.g. 30s, 10m, 2h, 1d (max 28d)', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
{ name: 'reason', description: 'Reason for the mute', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const user = interaction.options.getUser('user', true)
|
||||||
|
const durationInput = interaction.options.getString('duration', true)
|
||||||
|
const reason = interaction.options.getString('reason', true)
|
||||||
|
|
||||||
|
if (user.id === interaction.user.id) {
|
||||||
|
await interaction.reply({ content: "You can't mute yourself.", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ms = parseDuration(durationInput)
|
||||||
|
if (!ms) {
|
||||||
|
await interaction.reply({ content: 'Invalid duration — use a number plus s/m/h/d, e.g. `10m`, `2h`, `1d`.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const clampedMs = Math.min(ms, MAX_TIMEOUT_MS)
|
||||||
|
|
||||||
|
const member = interaction.guild.members.cache.get(user.id)
|
||||||
|
if (!member) {
|
||||||
|
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!member.moderatable) {
|
||||||
|
await interaction.reply({ content: "I don't have permission to timeout that member (role hierarchy).", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await member.timeout(clampedMs, reason)
|
||||||
|
const durationSeconds = Math.round(clampedMs / 1000)
|
||||||
|
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'mute', target: user, staffUser: interaction.user, reason, durationSeconds })
|
||||||
|
await interaction.reply({ content: `Muted ${user.tag} for ${durationInput}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
31
bot/src/discord/commands/news.command.js
Normal file
31
bot/src/discord/commands/news.command.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||||
|
|
||||||
|
const guildConfig = require('../../model/guildConfig')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'news',
|
||||||
|
description: 'View or set the channel news posts are announced to.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'channel',
|
||||||
|
description: 'Channel for news announcements. Omit to view the current setting.',
|
||||||
|
type: ApplicationCommandOptionType.Channel,
|
||||||
|
channel_types: [ChannelType.GuildText],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const channel = interaction.options.getChannel('channel')
|
||||||
|
if (!channel) {
|
||||||
|
const currentId = await guildConfig.getNewsChannelId(interaction.guildId)
|
||||||
|
const content = currentId ? `News channel is set to <#${currentId}>.` : 'No news channel is set yet.'
|
||||||
|
await interaction.reply({ content, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await guildConfig.setNewsChannelId(interaction.guildId, channel.id)
|
||||||
|
await interaction.reply({ content: `News channel set to ${channel}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
15
bot/src/discord/commands/ping.command.js
Normal file
15
bot/src/discord/commands/ping.command.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
const { PermissionFlagsBits } = require('discord.js')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'ping',
|
||||||
|
description: 'Health-check — replies pong if the bot is alive and staff-permitted.',
|
||||||
|
// Restricted by default to members with Moderate Members — proves slash
|
||||||
|
// commands can be permission-gated via Discord's own permission model,
|
||||||
|
// per the spec's "restrict staff commands via Discord's permission system".
|
||||||
|
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
await interaction.reply({ content: 'pong', ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
71
bot/src/discord/commands/role.command.js
Normal file
71
bot/src/discord/commands/role.command.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const tempRoles = require('../../model/tempRoles')
|
||||||
|
const { parseDuration } = require('../../utils/duration')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'role',
|
||||||
|
description: 'Assign or remove a role for a single member.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageRoles.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'add',
|
||||||
|
description: 'Add a role to a member, optionally temporary.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
{ name: 'role', description: 'Role to add', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
{ name: 'duration', description: 'Optional — makes this temporary, e.g. 1h, 2d, 7d', type: ApplicationCommandOptionType.String, required: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'remove',
|
||||||
|
description: 'Remove a role from a member.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
{ name: 'role', description: 'Role to remove', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const sub = interaction.options.getSubcommand()
|
||||||
|
const user = interaction.options.getUser('user', true)
|
||||||
|
const role = interaction.options.getRole('role', true)
|
||||||
|
const member = interaction.guild.members.cache.get(user.id)
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'add') {
|
||||||
|
await member.roles.add(role.id)
|
||||||
|
const durationInput = interaction.options.getString('duration')
|
||||||
|
if (!durationInput) {
|
||||||
|
await interaction.reply({ content: `Added ${role} to ${user.tag}.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ms = parseDuration(durationInput)
|
||||||
|
if (!ms) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: `Added ${role}, but "${durationInput}" isn't a valid duration so it won't expire automatically. Use e.g. 1h, 2d, 7d.`,
|
||||||
|
ephemeral: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const expiresAt = new Date(Date.now() + ms)
|
||||||
|
await tempRoles.add({ guildId: interaction.guildId, userId: user.id, roleId: role.id, expiresAt, createdBy: interaction.user.id })
|
||||||
|
await interaction.reply({ content: `Added ${role} to ${user.tag} until ${expiresAt.toLocaleString()}.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'remove') {
|
||||||
|
await member.roles.remove(role.id)
|
||||||
|
await tempRoles.remove(interaction.guildId, user.id, role.id)
|
||||||
|
await interaction.reply({ content: `Removed ${role} from ${user.tag}.`, ephemeral: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
85
bot/src/discord/commands/rolemenu.command.js
Normal file
85
bot/src/discord/commands/rolemenu.command.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
const {
|
||||||
|
PermissionFlagsBits,
|
||||||
|
ApplicationCommandOptionType,
|
||||||
|
ChannelType,
|
||||||
|
EmbedBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
} = require('discord.js')
|
||||||
|
|
||||||
|
const roleMenus = require('../../model/roleMenus')
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// needing a multi-step builder/modal flow.
|
||||||
|
const MAX_ROLES = 5
|
||||||
|
|
||||||
|
// role1/label1 are declared inline in `data` (ahead of the optional
|
||||||
|
// `description` option, per Discord's required-before-optional rule) — this
|
||||||
|
// generates the rest, all optional.
|
||||||
|
function roleOptions(from, to) {
|
||||||
|
const opts = []
|
||||||
|
for (let i = from; i <= to; i++) {
|
||||||
|
opts.push({ name: `role${i}`, description: `Role #${i}`, type: ApplicationCommandOptionType.Role, required: false })
|
||||||
|
opts.push({ name: `label${i}`, description: `Button label for role #${i} (default: role name)`, type: ApplicationCommandOptionType.String, required: false })
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'rolemenu',
|
||||||
|
description: 'Post a button menu for self-assignable roles (up to 5).',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
// Discord requires all required options before any optional ones across
|
||||||
|
// the whole array — role1 (required) must come before description
|
||||||
|
// (optional), even though they read more naturally in the other order.
|
||||||
|
options: [
|
||||||
|
{ name: 'channel', description: 'Channel to post the menu in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||||
|
{ name: 'title', description: 'Menu title', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
{ name: 'role1', description: 'Role #1', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
{ name: 'description', description: 'Menu description', type: ApplicationCommandOptionType.String, required: false },
|
||||||
|
{ name: 'label1', description: 'Button label for role #1 (default: role name)', type: ApplicationCommandOptionType.String, required: false },
|
||||||
|
...roleOptions(2, MAX_ROLES),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const channel = interaction.options.getChannel('channel', true)
|
||||||
|
const title = interaction.options.getString('title', true)
|
||||||
|
const description = interaction.options.getString('description') || undefined
|
||||||
|
|
||||||
|
const entries = []
|
||||||
|
for (let i = 1; i <= MAX_ROLES; i++) {
|
||||||
|
const role = interaction.options.getRole(`role${i}`)
|
||||||
|
if (!role) continue
|
||||||
|
const label = interaction.options.getString(`label${i}`) || role.name
|
||||||
|
entries.push({ roleId: role.id, label })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
await interaction.reply({ content: 'Provide at least one role (role1).', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder().setTitle(title).setColor(0x6a8fc2)
|
||||||
|
if (description) embed.setDescription(description)
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder().addComponents(
|
||||||
|
entries.map((e) =>
|
||||||
|
new ButtonBuilder().setCustomId(`rolemenu:${e.roleId}`).setLabel(e.label).setStyle(ButtonStyle.Secondary),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const message = await channel.send({ embeds: [embed], components: [row] })
|
||||||
|
await roleMenus.add({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
channelId: channel.id,
|
||||||
|
messageId: message.id,
|
||||||
|
mapping: entries,
|
||||||
|
createdBy: interaction.user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await interaction.reply({ content: `Role menu posted in ${channel}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
68
bot/src/discord/commands/roles.command.js
Normal file
68
bot/src/discord/commands/roles.command.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
// Bulk targeting is "by existing role" only — the spec also mentions an
|
||||||
|
// explicit list of members, but Discord slash commands have no multi-user
|
||||||
|
// picker, so that variant is deferred rather than faked with a handful of
|
||||||
|
// user1..user5 options that would feel arbitrary and cramped.
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'roles',
|
||||||
|
description: 'Bulk role operations across members who share an existing role.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageRoles.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'bulk-assign',
|
||||||
|
description: 'Add a role to every member who has another role.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
{ name: 'add-role', description: 'Role to add to those members', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bulk-remove',
|
||||||
|
description: 'Remove a role from every member who has another role.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
{ name: 'remove-role', description: 'Role to remove from those members', type: ApplicationCommandOptionType.Role, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const sub = interaction.options.getSubcommand()
|
||||||
|
// Fetching every member + looping role updates can easily exceed
|
||||||
|
// Discord's 3-second initial-response window.
|
||||||
|
await interaction.deferReply({ ephemeral: true })
|
||||||
|
|
||||||
|
const hasRole = interaction.options.getRole('has-role', true)
|
||||||
|
const members = await interaction.guild.members.fetch()
|
||||||
|
const targets = members.filter((m) => m.roles.cache.has(hasRole.id))
|
||||||
|
|
||||||
|
if (sub === 'bulk-assign') {
|
||||||
|
const addRole = interaction.options.getRole('add-role', true)
|
||||||
|
let count = 0
|
||||||
|
for (const member of targets.values()) {
|
||||||
|
if (!member.roles.cache.has(addRole.id)) {
|
||||||
|
await member.roles.add(addRole.id).catch(() => {})
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await interaction.editReply({ content: `Added ${addRole} to ${count} member(s) who have ${hasRole}.` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'bulk-remove') {
|
||||||
|
const removeRole = interaction.options.getRole('remove-role', true)
|
||||||
|
let count = 0
|
||||||
|
for (const member of targets.values()) {
|
||||||
|
if (member.roles.cache.has(removeRole.id)) {
|
||||||
|
await member.roles.remove(removeRole.id).catch(() => {})
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await interaction.editReply({ content: `Removed ${removeRole} from ${count} member(s) who have ${hasRole}.` })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
119
bot/src/discord/commands/schedule.command.js
Normal file
119
bot/src/discord/commands/schedule.command.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js')
|
||||||
|
const cron = require('node-cron')
|
||||||
|
|
||||||
|
const scheduledMessages = require('../../model/scheduledMessages')
|
||||||
|
const scheduler = require('../../scheduler/scheduler')
|
||||||
|
const { parseDuration } = require('../../utils/duration')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'schedule',
|
||||||
|
description: 'Manage recurring and one-off scheduled channel messages.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ManageGuild.toString(),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'recurring',
|
||||||
|
description: 'Schedule a recurring message on a cron schedule.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||||
|
{ name: 'cron', description: 'Cron expression, e.g. "0 9 * * 5" (Fridays 9am)', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'once',
|
||||||
|
description: 'Schedule a one-off message for a future time.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true },
|
||||||
|
{ name: 'in', description: 'When to post, e.g. 30m, 2h, 1d', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
{ name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'remove',
|
||||||
|
description: 'Remove a scheduled message by id.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [{ name: 'id', description: 'Scheduled message id (see /schedule list)', type: ApplicationCommandOptionType.Integer, required: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'list',
|
||||||
|
description: 'List all scheduled messages.',
|
||||||
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const sub = interaction.options.getSubcommand()
|
||||||
|
|
||||||
|
if (sub === 'recurring') {
|
||||||
|
const channel = interaction.options.getChannel('channel', true)
|
||||||
|
const cronExpr = interaction.options.getString('cron', true)
|
||||||
|
const message = interaction.options.getString('message', true)
|
||||||
|
if (!cron.validate(cronExpr)) {
|
||||||
|
await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = await scheduledMessages.addRecurring({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
channelId: channel.id,
|
||||||
|
content: message,
|
||||||
|
cronExpression: cronExpr,
|
||||||
|
createdBy: interaction.user.id,
|
||||||
|
createdByTag: interaction.user.tag,
|
||||||
|
})
|
||||||
|
await scheduler.refresh()
|
||||||
|
await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'once') {
|
||||||
|
const channel = interaction.options.getChannel('channel', true)
|
||||||
|
const inInput = interaction.options.getString('in', true)
|
||||||
|
const message = interaction.options.getString('message', true)
|
||||||
|
const ms = parseDuration(inInput)
|
||||||
|
if (!ms) {
|
||||||
|
await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const runAt = new Date(Date.now() + ms)
|
||||||
|
const id = await scheduledMessages.addOnce({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
channelId: channel.id,
|
||||||
|
content: message,
|
||||||
|
runAt,
|
||||||
|
createdBy: interaction.user.id,
|
||||||
|
createdByTag: interaction.user.tag,
|
||||||
|
})
|
||||||
|
await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'remove') {
|
||||||
|
const id = interaction.options.getInteger('id', true)
|
||||||
|
const removed = await scheduledMessages.remove(interaction.guildId, id)
|
||||||
|
await scheduler.refresh()
|
||||||
|
await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const rows = await scheduledMessages.list(interaction.guildId)
|
||||||
|
if (rows.length === 0) {
|
||||||
|
await interaction.reply({ content: 'No scheduled messages.', ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lines = rows.map((r) => {
|
||||||
|
const kind = r.cron_expression
|
||||||
|
? `cron \`${r.cron_expression}\``
|
||||||
|
: r.sent_at
|
||||||
|
? `sent ${new Date(r.sent_at).toLocaleString()}`
|
||||||
|
: `due ${new Date(r.run_at).toLocaleString()}`
|
||||||
|
return `**#${r.id}** <#${r.channel_id}> — ${kind}${r.enabled ? '' : ' (disabled)'}`
|
||||||
|
})
|
||||||
|
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
40
bot/src/discord/commands/warn.command.js
Normal file
40
bot/src/discord/commands/warn.command.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const modLog = require('../modLog')
|
||||||
|
const warnings = require('../../model/warnings')
|
||||||
|
|
||||||
|
// Escalation (e.g. "3 active warns -> auto-mute for X hours") and warning
|
||||||
|
// decay/expiry are in the original spec but deferred past this phase — this
|
||||||
|
// just records the warning and posts it to the mod-log, matching the
|
||||||
|
// "Suggested Build Order" step 2 scope (core moderation).
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'warn',
|
||||||
|
description: 'Log a warning against a member.',
|
||||||
|
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member to warn', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
{ name: 'reason', description: 'Reason for the warning', type: ApplicationCommandOptionType.String, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const user = interaction.options.getUser('user', true)
|
||||||
|
const reason = interaction.options.getString('reason', true)
|
||||||
|
|
||||||
|
if (user.id === interaction.user.id) {
|
||||||
|
await interaction.reply({ content: "You can't warn yourself.", ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await warnings.add({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
targetUserId: user.id,
|
||||||
|
targetTag: user.tag,
|
||||||
|
staffUserId: interaction.user.id,
|
||||||
|
staffTag: interaction.user.tag,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'warn', target: user, staffUser: interaction.user, reason })
|
||||||
|
await interaction.reply({ content: `Warned ${user.tag}.`, ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
34
bot/src/discord/commands/warnings.command.js
Normal file
34
bot/src/discord/commands/warnings.command.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const { PermissionFlagsBits, ApplicationCommandOptionType, EmbedBuilder } = require('discord.js')
|
||||||
|
|
||||||
|
const warnings = require('../../model/warnings')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'warnings',
|
||||||
|
description: "List a member's active warnings.",
|
||||||
|
default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(),
|
||||||
|
options: [
|
||||||
|
{ name: 'user', description: 'Member to look up', type: ApplicationCommandOptionType.User, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const user = interaction.options.getUser('user', true)
|
||||||
|
const rows = await warnings.listActive(interaction.guildId, user.id)
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
await interaction.reply({ content: `${user.tag} has no active warnings.`, ephemeral: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0xe0b070)
|
||||||
|
.setTitle(`Warnings — ${user.tag}`)
|
||||||
|
.setDescription(
|
||||||
|
rows
|
||||||
|
.map((w, i) => `**${i + 1}.** ${w.reason || '(no reason given)'} — by ${w.staff_tag || 'unknown'} on ${new Date(w.created_at).toLocaleDateString()}`)
|
||||||
|
.join('\n'),
|
||||||
|
)
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true })
|
||||||
|
},
|
||||||
|
}
|
||||||
40
bot/src/discord/commands/wiki.command.js
Normal file
40
bot/src/discord/commands/wiki.command.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const { ApplicationCommandOptionType } = require('discord.js')
|
||||||
|
|
||||||
|
const siteApiClient = require('../../site/siteApiClient')
|
||||||
|
|
||||||
|
// Public command — no default_member_permissions restriction. Read-only:
|
||||||
|
// searches wiki titles/content and links to the best match. Never posts to or
|
||||||
|
// edits the wiki. Category-scoped search (spec's optional "/wiki spells
|
||||||
|
// fireball") is deferred — the site's public search endpoint currently
|
||||||
|
// ignores category filters whenever a text query is given.
|
||||||
|
function siteOrigin() {
|
||||||
|
const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public'
|
||||||
|
return new URL(base).origin
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: {
|
||||||
|
name: 'wiki',
|
||||||
|
description: 'Search the wiki.',
|
||||||
|
options: [{ name: 'query', description: 'What to search for', type: ApplicationCommandOptionType.String, required: true }],
|
||||||
|
},
|
||||||
|
async execute(interaction) {
|
||||||
|
const query = interaction.options.getString('query', true)
|
||||||
|
await interaction.deferReply()
|
||||||
|
|
||||||
|
const result = await siteApiClient.searchWiki(query)
|
||||||
|
if (result.maintenance) {
|
||||||
|
await interaction.editReply({ content: `The wiki is unavailable right now: ${result.message || 'maintenance mode'}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!result.ok || !result.data || result.data.length === 0) {
|
||||||
|
await interaction.editReply({ content: `No wiki results for "${query}".` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = result.data[0]
|
||||||
|
const url = `${siteOrigin()}/wiki/${best.slug}`
|
||||||
|
const content = best.excerpt ? `**${best.title}**\n${best.excerpt}\n${url}` : `**${best.title}**\n${url}`
|
||||||
|
await interaction.editReply({ content })
|
||||||
|
},
|
||||||
|
}
|
||||||
144
bot/src/discord/discordManager.js
Normal file
144
bot/src/discord/discordManager.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// Owns the single discord.js Client instance for this process: lifecycle
|
||||||
|
// (start/stop/status) and slash-command registration/dispatch. Command
|
||||||
|
// definitions themselves live in ./commands — this file only wires them up.
|
||||||
|
const { Client, GatewayIntentBits, REST, Routes } = require('discord.js')
|
||||||
|
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
const commands = require('./commands')
|
||||||
|
const messageFilter = require('./messageFilter')
|
||||||
|
const scheduler = require('../scheduler/scheduler')
|
||||||
|
const roleMenuHandler = require('./roleMenuHandler')
|
||||||
|
const { handleGuildMemberAdd } = require('./guildMemberAdd')
|
||||||
|
const { handleGuildMemberRemove } = require('./guildMemberRemove')
|
||||||
|
const inviteTracker = require('./inviteTracker')
|
||||||
|
const tempRoleSweeper = require('../roles/tempRoleSweeper')
|
||||||
|
const inviteScheduler = require('../invites/inviteScheduler')
|
||||||
|
|
||||||
|
const log = createLogger('discord')
|
||||||
|
|
||||||
|
let client = null
|
||||||
|
let guildId = null
|
||||||
|
let status = 'disconnected' // disconnected | connecting | connected | error
|
||||||
|
let statusDetail = null
|
||||||
|
let lastConnectedAt = null
|
||||||
|
|
||||||
|
async function registerCommands(applicationId, targetGuildId) {
|
||||||
|
const rest = new REST({ version: '10' }).setToken(client.token)
|
||||||
|
await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), {
|
||||||
|
body: commands.all.map((c) => c.data),
|
||||||
|
})
|
||||||
|
log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
if (!client) {
|
||||||
|
status = 'disconnected'
|
||||||
|
statusDetail = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduler.stop()
|
||||||
|
tempRoleSweeper.stop()
|
||||||
|
inviteScheduler.stop()
|
||||||
|
try {
|
||||||
|
await client.destroy()
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('error while destroying client', { message: err.message })
|
||||||
|
}
|
||||||
|
client = null
|
||||||
|
status = 'disconnected'
|
||||||
|
statusDetail = null
|
||||||
|
log.info('discord client disconnected')
|
||||||
|
}
|
||||||
|
|
||||||
|
// start({ token, guildId }) — (re)connects. Always stops any existing client
|
||||||
|
// first so re-saving config or toggling Enabled off/on is idempotent.
|
||||||
|
async function start({ token, guildId: gid }) {
|
||||||
|
await stop()
|
||||||
|
guildId = gid
|
||||||
|
status = 'connecting'
|
||||||
|
statusDetail = null
|
||||||
|
|
||||||
|
// GuildMessages + MessageContent (Phase 3, filter) and GuildMembers
|
||||||
|
// (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled
|
||||||
|
// in the Discord Developer Portal, see the Phase 1 setup notes. GuildInvites
|
||||||
|
// (Phase 6b, invite-usage attribution) is NOT privileged — no portal toggle.
|
||||||
|
client = new Client({
|
||||||
|
intents: [
|
||||||
|
GatewayIntentBits.Guilds,
|
||||||
|
GatewayIntentBits.GuildMessages,
|
||||||
|
GatewayIntentBits.MessageContent,
|
||||||
|
GatewayIntentBits.GuildMembers,
|
||||||
|
GatewayIntentBits.GuildInvites,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
client.once('ready', async () => {
|
||||||
|
try {
|
||||||
|
await registerCommands(client.application.id, guildId)
|
||||||
|
await scheduler.start(client)
|
||||||
|
tempRoleSweeper.start(client)
|
||||||
|
inviteScheduler.start(client, guildId)
|
||||||
|
await inviteTracker.prime(client, guildId)
|
||||||
|
status = 'connected'
|
||||||
|
statusDetail = null
|
||||||
|
lastConnectedAt = new Date()
|
||||||
|
log.info('discord client ready', { user: client.user?.tag, guildId })
|
||||||
|
} catch (err) {
|
||||||
|
status = 'error'
|
||||||
|
statusDetail = `startup failed: ${err.message}`
|
||||||
|
log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on('interactionCreate', async (interaction) => {
|
||||||
|
if (await roleMenuHandler.handleInteraction(interaction)) return
|
||||||
|
if (!interaction.isChatInputCommand()) return
|
||||||
|
const command = commands.get(interaction.commandName)
|
||||||
|
if (!command) return
|
||||||
|
try {
|
||||||
|
await command.execute(interaction)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('command execution failed', { command: interaction.commandName, message: err.message })
|
||||||
|
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
|
||||||
|
if (interaction.replied || interaction.deferred) await interaction.followUp(payload)
|
||||||
|
else await interaction.reply(payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on('messageCreate', messageFilter.handleMessageCreate)
|
||||||
|
client.on('guildMemberAdd', handleGuildMemberAdd)
|
||||||
|
client.on('guildMemberRemove', handleGuildMemberRemove)
|
||||||
|
// Keep the invite-use cache fresh so guildMemberAdd can attribute joins.
|
||||||
|
client.on('inviteCreate', inviteTracker.onInviteCreate)
|
||||||
|
client.on('inviteDelete', inviteTracker.onInviteDelete)
|
||||||
|
|
||||||
|
client.on('error', (err) => {
|
||||||
|
status = 'error'
|
||||||
|
statusDetail = err.message
|
||||||
|
log.error('discord client error', { message: err.message })
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.login(token)
|
||||||
|
} catch (err) {
|
||||||
|
status = 'error'
|
||||||
|
statusDetail = err.message
|
||||||
|
client = null
|
||||||
|
log.error('discord login failed', { message: err.message })
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus() {
|
||||||
|
return { status, statusDetail, guildId, lastConnectedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
// For code that needs the live client + which guild it's connected to (the
|
||||||
|
// /internal/announce handler, slash commands already get both from the
|
||||||
|
// interaction itself so they don't need this). Returns null if disconnected.
|
||||||
|
function getConnection() {
|
||||||
|
if (!client || status !== 'connected') return null
|
||||||
|
return { client, guildId }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, stop, getStatus, getConnection }
|
||||||
45
bot/src/discord/guildMemberAdd.js
Normal file
45
bot/src/discord/guildMemberAdd.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Member join handling: record the join event (with best-effort invite
|
||||||
|
// attribution, Phase 6b) then apply the configured auto-role. Requires the
|
||||||
|
// Server Members privileged intent (already enabled per the Phase 1 setup notes)
|
||||||
|
// and, for invite attribution, the GuildInvites intent.
|
||||||
|
const guildConfig = require('../model/guildConfig')
|
||||||
|
const memberEvents = require('../model/memberEvents')
|
||||||
|
const inviteTracker = require('./inviteTracker')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('members')
|
||||||
|
|
||||||
|
async function handleGuildMemberAdd(member) {
|
||||||
|
// Attribute the invite first (diffs the invite-use cache), then record the join.
|
||||||
|
// Both are best-effort — a failure here must never block the auto-role below.
|
||||||
|
let invite = { code: null, inviterId: null, inviterTag: null }
|
||||||
|
try {
|
||||||
|
invite = await inviteTracker.attribute(member)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('invite attribution threw', { userId: member.id, message: err.message })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await memberEvents.record({
|
||||||
|
guildId: member.guild.id,
|
||||||
|
eventType: 'join',
|
||||||
|
discordUserId: member.id,
|
||||||
|
username: member.user?.tag,
|
||||||
|
inviteCode: invite.code,
|
||||||
|
inviterId: invite.inviterId,
|
||||||
|
inviterTag: invite.inviterTag,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('member join record failed', { userId: member.id, message: err.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const roleId = await guildConfig.getAutoRoleId(member.guild.id)
|
||||||
|
if (!roleId) return
|
||||||
|
await member.roles.add(roleId)
|
||||||
|
log.info('auto-role assigned', { userId: member.id, roleId })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('auto-role assignment failed', { userId: member.id, message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleGuildMemberAdd }
|
||||||
23
bot/src/discord/guildMemberRemove.js
Normal file
23
bot/src/discord/guildMemberRemove.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Member leave handling (Phase 6b): record a leave event for the dashboard's
|
||||||
|
// members feed. Fires on both voluntary leaves and kicks/bans — Discord doesn't
|
||||||
|
// distinguish them on this event, and the mod-action (if any) is logged
|
||||||
|
// separately via mod_actions, so a leave row here is purely the lifecycle fact.
|
||||||
|
const memberEvents = require('../model/memberEvents')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('members')
|
||||||
|
|
||||||
|
async function handleGuildMemberRemove(member) {
|
||||||
|
try {
|
||||||
|
await memberEvents.record({
|
||||||
|
guildId: member.guild.id,
|
||||||
|
eventType: 'leave',
|
||||||
|
discordUserId: member.id,
|
||||||
|
username: member.user?.tag,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('member leave record failed', { userId: member.id, message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleGuildMemberRemove }
|
||||||
74
bot/src/discord/inviteTracker.js
Normal file
74
bot/src/discord/inviteTracker.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Best-effort invite-usage attribution (Phase 6b). Discord doesn't tell you
|
||||||
|
// which invite a member used, so the standard approach is to keep a cache of
|
||||||
|
// each invite's use-count and, on guildMemberAdd, re-fetch and find the one
|
||||||
|
// whose count went up. Requires the GuildInvites intent + Manage Guild (the bot
|
||||||
|
// already creates/deletes invites, so it has the permission). All calls are
|
||||||
|
// best-effort: any failure just yields a null attribution and the join is still
|
||||||
|
// recorded. Vanity-URL and bot-added joins are inherently unattributable.
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('invites')
|
||||||
|
|
||||||
|
// guildId -> Map<inviteCode, uses>
|
||||||
|
const cache = new Map()
|
||||||
|
|
||||||
|
async function snapshot(guild) {
|
||||||
|
const map = new Map()
|
||||||
|
const invites = await guild.invites.fetch()
|
||||||
|
for (const inv of invites.values()) map.set(inv.code, inv.uses || 0)
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the cache for a guild (call once the client is ready).
|
||||||
|
async function prime(client, guildId) {
|
||||||
|
try {
|
||||||
|
const guild = client.guilds.cache.get(guildId) || (await client.guilds.fetch(guildId))
|
||||||
|
cache.set(guildId, await snapshot(guild))
|
||||||
|
log.info('invite cache primed', { guildId, count: cache.get(guildId).size })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('invite cache prime failed (missing Manage Guild / GuildInvites?)', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInviteCreate(invite) {
|
||||||
|
if (!invite.guild) return
|
||||||
|
const g = cache.get(invite.guild.id) || new Map()
|
||||||
|
g.set(invite.code, invite.uses || 0)
|
||||||
|
cache.set(invite.guild.id, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInviteDelete(invite) {
|
||||||
|
if (!invite.guild) return
|
||||||
|
const g = cache.get(invite.guild.id)
|
||||||
|
if (g) g.delete(invite.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff current invite uses against the cached snapshot to find which invite the
|
||||||
|
// joining member used, then refresh the cache. Returns { code, inviterId,
|
||||||
|
// inviterTag } with nulls when it can't be determined.
|
||||||
|
async function attribute(member) {
|
||||||
|
const empty = { code: null, inviterId: null, inviterTag: null }
|
||||||
|
try {
|
||||||
|
const guild = member.guild
|
||||||
|
const before = cache.get(guild.id) || new Map()
|
||||||
|
const current = await guild.invites.fetch()
|
||||||
|
|
||||||
|
let found = empty
|
||||||
|
for (const inv of current.values()) {
|
||||||
|
const prev = before.get(inv.code) || 0
|
||||||
|
if ((inv.uses || 0) > prev && found === empty) {
|
||||||
|
found = { code: inv.code, inviterId: inv.inviter?.id || null, inviterTag: inv.inviter?.tag || null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Map()
|
||||||
|
for (const inv of current.values()) next.set(inv.code, inv.uses || 0)
|
||||||
|
cache.set(guild.id, next)
|
||||||
|
return found
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('invite attribution failed', { message: err.message })
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { prime, onInviteCreate, onInviteDelete, attribute }
|
||||||
137
bot/src/discord/messageFilter.js
Normal file
137
bot/src/discord/messageFilter.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// messageCreate orchestration: allowlist bypass -> invite link -> banned word
|
||||||
|
// -> spam/mass-mention/mass-emoji. Invite/spam triggers always delete + warn
|
||||||
|
// (no severity tiers for those, unlike the word filter) — kept simple per the
|
||||||
|
// spec's "start simple" guidance. Filter-triggered mutes use a fixed 10-minute
|
||||||
|
// duration; per-severity-configurable durations are a future refinement.
|
||||||
|
const filterCache = require('../filter/filterCache')
|
||||||
|
const { findMatch } = require('../filter/normalize')
|
||||||
|
const inviteFilter = require('../filter/inviteFilter')
|
||||||
|
const spamFilter = require('../filter/spamFilter')
|
||||||
|
const warnings = require('../model/warnings')
|
||||||
|
const filterHits = require('../model/filterHits')
|
||||||
|
const spamHits = require('../model/spamHits')
|
||||||
|
const modLog = require('./modLog')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('filter')
|
||||||
|
|
||||||
|
const FILTER_MUTE_SECONDS = 600 // 10 minutes
|
||||||
|
|
||||||
|
function botActor(client) {
|
||||||
|
return { id: client.user.id, tag: client.user.tag }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard event capture (Phase 6b). Best-effort — recording a hit must never
|
||||||
|
// break the moderation action it accompanies, so failures are swallowed+logged.
|
||||||
|
async function recordFilterHit(message, hitType, matched, actionTaken) {
|
||||||
|
try {
|
||||||
|
await filterHits.record({
|
||||||
|
guildId: message.guildId,
|
||||||
|
hitType,
|
||||||
|
discordUserId: message.author.id,
|
||||||
|
username: message.author.tag,
|
||||||
|
channelId: message.channelId,
|
||||||
|
matched,
|
||||||
|
actionTaken,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('filter hit record failed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordSpamHit(message, spamType) {
|
||||||
|
try {
|
||||||
|
await spamHits.record({
|
||||||
|
guildId: message.guildId,
|
||||||
|
spamType,
|
||||||
|
discordUserId: message.author.id,
|
||||||
|
username: message.author.tag,
|
||||||
|
channelId: message.channelId,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('spam hit record failed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which spam rule tripped (for the spam_hits row). isRateLimited has a side
|
||||||
|
// effect (records this message's timestamp) so it must be evaluated first, and
|
||||||
|
// exactly once — mirroring the original OR-order.
|
||||||
|
function detectSpam(message) {
|
||||||
|
if (spamFilter.isRateLimited(message.guildId, message.author.id)) return 'rate_limit'
|
||||||
|
if (spamFilter.isMassMention(message)) return 'mass_mention'
|
||||||
|
if (spamFilter.isMassEmoji(message.content)) return 'mass_emoji'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isBypassed(message, cache) {
|
||||||
|
if (cache.allowChannels.has(message.channelId)) return true
|
||||||
|
const memberRoles = message.member ? message.member.roles.cache : null
|
||||||
|
if (memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id))) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyWarnAction(message, reason) {
|
||||||
|
const staff = botActor(message.client)
|
||||||
|
await warnings.add({
|
||||||
|
guildId: message.guildId,
|
||||||
|
targetUserId: message.author.id,
|
||||||
|
targetTag: message.author.tag,
|
||||||
|
staffUserId: staff.id,
|
||||||
|
staffTag: staff.tag,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
await modLog.record({ client: message.client, guildId: message.guildId, actionType: 'warn', target: message.author, staffUser: staff, reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyMuteAction(message, reason) {
|
||||||
|
const staff = botActor(message.client)
|
||||||
|
if (message.member && message.member.moderatable) {
|
||||||
|
await message.member.timeout(FILTER_MUTE_SECONDS * 1000, reason)
|
||||||
|
}
|
||||||
|
await modLog.record({
|
||||||
|
client: message.client,
|
||||||
|
guildId: message.guildId,
|
||||||
|
actionType: 'mute',
|
||||||
|
target: message.author,
|
||||||
|
staffUser: staff,
|
||||||
|
reason,
|
||||||
|
durationSeconds: FILTER_MUTE_SECONDS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMessageCreate(message) {
|
||||||
|
if (message.author.bot || !message.guildId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cache = await filterCache.getOrLoad(message.guildId)
|
||||||
|
if (await isBypassed(message, cache)) return
|
||||||
|
|
||||||
|
const foreignCode = await inviteFilter.foreignInviteCode(message)
|
||||||
|
if (foreignCode) {
|
||||||
|
await message.delete().catch(() => {})
|
||||||
|
await recordFilterHit(message, 'invite', foreignCode, 'warn')
|
||||||
|
await applyWarnAction(message, 'Posted a Discord invite link')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = findMatch(message.content, cache.words)
|
||||||
|
if (match) {
|
||||||
|
await message.delete().catch(() => {})
|
||||||
|
await recordFilterHit(message, 'word', match.word, match.severity)
|
||||||
|
if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`)
|
||||||
|
else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const spamType = detectSpam(message)
|
||||||
|
if (spamType) {
|
||||||
|
await message.delete().catch(() => {})
|
||||||
|
await recordSpamHit(message, spamType)
|
||||||
|
await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('messageFilter failed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleMessageCreate }
|
||||||
53
bot/src/discord/modLog.js
Normal file
53
bot/src/discord/modLog.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// Shared by every moderation command (ban/kick/mute/warn): writes the audit
|
||||||
|
// row and posts the embed to the configured mod-log channel. Takes `client`
|
||||||
|
// as a parameter (from interaction.client) rather than importing
|
||||||
|
// discordManager directly, to avoid a require cycle (discordManager -> commands
|
||||||
|
// -> modLog -> discordManager).
|
||||||
|
const { EmbedBuilder } = require('discord.js')
|
||||||
|
|
||||||
|
const db = require('../db')
|
||||||
|
const guildConfig = require('../model/guildConfig')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('modlog')
|
||||||
|
|
||||||
|
const COLOR = { ban: 0xd98b84, kick: 0xe0b070, mute: 0xe0b070, warn: 0xe0b070 }
|
||||||
|
|
||||||
|
async function record({ client, guildId, actionType, target, staffUser, reason, durationSeconds }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO mod_actions (guild_id, action_type, target_user_id, target_tag, staff_user_id, staff_tag, reason, duration_seconds)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, actionType, target.id, target.tag || null, staffUser.id, staffUser.tag || null, reason || null, durationSeconds || null],
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channelId = await guildConfig.getModLogChannelId(guildId)
|
||||||
|
if (!channelId) return
|
||||||
|
const channel = await client.channels.fetch(channelId)
|
||||||
|
if (!channel || !channel.isTextBased()) return
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(COLOR[actionType] || 0x9aa5b1)
|
||||||
|
.setTitle(actionType.toUpperCase())
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Target', value: `${target.tag || target.id} (${target.id})`, inline: true },
|
||||||
|
{ name: 'Staff', value: `${staffUser.tag || staffUser.id} (${staffUser.id})`, inline: true },
|
||||||
|
)
|
||||||
|
.setTimestamp()
|
||||||
|
if (reason) embed.addFields({ name: 'Reason', value: reason })
|
||||||
|
if (durationSeconds) embed.addFields({ name: 'Duration', value: formatDuration(durationSeconds), inline: true })
|
||||||
|
|
||||||
|
await channel.send({ embeds: [embed] })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('failed to post mod-log embed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (seconds % 86400 === 0) return `${seconds / 86400}d`
|
||||||
|
if (seconds % 3600 === 0) return `${seconds / 3600}h`
|
||||||
|
if (seconds % 60 === 0) return `${seconds / 60}m`
|
||||||
|
return `${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { record }
|
||||||
26
bot/src/discord/newsAnnounce.js
Normal file
26
bot/src/discord/newsAnnounce.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Shared by the /internal/announce webhook (site publishes a news post) and
|
||||||
|
// the manual /announce command (staff re-posts/boosts an existing one) — so
|
||||||
|
// both paths produce an identical embed.
|
||||||
|
const { EmbedBuilder } = require('discord.js')
|
||||||
|
|
||||||
|
const guildConfig = require('../model/guildConfig')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('news')
|
||||||
|
|
||||||
|
async function postAnnounce(client, guildId, { title, excerpt, url, imageUrl }) {
|
||||||
|
const channelId = await guildConfig.getNewsChannelId(guildId)
|
||||||
|
if (!channelId) throw new Error('No news channel configured — set one with /news first.')
|
||||||
|
|
||||||
|
const channel = await client.channels.fetch(channelId)
|
||||||
|
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)
|
||||||
|
if (excerpt) embed.setDescription(excerpt)
|
||||||
|
if (imageUrl) embed.setImage(imageUrl)
|
||||||
|
|
||||||
|
await channel.send({ embeds: [embed] })
|
||||||
|
log.info('news announced', { title, channelId })
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { postAnnounce }
|
||||||
41
bot/src/discord/roleMenuHandler.js
Normal file
41
bot/src/discord/roleMenuHandler.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Button-based self-assignable role menus. customId is `rolemenu:<roleId>` —
|
||||||
|
// the message's own id (not known until after it's sent, so it can't be
|
||||||
|
// embedded in the customId itself) is instead used to look up the tracked
|
||||||
|
// role_menus row and confirm the clicked roleId is really part of that
|
||||||
|
// menu's mapping, so a stale/foreign button can't toggle an untracked role.
|
||||||
|
const roleMenus = require('../model/roleMenus')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('rolemenu')
|
||||||
|
|
||||||
|
const PREFIX = 'rolemenu:'
|
||||||
|
|
||||||
|
// Returns true if this handler owned the interaction (caller should stop
|
||||||
|
// looking for another handler), false if it's not a role-menu button at all.
|
||||||
|
async function handleInteraction(interaction) {
|
||||||
|
if (!interaction.isButton() || !interaction.customId.startsWith(PREFIX)) return false
|
||||||
|
|
||||||
|
const roleId = interaction.customId.slice(PREFIX.length)
|
||||||
|
try {
|
||||||
|
const menu = await roleMenus.getByMessageId(interaction.message.id)
|
||||||
|
if (!menu || !menu.mapping.some((m) => m.roleId === roleId)) {
|
||||||
|
await interaction.reply({ content: 'This role menu is no longer valid.', ephemeral: true })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = interaction.member
|
||||||
|
if (member.roles.cache.has(roleId)) {
|
||||||
|
await member.roles.remove(roleId)
|
||||||
|
await interaction.reply({ content: `Removed <@&${roleId}>.`, ephemeral: true })
|
||||||
|
} else {
|
||||||
|
await member.roles.add(roleId)
|
||||||
|
await interaction.reply({ content: `Added <@&${roleId}>.`, ephemeral: true })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('role menu toggle failed', { message: err.message })
|
||||||
|
await interaction.reply({ content: 'Something went wrong toggling that role.', ephemeral: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleInteraction }
|
||||||
31
bot/src/filter/filterCache.js
Normal file
31
bot/src/filter/filterCache.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// In-memory per-guild filter state (word list + allowlist), loaded at startup
|
||||||
|
// and refreshed on config change — the messageCreate handler runs on every
|
||||||
|
// message, so it must never hit the DB per message (per the spec's
|
||||||
|
// performance note).
|
||||||
|
const filterWords = require('../model/filterWords')
|
||||||
|
const filterAllowlist = require('../model/filterAllowlist')
|
||||||
|
|
||||||
|
const cache = new Map() // guildId -> { words, allowRoles: Set, allowChannels: Set }
|
||||||
|
|
||||||
|
async function load(guildId) {
|
||||||
|
const [words, roles, channels] = await Promise.all([
|
||||||
|
filterWords.list(guildId),
|
||||||
|
filterAllowlist.getRoles(guildId),
|
||||||
|
filterAllowlist.getChannels(guildId),
|
||||||
|
])
|
||||||
|
const entry = { words, allowRoles: new Set(roles), allowChannels: new Set(channels) }
|
||||||
|
cache.set(guildId, entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy-loads on first access per guild (e.g. the first message after boot).
|
||||||
|
async function getOrLoad(guildId) {
|
||||||
|
return cache.get(guildId) || load(guildId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by /filter and /filterallow after any mutation.
|
||||||
|
function refresh(guildId) {
|
||||||
|
return load(guildId)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getOrLoad, refresh }
|
||||||
26
bot/src/filter/inviteFilter.js
Normal file
26
bot/src/filter/inviteFilter.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Detects Discord invite links and blocks any that don't resolve to the
|
||||||
|
// current guild (anti-raid/anti-advertising). An invite that fails to resolve
|
||||||
|
// (expired/invalid/vanity-only) is treated as foreign too — safer default
|
||||||
|
// than silently letting an unresolvable link through.
|
||||||
|
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi
|
||||||
|
|
||||||
|
// Returns the first foreign (or unresolvable) invite code found in the message,
|
||||||
|
// or null if the message contains no foreign invites. Returning the code (rather
|
||||||
|
// than a bare boolean) lets the caller record which invite was blocked.
|
||||||
|
async function foreignInviteCode(message) {
|
||||||
|
const matches = [...message.content.matchAll(INVITE_REGEX)]
|
||||||
|
if (matches.length === 0) return null
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const code = match[1]
|
||||||
|
try {
|
||||||
|
const invite = await message.client.fetchInvite(code)
|
||||||
|
if (invite.guild?.id !== message.guildId) return code
|
||||||
|
} catch {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { foreignInviteCode }
|
||||||
33
bot/src/filter/normalize.js
Normal file
33
bot/src/filter/normalize.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Basic obfuscation-resistant normalization for the word filter: lowercase,
|
||||||
|
// common leetspeak substitutions, and collapsing 3+ repeated characters
|
||||||
|
// ("sooooo" -> "so") to one. Deliberately simple per the spec ("start simple,
|
||||||
|
// leave room to tighten later") — spaced-out letters ("b a d") and more exotic
|
||||||
|
// unicode lookalikes aren't handled yet.
|
||||||
|
const SUBS = { 4: 'a', '@': 'a', 3: 'e', 1: 'i', '!': 'i', 0: 'o', $: 's', 5: 's', 7: 't' }
|
||||||
|
const SUB_CHARS = /[4@31!05$7]/g
|
||||||
|
|
||||||
|
function normalize(text) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(SUB_CHARS, (ch) => SUBS[ch] || ch)
|
||||||
|
.replace(/(.)\1{2,}/g, '$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(str) {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word-boundary match against already-normalized text. `word` is normalized
|
||||||
|
// here too, so callers can pass the raw stored value.
|
||||||
|
function matches(normalizedText, word) {
|
||||||
|
const pattern = new RegExp(`\\b${escapeRegex(normalize(word))}\\b`, 'i')
|
||||||
|
return pattern.test(normalizedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the first matching filter_words row ({word, severity}) or null.
|
||||||
|
function findMatch(content, words) {
|
||||||
|
const normalizedText = normalize(content)
|
||||||
|
return words.find((w) => matches(normalizedText, w.word)) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalize, matches, findMatch }
|
||||||
42
bot/src/filter/spamFilter.js
Normal file
42
bot/src/filter/spamFilter.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Basic in-memory spam/rate-limit detection. Per-user message-rate tracking is
|
||||||
|
// the only stateful piece here (mass-mention/mass-emoji are per-message
|
||||||
|
// counts) — kept in memory rather than the DB since this runs on every
|
||||||
|
// message and needs to be fast.
|
||||||
|
const RATE_LIMIT_COUNT = 5
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 5000
|
||||||
|
const MENTION_THRESHOLD = 5
|
||||||
|
const EMOJI_THRESHOLD = 10
|
||||||
|
const SWEEP_INTERVAL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
const history = new Map() // `${guildId}:${userId}` -> timestamps[]
|
||||||
|
|
||||||
|
function isRateLimited(guildId, userId) {
|
||||||
|
const key = `${guildId}:${userId}`
|
||||||
|
const now = Date.now()
|
||||||
|
const timestamps = (history.get(key) || []).filter((t) => now - t < RATE_LIMIT_WINDOW_MS)
|
||||||
|
timestamps.push(now)
|
||||||
|
history.set(key, timestamps)
|
||||||
|
return timestamps.length > RATE_LIMIT_COUNT
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMassMention(message) {
|
||||||
|
return message.mentions.users.size + message.mentions.roles.size > MENTION_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJI_REGEX = /<a?:\w+:\d+>|\p{Extended_Pictographic}/gu
|
||||||
|
|
||||||
|
function isMassEmoji(content) {
|
||||||
|
const count = (content.match(EMOJI_REGEX) || []).length
|
||||||
|
return count > EMOJI_THRESHOLD
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic cleanup so `history` doesn't grow unbounded over a long-running
|
||||||
|
// process — drops any key with no recent activity.
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, timestamps] of history) {
|
||||||
|
if (timestamps.every((t) => now - t >= RATE_LIMIT_WINDOW_MS)) history.delete(key)
|
||||||
|
}
|
||||||
|
}, SWEEP_INTERVAL_MS).unref()
|
||||||
|
|
||||||
|
module.exports = { isRateLimited, isMassMention, isMassEmoji }
|
||||||
50
bot/src/internal/internal.controller.js
Normal file
50
bot/src/internal/internal.controller.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
const discordManager = require('../discord/discordManager')
|
||||||
|
const newsAnnounce = require('../discord/newsAnnounce')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('internal')
|
||||||
|
|
||||||
|
// POST /internal/config — called by the main server right after an admin
|
||||||
|
// saves the Discord Bot panel, and by the bot's own bootstrap on startup
|
||||||
|
// (via a GET to the server for the current config, then this same start/stop
|
||||||
|
// logic locally). Body: { token, guildId, enabled }.
|
||||||
|
async function setConfig(req, res) {
|
||||||
|
const { token, guildId, enabled } = req.body || {}
|
||||||
|
try {
|
||||||
|
if (enabled) {
|
||||||
|
if (!token || !guildId) {
|
||||||
|
return res.status(400).json({ message: 'token and guildId are required when enabled' })
|
||||||
|
}
|
||||||
|
await discordManager.start({ token, guildId })
|
||||||
|
} else {
|
||||||
|
await discordManager.stop()
|
||||||
|
}
|
||||||
|
return res.json(discordManager.getStatus())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('setConfig failed', { message: err.message })
|
||||||
|
// Still 200 with an error status — the caller (admin panel) should surface
|
||||||
|
// discordManager's status/statusDetail rather than treat this as a 5xx.
|
||||||
|
return res.json(discordManager.getStatus())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /internal/status — live connection state, polled by the admin panel.
|
||||||
|
function getStatusHandler(req, res) {
|
||||||
|
return res.json(discordManager.getStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /internal/announce — called by the main server right after a news
|
||||||
|
// post is published. Body: { title, excerpt, url, imageUrl }.
|
||||||
|
async function announce(req, res) {
|
||||||
|
const connection = discordManager.getConnection()
|
||||||
|
if (!connection) return res.status(503).json({ message: 'Bot is not connected' })
|
||||||
|
try {
|
||||||
|
await newsAnnounce.postAnnounce(connection.client, connection.guildId, req.body || {})
|
||||||
|
return res.json({ posted: true })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('announce failed', { message: err.message })
|
||||||
|
return res.status(400).json({ message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { setConfig, getStatus: getStatusHandler, announce }
|
||||||
14
bot/src/internal/internal.routes.js
Normal file
14
bot/src/internal/internal.routes.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
const requireInternalKey = require('./requireInternalKey')
|
||||||
|
const ctrl = require('./internal.controller')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.use(requireInternalKey)
|
||||||
|
|
||||||
|
router.post('/config', ctrl.setConfig)
|
||||||
|
router.get('/status', ctrl.getStatus)
|
||||||
|
router.post('/announce', ctrl.announce)
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
19
bot/src/internal/requireInternalKey.js
Normal file
19
bot/src/internal/requireInternalKey.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Gate for the bot's /internal/* API. The only caller is the main UOMysticmoon
|
||||||
|
// 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
|
||||||
|
// to brute-force the shared secret one byte at a time.
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
function requireInternalKey(req, res, next) {
|
||||||
|
const expected = process.env.BOT_INTERNAL_KEY || ''
|
||||||
|
const provided = req.get('X-Internal-Key') || ''
|
||||||
|
|
||||||
|
const a = Buffer.from(expected)
|
||||||
|
const b = Buffer.from(provided)
|
||||||
|
const match = expected.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||||
|
|
||||||
|
if (!match) return res.status(401).json({ message: 'Unauthorized' })
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = requireInternalKey
|
||||||
37
bot/src/invites/inviteRotator.js
Normal file
37
bot/src/invites/inviteRotator.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Shared by both /invite rotate and the weekly cron job (inviteScheduler.js)
|
||||||
|
// so manual and automatic rotations log identically. maxAge is set to match
|
||||||
|
// the rotation cadence as defense-in-depth: if the scheduled rotation were
|
||||||
|
// ever to silently stop running, the invite still expires on its own instead
|
||||||
|
// of staying live forever.
|
||||||
|
const guildConfig = require('../model/guildConfig')
|
||||||
|
const inviteLog = require('../model/inviteLog')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('invites')
|
||||||
|
|
||||||
|
const ROTATION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 // 7 days
|
||||||
|
|
||||||
|
async function rotate(client, guildId, { triggeredBy, triggeredByTag } = {}) {
|
||||||
|
const channelId = await guildConfig.getInviteChannelId(guildId)
|
||||||
|
if (!channelId) throw new Error('No invite channel configured — set one with /invite channel first.')
|
||||||
|
|
||||||
|
const channel = await client.channels.fetch(channelId)
|
||||||
|
if (!channel || !channel.isTextBased()) throw new Error('Configured invite channel is missing or not text-based.')
|
||||||
|
|
||||||
|
const current = await inviteLog.getCurrent(guildId)
|
||||||
|
if (current) {
|
||||||
|
try {
|
||||||
|
await channel.guild.invites.delete(current.invite_code, 'Invite rotation')
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('failed to revoke previous invite (may already be gone)', { message: err.message })
|
||||||
|
}
|
||||||
|
await inviteLog.markRevoked(current.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const invite = await channel.createInvite({ maxAge: ROTATION_MAX_AGE_SECONDS, unique: true, reason: 'Invite rotation' })
|
||||||
|
await inviteLog.record({ guildId, channelId, inviteCode: invite.code, triggeredBy, triggeredByTag })
|
||||||
|
log.info('invite rotated', { code: invite.code, triggeredBy: triggeredByTag || 'automatic (scheduled)' })
|
||||||
|
return invite
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { rotate }
|
||||||
31
bot/src/invites/inviteScheduler.js
Normal file
31
bot/src/invites/inviteScheduler.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Weekly automatic invite rotation (Sundays at midnight). A missing invite
|
||||||
|
// channel config just skips quietly (warn-logged) — most guilds won't set
|
||||||
|
// this up on day one, and that shouldn't spam errors every week until they do.
|
||||||
|
const cron = require('node-cron')
|
||||||
|
|
||||||
|
const inviteRotator = require('./inviteRotator')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('invites')
|
||||||
|
|
||||||
|
let task = null
|
||||||
|
|
||||||
|
function start(client, guildId) {
|
||||||
|
task = cron.schedule('0 0 * * 0', async () => {
|
||||||
|
try {
|
||||||
|
await inviteRotator.rotate(client, guildId, {})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('scheduled invite rotation skipped', { message: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
log.info('invite rotation scheduler started')
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (task) {
|
||||||
|
task.stop()
|
||||||
|
task = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, stop }
|
||||||
40
bot/src/model/filterAllowlist.js
Normal file
40
bot/src/model/filterAllowlist.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Roles/channels that bypass word/invite/spam filtering entirely (staff roles,
|
||||||
|
// bot-commands channels, etc.). Stored as CSV in guild_config rather than a
|
||||||
|
// separate table — short, rarely-changed lists.
|
||||||
|
const guildConfig = require('./guildConfig')
|
||||||
|
|
||||||
|
const ROLES_KEY = 'filter_allow_roles'
|
||||||
|
const CHANNELS_KEY = 'filter_allow_channels'
|
||||||
|
|
||||||
|
function parseCsv(value) {
|
||||||
|
return value ? value.split(',').filter(Boolean) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRoles(guildId) {
|
||||||
|
return parseCsv(await guildConfig.get(guildId, ROLES_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChannels(guildId) {
|
||||||
|
return parseCsv(await guildConfig.get(guildId, CHANNELS_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle: adds the id if absent, removes it if present. Returns the new state (true = now allowed).
|
||||||
|
async function toggleRole(guildId, roleId) {
|
||||||
|
const roles = await getRoles(guildId)
|
||||||
|
const idx = roles.indexOf(roleId)
|
||||||
|
if (idx === -1) roles.push(roleId)
|
||||||
|
else roles.splice(idx, 1)
|
||||||
|
await guildConfig.set(guildId, ROLES_KEY, roles.join(','))
|
||||||
|
return idx === -1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleChannel(guildId, channelId) {
|
||||||
|
const channels = await getChannels(guildId)
|
||||||
|
const idx = channels.indexOf(channelId)
|
||||||
|
if (idx === -1) channels.push(channelId)
|
||||||
|
else channels.splice(idx, 1)
|
||||||
|
await guildConfig.set(guildId, CHANNELS_KEY, channels.join(','))
|
||||||
|
return idx === -1
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getRoles, getChannels, toggleRole, toggleChannel }
|
||||||
15
bot/src/model/filterHits.js
Normal file
15
bot/src/model/filterHits.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Automated content-filter hits (Phase 6b). Bot-owned; recorded whenever the
|
||||||
|
// word filter or foreign-invite filter deletes a message. mod_actions still
|
||||||
|
// records the resulting warn/mute separately. Schema: server/db/schema.sql
|
||||||
|
// (filter_hits).
|
||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function record({ guildId, hitType, discordUserId, username, channelId, matched, actionTaken }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO filter_hits (guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, hitType, discordUserId, username || null, channelId || null, matched || null, actionTaken],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { record }
|
||||||
22
bot/src/model/filterWords.js
Normal file
22
bot/src/model/filterWords.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function add({ guildId, word, severity, addedBy, addedByTag }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO filter_words (guild_id, word, severity, added_by, added_by_tag)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE severity = VALUES(severity), added_by = VALUES(added_by), added_by_tag = VALUES(added_by_tag)`,
|
||||||
|
[guildId, word.toLowerCase(), severity || 'delete', addedBy || null, addedByTag || null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if a row was actually removed.
|
||||||
|
async function remove(guildId, word) {
|
||||||
|
const res = await db.query('DELETE FROM filter_words WHERE guild_id = ? AND word = ?', [guildId, word.toLowerCase()])
|
||||||
|
return Number(res.affectedRows || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list(guildId) {
|
||||||
|
return db.query('SELECT word, severity FROM filter_words WHERE guild_id = ? ORDER BY word ASC', [guildId])
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { add, remove, list }
|
||||||
47
bot/src/model/guildConfig.js
Normal file
47
bot/src/model/guildConfig.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Per-guild key/value config the bot owns (see guild_config in
|
||||||
|
// server/db/schema.sql). Generic get/set now; filters/schedules/role-menu
|
||||||
|
// config reuses this same table in later phases.
|
||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
const MOD_LOG_CHANNEL_KEY = 'mod_log_channel_id'
|
||||||
|
const AUTO_ROLE_KEY = 'auto_role_id'
|
||||||
|
const INVITE_CHANNEL_KEY = 'invite_channel_id'
|
||||||
|
const NEWS_CHANNEL_KEY = 'news_channel_id'
|
||||||
|
|
||||||
|
async function get(guildId, key) {
|
||||||
|
const rows = await db.query('SELECT value FROM guild_config WHERE guild_id = ? AND `key` = ? LIMIT 1', [guildId, key])
|
||||||
|
return rows[0] ? rows[0].value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function set(guildId, key, value) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO guild_config (guild_id, \`key\`, value) VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
|
||||||
|
[guildId, key, value],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getModLogChannelId = (guildId) => get(guildId, MOD_LOG_CHANNEL_KEY)
|
||||||
|
const setModLogChannelId = (guildId, channelId) => set(guildId, MOD_LOG_CHANNEL_KEY, channelId)
|
||||||
|
|
||||||
|
const getAutoRoleId = (guildId) => get(guildId, AUTO_ROLE_KEY)
|
||||||
|
const setAutoRoleId = (guildId, roleId) => set(guildId, AUTO_ROLE_KEY, roleId)
|
||||||
|
|
||||||
|
const getInviteChannelId = (guildId) => get(guildId, INVITE_CHANNEL_KEY)
|
||||||
|
const setInviteChannelId = (guildId, channelId) => set(guildId, INVITE_CHANNEL_KEY, channelId)
|
||||||
|
|
||||||
|
const getNewsChannelId = (guildId) => get(guildId, NEWS_CHANNEL_KEY)
|
||||||
|
const setNewsChannelId = (guildId, channelId) => set(guildId, NEWS_CHANNEL_KEY, channelId)
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
get,
|
||||||
|
set,
|
||||||
|
getModLogChannelId,
|
||||||
|
setModLogChannelId,
|
||||||
|
getAutoRoleId,
|
||||||
|
setAutoRoleId,
|
||||||
|
getInviteChannelId,
|
||||||
|
setInviteChannelId,
|
||||||
|
getNewsChannelId,
|
||||||
|
setNewsChannelId,
|
||||||
|
}
|
||||||
29
bot/src/model/inviteLog.js
Normal file
29
bot/src/model/inviteLog.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function record({ guildId, channelId, inviteCode, triggeredBy, triggeredByTag }) {
|
||||||
|
const res = await db.query(
|
||||||
|
`INSERT INTO invite_log (guild_id, channel_id, invite_code, triggered_by, triggered_by_tag)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, channelId, inviteCode, triggeredBy || null, triggeredByTag || null],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
// The active (not-yet-revoked) invite for a guild, if any.
|
||||||
|
async function getCurrent(guildId) {
|
||||||
|
const rows = await db.query(
|
||||||
|
'SELECT * FROM invite_log WHERE guild_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1',
|
||||||
|
[guildId],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRevoked(id) {
|
||||||
|
await db.query('UPDATE invite_log SET revoked_at = NOW() WHERE id = ?', [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list(guildId, limit = 10) {
|
||||||
|
return db.query('SELECT * FROM invite_log WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?', [guildId, limit])
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { record, getCurrent, markRevoked, list }
|
||||||
14
bot/src/model/memberEvents.js
Normal file
14
bot/src/model/memberEvents.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Guild member join/leave events (Phase 6b). Bot-owned; the site reads these for
|
||||||
|
// the moderation dashboard's members feed + invite-usage view. Schema in
|
||||||
|
// server/db/schema.sql (member_events).
|
||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function record({ guildId, eventType, discordUserId, username, inviteCode, inviterId, inviterTag }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO member_events (guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, eventType, discordUserId, username || null, inviteCode || null, inviterId || null, inviterTag || null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { record }
|
||||||
17
bot/src/model/roleMenus.js
Normal file
17
bot/src/model/roleMenus.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function add({ guildId, channelId, messageId, mapping, createdBy }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO role_menus (guild_id, channel_id, message_id, mapping, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, channelId, messageId, JSON.stringify(mapping), createdBy || null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getByMessageId(messageId) {
|
||||||
|
const rows = await db.query('SELECT * FROM role_menus WHERE message_id = ? LIMIT 1', [messageId])
|
||||||
|
if (!rows[0]) return null
|
||||||
|
return { ...rows[0], mapping: JSON.parse(rows[0].mapping) }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { add, getByMessageId }
|
||||||
57
bot/src/model/scheduledMessages.js
Normal file
57
bot/src/model/scheduledMessages.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function addRecurring({ guildId, channelId, content, cronExpression, createdBy, createdByTag }) {
|
||||||
|
const res = await db.query(
|
||||||
|
`INSERT INTO scheduled_messages (guild_id, channel_id, content, cron_expression, created_by, created_by_tag)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, channelId, content, cronExpression, createdBy || null, createdByTag || null],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addOnce({ guildId, channelId, content, runAt, createdBy, createdByTag }) {
|
||||||
|
const res = await db.query(
|
||||||
|
`INSERT INTO scheduled_messages (guild_id, channel_id, content, run_at, created_by, created_by_tag)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, channelId, content, runAt, createdBy || null, createdByTag || null],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if a row was actually removed (scoped to the guild so one
|
||||||
|
// guild can't remove another's rows).
|
||||||
|
async function remove(guildId, id) {
|
||||||
|
const res = await db.query('DELETE FROM scheduled_messages WHERE id = ? AND guild_id = ?', [id, guildId])
|
||||||
|
return Number(res.affectedRows || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list(guildId) {
|
||||||
|
return db.query(
|
||||||
|
`SELECT id, channel_id, content, cron_expression, run_at, enabled, sent_at FROM scheduled_messages
|
||||||
|
WHERE guild_id = ? ORDER BY id ASC`,
|
||||||
|
[guildId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All enabled recurring rows across every guild the bot serves — v1 only
|
||||||
|
// ever has one, but the scheduler doesn't need to special-case that.
|
||||||
|
async function listEnabledRecurring() {
|
||||||
|
return db.query(
|
||||||
|
`SELECT id, guild_id, channel_id, content, cron_expression FROM scheduled_messages
|
||||||
|
WHERE cron_expression IS NOT NULL AND enabled = 1`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-off rows due to post right now.
|
||||||
|
async function listDueOneOff() {
|
||||||
|
return db.query(
|
||||||
|
`SELECT id, guild_id, channel_id, content FROM scheduled_messages
|
||||||
|
WHERE run_at IS NOT NULL AND sent_at IS NULL AND enabled = 1 AND run_at <= NOW()`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markSent(id) {
|
||||||
|
await db.query('UPDATE scheduled_messages SET sent_at = NOW() WHERE id = ?', [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { addRecurring, addOnce, remove, list, listEnabledRecurring, listDueOneOff, markSent }
|
||||||
14
bot/src/model/spamHits.js
Normal file
14
bot/src/model/spamHits.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Automated spam-detection hits (Phase 6b). Bot-owned; recorded when the
|
||||||
|
// rate-limit / mass-mention / mass-emoji checks trip. mod_actions still logs the
|
||||||
|
// resulting warn separately. Schema: server/db/schema.sql (spam_hits).
|
||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function record({ guildId, spamType, discordUserId, username, channelId }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO spam_hits (guild_id, spam_type, discord_user_id, username, channel_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, spamType, discordUserId, username || null, channelId || null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { record }
|
||||||
26
bot/src/model/tempRoles.js
Normal file
26
bot/src/model/tempRoles.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
// Upsert — re-granting the same temp role refreshes its expiry instead of
|
||||||
|
// creating a duplicate row (see UNIQUE(guild,user,role) in schema.sql).
|
||||||
|
async function add({ guildId, userId, roleId, expiresAt, createdBy }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO temp_roles (guild_id, user_id, role_id, expires_at, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at), created_by = VALUES(created_by)`,
|
||||||
|
[guildId, userId, roleId, expiresAt, createdBy || null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(guildId, userId, roleId) {
|
||||||
|
await db.query('DELETE FROM temp_roles WHERE guild_id = ? AND user_id = ? AND role_id = ?', [guildId, userId, roleId])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listExpired() {
|
||||||
|
return db.query('SELECT id, guild_id, user_id, role_id FROM temp_roles WHERE expires_at <= NOW()')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeById(id) {
|
||||||
|
await db.query('DELETE FROM temp_roles WHERE id = ?', [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { add, remove, listExpired, removeById }
|
||||||
26
bot/src/model/warnings.js
Normal file
26
bot/src/model/warnings.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Standing warnings (separate from mod_actions so /warnings can list a
|
||||||
|
// user's active warnings). expires_at is always NULL for now — decay/escalation
|
||||||
|
// (e.g. "3 active warns -> auto-mute") is deferred past Phase 2, see
|
||||||
|
// warn.command.js.
|
||||||
|
const db = require('../db')
|
||||||
|
|
||||||
|
async function add({ guildId, targetUserId, targetTag, staffUserId, staffTag, reason }) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO warnings (guild_id, target_user_id, target_tag, staff_user_id, staff_tag, reason)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[guildId, targetUserId, targetTag || null, staffUserId, staffTag || null, reason || null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active = not expired. Every row is active today since expires_at is never
|
||||||
|
// set, but the query is written to already respect it once decay lands.
|
||||||
|
async function listActive(guildId, targetUserId) {
|
||||||
|
return db.query(
|
||||||
|
`SELECT id, reason, staff_tag, created_at FROM warnings
|
||||||
|
WHERE guild_id = ? AND target_user_id = ? AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
[guildId, targetUserId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { add, listActive }
|
||||||
48
bot/src/roles/tempRoleSweeper.js
Normal file
48
bot/src/roles/tempRoleSweeper.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Once-a-minute sweep for expired temp_roles: removes the Discord role (best
|
||||||
|
// effort — the member/guild/role may already be gone) then deletes the row
|
||||||
|
// regardless, so a stale row can never block future re-grants of the same
|
||||||
|
// role to the same member.
|
||||||
|
const cron = require('node-cron')
|
||||||
|
|
||||||
|
const tempRoles = require('../model/tempRoles')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('temproles')
|
||||||
|
|
||||||
|
let client = null
|
||||||
|
let task = null
|
||||||
|
|
||||||
|
async function sweep() {
|
||||||
|
try {
|
||||||
|
const expired = await tempRoles.listExpired()
|
||||||
|
for (const row of expired) {
|
||||||
|
try {
|
||||||
|
const guild = await client.guilds.fetch(row.guild_id)
|
||||||
|
const member = await guild.members.fetch(row.user_id).catch(() => null)
|
||||||
|
if (member) await member.roles.remove(row.role_id).catch(() => {})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('failed to remove expired temp role', { message: err.message, roleId: row.role_id, userId: row.user_id })
|
||||||
|
} finally {
|
||||||
|
await tempRoles.removeById(row.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('temp role sweep failed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(discordClient) {
|
||||||
|
client = discordClient
|
||||||
|
task = cron.schedule('* * * * *', sweep)
|
||||||
|
log.info('temp role sweeper started')
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (task) {
|
||||||
|
task.stop()
|
||||||
|
task = null
|
||||||
|
}
|
||||||
|
client = null
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, stop }
|
||||||
83
bot/src/scheduler/scheduler.js
Normal file
83
bot/src/scheduler/scheduler.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Recurring + one-off scheduled channel messages. Recurring rows are each
|
||||||
|
// registered as their own node-cron task; one-off rows are picked up by a
|
||||||
|
// once-a-minute sweep that checks for anything due and marks it sent so it
|
||||||
|
// never reposts. Needs a live discord.js Client to actually send — wired up
|
||||||
|
// by discordManager.js (start() once the client is ready, stop() alongside
|
||||||
|
// client teardown).
|
||||||
|
const cron = require('node-cron')
|
||||||
|
|
||||||
|
const scheduledMessages = require('../model/scheduledMessages')
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('scheduler')
|
||||||
|
|
||||||
|
let discordClient = null
|
||||||
|
const recurringTasks = new Map() // id -> node-cron ScheduledTask
|
||||||
|
let sweepTask = null
|
||||||
|
|
||||||
|
async function sendToChannel(channelId, content) {
|
||||||
|
try {
|
||||||
|
const channel = await discordClient.channels.fetch(channelId)
|
||||||
|
if (!channel || !channel.isTextBased()) {
|
||||||
|
log.warn('scheduled message skipped — channel missing or not text-based', { channelId })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await channel.send({ content })
|
||||||
|
log.info('sent scheduled message', { channelId })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('failed to send scheduled message', { channelId, message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecurring() {
|
||||||
|
for (const task of recurringTasks.values()) task.stop()
|
||||||
|
recurringTasks.clear()
|
||||||
|
|
||||||
|
const rows = await scheduledMessages.listEnabledRecurring()
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!cron.validate(row.cron_expression)) {
|
||||||
|
log.warn('skipping scheduled message with invalid cron expression', { id: row.id, cron: row.cron_expression })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const task = cron.schedule(row.cron_expression, () => sendToChannel(row.channel_id, row.content))
|
||||||
|
recurringTasks.set(row.id, task)
|
||||||
|
}
|
||||||
|
log.info('loaded recurring scheduled messages', { count: recurringTasks.size })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sweepDueOneOff() {
|
||||||
|
try {
|
||||||
|
const due = await scheduledMessages.listDueOneOff()
|
||||||
|
for (const row of due) {
|
||||||
|
await sendToChannel(row.channel_id, row.content)
|
||||||
|
await scheduledMessages.markSent(row.id)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('one-off sweep failed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(client) {
|
||||||
|
discordClient = client
|
||||||
|
await loadRecurring()
|
||||||
|
sweepTask = cron.schedule('* * * * *', sweepDueOneOff)
|
||||||
|
log.info('scheduler started')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by /schedule after any add/remove so changes apply without a restart.
|
||||||
|
async function refresh() {
|
||||||
|
if (!discordClient) return
|
||||||
|
await loadRecurring()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
for (const task of recurringTasks.values()) task.stop()
|
||||||
|
recurringTasks.clear()
|
||||||
|
if (sweepTask) {
|
||||||
|
sweepTask.stop()
|
||||||
|
sweepTask = null
|
||||||
|
}
|
||||||
|
discordClient = null
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, stop, refresh }
|
||||||
52
bot/src/server.js
Normal file
52
bot/src/server.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
require('dotenv').config()
|
||||||
|
|
||||||
|
const app = require('./app')
|
||||||
|
const bootstrap = require('./bootstrap')
|
||||||
|
const createLogger = require('./utils/logger')
|
||||||
|
const discordManager = require('./discord/discordManager')
|
||||||
|
const pkg = require('../package.json')
|
||||||
|
|
||||||
|
const log = createLogger('server')
|
||||||
|
const PORT = Number(process.env.PORT) || 4100
|
||||||
|
const HOST = '0.0.0.0'
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
log.info(`starting UOMysticmoon bot v${pkg.version}`, {
|
||||||
|
node: process.version,
|
||||||
|
logFile: createLogger.logFilePath || 'disabled (console only)',
|
||||||
|
})
|
||||||
|
|
||||||
|
const server = app.listen(PORT, HOST, () => {
|
||||||
|
log.info(`internal API listening on http://${HOST}:${PORT}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
await bootstrap()
|
||||||
|
|
||||||
|
setupShutdown(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupShutdown(server) {
|
||||||
|
let closing = false
|
||||||
|
const shutdown = async (signal) => {
|
||||||
|
if (closing) return
|
||||||
|
closing = true
|
||||||
|
log.warn(`${signal} received — shutting down gracefully`)
|
||||||
|
server.close(() => log.info('internal API closed'))
|
||||||
|
await discordManager.stop()
|
||||||
|
await createLogger.close()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||||
|
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
log.error('uncaughtException', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch((err) => {
|
||||||
|
log.error('failed to start bot', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
42
bot/src/site/siteApiClient.js
Normal file
42
bot/src/site/siteApiClient.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Read-only client for the main site's PUBLIC API (no shared secret — this is
|
||||||
|
// the same unauthenticated data any visitor's browser can fetch). Used by
|
||||||
|
// /wiki (search) and /announce (re-post an existing news item). Distinct from
|
||||||
|
// botInternalClient.js, which is the shared-secret-gated server<->bot channel.
|
||||||
|
const createLogger = require('../utils/logger')
|
||||||
|
|
||||||
|
const log = createLogger('site-api')
|
||||||
|
|
||||||
|
const BASE_URL = (process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public').replace(/\/+$/, '')
|
||||||
|
const TIMEOUT_MS = 5000
|
||||||
|
|
||||||
|
async function call(path) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}${path}`, { signal: controller.signal })
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
// Public content routes 503 with this shape while the site is in
|
||||||
|
// maintenance mode (see server/src/middleware/siteMode.js) — surface it
|
||||||
|
// distinctly so commands can show a clear message instead of a generic error.
|
||||||
|
if (res.status === 503 && data?.mode === 'maintenance') {
|
||||||
|
return { ok: false, maintenance: true, message: data.message }
|
||||||
|
}
|
||||||
|
if (!res.ok) return { ok: false, error: `site responded ${res.status}` }
|
||||||
|
return { ok: true, data }
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('site API call failed', { path, message: err.message })
|
||||||
|
return { ok: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNewsPost(idOrSlug) {
|
||||||
|
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchWiki(query) {
|
||||||
|
return call(`/wiki?q=${encodeURIComponent(query)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getNewsPost, searchWiki }
|
||||||
16
bot/src/utils/duration.js
Normal file
16
bot/src/utils/duration.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Parses simple duration strings ("30s", "10m", "2h", "1d") to milliseconds.
|
||||||
|
// Returns null for anything unparseable. Discord's own timeout API caps at 28
|
||||||
|
// days — callers should clamp to MAX_TIMEOUT_MS rather than trust user input.
|
||||||
|
const UNIT_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
|
||||||
|
|
||||||
|
const MAX_TIMEOUT_MS = 28 * 86_400_000
|
||||||
|
|
||||||
|
function parseDuration(input) {
|
||||||
|
if (!input) return null
|
||||||
|
const match = /^(\d+)\s*(s|m|h|d)$/i.exec(input.trim())
|
||||||
|
if (!match) return null
|
||||||
|
const [, amount, unit] = match
|
||||||
|
return Number(amount) * UNIT_MS[unit.toLowerCase()]
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseDuration, MAX_TIMEOUT_MS }
|
||||||
97
bot/src/utils/logger.js
Normal file
97
bot/src/utils/logger.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// Dual-transport logger: writes to the console AND to a log file.
|
||||||
|
// Levels: error | warn | info | debug.
|
||||||
|
// LOG_LEVEL console verbosity (default info)
|
||||||
|
// FILE_LOG_LEVEL file verbosity (default debug — keep a full record on disk)
|
||||||
|
// LOG_TO_FILE enable file logging (default true)
|
||||||
|
// LOG_DIR log directory (default <bot>/logs)
|
||||||
|
// LOG_FILE log file name (default bot.log)
|
||||||
|
//
|
||||||
|
// Copied from server/src/utils/logger.js rather than shared — the bot is an
|
||||||
|
// independently deployable process with its own package.json/Dockerfile.
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 }
|
||||||
|
|
||||||
|
const consoleThreshold = LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] ?? LEVELS.info
|
||||||
|
const fileThreshold = LEVELS[(process.env.FILE_LOG_LEVEL || 'debug').toLowerCase()] ?? LEVELS.debug
|
||||||
|
|
||||||
|
// Color only on an interactive TTY — never in files or Docker logs.
|
||||||
|
const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR == null
|
||||||
|
const COLOR = { error: '\x1b[31m', warn: '\x1b[33m', info: '\x1b[36m', debug: '\x1b[90m' }
|
||||||
|
const RESET = '\x1b[0m'
|
||||||
|
|
||||||
|
// ── File transport ────────────────────────────────────────────────────
|
||||||
|
const fileEnabled = (process.env.LOG_TO_FILE || 'true').toLowerCase() !== 'false'
|
||||||
|
let fileStream = null
|
||||||
|
let logFilePath = null
|
||||||
|
|
||||||
|
if (fileEnabled) {
|
||||||
|
try {
|
||||||
|
const dir = process.env.LOG_DIR || path.join(__dirname, '..', '..', 'logs')
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
logFilePath = path.join(dir, process.env.LOG_FILE || 'bot.log')
|
||||||
|
fileStream = fs.createWriteStream(logFilePath, { flags: 'a' })
|
||||||
|
fileStream.on('error', (err) => {
|
||||||
|
process.stderr.write(`[logger] file logging disabled: ${err.message}\n`)
|
||||||
|
fileStream = null
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(`[logger] could not open log file: ${err.message}\n`)
|
||||||
|
fileStream = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(meta) {
|
||||||
|
if (meta == null) return ''
|
||||||
|
if (typeof meta === 'string') return meta
|
||||||
|
if (meta instanceof Error) return JSON.stringify({ message: meta.message, stack: meta.stack })
|
||||||
|
try {
|
||||||
|
return JSON.stringify(meta)
|
||||||
|
} catch {
|
||||||
|
return String(meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(level, tag, msg, meta) {
|
||||||
|
const levelNum = LEVELS[level]
|
||||||
|
if (levelNum === undefined) return
|
||||||
|
|
||||||
|
const ts = new Date().toISOString()
|
||||||
|
const lvl = level.toUpperCase().padEnd(5)
|
||||||
|
const label = tag ? ` [${tag}]` : ''
|
||||||
|
const metaStr = meta === undefined ? '' : ` ${fmt(meta)}`
|
||||||
|
const plain = `${ts} ${lvl}${label} ${msg}${metaStr}`
|
||||||
|
|
||||||
|
// Console transport
|
||||||
|
if (levelNum <= consoleThreshold) {
|
||||||
|
const line = useColor ? `${COLOR[level] || ''}${plain}${RESET}` : plain
|
||||||
|
const stream = level === 'error' || level === 'warn' ? process.stderr : process.stdout
|
||||||
|
stream.write(`${line}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File transport (plain text, no color)
|
||||||
|
if (fileStream && levelNum <= fileThreshold) {
|
||||||
|
fileStream.write(`${plain}\n`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLogger(tag) {
|
||||||
|
return {
|
||||||
|
error: (msg, meta) => emit('error', tag, msg, meta),
|
||||||
|
warn: (msg, meta) => emit('warn', tag, msg, meta),
|
||||||
|
info: (msg, meta) => emit('info', tag, msg, meta),
|
||||||
|
debug: (msg, meta) => emit('debug', tag, msg, meta),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush and close the file stream (called on graceful shutdown).
|
||||||
|
createLogger.close = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
if (fileStream) fileStream.end(resolve)
|
||||||
|
else resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
createLogger.emit = emit
|
||||||
|
createLogger.logFilePath = logFilePath
|
||||||
|
module.exports = createLogger
|
||||||
850
client/package-lock.json
generated
850
client/package-lock.json
generated
@@ -8,6 +8,13 @@
|
|||||||
"name": "uomysticmoon-client",
|
"name": "uomysticmoon-client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/extension-image": "^2.27.2",
|
||||||
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
|
"@tiptap/extension-text-align": "^2.27.2",
|
||||||
|
"@tiptap/react": "^2.27.2",
|
||||||
|
"@tiptap/starter-kit": "^2.27.2",
|
||||||
|
"diff": "^5.2.2",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.2"
|
"react-router-dom": "^6.26.2"
|
||||||
@@ -740,6 +747,22 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@popperjs/core": {
|
||||||
|
"version": "2.11.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
|
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@remirror/core-constants": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.3",
|
"version": "1.23.3",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||||
@@ -1145,6 +1168,434 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/core": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bold": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bubble-menu": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tippy.js": "^6.3.7"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bullet-list": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code-block": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-document": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-dropcursor": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-floating-menu": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tippy.js": "^6.3.7"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-gapcursor": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-hard-break": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-heading": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-history": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-image": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-italic": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.3.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-ordered-list": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-paragraph": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-strike": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-text": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@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": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/pm": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-changeset": "^2.3.0",
|
||||||
|
"prosemirror-collab": "^1.3.1",
|
||||||
|
"prosemirror-commands": "^1.6.2",
|
||||||
|
"prosemirror-dropcursor": "^1.8.1",
|
||||||
|
"prosemirror-gapcursor": "^1.3.2",
|
||||||
|
"prosemirror-history": "^1.4.1",
|
||||||
|
"prosemirror-inputrules": "^1.4.0",
|
||||||
|
"prosemirror-keymap": "^1.2.2",
|
||||||
|
"prosemirror-markdown": "^1.13.1",
|
||||||
|
"prosemirror-menu": "^1.2.4",
|
||||||
|
"prosemirror-model": "^1.23.0",
|
||||||
|
"prosemirror-schema-basic": "^1.2.3",
|
||||||
|
"prosemirror-schema-list": "^1.4.1",
|
||||||
|
"prosemirror-state": "^1.4.3",
|
||||||
|
"prosemirror-tables": "^1.6.4",
|
||||||
|
"prosemirror-trailing-node": "^3.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2",
|
||||||
|
"prosemirror-view": "^1.37.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/react": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/extension-bubble-menu": "^2.27.2",
|
||||||
|
"@tiptap/extension-floating-menu": "^2.27.2",
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"fast-deep-equal": "^3",
|
||||||
|
"use-sync-external-store": "^1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^2.7.0",
|
||||||
|
"@tiptap/pm": "^2.7.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/starter-kit": {
|
||||||
|
"version": "2.27.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz",
|
||||||
|
"integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "^2.27.2",
|
||||||
|
"@tiptap/extension-blockquote": "^2.27.2",
|
||||||
|
"@tiptap/extension-bold": "^2.27.2",
|
||||||
|
"@tiptap/extension-bullet-list": "^2.27.2",
|
||||||
|
"@tiptap/extension-code": "^2.27.2",
|
||||||
|
"@tiptap/extension-code-block": "^2.27.2",
|
||||||
|
"@tiptap/extension-document": "^2.27.2",
|
||||||
|
"@tiptap/extension-dropcursor": "^2.27.2",
|
||||||
|
"@tiptap/extension-gapcursor": "^2.27.2",
|
||||||
|
"@tiptap/extension-hard-break": "^2.27.2",
|
||||||
|
"@tiptap/extension-heading": "^2.27.2",
|
||||||
|
"@tiptap/extension-history": "^2.27.2",
|
||||||
|
"@tiptap/extension-horizontal-rule": "^2.27.2",
|
||||||
|
"@tiptap/extension-italic": "^2.27.2",
|
||||||
|
"@tiptap/extension-list-item": "^2.27.2",
|
||||||
|
"@tiptap/extension-ordered-list": "^2.27.2",
|
||||||
|
"@tiptap/extension-paragraph": "^2.27.2",
|
||||||
|
"@tiptap/extension-strike": "^2.27.2",
|
||||||
|
"@tiptap/extension-text": "^2.27.2",
|
||||||
|
"@tiptap/extension-text-style": "^2.27.2",
|
||||||
|
"@tiptap/pm": "^2.27.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -1197,6 +1648,41 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/markdown-it": {
|
||||||
|
"version": "14.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||||
|
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/linkify-it": "^5",
|
||||||
|
"@types/mdurl": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -1218,6 +1704,12 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/argparse": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
|
"license": "Python-2.0"
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.40",
|
"version": "2.10.40",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||||
@@ -1293,6 +1785,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1311,6 +1809,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/diff": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.4.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||||
|
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.380",
|
"version": "1.5.380",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",
|
||||||
@@ -1318,6 +1834,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@@ -1367,6 +1895,24 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escape-string-regexp": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1424,6 +1970,31 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/markdown-it"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -1446,6 +2017,39 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/markdown-it": {
|
||||||
|
"version": "14.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz",
|
||||||
|
"integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/markdown-it"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1",
|
||||||
|
"entities": "^4.4.0",
|
||||||
|
"linkify-it": "^5.0.1",
|
||||||
|
"mdurl": "^2.0.0",
|
||||||
|
"punycode.js": "^2.3.1",
|
||||||
|
"uc.micro": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"markdown-it": "bin/markdown-it.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -1482,6 +2086,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/orderedmap": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1518,6 +2128,210 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prosemirror-changeset": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-collab": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-commands": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-dropcursor": {
|
||||||
|
"version": "1.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||||
|
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0",
|
||||||
|
"prosemirror-view": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-gapcursor": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.0.0",
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-history": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.2.2",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.31.0",
|
||||||
|
"rope-sequence": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-inputrules": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-keymap": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"w3c-keyname": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^14.0.0",
|
||||||
|
"markdown-it": "^14.0.0",
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-menu": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crelt": "^1.0.0",
|
||||||
|
"prosemirror-commands": "^1.0.0",
|
||||||
|
"prosemirror-history": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-model": {
|
||||||
|
"version": "1.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
|
||||||
|
"integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"orderedmap": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-basic": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-list": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-state": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.27.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-tables": {
|
||||||
|
"version": "1.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||||
|
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.4",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-transform": "^1.10.5",
|
||||||
|
"prosemirror-view": "^1.41.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-trailing-node": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@remirror/core-constants": "3.0.0",
|
||||||
|
"escape-string-regexp": "^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prosemirror-model": "^1.22.1",
|
||||||
|
"prosemirror-state": "^1.4.2",
|
||||||
|
"prosemirror-view": "^1.33.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-transform": {
|
||||||
|
"version": "1.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||||
|
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-view": {
|
||||||
|
"version": "1.41.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.9.tgz",
|
||||||
|
"integrity": "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.25.8",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -1630,6 +2444,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rope-sequence": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@@ -1659,6 +2479,21 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tippy.js": {
|
||||||
|
"version": "6.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
|
||||||
|
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@popperjs/core": "^2.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -1690,6 +2525,15 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
@@ -1750,6 +2594,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/extension-image": "^2.27.2",
|
||||||
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
|
"@tiptap/extension-text-align": "^2.27.2",
|
||||||
|
"@tiptap/react": "^2.27.2",
|
||||||
|
"@tiptap/starter-kit": "^2.27.2",
|
||||||
|
"diff": "^5.2.2",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.2"
|
"react-router-dom": "^6.26.2"
|
||||||
|
|||||||
BIN
client/public/assets/img/hero-moon.png
Normal file
BIN
client/public/assets/img/hero-moon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -3,6 +3,8 @@ 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'
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
import Portal from './routes/public/Portal.jsx'
|
import Portal from './routes/public/Portal.jsx'
|
||||||
@@ -14,18 +16,41 @@ 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 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 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 DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
||||||
|
import ShardAdmin from './routes/admin/views/ShardAdmin.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 UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||||
|
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||||
|
import Moderation from './routes/admin/views/Moderation.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 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 (
|
||||||
@@ -49,10 +74,19 @@ 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="/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
|
||||||
@@ -65,13 +99,50 @@ 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="settings" element={<SettingsAdmin />} />
|
<Route path="settings" element={<SettingsAdmin />} />
|
||||||
|
<Route
|
||||||
|
path="moderation"
|
||||||
|
element={
|
||||||
|
<RoleGate roles={['admin', 'moderator']}>
|
||||||
|
<Outlet />
|
||||||
|
</RoleGate>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Moderation />} />
|
||||||
|
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||||
|
</Route>
|
||||||
<Route path="activity" element={<ActivityAdmin />} />
|
<Route path="activity" element={<ActivityAdmin />} />
|
||||||
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||||
|
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||||
|
<Route path="shard" element={<ShardAdmin />} />
|
||||||
|
<Route path="characters" element={<AdminCharacters />} />
|
||||||
|
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||||
|
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||||
<Route path="users" element={<UsersAdmin />} />
|
<Route path="users" element={<UsersAdmin />} />
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
|||||||
@@ -41,18 +41,66 @@ function safeParse(text) {
|
|||||||
export const api = {
|
export const api = {
|
||||||
// ----- auth -----
|
// ----- auth -----
|
||||||
me: () => req('/auth/me'),
|
me: () => req('/auth/me'),
|
||||||
login: (username, password) => req('/auth/login', { method: 'POST', body: { username, password } }),
|
// `extra` carries the honeypot field (and any future login fields).
|
||||||
|
login: (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 } }),
|
||||||
|
loginTotp: (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
|
||||||
|
// the callback, so only the code is sent). Returns { user, returnTo }.
|
||||||
|
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
|
||||||
logout: () => req('/auth/logout', { method: 'POST' }),
|
logout: () => req('/auth/logout', { method: 'POST' }),
|
||||||
|
// Public SSO provider discovery — drives the login-page provider buttons.
|
||||||
|
authProviders: () => req('/auth/providers'),
|
||||||
|
|
||||||
// ----- public -----
|
// ----- public -----
|
||||||
publicSettings: () => req('/public/settings'),
|
publicSettings: () => req('/public/settings'),
|
||||||
status: () => req('/public/status'),
|
status: () => req('/public/status'),
|
||||||
posts: (category) => req(`/public/posts/${category}`),
|
posts: (category) => req(`/public/posts/${category}`),
|
||||||
post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`),
|
post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`),
|
||||||
wiki: () => req('/public/wiki'),
|
wiki: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.category) qs.set('category', opts.category)
|
||||||
|
if (opts.tag) qs.set('tag', opts.tag)
|
||||||
|
if (opts.q) qs.set('q', opts.q)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/public/wiki${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
wikiCategories: () => req('/public/wiki/categories'),
|
||||||
|
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'),
|
||||||
|
},
|
||||||
|
// 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'),
|
||||||
@@ -64,23 +112,171 @@ 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)
|
||||||
return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true })
|
return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true })
|
||||||
},
|
},
|
||||||
listWiki: () => req('/admin/wiki'),
|
// Generalized upload for rich-text editors → { url }.
|
||||||
|
upload: (file) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('image', file)
|
||||||
|
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}`),
|
||||||
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 }),
|
||||||
updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', body: data }),
|
updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', body: data }),
|
||||||
|
publishWiki: (slug, published) =>
|
||||||
|
req(`/admin/wiki/${slug}/publish`, { method: 'PATCH', body: { published } }),
|
||||||
deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }),
|
deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }),
|
||||||
|
listWikiRevisions: (slug) => req(`/admin/wiki/${slug}/revisions`),
|
||||||
|
getWikiRevision: (slug, id) => req(`/admin/wiki/${slug}/revisions/${id}`),
|
||||||
|
restoreWikiRevision: (slug, id) =>
|
||||||
|
req(`/admin/wiki/${slug}/revisions/${id}/restore`, { method: 'POST' }),
|
||||||
|
listWikiTags: () => req('/admin/wiki/tags'),
|
||||||
|
listWikiCategories: () => req('/admin/wiki/categories'),
|
||||||
|
createWikiCategory: (data) => req('/admin/wiki/categories', { method: 'POST', body: data }),
|
||||||
|
updateWikiCategory: (id, data) =>
|
||||||
|
req(`/admin/wiki/categories/${id}`, { method: 'PUT', body: data }),
|
||||||
|
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
|
||||||
getSettings: () => req('/admin/settings'),
|
getSettings: () => req('/admin/settings'),
|
||||||
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
||||||
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
||||||
|
botActivity: () => req('/admin/bot-activity'),
|
||||||
|
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
||||||
listUsers: () => req('/admin/users'),
|
listUsers: () => req('/admin/users'),
|
||||||
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' }),
|
||||||
|
|
||||||
|
// ----- moderation dashboard (admin + moderator) -----
|
||||||
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||||
|
modRecent: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.type) qs.set('type', params.type)
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
|
||||||
|
modMembers: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.type) qs.set('type', params.type)
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/members${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modFilterHits: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modSpamHits: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
|
||||||
|
modUserActions: (discordId, params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.type) qs.set('type', params.type)
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
|
||||||
|
addModNote: (discordId, data) =>
|
||||||
|
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
|
||||||
|
|
||||||
|
// ----- account security (self-service 2FA) -----
|
||||||
|
getAccount: () => req('/admin/account'),
|
||||||
|
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
||||||
|
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
|
||||||
|
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
|
||||||
|
|
||||||
|
// ----- linked SSO identities (self-service) -----
|
||||||
|
linkedIdentities: () => req('/admin/account/identities'),
|
||||||
|
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'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----- auth providers / SSO config (admin only) -----
|
||||||
|
listAuthProviders: () => req('/admin/auth/providers'),
|
||||||
|
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||||
|
updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
|
||||||
|
deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// ----- Discord bot control (admin only) -----
|
||||||
|
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
|
||||||
|
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' }),
|
||||||
|
|
||||||
|
// ----- 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'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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'],
|
||||||
|
})
|
||||||
141
client/src/components/CharacterSheet.jsx
Normal file
141
client/src/components/CharacterSheet.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// 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).
|
||||||
|
|
||||||
|
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||||
|
|
||||||
|
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 }) {
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
client/src/components/GameAccounts.jsx
Normal file
156
client/src/components/GameAccounts.jsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from './PageState.jsx'
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameAccounts({ scope, charTo }) {
|
||||||
|
const [accounts, setAccounts] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setAccounts(await scope.accounts())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load your game accounts.')
|
||||||
|
}
|
||||||
|
}, [scope])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!accounts) return <Loading />
|
||||||
|
|
||||||
|
// Not linked yet — prompt to link.
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return (
|
||||||
|
<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 }}>
|
||||||
|
You haven’t linked a game account yet. 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linked — characters grouped by account.
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<section key={a.account}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
|
{a.account}
|
||||||
|
</div>
|
||||||
|
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
<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 />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
client/src/components/HeroElement.jsx
Normal file
195
client/src/components/HeroElement.jsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
|
const MOON_IMAGE = '/assets/img/hero-moon.png'
|
||||||
|
|
||||||
|
// Font family tokens a line may opt into; default is the page serif.
|
||||||
|
const FONT = { display: 'var(--display)', sans: 'var(--sans)' }
|
||||||
|
|
||||||
|
// fontSize may be a number (px, from the editor) or a CSS string (e.g. a clamp()
|
||||||
|
// used by the pre-populated default so the hero stays responsive until edited).
|
||||||
|
function sizeToCss(v) {
|
||||||
|
return typeof v === 'number' ? `${v}px` : v
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineStyle(line) {
|
||||||
|
return {
|
||||||
|
display: 'block', // each line stacks (so a span line behaves like the others)
|
||||||
|
margin: line.marginTop != null ? `${line.marginTop}px 0 0` : '0',
|
||||||
|
fontFamily: FONT[line.font] || undefined,
|
||||||
|
fontSize: sizeToCss(line.fontSize),
|
||||||
|
color: line.color || 'inherit',
|
||||||
|
fontWeight: line.weight || undefined,
|
||||||
|
fontStyle: line.italic ? 'italic' : undefined,
|
||||||
|
letterSpacing: line.letterSpacing || undefined,
|
||||||
|
textTransform: line.transform || undefined,
|
||||||
|
lineHeight: line.lineHeight || undefined,
|
||||||
|
maxWidth: line.maxWidth ? `${line.maxWidth}px` : undefined,
|
||||||
|
marginLeft: line.maxWidth ? 'auto' : undefined,
|
||||||
|
marginRight: line.maxWidth ? 'auto' : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextBlock({ props }) {
|
||||||
|
const align = props.align || 'center'
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
||||||
|
{(props.lines || []).map((line, i) => {
|
||||||
|
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 (
|
||||||
|
<Tag key={i} style={lineStyle(line)}>
|
||||||
|
{line.text}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Buttons({ props }) {
|
||||||
|
const justify = props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center'
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: props.gap ?? 12, justifyContent: justify }}>
|
||||||
|
{(props.items || []).map((b, i) => (
|
||||||
|
<Link key={i} to={b.to || '#'} className={`btn ${b.variant === 'ghost' ? 'btn-ghost' : 'btn-primary'}`}>
|
||||||
|
{b.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ props }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '6px 14px',
|
||||||
|
background: props.bgColor || 'rgba(11,22,48,0.6)',
|
||||||
|
color: props.textColor || '#c2d2e6',
|
||||||
|
borderRadius: props.borderRadius ?? 999,
|
||||||
|
fontSize: '0.74rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.18em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroImage({ props }) {
|
||||||
|
if (!props.src) {
|
||||||
|
// Editor placeholder until an image is chosen (a srcless image never ships live).
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{ width: 160, height: 100, display: 'grid', placeItems: 'center', border: '1px dashed var(--accent)', borderRadius: 8, color: 'var(--muted)', fontSize: '0.8rem', background: 'rgba(11,22,48,0.4)' }}
|
||||||
|
>
|
||||||
|
Upload an image
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={props.src}
|
||||||
|
alt={props.alt || ''}
|
||||||
|
style={{ width: `${props.width || 40}%`, height: 'auto', display: 'block', borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function content(element) {
|
||||||
|
switch (element.type) {
|
||||||
|
case 'text_block':
|
||||||
|
return <TextBlock props={element.props || {}} />
|
||||||
|
case 'buttons':
|
||||||
|
return <Buttons props={element.props || {}} />
|
||||||
|
case 'moon': {
|
||||||
|
const props = element.props || {}
|
||||||
|
const size = props.size || 96
|
||||||
|
const glow = props.glow ?? 0.45
|
||||||
|
// Image source is configurable; old layouts with no src fall back to the
|
||||||
|
// default hero moon so they render exactly as before. Size/glow unchanged.
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={props.src || MOON_IMAGE}
|
||||||
|
alt={props.alt || ''}
|
||||||
|
draggable={false}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: 'auto',
|
||||||
|
display: 'block',
|
||||||
|
filter: glow ? `drop-shadow(0 0 ${size * 0.45}px rgba(216,226,239,${glow}))` : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'badge':
|
||||||
|
return <Badge props={element.props || {}} />
|
||||||
|
case 'image':
|
||||||
|
return <HeroImage props={element.props || {}} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute-positioned wrapper + type-specific content. In `editor` mode the inner
|
||||||
|
// content is made non-interactive (so clicks select/drag the wrapper) and the
|
||||||
|
// wrapper takes selection styling + an onPointerDown handler.
|
||||||
|
export default function HeroElement({
|
||||||
|
element,
|
||||||
|
wrapperStyle,
|
||||||
|
editor = false,
|
||||||
|
selected = false,
|
||||||
|
onPointerDown,
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
const anchor = element.anchor || 'center'
|
||||||
|
const transform =
|
||||||
|
anchor === 'center'
|
||||||
|
? 'translate(-50%, -50%)'
|
||||||
|
: anchor === 'top-right'
|
||||||
|
? 'translateX(-100%)'
|
||||||
|
: undefined
|
||||||
|
// text_block/buttons may set a box width (px); kept within the containing block
|
||||||
|
// (the hero section live, or the editor canvas) with small side gutters.
|
||||||
|
const boxWidth =
|
||||||
|
(element.type === 'text_block' || element.type === 'buttons') && element.props?.width
|
||||||
|
? `min(${element.props.width}px, calc(100% - 36px))`
|
||||||
|
: undefined
|
||||||
|
const cls = [editor ? 'hero-el-editable' : '', selected ? 'is-selected' : ''].filter(Boolean).join(' ')
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cls || undefined}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${element.x}%`,
|
||||||
|
top: `${element.y}%`,
|
||||||
|
zIndex: element.z || 0,
|
||||||
|
transform,
|
||||||
|
width: boxWidth,
|
||||||
|
cursor: editor ? 'move' : undefined,
|
||||||
|
...wrapperStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={editor ? { pointerEvents: 'none' } : undefined}>{content(element)}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
// The little glowing moon used in the logo, login, and maintenance screens.
|
// The little glowing moon used in the logo, login, maintenance screens, and the
|
||||||
export default function MoonDot({ size = 13, glow = 0.45 }) {
|
// hero canvas. `color` overrides the radial-gradient start point (else the CSS
|
||||||
return (
|
// .moon default is used).
|
||||||
<span
|
export default function MoonDot({ size = 13, glow = 0.45, color }) {
|
||||||
className="moon"
|
const style = { width: size, height: size, boxShadow: `0 0 ${size * 0.8}px rgba(216,226,239,${glow})` }
|
||||||
style={{ width: size, height: size, boxShadow: `0 0 ${size * 0.8}px rgba(216,226,239,${glow})` }}
|
if (color) style.background = `radial-gradient(circle at 35% 30%, ${color}, #9fb0c6 55%, #5d6e88)`
|
||||||
/>
|
return <span className="moon" style={style} />
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
37
client/src/components/ProviderIcon.jsx
Normal file
37
client/src/components/ProviderIcon.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Inline SVG brand icons for SSO providers. No binary assets — these scale
|
||||||
|
// crisply at any size and keep their own brand colors. `icon` matches the
|
||||||
|
// provider `kind` from the discovery endpoint ('google' | 'discord' | oidc/oauth2).
|
||||||
|
// Anything unknown falls back to a neutral key glyph in the current text color.
|
||||||
|
|
||||||
|
function GoogleMark({ size }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 48 48" aria-hidden="true" focusable="false">
|
||||||
|
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
|
||||||
|
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
|
||||||
|
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
|
||||||
|
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiscordMark({ size }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="#5865F2" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenericMark({ size }) {
|
||||||
|
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">
|
||||||
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProviderIcon({ icon, size = 18 }) {
|
||||||
|
if (icon === 'google') return <GoogleMark size={size} />
|
||||||
|
if (icon === 'discord') return <DiscordMark size={size} />
|
||||||
|
return <GenericMark size={size} />
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
218
client/src/components/RichTextEditor.jsx
Normal file
218
client/src/components/RichTextEditor.jsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/react'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Link from '@tiptap/extension-link'
|
||||||
|
import Image from '@tiptap/extension-image'
|
||||||
|
import TextAlign from '@tiptap/extension-text-align'
|
||||||
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
|
// Toolbar button.
|
||||||
|
function Btn({ onClick, active, disabled, title, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={title}
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep editor selection
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`rte-btn${active ? ' is-active' : ''}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
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:
|
||||||
|
// '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).
|
||||||
|
// 'minimal' — image upload only; text formatting stripped (Screenshots captions).
|
||||||
|
export default function RichTextEditor({ value, onChange, pages = [], variant = 'full' }) {
|
||||||
|
const showText = variant !== 'minimal' // bold/italic/strike, headings, lists, quotes, links
|
||||||
|
const showWikiLink = variant === 'full' && pages.length > 0
|
||||||
|
const fileRef = useRef(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [linkMenu, setLinkMenu] = useState(false)
|
||||||
|
const [linkFilter, setLinkFilter] = useState('')
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({ heading: { levels: [2, 3] } }),
|
||||||
|
Link.configure({ openOnClick: false, autolink: true }),
|
||||||
|
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 || '',
|
||||||
|
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Safety net: sync if the parent resets `value` externally (won't fire during
|
||||||
|
// normal typing because the parent value equals what the editor just emitted).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
if (value != null && value !== editor.getHTML()) {
|
||||||
|
editor.commands.setContent(value, false)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [value, editor])
|
||||||
|
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
function setLink() {
|
||||||
|
const prev = editor.getAttributes('link').href || ''
|
||||||
|
const url = window.prompt('Link URL (leave blank to remove)', prev)
|
||||||
|
if (url === null) return
|
||||||
|
if (url === '') return editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
|
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertInternalLink(page) {
|
||||||
|
const { from, to } = editor.state.selection
|
||||||
|
if (from === to) {
|
||||||
|
editor.chain().focus().insertContent(`<a href="/wiki/${page.slug}">${escapeHtml(page.title)}</a> `).run()
|
||||||
|
} else {
|
||||||
|
editor.chain().focus().extendMarkRange('link').setLink({ href: `/wiki/${page.slug}` }).run()
|
||||||
|
}
|
||||||
|
setLinkMenu(false)
|
||||||
|
setLinkFilter('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPickImage(e) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = '' // allow re-selecting the same file
|
||||||
|
if (!file) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const { url } = await api.admin.upload(file)
|
||||||
|
editor.chain().focus().setImage({ src: url, alt: file.name }).run()
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Image upload failed.')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rte">
|
||||||
|
<div className="rte-toolbar">
|
||||||
|
{showText && (
|
||||||
|
<>
|
||||||
|
<Btn title="Bold" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||||
|
<b>B</b>
|
||||||
|
</Btn>
|
||||||
|
<Btn title="Italic" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
||||||
|
<i>I</i>
|
||||||
|
</Btn>
|
||||||
|
<Btn title="Strikethrough" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||||
|
<s>S</s>
|
||||||
|
</Btn>
|
||||||
|
<span className="rte-sep" />
|
||||||
|
<Btn title="Heading 2 (table of contents)" active={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
|
||||||
|
H2
|
||||||
|
</Btn>
|
||||||
|
<Btn title="Heading 3" active={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
|
||||||
|
H3
|
||||||
|
</Btn>
|
||||||
|
<span className="rte-sep" />
|
||||||
|
<Btn title="Bullet list" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||||
|
• List
|
||||||
|
</Btn>
|
||||||
|
<Btn title="Numbered list" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||||
|
1. List
|
||||||
|
</Btn>
|
||||||
|
<Btn title="Quote" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
||||||
|
❝
|
||||||
|
</Btn>
|
||||||
|
<Btn title="Code block" active={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
|
||||||
|
{'</>'}
|
||||||
|
</Btn>
|
||||||
|
<Btn title="Divider" onClick={() => editor.chain().focus().setHorizontalRule().run()}>
|
||||||
|
—
|
||||||
|
</Btn>
|
||||||
|
<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>
|
||||||
|
{showWikiLink && (
|
||||||
|
<Btn title="Link to another wiki page" onClick={() => setLinkMenu((v) => !v)}>
|
||||||
|
📄
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Btn title="Insert image" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||||
|
{uploading ? '…' : '🖼'}
|
||||||
|
</Btn>
|
||||||
|
<span className="rte-sep" />
|
||||||
|
<Btn title="Undo" disabled={!editor.can().undo()} onClick={() => editor.chain().focus().undo().run()}>
|
||||||
|
↶
|
||||||
|
</Btn>
|
||||||
|
<Btn title="Redo" disabled={!editor.can().redo()} onClick={() => editor.chain().focus().redo().run()}>
|
||||||
|
↷
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linkMenu && (
|
||||||
|
<div className="rte-linkmenu">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="input"
|
||||||
|
placeholder="Filter pages…"
|
||||||
|
value={linkFilter}
|
||||||
|
onChange={(e) => setLinkFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="rte-linkmenu-list">
|
||||||
|
{pages
|
||||||
|
.filter((p) => {
|
||||||
|
const q = linkFilter.trim().toLowerCase()
|
||||||
|
return !q || p.title.toLowerCase().includes(q) || p.slug.includes(q)
|
||||||
|
})
|
||||||
|
.slice(0, 30)
|
||||||
|
.map((p) => (
|
||||||
|
<button key={p.slug} type="button" className="rte-linkmenu-item" onClick={() => insertInternalLink(p)}>
|
||||||
|
<span>{p.title}</span>
|
||||||
|
<span className="rte-linkmenu-slug">/{p.slug}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EditorContent editor={editor} className="rte-content prose" />
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" onChange={onPickImage} hidden />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
client/src/components/RoleGate.jsx
Normal file
11
client/src/components/RoleGate.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
|
// Client-side role gate for admin sub-sections. Real enforcement is server-side
|
||||||
|
// (requireRole); this just keeps the UI honest — a user without one of `roles`
|
||||||
|
// is redirected rather than shown a page that will only 403 on every call.
|
||||||
|
export default function RoleGate({ roles, children, redirect = '/admin' }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
if (user && !roles.includes(user.role)) return <Navigate to={redirect} replace />
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -1,26 +1,37 @@
|
|||||||
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'
|
||||||
|
|
||||||
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: 'About', to: '/site/about' },
|
||||||
{ label: 'Website', to: '/site' },
|
]
|
||||||
{ label: 'New Player Guide', to: '/wiki/new-player-guide' },
|
|
||||||
{ label: 'Maps & Atlas', to: '/wiki/maps-atlas' },
|
const linkStyle = ({ isActive }) => ({
|
||||||
{ label: 'Systems', to: '/wiki/systems' },
|
background: isActive ? 'var(--accent)' : undefined,
|
||||||
{ label: 'Rules', to: '/wiki/rules' },
|
color: isActive ? 'var(--bg-deep)' : undefined,
|
||||||
],
|
borderColor: isActive ? 'var(--accent)' : undefined,
|
||||||
}
|
})
|
||||||
|
|
||||||
|
export default function SiteHeader() {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
|
||||||
|
// 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 +45,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
|
UOMysticmoon
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,12 +22,37 @@ export function AuthProvider({ children }) {
|
|||||||
refresh()
|
refresh()
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
const login = useCallback(async (username, password) => {
|
// Step 1. Returns { user } on success, or { totpRequired, challenge } when the
|
||||||
const data = await api.login(username, password)
|
// account has 2FA on (caller then calls loginTotp). `extra` carries honeypot.
|
||||||
|
const login = useCallback(async (username, password, extra) => {
|
||||||
|
const data = await api.login(username, password, extra)
|
||||||
|
if (data.user) setUser(data.user)
|
||||||
|
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.
|
||||||
|
const loginTotp = useCallback(async (challenge, code) => {
|
||||||
|
const data = await api.loginTotp(challenge, code)
|
||||||
setUser(data.user)
|
setUser(data.user)
|
||||||
return data.user
|
return data.user
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in
|
||||||
|
// an httpOnly cookie, so only the code is sent. Returns { user, returnTo }.
|
||||||
|
const ssoLoginTotp = useCallback(async (code) => {
|
||||||
|
const data = await api.ssoLoginTotp(code)
|
||||||
|
setUser(data.user)
|
||||||
|
return data
|
||||||
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await api.logout()
|
await api.logout()
|
||||||
@@ -37,7 +62,7 @@ export function AuthProvider({ children }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
<AuthContext.Provider value={{ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
108
client/src/lib/heroLayout.js
Normal file
108
client/src/lib/heroLayout.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// Shared hero-layout helpers used by the public portal and the admin editor.
|
||||||
|
|
||||||
|
export const DEFAULT_HERO_IMAGE = '/assets/img/uomysticmoon-main-hero.png'
|
||||||
|
|
||||||
|
// The original hand-tuned multi-gradient hero background (used only for the
|
||||||
|
// untouched default so the live page is byte-for-byte unchanged until edited).
|
||||||
|
export const HERO_BG =
|
||||||
|
"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('" +
|
||||||
|
DEFAULT_HERO_IMAGE +
|
||||||
|
"')"
|
||||||
|
|
||||||
|
// Single-stop dark overlay driven by the editor's opacity slider.
|
||||||
|
export function buildOverlay(opacity) {
|
||||||
|
return `linear-gradient(180deg,rgba(11,15,20,${opacity * 0.15}) 0%,rgba(11,15,20,${opacity}) 100%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export function heroBackground(layout, { isDefault = false } = {}) {
|
||||||
|
const bg = layout.background || {}
|
||||||
|
const backgroundImage =
|
||||||
|
isDefault && !bg.image_url
|
||||||
|
? HERO_BG
|
||||||
|
: `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || DEFAULT_HERO_IMAGE}')`
|
||||||
|
return {
|
||||||
|
backgroundColor: 'var(--bg-deep)',
|
||||||
|
backgroundImage,
|
||||||
|
backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: bg.size || 'cover',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a stored layout string; return null if missing/malformed/wrong version.
|
||||||
|
export function parseLayout(str) {
|
||||||
|
try {
|
||||||
|
const l = str ? JSON.parse(str) : null
|
||||||
|
return l && l.version === 1 && Array.isArray(l.elements) ? l : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// default stays responsive (editor-created text uses px).
|
||||||
|
export function defaultLayout(teaser) {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
background: { image_url: null, position_x: 'left', position_y: 'center', size: 'cover' },
|
||||||
|
overlay: { opacity: 0.72 },
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
id: 'default-text',
|
||||||
|
type: 'text_block',
|
||||||
|
x: 50,
|
||||||
|
y: 42,
|
||||||
|
z: 1,
|
||||||
|
anchor: 'center',
|
||||||
|
props: {
|
||||||
|
align: 'center',
|
||||||
|
width: 760,
|
||||||
|
lines: [
|
||||||
|
{ 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: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
|
||||||
|
{ text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'default-buttons',
|
||||||
|
type: 'buttons',
|
||||||
|
x: 50,
|
||||||
|
y: 72,
|
||||||
|
z: 2,
|
||||||
|
anchor: 'center',
|
||||||
|
props: {
|
||||||
|
align: 'center',
|
||||||
|
gap: 12,
|
||||||
|
items: [
|
||||||
|
{ label: 'Enter the Website', to: '/site', variant: 'primary' },
|
||||||
|
{ label: 'Open the Wiki', to: '/wiki', variant: 'ghost' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'default-quick-links',
|
||||||
|
type: 'buttons',
|
||||||
|
x: 50,
|
||||||
|
y: 85,
|
||||||
|
z: 3,
|
||||||
|
anchor: 'center',
|
||||||
|
props: {
|
||||||
|
align: 'center',
|
||||||
|
gap: 10,
|
||||||
|
items: [
|
||||||
|
{ label: 'News', to: '/site/news', variant: 'ghost' },
|
||||||
|
{ label: 'Screenshots', to: '/site/screenshots', variant: 'ghost' },
|
||||||
|
{ label: 'Five on Friday', to: '/site/five-on-friday', variant: 'ghost' },
|
||||||
|
{ label: 'Monthly Newsletter', to: '/site/newsletter', variant: 'ghost' },
|
||||||
|
{ label: 'About', to: '/site/about', variant: 'ghost' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
88
client/src/lib/shardEvents.js
Normal file
88
client/src/lib/shardEvents.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// 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}` : ''}`
|
||||||
|
// 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, ' ')
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user