The shared rich-text editor's toolbar buttons (especially the link and image icons) were too small, and the editor body was short and only scrolled vertically. These styles are shared by every RichTextEditor on the site, so the fix applies to the wiki editor, the post editor, and any future ones. - Toolbar buttons: 30px -> 38px, base font 0.85rem -> 1rem, with roomier toolbar padding and gap. - Icon (glyph) buttons (Link, Insert image, wiki-page, Quote, Divider, Undo, Redo) bumped to 1.25rem so they read clearly. - Editor body: max-height 460px -> min(640px, 65vh); ProseMirror min-height 220px -> 320px. - Body now scrolls both ways: overflow-y:auto -> overflow:auto (wide images, code blocks, tables can scroll sideways). - Nudged the internal-link popover offset to match the taller toolbar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UOMysticmoon Website
Public site, wiki, and protected admin panel for the UOMysticmoon private Ultima Online shard — a full-stack app in one repo:
- Backend — Node.js + Express REST API (layered
router → controller → model → db), MariaDB, JWT-in-cookie auth. - 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.
The design reference is BACKEND_DESIGN.md (API contract, schema, security).
Contents
- Tech stack
- Project structure
- Prerequisites
- Setup & run
- First admin & site mode
- Pages & routes
- API endpoints
- Environment variables
- Security
- Logging
- Deployment behind Pangolin
Tech stack
| Layer | Tech |
|---|---|
| Backend | Node.js 20+, Express 4, mariadb driver (parameterized SQL, no ORM) |
| Auth | JWT in an httpOnly cookie, bcrypt password hashing |
| Database | MariaDB 11 (own container) |
| Frontend | React 18, Vite 5, React Router 6 |
Nodemailer (SMTP) with a mailto: fallback |
|
| Deploy | Docker Compose, Pangolin reverse proxy |
Project structure
UOMSITE/
├─ server/ Express API
│ ├─ src/
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
│ │ ├─ app.js middleware + static SPA + routes
│ │ ├─ router/v1/ auth / public / admin route groups
│ │ ├─ model/ users · posts · wiki · settings · activity (.model + .db)
│ │ ├─ middleware/ siteMode · noindex · rateLimit · validate
│ │ └─ utils/ auth (JWT/cookies) · db (pool) · mailer · logger
│ ├─ db/ schema.sql + seed.js
│ └─ .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, AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Users) + editors
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, …
│ │ ├─ 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
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.
cp .env.example .env
# 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)
docker compose up -d --build
- 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.logon the host) - Stop:
docker compose down(add-vto 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):
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):
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 server # nodemon → http://localhost:3000
3. Start the frontend (terminal 2):
npm run install-client
npm run client # Vite → http://localhost:5173
Develop at http://localhost:5173 (hot reload). On Windows, the Vite proxy targets
127.0.0.1:3000 to avoid the IPv6-localhost pitfall.
Tip:
npm run install-allinstalls 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):
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
userstable is empty andADMIN_USERNAME/ADMIN_PASSWORDare set, the first admin is created automatically. You can also runnpm run seed. After it exists you may blank those env vars. - The site starts in
maintenancemode: 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/users |
User management |
API endpoints
| Group | Base | Auth |
|---|---|---|
| Auth | /api/v1/auth (login, logout, me) |
cookie |
| 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, users) |
cookie (admin) |
Post categories (URL form): news, five-on-friday, newsletter, screenshots.
See BACKEND_DESIGN.md §4 for the full contract.
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 |
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 |
JWT_EXPIRES_IN |
1d |
token + cookie lifetime |
COOKIE_SECURE |
auto |
auto = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
COOKIE_NAME |
uomm_token |
|
ADMIN_USERNAME / ADMIN_PASSWORD |
— | first-admin bootstrap (first boot only) |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS |
— | optional; blank → contact form uses mailto: |
CONTACT_TO |
UOMysticmoon@gmail.com |
contact recipient |
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) |
Security
JWT in an httpOnly, SameSite=Lax cookie (Secure auto-detected) · bcrypt hashing · login &
contact rate limiting · express-validator on writes · helmet · admin routes noindex +
robots.txt disallow · trust proxy for correct client IPs behind Pangolin · first admin seeded
from env (no hardcoded credentials) · .env git-ignored. Passwords and request bodies are never
logged. SMTP is optional — the contact form falls back to a mailto: link when unconfigured.
Logging
Every log line goes to both the console and a log file, timestamped and leveled
(error / warn / info / debug):
2026-06-26T18:55:01.123Z INFO [server] listening on http://0.0.0.0:3000 ...
2026-06-26T18:55:09.880Z INFO [http] 192.168.1.40 admin POST /api/v1/auth/login 200 12 ms - 48 bytes
2026-06-26T18:55:14.402Z WARN [auth] login failed {"username":"root","ip":"192.168.1.40"}
2026-06-26T18:55:20.110Z ERROR [error] GET /api/v1/public/wiki -> 500 ... {"stack":"..."}
Captured: startup config banner, schema/seed steps, HTTP access logs (real client IP via
trust proxy, the authenticated admin, method/URL/status/time/size), login success/failure,
rate-limit hits, site-mode changes, all errors with stack traces, and graceful shutdown. Console
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.
Deployment behind Pangolin
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.