Compare commits
66 Commits
673c0400c5
...
feature/mo
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
38
.env.example
38
.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,6 +33,21 @@ 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=
|
||||||
@@ -43,3 +63,19 @@ 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
|
||||||
|
|||||||
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).
|
||||||
146
README.md
146
README.md
@@ -3,7 +3,7 @@
|
|||||||
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 — a full-stack app in one repo:
|
shard — a full-stack app in one repo:
|
||||||
|
|
||||||
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, JWT-in-cookie auth.
|
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
|
||||||
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
|
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
|
||||||
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
|
- **Deploy** — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
|||||||
- [First admin & site mode](#first-admin--site-mode)
|
- [First admin & site mode](#first-admin--site-mode)
|
||||||
- [Pages & routes](#pages--routes)
|
- [Pages & routes](#pages--routes)
|
||||||
- [API endpoints](#api-endpoints)
|
- [API endpoints](#api-endpoints)
|
||||||
|
- [API documentation (Swagger)](#api-documentation-swagger)
|
||||||
- [Environment variables](#environment-variables)
|
- [Environment variables](#environment-variables)
|
||||||
- [Security](#security)
|
- [Security](#security)
|
||||||
- [Logging](#logging)
|
- [Logging](#logging)
|
||||||
@@ -35,10 +36,11 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc
|
|||||||
| Layer | Tech |
|
| Layer | Tech |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Backend | Node.js 20+, Express 4, `mariadb` driver (parameterized SQL, no ORM) |
|
| Backend | Node.js 20+, Express 4, `mariadb` driver (parameterized SQL, no ORM) |
|
||||||
| Auth | JWT in an httpOnly cookie, bcrypt password hashing |
|
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
|
||||||
| Database | MariaDB 11 (own container) |
|
| Database | MariaDB 11 (own container) |
|
||||||
| Frontend | React 18, Vite 5, React Router 6 |
|
| Frontend | React 18, Vite 5, React Router 6 |
|
||||||
| Email | Nodemailer (SMTP) with a `mailto:` fallback |
|
| Email | Nodemailer (SMTP) 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 |
|
| Deploy | Docker Compose, Pangolin reverse proxy |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -51,18 +53,20 @@ UOMSITE/
|
|||||||
│ ├─ src/
|
│ ├─ src/
|
||||||
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
|
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
|
||||||
│ │ ├─ app.js middleware + static SPA + routes
|
│ │ ├─ app.js middleware + static SPA + routes
|
||||||
│ │ ├─ router/v1/ auth / public / admin route groups
|
│ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry)
|
||||||
│ │ ├─ model/ users · posts · wiki · settings · activity (.model + .db)
|
│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups
|
||||||
│ │ ├─ middleware/ siteMode · noindex · rateLimit · validate
|
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
|
||||||
│ │ └─ utils/ auth (JWT/cookies) · db (pool) · mailer · logger
|
│ │ ├─ 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
|
│ ├─ db/ schema.sql + seed.js
|
||||||
|
│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec)
|
||||||
│ └─ .env.example
|
│ └─ .env.example
|
||||||
├─ client/ React + Vite SPA
|
├─ client/ React + Vite SPA
|
||||||
│ ├─ src/
|
│ ├─ src/
|
||||||
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
|
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
|
||||||
│ │ ├─ routes/wiki/ Wiki landing + WikiArticle
|
│ │ ├─ routes/wiki/ Wiki landing + WikiArticle
|
||||||
│ │ ├─ routes/admin/ AdminLogin, AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Users) + editors
|
│ │ ├─ 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, …
|
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), …
|
||||||
│ │ ├─ contexts/ AuthContext, SiteContext
|
│ │ ├─ contexts/ AuthContext, SiteContext
|
||||||
│ │ ├─ api/client.js fetch wrapper (sends cookies)
|
│ │ ├─ api/client.js fetch wrapper (sends cookies)
|
||||||
│ │ └─ styles/theme.css design tokens
|
│ │ └─ styles/theme.css design tokens
|
||||||
@@ -187,7 +191,10 @@ npm start # node server → serves API + SPA at http://localhost:3
|
|||||||
| `/admin/wiki` | Wiki pages CRUD |
|
| `/admin/wiki` | Wiki pages CRUD |
|
||||||
| `/admin/settings` | Site settings |
|
| `/admin/settings` | Site settings |
|
||||||
| `/admin/activity` | Activity log |
|
| `/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/users` | User management |
|
||||||
|
| `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -195,12 +202,52 @@ npm start # node server → serves API + SPA at http://localhost:3
|
|||||||
|
|
||||||
| 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 |
|
||||||
|
| Auth (mobile) | `/api/v1/auth/mobile` (`login`, `refresh`, `logout`) | bearer (access + refresh tokens) |
|
||||||
|
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
|
||||||
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
|
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
|
||||||
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `users`) | cookie (admin) |
|
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
|
||||||
|
|
||||||
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||||
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) §4 for the full contract.
|
`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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API documentation (Swagger)
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -212,13 +259,22 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `NODE_ENV` | `production` | |
|
| `NODE_ENV` | `production` | |
|
||||||
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
|
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
|
||||||
|
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
|
||||||
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
|
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
|
||||||
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `uomysticmoon` / `uomm` / — | app database credentials |
|
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `uomysticmoon` / `uomm` / — | app database credentials |
|
||||||
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
|
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
|
||||||
| `JWT_SECRET` | — | **required** — long random string |
|
| `JWT_SECRET` | — | **required** — long random string; signs session, mobile, and SSO-flow tokens |
|
||||||
| `JWT_EXPIRES_IN` | `1d` | token + cookie lifetime |
|
| `JWT_EXPIRES_IN` | `1d` | web session token + cookie lifetime |
|
||||||
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
|
| `COOKIE_SECURE` | `auto` | `auto` = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
|
||||||
| `COOKIE_NAME` | `uomm_token` | |
|
| `COOKIE_NAME` | `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) |
|
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | — | first-admin bootstrap (first boot only) |
|
||||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | — | optional; blank → contact form uses `mailto:` |
|
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | — | optional; blank → contact form uses `mailto:` |
|
||||||
| `CONTACT_TO` | `UOMysticmoon@gmail.com` | contact recipient |
|
| `CONTACT_TO` | `UOMysticmoon@gmail.com` | contact recipient |
|
||||||
@@ -230,11 +286,65 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
JWT in an httpOnly, `SameSite=Lax` cookie (`Secure` auto-detected) · bcrypt hashing · login &
|
**Session & authorization**
|
||||||
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
|
- All auth flows go through one **session service** (`server/src/auth/`): controllers call
|
||||||
from env (no hardcoded credentials) · `.env` git-ignored. Passwords and request bodies are never
|
`sessionService.createSession(user, authMethod)` and middleware calls `validateSession()`, so web
|
||||||
logged. SMTP is optional — the contact form falls back to a `mailto:` link when unconfigured.
|
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. SMTP is optional — the contact
|
||||||
|
form falls back to a `mailto:` link when unconfigured.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
836
client/package-lock.json
generated
836
client/package-lock.json
generated
@@ -8,6 +8,12 @@
|
|||||||
"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/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 +746,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 +1167,421 @@
|
|||||||
"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-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 +1634,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 +1690,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 +1771,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 +1795,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 +1820,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 +1881,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 +1956,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 +2003,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 +2072,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 +2114,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 +2430,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 +2465,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 +2511,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 +2580,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,12 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/extension-image": "^2.27.2",
|
||||||
|
"@tiptap/extension-link": "^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,7 @@ import { AuthProvider } from './contexts/AuthContext.jsx'
|
|||||||
import { SiteProvider } from './contexts/SiteContext.jsx'
|
import { SiteProvider } from './contexts/SiteContext.jsx'
|
||||||
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
||||||
import RequireAuth from './components/RequireAuth.jsx'
|
import RequireAuth from './components/RequireAuth.jsx'
|
||||||
|
import RoleGate from './components/RoleGate.jsx'
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
import Portal from './routes/public/Portal.jsx'
|
import Portal from './routes/public/Portal.jsx'
|
||||||
@@ -23,9 +24,16 @@ 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 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 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'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -66,9 +74,25 @@ export default function App() {
|
|||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="posts" element={<PostsAdmin />} />
|
<Route path="posts" element={<PostsAdmin />} />
|
||||||
<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="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>
|
||||||
|
|
||||||
|
|||||||
@@ -41,15 +41,33 @@ 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 } }),
|
||||||
|
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}`),
|
||||||
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
||||||
|
|
||||||
@@ -69,18 +87,104 @@ export const api = {
|
|||||||
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 })
|
||||||
|
},
|
||||||
|
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' }),
|
||||||
|
|
||||||
|
// ----- 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 }),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
182
client/src/components/HeroElement.jsx
Normal file
182
client/src/components/HeroElement.jsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
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)$/.test(line.tag) ? line.tag : 'p'
|
||||||
|
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} />
|
||||||
|
}
|
||||||
186
client/src/components/RichTextEditor.jsx
Normal file
186
client/src/components/RichTextEditor.jsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
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 { 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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }),
|
||||||
|
],
|
||||||
|
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="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
|
||||||
|
}
|
||||||
@@ -22,12 +22,29 @@ 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
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 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 +54,7 @@ export function AuthProvider({ children }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
<AuthContext.Provider value={{ user, loading, login, 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: 'p', 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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,22 +4,37 @@ import MoonDot from '../../components/MoonDot.jsx'
|
|||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
|
// `roles` (when present) restricts which roles see a nav item. Items without it
|
||||||
|
// are shown to admin/editor as before. Moderators are further confined to just
|
||||||
|
// their own section + account security (see the redirect effect below).
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/admin', label: 'Dashboard', end: true },
|
{ to: '/admin', label: 'Dashboard', end: true },
|
||||||
{ to: '/admin/posts', label: 'Posts' },
|
{ to: '/admin/posts', label: 'Posts' },
|
||||||
{ to: '/admin/wiki', label: 'Wiki' },
|
{ to: '/admin/wiki', label: 'Wiki' },
|
||||||
|
{ to: '/admin/hero', label: 'Hero Editor' },
|
||||||
|
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
|
||||||
{ to: '/admin/settings', label: 'Settings' },
|
{ to: '/admin/settings', label: 'Settings' },
|
||||||
{ to: '/admin/activity', label: 'Activity' },
|
{ to: '/admin/activity', label: 'Activity' },
|
||||||
|
{ to: '/admin/bot-activity', label: 'Bot Activity' },
|
||||||
|
{ to: '/admin/discord-bot', label: 'Discord Bot' },
|
||||||
|
{ to: '/admin/auth-providers', label: 'Authentication' },
|
||||||
{ to: '/admin/users', label: 'Users' },
|
{ to: '/admin/users', label: 'Users' },
|
||||||
|
{ to: '/admin/account', label: 'Account' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const TITLES = {
|
const TITLES = {
|
||||||
'/admin': 'Dashboard',
|
'/admin': 'Dashboard',
|
||||||
'/admin/posts': 'Posts',
|
'/admin/posts': 'Posts',
|
||||||
'/admin/wiki': 'Wiki Pages',
|
'/admin/wiki': 'Wiki Pages',
|
||||||
|
'/admin/hero': 'Hero Editor',
|
||||||
|
'/admin/moderation': 'Moderation',
|
||||||
'/admin/settings': 'Site Settings',
|
'/admin/settings': 'Site Settings',
|
||||||
'/admin/activity': 'Activity Log',
|
'/admin/activity': 'Activity Log',
|
||||||
|
'/admin/bot-activity': 'Bot Activity',
|
||||||
|
'/admin/discord-bot': 'Discord Bot',
|
||||||
|
'/admin/auth-providers': 'Authentication',
|
||||||
'/admin/users': 'Users',
|
'/admin/users': 'Users',
|
||||||
|
'/admin/account': 'Account Security',
|
||||||
}
|
}
|
||||||
|
|
||||||
const navBtnBase = {
|
const navBtnBase = {
|
||||||
@@ -38,9 +53,31 @@ export default function AdminLayout() {
|
|||||||
const { mode } = useSite()
|
const { mode } = useSite()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const title = TITLES[location.pathname] || 'Admin'
|
const title =
|
||||||
|
TITLES[location.pathname] ||
|
||||||
|
(location.pathname.startsWith('/admin/moderation') ? 'Moderation' : 'Admin')
|
||||||
|
// The hero canvas editor needs room — let it use the full content width.
|
||||||
|
const wide = location.pathname === '/admin/hero'
|
||||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||||
|
|
||||||
|
// Moderators only get the moderation section + their own account security.
|
||||||
|
const isModerator = user?.role === 'moderator'
|
||||||
|
const navItems = NAV.filter((n) => {
|
||||||
|
if (n.roles && !n.roles.includes(user?.role)) return false
|
||||||
|
if (isModerator) return n.to === '/admin/moderation' || n.to === '/admin/account'
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Confine a moderator who deep-links (or is redirected to the index) to a page
|
||||||
|
// outside their remit — the API would 403 anyway, so send them to their home.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isModerator) return
|
||||||
|
const p = location.pathname
|
||||||
|
if (!p.startsWith('/admin/moderation') && p !== '/admin/account') {
|
||||||
|
navigate('/admin/moderation', { replace: true })
|
||||||
|
}
|
||||||
|
}, [isModerator, location.pathname, navigate])
|
||||||
|
|
||||||
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const meta = document.createElement('meta')
|
const meta = document.createElement('meta')
|
||||||
@@ -82,7 +119,7 @@ export default function AdminLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{NAV.map((n) => (
|
{navItems.map((n) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={n.to}
|
key={n.to}
|
||||||
to={n.to}
|
to={n.to}
|
||||||
@@ -145,7 +182,7 @@ export default function AdminLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 1000, width: '100%' }}>
|
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: wide ? 'none' : 1000, width: '100%' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,33 +1,106 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import MoonDot from '../../components/MoonDot.jsx'
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
|
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// Friendly copy for the ?sso_error codes the SSO callback can redirect back with.
|
||||||
|
const SSO_ERRORS = {
|
||||||
|
not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.',
|
||||||
|
denied: 'Sign-in was cancelled.',
|
||||||
|
unavailable: 'That sign-in method is not available right now.',
|
||||||
|
bad_state: 'Your sign-in session expired. Please try again.',
|
||||||
|
error: 'Could not complete sign-in. Please try again.',
|
||||||
|
}
|
||||||
|
|
||||||
const BG =
|
const BG =
|
||||||
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
||||||
|
|
||||||
|
// Hidden anti-bot field. Off-screen via CSS (NOT display:none/hidden, which bots
|
||||||
|
// skip) so real users never fill it but naive scripted bots do. Name must match
|
||||||
|
// the server's HONEYPOT_FIELD ('company').
|
||||||
|
const honeypotStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '-9999px',
|
||||||
|
top: 'auto',
|
||||||
|
width: '1px',
|
||||||
|
height: '1px',
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminLogin() {
|
export default function AdminLogin() {
|
||||||
const { user, login } = useAuth()
|
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const dest = location.state?.from?.pathname || '/admin'
|
const dest = location.state?.from?.pathname || '/admin'
|
||||||
|
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [company, setCompany] = useState('') // honeypot — must stay empty
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
// Two-factor step state. `ssoTotp` marks the SSO variant: the challenge lives in
|
||||||
|
// an httpOnly cookie (not React state), so the code posts to a different endpoint.
|
||||||
|
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
|
||||||
|
const [challenge, setChallenge] = useState('')
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [ssoTotp, setSsoTotp] = useState(false)
|
||||||
|
|
||||||
|
// SSO providers to offer (empty if none configured) + any error the callback
|
||||||
|
// bounced us back with (?sso_error=...).
|
||||||
|
const [providers, setProviders] = useState([])
|
||||||
|
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
||||||
|
|
||||||
// Already signed in → go straight to the panel.
|
// Already signed in → go straight to the panel.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) navigate(dest, { replace: true })
|
if (user) navigate(dest, { replace: true })
|
||||||
}, [user, dest, navigate])
|
}, [user, dest, navigate])
|
||||||
|
|
||||||
|
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
|
||||||
|
// step: it has staged an httpOnly TOTP challenge and needs the authenticator code
|
||||||
|
// before it will issue a session. Jump straight to the code step.
|
||||||
|
useEffect(() => {
|
||||||
|
if (new URLSearchParams(location.search).get('sso_totp')) {
|
||||||
|
setStage('totp')
|
||||||
|
setSsoTotp(true)
|
||||||
|
}
|
||||||
|
}, [location.search])
|
||||||
|
|
||||||
|
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
|
||||||
|
// still works with password login and simply shows no provider buttons.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
api
|
||||||
|
.authProviders()
|
||||||
|
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
|
||||||
|
.catch(() => active && setProviders([]))
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Full-page redirect into the provider's OAuth flow, preserving the intended
|
||||||
|
// destination so the callback can return the user there.
|
||||||
|
function startSso(provider) {
|
||||||
|
const q = dest && dest !== '/admin' ? `?returnTo=${encodeURIComponent(dest)}` : ''
|
||||||
|
window.location.assign(provider.loginUrl + q)
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit(e) {
|
async function onSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await login(username, password)
|
const data = await login(username, password, { company })
|
||||||
|
if (data.totpRequired) {
|
||||||
|
setChallenge(data.challenge)
|
||||||
|
setStage('totp')
|
||||||
|
setBusy(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
navigate(dest, { replace: true })
|
navigate(dest, { replace: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
||||||
@@ -35,6 +108,33 @@ export default function AdminLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSubmitTotp(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
if (ssoTotp) {
|
||||||
|
const { returnTo } = await ssoLoginTotp(code)
|
||||||
|
navigate(returnTo || '/admin', { replace: true })
|
||||||
|
} else {
|
||||||
|
await loginTotp(challenge, code)
|
||||||
|
navigate(dest, { replace: true })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const expired = err.status === 401 && /expired/i.test(err.message)
|
||||||
|
setError(
|
||||||
|
expired
|
||||||
|
? 'Your verification session expired. Please sign in again.'
|
||||||
|
: 'Invalid verification code.',
|
||||||
|
)
|
||||||
|
setBusy(false)
|
||||||
|
if (expired) {
|
||||||
|
setStage('creds')
|
||||||
|
setSsoTotp(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
style={{
|
style={{
|
||||||
@@ -63,7 +163,7 @@ export default function AdminLogin() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={onSubmit}
|
onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--line)',
|
border: '1px solid var(--line)',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@@ -73,6 +173,8 @@ export default function AdminLogin() {
|
|||||||
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{stage === 'creds' ? (
|
||||||
|
<>
|
||||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||||
<span className="field-label">Username</span>
|
<span className="field-label">Username</span>
|
||||||
<input
|
<input
|
||||||
@@ -95,9 +197,43 @@ export default function AdminLogin() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{error && (
|
{/* Honeypot: hidden from humans, left empty; bots that fill it are rejected. */}
|
||||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
|
<div style={honeypotStyle} aria-hidden="true">
|
||||||
{error}
|
<label>
|
||||||
|
Company
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="company"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
value={company}
|
||||||
|
onChange={(e) => setCompany(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||||
|
<span className="field-label">Authentication code</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
autoFocus
|
||||||
|
placeholder="6-digit code"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||||
|
Enter the code from your authenticator app.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(error || (stage === 'creds' && ssoError)) && (
|
||||||
|
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center', lineHeight: 1.5 }}>
|
||||||
|
{error || ssoError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -107,8 +243,47 @@ export default function AdminLogin() {
|
|||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
|
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
|
||||||
>
|
>
|
||||||
{busy ? 'Signing in…' : 'Sign in'}
|
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* SSO providers — only on the credentials step, only if any are enabled. */}
|
||||||
|
{stage === 'creds' && providers.length > 0 && (
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||||
|
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => startSso(p)}
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 10,
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 11,
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
color: 'var(--ink)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||||
|
<ProviderIcon icon={p.icon} size={18} />
|
||||||
|
</span>
|
||||||
|
Continue with {p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
|
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||||
Protected area — not indexed. Sessions expire after 1 day.
|
Protected area — not indexed. Sessions expire after 1 day.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
308
client/src/routes/admin/views/AccountAdmin.jsx
Normal file
308
client/src/routes/admin/views/AccountAdmin.jsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import ProviderIcon from '../../../components/ProviderIcon.jsx'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Link/unlink external SSO identities to this account. Linking redirects through
|
||||||
|
// the provider's OAuth flow (/auth/sso/:id/link) and returns here with ?linked
|
||||||
|
// or ?link_error. Only providers that are enabled + valid can be linked.
|
||||||
|
function LinkedAccounts() {
|
||||||
|
const [linked, setLinked] = useState(null)
|
||||||
|
const [available, setAvailable] = useState([])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const banner = (() => {
|
||||||
|
const q = new URLSearchParams(window.location.search)
|
||||||
|
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||||
|
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||||
|
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||||
|
return null
|
||||||
|
})()
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [ids, avail] = await Promise.all([
|
||||||
|
api.admin.linkedIdentities(),
|
||||||
|
api.authProviders().catch(() => []),
|
||||||
|
])
|
||||||
|
setLinked(ids)
|
||||||
|
setAvailable(Array.isArray(avail) ? avail : [])
|
||||||
|
} catch {
|
||||||
|
setError('Could not load linked accounts.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||||
|
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||||
|
|
||||||
|
async function unlink(provider) {
|
||||||
|
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||||
|
try {
|
||||||
|
await api.admin.unlinkIdentity(provider)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not unlink.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!linked) return null
|
||||||
|
|
||||||
|
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||||
|
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||||
|
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||||
|
Linked accounts
|
||||||
|
</h2>
|
||||||
|
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||||
|
Link a Google, Discord, or other SSO account so you can sign in with it. SSO can only sign in
|
||||||
|
to an account it is linked to — linking here is what grants that access.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{banner && (
|
||||||
|
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>
|
||||||
|
{banner.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linked.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||||
|
{linked.map((i) => (
|
||||||
|
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||||
|
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||||
|
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||||
|
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||||
|
Unlink
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linkable.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 6 }}>
|
||||||
|
{linkable.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => window.location.assign(`/api/v1/auth/sso/${p.id}/link`)}
|
||||||
|
className="btn"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'center', width: '100%', maxWidth: 320, borderRadius: 8, padding: 10, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.04)', color: 'var(--ink)' }}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||||
|
<ProviderIcon icon={p.icon} size={18} />
|
||||||
|
</span>
|
||||||
|
Link {p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linked.length === 0 && linkable.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.86rem' }}>
|
||||||
|
No SSO providers are enabled. Configure them under <strong>Authentication</strong>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-service account security: enable / disable optional TOTP two-factor.
|
||||||
|
export default function AccountAdmin() {
|
||||||
|
const [account, setAccount] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Enrollment state.
|
||||||
|
const [setup, setSetup] = useState(null) // { qr, otpauthUrl }
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setAccount(await api.admin.getAccount())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load your account.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
|
||||||
|
async function beginSetup() {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setSetup(await api.admin.totpSetup())
|
||||||
|
setCode('')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not start setup.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmEnable() {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.admin.totpEnable(code.trim())
|
||||||
|
setSetup(null)
|
||||||
|
setCode('')
|
||||||
|
setMsg('Two-factor authentication is now enabled.')
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not enable two-factor.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disable() {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.admin.totpDisable(code.trim())
|
||||||
|
setCode('')
|
||||||
|
setMsg('Two-factor authentication has been disabled.')
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not disable two-factor.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = account?.totp_enabled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 560 }}>
|
||||||
|
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||||
|
Two-factor authentication
|
||||||
|
</h2>
|
||||||
|
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||||
|
Add a time-based one-time code (TOTP) from an authenticator app as a second step at login.
|
||||||
|
Optional, and only affects your own account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
color: enabled ? '#7fd0a4' : 'var(--muted)',
|
||||||
|
marginBottom: 22,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 9,
|
||||||
|
height: 9,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: enabled ? '#7fd0a4' : 'var(--dim)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{enabled ? 'Enabled' : 'Not enabled'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enable flow */}
|
||||||
|
{!enabled && !setup && (
|
||||||
|
<div>
|
||||||
|
<button onClick={beginSetup} disabled={busy} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Preparing…' : 'Set up two-factor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!enabled && setup && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||||
|
1. Scan this QR code with your authenticator app, then enter the current 6-digit code to confirm.
|
||||||
|
</p>
|
||||||
|
<img
|
||||||
|
src={setup.qr}
|
||||||
|
alt="TOTP QR code"
|
||||||
|
width={180}
|
||||||
|
height={180}
|
||||||
|
style={{ borderRadius: 8, background: '#fff', padding: 8, alignSelf: 'flex-start' }}
|
||||||
|
/>
|
||||||
|
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||||
|
<span className="field-label">Verification code</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="6-digit code"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<button onClick={confirmEnable} disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Enabling…' : 'Confirm & enable'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setSetup(null)} disabled={busy} className="pill">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Disable flow */}
|
||||||
|
{enabled && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||||
|
Enter a current code from your authenticator to turn two-factor off.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||||
|
<span className="field-label">Verification code</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="6-digit code"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button onClick={disable} disabled={busy || !code.trim()} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
|
||||||
|
{busy ? 'Disabling…' : 'Disable two-factor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
|
||||||
|
{error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||||
|
|
||||||
|
<LinkedAccounts />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
380
client/src/routes/admin/views/AuthProvidersAdmin.jsx
Normal file
380
client/src/routes/admin/views/AuthProvidersAdmin.jsx
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import ProviderIcon from '../../../components/ProviderIcon.jsx'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Admin config for authentication providers. Local password + TOTP is always on
|
||||||
|
// (informational tab). Google/Discord are built-ins with a fixed config surface
|
||||||
|
// (Enabled + Client ID + Client Secret). Custom providers use the full OIDC editor.
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'local', label: 'Local Accounts' },
|
||||||
|
{ id: 'google', label: 'Google' },
|
||||||
|
{ id: 'discord', label: 'Discord' },
|
||||||
|
{ id: 'custom', label: 'Custom Providers' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// The redirect/callback URL to register with the provider. Mirrors the server's
|
||||||
|
// redirect_uri (APP_BASE_URL + this path); shown so admins can copy it exactly.
|
||||||
|
function callbackUrl(id) {
|
||||||
|
return `${window.location.origin}/api/v1/auth/sso/${id}/callback`
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthWarning({ provider }) {
|
||||||
|
if (!provider || !provider.enabled || provider.health.valid) return null
|
||||||
|
return (
|
||||||
|
<p className="sans" style={{ margin: '4px 0 0', color: '#e0b070', fontSize: '0.82rem', lineHeight: 1.5 }}>
|
||||||
|
Enabled but incomplete (missing: {provider.health.missing.join(', ')}). Hidden from the login
|
||||||
|
page until fully configured.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CallbackHint({ id }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<span className="field-label">Redirect / callback URL (register this with the provider)</span>
|
||||||
|
<code
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'block', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--line)', background: 'var(--bg-deep)', color: 'var(--muted)', fontSize: '0.82rem', wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{callbackUrl(id)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({ checked, onChange, label }) {
|
||||||
|
return (
|
||||||
|
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Built-in (Google / Discord) config form ────────────────────────────────
|
||||||
|
function BuiltinForm({ provider, onSaved }) {
|
||||||
|
const [enabled, setEnabled] = useState(provider.enabled)
|
||||||
|
const [clientId, setClientId] = useState(provider.clientId || '')
|
||||||
|
const [secret, setSecret] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Re-sync when switching between provider tabs.
|
||||||
|
useEffect(() => {
|
||||||
|
setEnabled(provider.enabled)
|
||||||
|
setClientId(provider.clientId || '')
|
||||||
|
setSecret('')
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
}, [provider.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const body = { enabled, clientId }
|
||||||
|
if (secret) body.secret = secret // only send a new secret when entered
|
||||||
|
await api.admin.updateAuthProvider(provider.id, body)
|
||||||
|
setSecret('')
|
||||||
|
setMsg('Saved.')
|
||||||
|
await onSaved()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not save.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<span style={{ display: 'inline-flex', width: 26, height: 26 }}>
|
||||||
|
<ProviderIcon icon={provider.kind} size={26} />
|
||||||
|
</span>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||||
|
{provider.name}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Toggle checked={enabled} onChange={setEnabled} label="Enable this sign-in method" />
|
||||||
|
<HealthWarning provider={provider} />
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Client ID</span>
|
||||||
|
<input type="text" value={clientId} onChange={(e) => setClientId(e.target.value)} className="input" autoComplete="off" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Client Secret</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={secret}
|
||||||
|
onChange={(e) => setSecret(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={provider.hasSecret ? '•••••••• configured — leave blank to keep' : 'Client secret'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<CallbackHint id={provider.id} />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
|
||||||
|
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Local accounts (informational) ─────────────────────────────────────────
|
||||||
|
function LocalInfo() {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 560 }}>
|
||||||
|
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||||
|
Local accounts
|
||||||
|
</h2>
|
||||||
|
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||||
|
Username & password sign-in (with optional TOTP two-factor) is always enabled and cannot
|
||||||
|
be turned off — it is how you manage accounts and link SSO identities. Manage users under
|
||||||
|
<strong> Users</strong>, and your own two-factor under <strong>Account</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Custom OIDC/OAuth2 providers ────────────────────────────────────────────
|
||||||
|
const EMPTY_CUSTOM = {
|
||||||
|
id: '', name: '', kind: 'oidc', enabled: false, clientId: '', secret: '',
|
||||||
|
authorizeUrl: '', tokenUrl: '', userinfoUrl: '', scopes: 'openid email profile', priority: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomEditor({ initial, onDone, onCancel }) {
|
||||||
|
const isNew = !initial.id
|
||||||
|
const [f, setF] = useState(isNew ? EMPTY_CUSTOM : { ...initial, secret: '' })
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const set = (k) => (e) => setF((prev) => ({ ...prev, [k]: e.target.value }))
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name: f.name, kind: f.kind, enabled: f.enabled, clientId: f.clientId,
|
||||||
|
authorizeUrl: f.authorizeUrl, tokenUrl: f.tokenUrl, userinfoUrl: f.userinfoUrl,
|
||||||
|
scopes: f.scopes, priority: Number(f.priority) || 100,
|
||||||
|
}
|
||||||
|
if (f.secret) body.secret = f.secret
|
||||||
|
if (isNew) await api.admin.createAuthProvider({ id: f.id, ...body })
|
||||||
|
else await api.admin.updateAuthProvider(initial.id, body)
|
||||||
|
await onDone()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not save provider.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 20, marginTop: 16, display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 640 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>
|
||||||
|
{isNew ? 'Add custom provider' : `Edit ${initial.name}`}
|
||||||
|
</h3>
|
||||||
|
{isNew && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">ID (slug)</span>
|
||||||
|
<input className="input" value={f.id} onChange={set('id')} placeholder="authentik" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Type</span>
|
||||||
|
<select className="input" value={f.kind} onChange={set('kind')}>
|
||||||
|
<option value="oidc">OIDC</option>
|
||||||
|
<option value="oauth2">OAuth2</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Display name</span>
|
||||||
|
<input className="input" value={f.name} onChange={set('name')} placeholder="Authentik" />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Client ID</span>
|
||||||
|
<input className="input" value={f.clientId} onChange={set('clientId')} autoComplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Client Secret</span>
|
||||||
|
<input className="input" type="password" value={f.secret} onChange={set('secret')} autoComplete="new-password" placeholder={!isNew && initial.hasSecret ? '•••• leave blank to keep' : ''} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Authorization URL</span>
|
||||||
|
<input className="input" value={f.authorizeUrl} onChange={set('authorizeUrl')} placeholder="https://idp.example/application/o/authorize/" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Token URL</span>
|
||||||
|
<input className="input" value={f.tokenUrl} onChange={set('tokenUrl')} placeholder="https://idp.example/application/o/token/" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">UserInfo URL</span>
|
||||||
|
<input className="input" value={f.userinfoUrl} onChange={set('userinfoUrl')} placeholder="https://idp.example/application/o/userinfo/" />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Scopes</span>
|
||||||
|
<input className="input" value={f.scopes} onChange={set('scopes')} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Priority</span>
|
||||||
|
<input className="input" type="number" value={f.priority} onChange={set('priority')} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Toggle checked={f.enabled} onChange={(v) => setF((p) => ({ ...p, enabled: v }))} label="Enabled" />
|
||||||
|
{!isNew && <CallbackHint id={initial.id} />}
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Saving…' : 'Save provider'}
|
||||||
|
</button>
|
||||||
|
<button onClick={onCancel} disabled={busy} className="pill">Cancel</button>
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomProviders({ items, onChanged }) {
|
||||||
|
const [editing, setEditing] = useState(null) // null | 'new' | provider
|
||||||
|
|
||||||
|
async function del(p) {
|
||||||
|
if (!window.confirm(`Delete provider "${p.name}"? This cannot be undone.`)) return
|
||||||
|
await api.admin.deleteAuthProvider(p.id)
|
||||||
|
await onChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||||
|
OAuth2 / OIDC providers (Authentik, Keycloak, Okta, Azure AD, Zitadel, …)
|
||||||
|
</p>
|
||||||
|
{!editing && (
|
||||||
|
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">+ Add provider</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 && !editing && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.88rem' }}>No custom providers yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Name</th>
|
||||||
|
<th className="adm-th">Type</th>
|
||||||
|
<th className="adm-th">Status</th>
|
||||||
|
<th className="adm-th" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((p) => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--head)' }}>{p.name}</td>
|
||||||
|
<td className="adm-td dim">{p.kind}</td>
|
||||||
|
<td className="adm-td">
|
||||||
|
{p.enabled && p.health.valid ? (
|
||||||
|
<span className="sans" style={{ color: '#7fd0a4' }}>Live</span>
|
||||||
|
) : p.enabled ? (
|
||||||
|
<span className="sans" style={{ color: '#e0b070' }}>Incomplete</span>
|
||||||
|
) : (
|
||||||
|
<span className="sans dim">Disabled</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||||
|
<span className="link-accent" onClick={() => setEditing(p)}>Edit</span>
|
||||||
|
<span className="link-accent" onClick={() => del(p)} style={{ marginLeft: 14, color: '#d98b84' }}>Delete</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<CustomEditor
|
||||||
|
initial={editing === 'new' ? {} : editing}
|
||||||
|
onCancel={() => setEditing(null)}
|
||||||
|
onDone={async () => {
|
||||||
|
setEditing(null)
|
||||||
|
await onChanged()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthProvidersAdmin() {
|
||||||
|
const [providers, setProviders] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [tab, setTab] = useState('local')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setProviders(await api.admin.listAuthProviders())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load authentication providers.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!providers) return <Loading />
|
||||||
|
|
||||||
|
const byId = (id) => providers.find((p) => p.id === id)
|
||||||
|
const customs = providers.filter((p) => !p.builtin)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div style={{ display: 'flex', gap: 6, borderBottom: '1px solid var(--line-soft)', marginBottom: 24, flexWrap: 'wrap' }}>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
padding: '9px 16px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
color: tab === t.id ? 'var(--head)' : 'var(--muted)',
|
||||||
|
borderBottom: `2px solid ${tab === t.id ? 'var(--accent)' : 'transparent'}`,
|
||||||
|
marginBottom: -1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'local' && <LocalInfo />}
|
||||||
|
{tab === 'google' && <BuiltinForm provider={byId('google')} onSaved={load} />}
|
||||||
|
{tab === 'discord' && <BuiltinForm provider={byId('discord')} onSaved={load} />}
|
||||||
|
{tab === 'custom' && <CustomProviders items={customs} onChanged={load} />}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
client/src/routes/admin/views/BotActivityAdmin.jsx
Normal file
142
client/src/routes/admin/views/BotActivityAdmin.jsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
|
import { dateTime } from '../../../lib/format.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Read-only visibility into the botScore middleware: who is currently banned and
|
||||||
|
// a feed of recent scoring events. The only action is an emergency unban for
|
||||||
|
// false positives — there is no ban/adjust-weights surface here by design.
|
||||||
|
const mono = { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.82rem' }
|
||||||
|
|
||||||
|
export default function BotActivityAdmin() {
|
||||||
|
const [tick, setTick] = useState(0)
|
||||||
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||||
|
const { loading, error, data } = useAsync(() => api.admin.botActivity(), [tick])
|
||||||
|
const [busyIp, setBusyIp] = useState('')
|
||||||
|
|
||||||
|
const ips = data?.ips || []
|
||||||
|
const events = data?.events || []
|
||||||
|
const banned = ips.filter((e) => e.banned)
|
||||||
|
|
||||||
|
async function unban(ip) {
|
||||||
|
if (!window.confirm(`Unban ${ip}? This clears its score and ban immediately.`)) return
|
||||||
|
setBusyIp(ip)
|
||||||
|
try {
|
||||||
|
await api.admin.unbanIp(ip)
|
||||||
|
reload()
|
||||||
|
} catch {
|
||||||
|
// Surface nothing intrusive; a reload will re-fetch true state either way.
|
||||||
|
reload()
|
||||||
|
} finally {
|
||||||
|
setBusyIp('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load bot activity." />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ display: 'flex', flexDirection: 'column', gap: 34 }}>
|
||||||
|
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||||
|
Live, in-memory scoring and ban state from the bot-protection middleware. State resets
|
||||||
|
when the server restarts.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Currently banned IPs */}
|
||||||
|
<div>
|
||||||
|
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.1rem', color: 'var(--head)' }}>
|
||||||
|
Currently banned{banned.length > 0 ? ` (${banned.length})` : ''}
|
||||||
|
</h2>
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">IP</th>
|
||||||
|
<th className="adm-th">Score</th>
|
||||||
|
<th className="adm-th">Banned until</th>
|
||||||
|
<th className="adm-th" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{banned.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>
|
||||||
|
No IPs are currently banned.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{banned.map((e) => (
|
||||||
|
<tr key={e.ip}>
|
||||||
|
<td className="adm-td" style={{ ...mono, color: 'var(--head)' }}>
|
||||||
|
{e.ip}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td">{e.score}</td>
|
||||||
|
<td className="adm-td dim">{dateTime(e.bannedUntil)}</td>
|
||||||
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => unban(e.ip)}
|
||||||
|
disabled={busyIp === e.ip}
|
||||||
|
className="btn btn-sq"
|
||||||
|
style={{ borderColor: '#d98b84', color: '#d98b84', padding: '5px 12px', fontSize: '0.82rem' }}
|
||||||
|
>
|
||||||
|
{busyIp === e.ip ? 'Unbanning…' : 'Unban'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent scoring events */}
|
||||||
|
<div>
|
||||||
|
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.1rem', color: 'var(--head)' }}>
|
||||||
|
Recent events
|
||||||
|
</h2>
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
<th className="adm-th">IP</th>
|
||||||
|
<th className="adm-th">Reason</th>
|
||||||
|
<th className="adm-th">Path</th>
|
||||||
|
<th className="adm-th">Points</th>
|
||||||
|
<th className="adm-th">Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{events.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>
|
||||||
|
No events recorded yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{events.map((ev, i) => (
|
||||||
|
<tr key={`${ev.ts}-${ev.ip}-${i}`}>
|
||||||
|
<td className="adm-td dim">{dateTime(ev.ts)}</td>
|
||||||
|
<td className="adm-td" style={{ ...mono, color: 'var(--text)' }}>
|
||||||
|
{ev.ip}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td">
|
||||||
|
<span style={{ ...mono, color: ev.type === 'ban' ? '#d98b84' : 'var(--accent)' }}>
|
||||||
|
{ev.reason}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="adm-td dim" style={{ ...mono, wordBreak: 'break-all' }}>
|
||||||
|
{ev.path || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td dim">{ev.points ? `+${ev.points}` : '—'}</td>
|
||||||
|
<td className="adm-td">{ev.score}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
151
client/src/routes/admin/views/DiscordBotAdmin.jsx
Normal file
151
client/src/routes/admin/views/DiscordBotAdmin.jsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Discord bot control panel (Phase 1). The bot token is write-only over this
|
||||||
|
// API — stored encrypted in the DB, never returned — same convention as the
|
||||||
|
// Google/Discord login-SSO secrets on the Authentication page. Saving pushes
|
||||||
|
// the config straight to the bot process, so Enabled takes effect immediately
|
||||||
|
// with no redeploy.
|
||||||
|
|
||||||
|
function Toggle({ checked, onChange, label }) {
|
||||||
|
return (
|
||||||
|
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR = {
|
||||||
|
connected: '#7fd0a4',
|
||||||
|
connecting: '#e0b070',
|
||||||
|
error: '#d98b84',
|
||||||
|
disconnected: 'var(--muted)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPanel({ config }) {
|
||||||
|
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
||||||
|
return (
|
||||||
|
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
|
||||||
|
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
|
||||||
|
{config.status || 'disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{config.statusDetail && (
|
||||||
|
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
|
||||||
|
)}
|
||||||
|
{config.lastConnectedAt && (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
|
||||||
|
Last connected: {new Date(config.lastConnectedAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DiscordBotAdmin() {
|
||||||
|
const [config, setConfig] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [guildId, setGuildId] = useState('')
|
||||||
|
const [token, setToken] = useState('')
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [saveError, setSaveError] = useState('')
|
||||||
|
const pollRef = useRef(null)
|
||||||
|
|
||||||
|
// Only the very first load seeds the editable fields (guildId/enabled).
|
||||||
|
// Every subsequent poll tick updates `config` (status/hasToken/etc.) so the
|
||||||
|
// live-status panel stays fresh, but must NOT touch the form state — doing
|
||||||
|
// so would silently overwrite whatever the admin is mid-typing/toggling
|
||||||
|
// before they get a chance to hit Save.
|
||||||
|
const initializedRef = useRef(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const c = await api.admin.getDiscordBotConfig()
|
||||||
|
setConfig(c)
|
||||||
|
if (!initializedRef.current) {
|
||||||
|
setGuildId(c.guildId || '')
|
||||||
|
setEnabled(c.enabled)
|
||||||
|
initializedRef.current = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Could not load Discord bot config.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
pollRef.current = setInterval(load, 5000)
|
||||||
|
return () => clearInterval(pollRef.current)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setBusy(true)
|
||||||
|
setMsg('')
|
||||||
|
setSaveError('')
|
||||||
|
try {
|
||||||
|
const body = { guildId, enabled }
|
||||||
|
if (token) body.token = token // only send a new token when entered
|
||||||
|
const saved = await api.admin.saveDiscordBotConfig(body)
|
||||||
|
setConfig(saved)
|
||||||
|
setToken('')
|
||||||
|
setMsg('Saved.')
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err.message || 'Could not save.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!config) return <Loading />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||||
|
Discord Bot
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<StatusPanel config={config} />
|
||||||
|
|
||||||
|
<Toggle checked={enabled} onChange={setEnabled} label="Enable the bot" />
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Guild (server) ID</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={guildId}
|
||||||
|
onChange={(e) => setGuildId(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="123456789012345678"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Bot Token</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Bot token'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
|
||||||
|
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
652
client/src/routes/admin/views/HeroEditor.jsx
Normal file
652
client/src/routes/admin/views/HeroEditor.jsx
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import HeroElement from '../../../components/HeroElement.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
import { defaultLayout, parseLayout, heroBackground } from '../../../lib/heroLayout.js'
|
||||||
|
|
||||||
|
const POS_Y = ['top', 'center', 'bottom']
|
||||||
|
const POS_X = ['left', 'center', 'right']
|
||||||
|
|
||||||
|
const clamp = (v, min, max) => Math.max(min, Math.min(max, v))
|
||||||
|
const round2 = (v) => Math.round(v * 100) / 100
|
||||||
|
const genId = () => (crypto.randomUUID ? crypto.randomUUID() : `el-${Date.now()}-${Math.random()}`)
|
||||||
|
const hexOf = (v) => (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(v || '') ? v : '#ffffff')
|
||||||
|
|
||||||
|
// Soft page-weight warning shown before uploading a large hero image. This is
|
||||||
|
// only a nudge (hero images render on the public landing page); the server hard-
|
||||||
|
// limits uploads at 8 MB. Returns true when the caller should abort the upload.
|
||||||
|
const WARN_UPLOAD_MB = 5
|
||||||
|
function tooLargeToUpload(size) {
|
||||||
|
return (
|
||||||
|
size > WARN_UPLOAD_MB * 1024 * 1024 &&
|
||||||
|
!confirm(`This image is over ${WARN_UPLOAD_MB} MB and may slow the page. Upload anyway?`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function newElement(type, z) {
|
||||||
|
const base = { id: genId(), type, x: 50, y: 50, z, anchor: 'center' }
|
||||||
|
if (type === 'text_block') {
|
||||||
|
return { ...base, props: { align: 'center', width: 600, lines: [{ text: 'New heading', tag: 'h2', fontSize: 36, color: '#ffffff', weight: 600 }] } }
|
||||||
|
}
|
||||||
|
if (type === 'buttons') {
|
||||||
|
return { ...base, y: 60, props: { align: 'center', gap: 12, items: [{ label: 'Button', to: '/', variant: 'primary' }] } }
|
||||||
|
}
|
||||||
|
if (type === 'moon') return { ...base, props: { size: 96, glow: 0.5 } }
|
||||||
|
if (type === 'badge') return { ...base, props: { text: 'New badge', bgColor: '#1a2d4a', textColor: '#c2d2e6', borderRadius: 999 } }
|
||||||
|
if (type === 'image') return { ...base, props: { src: '', width: 40, alt: '' } }
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESIZABLE = { text_block: 'width', image: 'width', moon: 'size' }
|
||||||
|
|
||||||
|
// Scale a text line's font size by a ratio when its box is resized, so the corner
|
||||||
|
// handle acts as a WYSIWYG zoom that keeps the h1/h2/p ratios intact. Numeric px
|
||||||
|
// sizes (editor-authored) and simple rem/em/px strings scale; responsive strings
|
||||||
|
// like clamp()/vw are left alone so they keep adapting to the viewport.
|
||||||
|
const FONT_UNIT_RE = /^(\d*\.?\d+)(rem|em|px)$/
|
||||||
|
function scaleFontSize(v, ratio) {
|
||||||
|
if (typeof v === 'number') return Math.max(6, Math.round(v * ratio))
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const m = FONT_UNIT_RE.exec(v.trim())
|
||||||
|
if (m) return `${round2(parseFloat(m[1]) * ratio)}${m[2]}`
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroEditor() {
|
||||||
|
const [layout, setLayout] = useState(null)
|
||||||
|
const [live, setLive] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [selectedId, setSelectedId] = useState(null)
|
||||||
|
const [snap, setSnap] = useState(false)
|
||||||
|
const [scale, setScale] = useState(1)
|
||||||
|
const teaserRef = useRef('')
|
||||||
|
const skipSave = useRef(true)
|
||||||
|
const canvasRef = useRef(null) // the 1280x720 stage (scaled to fit)
|
||||||
|
const colRef = useRef(null) // measures available width
|
||||||
|
|
||||||
|
// Render the canvas as a scaled 1280x720 stage so it's a faithful miniature of
|
||||||
|
// the live hero (viewport-unit fonts + % positions all scale together).
|
||||||
|
useEffect(() => {
|
||||||
|
const el = colRef.current
|
||||||
|
if (!el) return
|
||||||
|
const recompute = () => setScale(Math.min(el.clientWidth / 1280, (window.innerHeight * 0.66) / 720))
|
||||||
|
recompute()
|
||||||
|
const ro = new ResizeObserver(recompute)
|
||||||
|
ro.observe(el)
|
||||||
|
window.addEventListener('resize', recompute)
|
||||||
|
return () => {
|
||||||
|
ro.disconnect()
|
||||||
|
window.removeEventListener('resize', recompute)
|
||||||
|
}
|
||||||
|
}, [loading])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
api.admin
|
||||||
|
.getSettings()
|
||||||
|
.then((s) => {
|
||||||
|
if (!active) return
|
||||||
|
teaserRef.current = s.homepage_teaser || ''
|
||||||
|
const liveL = parseLayout(s.hero_layout)
|
||||||
|
setLive(liveL)
|
||||||
|
skipSave.current = true
|
||||||
|
setLayout(parseLayout(s.hero_layout_draft) || liveL || defaultLayout(teaserRef.current))
|
||||||
|
})
|
||||||
|
.catch(() => active && setError('Could not load hero settings.'))
|
||||||
|
.finally(() => active && setLoading(false))
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!layout) return
|
||||||
|
if (skipSave.current) {
|
||||||
|
skipSave.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus('Saving…')
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
api.admin
|
||||||
|
.updateSettings({ hero_layout_draft: JSON.stringify(layout) })
|
||||||
|
.then(() => setStatus('Draft saved'))
|
||||||
|
.catch(() => setStatus('Save failed'))
|
||||||
|
}, 800)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [layout])
|
||||||
|
|
||||||
|
// Delete key removes the selected element (unless typing in a field).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) return
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key !== 'Delete' && e.key !== 'Backspace') return
|
||||||
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return
|
||||||
|
e.preventDefault()
|
||||||
|
setLayout((l) => ({ ...l, elements: l.elements.filter((el) => el.id !== selectedId) }))
|
||||||
|
setSelectedId(null)
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [selectedId])
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!layout) return null
|
||||||
|
|
||||||
|
const bg = layout.background || {}
|
||||||
|
const overlay = layout.overlay?.opacity ?? 0.72
|
||||||
|
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
|
||||||
|
const selected = layout.elements.find((e) => e.id === selectedId) || null
|
||||||
|
|
||||||
|
const patchBg = (patch) => setLayout((l) => ({ ...l, background: { ...l.background, ...patch } }))
|
||||||
|
const setOpacity = (opacity) => setLayout((l) => ({ ...l, overlay: { ...l.overlay, opacity } }))
|
||||||
|
const updateElement = (id, patch) =>
|
||||||
|
setLayout((l) => ({ ...l, elements: l.elements.map((e) => (e.id === id ? { ...e, ...patch } : e)) }))
|
||||||
|
const updateProps = (id, patch) =>
|
||||||
|
setLayout((l) => ({ ...l, elements: l.elements.map((e) => (e.id === id ? { ...e, props: { ...e.props, ...patch } } : e)) }))
|
||||||
|
const removeElement = (id) => {
|
||||||
|
setLayout((l) => ({ ...l, elements: l.elements.filter((e) => e.id !== id) }))
|
||||||
|
setSelectedId(null)
|
||||||
|
}
|
||||||
|
const bumpZ = (id, dir) =>
|
||||||
|
setLayout((l) => ({ ...l, elements: l.elements.map((e) => (e.id === id ? { ...e, z: Math.max(0, (e.z || 0) + dir) } : e)) }))
|
||||||
|
const addElement = (type) => {
|
||||||
|
const z = Math.max(0, ...layout.elements.map((e) => e.z || 0)) + 1
|
||||||
|
const el = newElement(type, z)
|
||||||
|
setLayout((l) => ({ ...l, elements: [...l.elements, el] }))
|
||||||
|
setSelectedId(el.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onElPointerDown(e, el) {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedId(el.id)
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect()
|
||||||
|
const sx = e.clientX
|
||||||
|
const sy = e.clientY
|
||||||
|
const ox = el.x
|
||||||
|
const oy = el.y
|
||||||
|
const node = e.currentTarget
|
||||||
|
try {
|
||||||
|
node.setPointerCapture(e.pointerId)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const stepX = (8 / 1280) * 100 // 8px snap on the 1280x720 stage, as %
|
||||||
|
const stepY = (8 / 720) * 100
|
||||||
|
const move = (ev) => {
|
||||||
|
let nx = clamp(ox + ((ev.clientX - sx) / rect.width) * 100, 0, 100)
|
||||||
|
let ny = clamp(oy + ((ev.clientY - sy) / rect.height) * 100, 0, 100)
|
||||||
|
if (snap) {
|
||||||
|
nx = Math.round(nx / stepX) * stepX
|
||||||
|
ny = Math.round(ny / stepY) * stepY
|
||||||
|
}
|
||||||
|
updateElement(el.id, { x: round2(nx), y: round2(ny) })
|
||||||
|
}
|
||||||
|
const up = () => {
|
||||||
|
node.removeEventListener('pointermove', move)
|
||||||
|
node.removeEventListener('pointerup', up)
|
||||||
|
}
|
||||||
|
node.addEventListener('pointermove', move)
|
||||||
|
node.addEventListener('pointerup', up)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corner-handle resize: adjusts the type-appropriate dimension.
|
||||||
|
function onResizePointerDown(e, el) {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
e.stopPropagation()
|
||||||
|
const dim = RESIZABLE[el.type]
|
||||||
|
if (!dim) return
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect()
|
||||||
|
const sx = e.clientX
|
||||||
|
const orig = el.props?.[dim] ?? (dim === 'width' && el.type === 'image' ? 40 : dim === 'width' ? 600 : 64)
|
||||||
|
// Snapshot the starting width + lines for text blocks so font scaling is always
|
||||||
|
// computed against the drag origin (no rounding drift as the pointer moves).
|
||||||
|
const baseWidth = el.type === 'text_block' ? orig : 0
|
||||||
|
const baseLines = el.type === 'text_block' ? el.props?.lines || [] : null
|
||||||
|
const node = e.currentTarget
|
||||||
|
try {
|
||||||
|
node.setPointerCapture(e.pointerId)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const move = (ev) => {
|
||||||
|
const dxPx = ev.clientX - sx
|
||||||
|
const dxLogical = dxPx / scale // client px → stage px
|
||||||
|
if (el.type === 'image') {
|
||||||
|
updateProps(el.id, { width: Math.round(clamp(orig + (dxPx / rect.width) * 100, 5, 100)) }) // %
|
||||||
|
} else if (el.type === 'moon') {
|
||||||
|
updateProps(el.id, { size: Math.round(clamp(orig + dxLogical, 24, 400)) }) // px
|
||||||
|
} else {
|
||||||
|
// text_block: resize the box and scale every line's font proportionally.
|
||||||
|
const width = Math.round(clamp(orig + dxLogical, 120, 1180))
|
||||||
|
const ratio = baseWidth ? width / baseWidth : 1
|
||||||
|
const lines = baseLines.map((l) => ({ ...l, fontSize: scaleFontSize(l.fontSize, ratio) }))
|
||||||
|
updateProps(el.id, { width, lines })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const up = () => {
|
||||||
|
node.removeEventListener('pointermove', move)
|
||||||
|
node.removeEventListener('pointerup', up)
|
||||||
|
}
|
||||||
|
node.addEventListener('pointermove', move)
|
||||||
|
node.addEventListener('pointerup', up)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUploadBg(e) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = ''
|
||||||
|
if (!file) return
|
||||||
|
if (tooLargeToUpload(file.size)) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const { url } = await api.admin.upload(file)
|
||||||
|
patchBg({ image_url: url })
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(err.message || 'Upload failed')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preview() {
|
||||||
|
await api.admin.updateSettings({ hero_layout_draft: JSON.stringify(layout) }).catch(() => {})
|
||||||
|
window.open('/?preview=1', '_blank', 'noopener')
|
||||||
|
}
|
||||||
|
async function publish() {
|
||||||
|
const json = JSON.stringify(layout)
|
||||||
|
try {
|
||||||
|
await api.admin.updateSettings({ hero_layout: json, hero_layout_draft: json })
|
||||||
|
setLive(layout)
|
||||||
|
setStatus('Published ✓')
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(err.message || 'Publish failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function revert() {
|
||||||
|
if (!confirm('Discard draft changes and revert to the live hero?')) return
|
||||||
|
skipSave.current = true
|
||||||
|
setSelectedId(null)
|
||||||
|
setLayout(live || defaultLayout(teaserRef.current))
|
||||||
|
await api.admin.updateSettings({ hero_layout_draft: live ? JSON.stringify(live) : '' }).catch(() => {})
|
||||||
|
setStatus('Reverted to live')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
|
||||||
|
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||||
|
Compose the portal hero. {status && <span style={{ color: 'var(--accent)' }}>· {status}</span>}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<button onClick={revert} className="pill">Revert to live</button>
|
||||||
|
<button onClick={preview} className="pill">Preview ↗</button>
|
||||||
|
<button onClick={publish} className="btn btn-primary btn-sq">Publish</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Element tray */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<span className="field-label" style={{ margin: 0 }}>Add:</span>
|
||||||
|
<button onClick={() => addElement('text_block')} className="pill">+ Text</button>
|
||||||
|
<button onClick={() => addElement('buttons')} className="pill">+ Buttons</button>
|
||||||
|
<button onClick={() => addElement('moon')} className="pill">+ Moon</button>
|
||||||
|
<button onClick={() => addElement('badge')} className="pill">+ Badge</button>
|
||||||
|
<button onClick={() => addElement('image')} className="pill">+ Image</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSnap((v) => !v)}
|
||||||
|
className="pill"
|
||||||
|
style={{ marginLeft: 'auto', borderColor: snap ? 'var(--accent)' : 'var(--line)', color: snap ? 'var(--accent)' : 'var(--muted)' }}
|
||||||
|
>
|
||||||
|
Snap grid: {snap ? 'on' : 'off'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||||
|
<div ref={colRef} style={{ flex: '1 1 620px', minWidth: 320 }}>
|
||||||
|
<div style={{ position: 'relative', width: 1280 * scale, height: 720 * scale, maxWidth: '100%', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--line)', background: 'var(--bg-deep)' }}>
|
||||||
|
<div
|
||||||
|
ref={canvasRef}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setSelectedId(null)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
...heroBackground(layout),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{snap && (
|
||||||
|
<div className="hero-canvas-grid" style={{ position: 'absolute', inset: 0, backgroundSize: '16px 16px', pointerEvents: 'none', zIndex: 0 }} />
|
||||||
|
)}
|
||||||
|
{elements.map((el) => (
|
||||||
|
<HeroElement
|
||||||
|
key={el.id}
|
||||||
|
element={el}
|
||||||
|
editor
|
||||||
|
selected={el.id === selectedId}
|
||||||
|
onPointerDown={(e) => onElPointerDown(e, el)}
|
||||||
|
>
|
||||||
|
{el.id === selectedId && RESIZABLE[el.type] && (
|
||||||
|
<div
|
||||||
|
onPointerDown={(e) => onResizePointerDown(e, el)}
|
||||||
|
title="Resize"
|
||||||
|
style={{ position: 'absolute', right: -6, bottom: -6, width: 14, height: 14, borderRadius: 3, background: 'var(--accent)', border: '1px solid var(--bg-deep)', cursor: 'nwse-resize', pointerEvents: 'auto' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</HeroElement>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
|
||||||
|
Click to select · drag to move · drag the corner handle to resize (text scales with the box) · Delete key removes the selected element.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="panel-flat" style={{ flex: '0 0 320px', padding: 18, display: 'flex', flexDirection: 'column', gap: 16, position: 'sticky', top: 20 }}>
|
||||||
|
{selected ? (
|
||||||
|
<ElementPanel
|
||||||
|
key={selected.id}
|
||||||
|
element={selected}
|
||||||
|
onProps={(patch) => updateProps(selected.id, patch)}
|
||||||
|
onRemove={() => removeElement(selected.id)}
|
||||||
|
onForward={() => bumpZ(selected.id, 1)}
|
||||||
|
onBack={() => bumpZ(selected.id, -1)}
|
||||||
|
onDeselect={() => setSelectedId(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BackgroundPanel bg={bg} overlay={overlay} uploading={uploading} onUpload={onUploadBg} patchBg={patchBg} setOpacity={setOpacity} />
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Background / overlay panel (no element selected) ────────────────────
|
||||||
|
function BackgroundPanel({ bg, overlay, uploading, onUpload, patchBg, setOpacity }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="field-label" style={{ margin: 0 }}>Background & overlay</p>
|
||||||
|
<div>
|
||||||
|
<span className="field-label">Background image</span>
|
||||||
|
{bg.image_url ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<img src={bg.image_url} alt="" style={{ width: 70, height: 44, objectFit: 'cover', borderRadius: 6, border: '1px solid var(--line)' }} />
|
||||||
|
<button onClick={() => patchBg({ image_url: null })} className="pill" style={{ fontSize: '0.8rem' }}>Clear</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>Using the default hero image.</p>
|
||||||
|
)}
|
||||||
|
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', marginTop: 10, cursor: 'pointer' }}>
|
||||||
|
{uploading ? 'Uploading…' : 'Upload image'}
|
||||||
|
<input type="file" accept="image/*" onChange={onUpload} hidden disabled={uploading} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="field-label">Background position</span>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, maxWidth: 132 }}>
|
||||||
|
{POS_Y.map((py) =>
|
||||||
|
POS_X.map((px) => {
|
||||||
|
const activePos = (bg.position_x || 'left') === px && (bg.position_y || 'center') === py
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${px}-${py}`}
|
||||||
|
title={`${py} ${px}`}
|
||||||
|
onClick={() => patchBg({ position_x: px, position_y: py })}
|
||||||
|
style={{ height: 36, borderRadius: 6, cursor: 'pointer', border: `1px solid ${activePos ? 'var(--accent)' : 'var(--line)'}`, background: activePos ? 'var(--blue)' : 'transparent' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="field-label">Overlay darkness — {Math.round(overlay * 100)}%</span>
|
||||||
|
<input type="range" min="0" max="1" step="0.01" value={overlay} onChange={(e) => setOpacity(Number(e.target.value))} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-element properties ──────────────────────────────────────────────
|
||||||
|
function ElementPanel({ element, onProps, onRemove, onForward, onBack, onDeselect }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<p className="field-label" style={{ margin: 0 }}>{element.type.replace('_', ' ')}</p>
|
||||||
|
<span className="link-accent" style={{ fontSize: '0.8rem' }} onClick={onDeselect}>Done</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{element.type === 'text_block' && <TextBlockPanel element={element} onProps={onProps} />}
|
||||||
|
{element.type === 'buttons' && <ButtonsPanel element={element} onProps={onProps} />}
|
||||||
|
{element.type === 'moon' && <MoonPanel element={element} onProps={onProps} />}
|
||||||
|
{element.type === 'badge' && <BadgePanel element={element} onProps={onProps} />}
|
||||||
|
{element.type === 'image' && <ImagePanel element={element} onProps={onProps} />}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, borderTop: '1px solid var(--line)', paddingTop: 12 }}>
|
||||||
|
<button onClick={onBack} className="pill" style={{ fontSize: '0.8rem' }}>Send back</button>
|
||||||
|
<button onClick={onForward} className="pill" style={{ fontSize: '0.8rem' }}>Bring forward</button>
|
||||||
|
<button onClick={onRemove} className="pill" style={{ marginLeft: 'auto', fontSize: '0.8rem', color: '#d98b84', borderColor: '#6e3b38' }}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALIGNS = ['left', 'center', 'right']
|
||||||
|
|
||||||
|
function AlignField({ value, onChange }) {
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Align</span>
|
||||||
|
<select className="input" value={value || 'center'} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{ALIGNS.map((a) => (
|
||||||
|
<option key={a} value={a}>{a}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextBlockPanel({ element, onProps }) {
|
||||||
|
const lines = element.props?.lines || []
|
||||||
|
const setLine = (i, patch) => onProps({ lines: lines.map((l, idx) => (idx === i ? { ...l, ...patch } : l)) })
|
||||||
|
const addLine = () => onProps({ lines: [...lines, { text: 'New line', tag: 'p', fontSize: 18, color: '#dbe2ea', weight: 400 }] })
|
||||||
|
const removeLine = (i) => onProps({ lines: lines.filter((_, idx) => idx !== i) })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<AlignField value={element.props?.align} onChange={(v) => onProps({ align: v })} />
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<div key={i} style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<input className="input" value={line.text} onChange={(e) => setLine(i, { text: e.target.value })} placeholder="Text" />
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<select className="input" value={line.tag || 'p'} onChange={(e) => setLine(i, { tag: e.target.value })} style={{ flex: 1 }}>
|
||||||
|
{['h1', 'h2', 'h3', 'p', 'span'].map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
value={typeof line.fontSize === 'number' ? line.fontSize : ''}
|
||||||
|
onChange={(e) => { const n = parseInt(e.target.value, 10); setLine(i, { fontSize: Number.isFinite(n) ? n : undefined }) }}
|
||||||
|
placeholder="px"
|
||||||
|
style={{ width: 70 }}
|
||||||
|
/>
|
||||||
|
<input type="color" value={hexOf(line.color)} onChange={(e) => setLine(i, { color: e.target.value })} style={{ width: 40, height: 38, padding: 2, border: '1px solid var(--line)', borderRadius: 6, background: 'var(--bg)' }} title="Color" />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<input type="checkbox" checked={(line.weight || 400) >= 700} onChange={(e) => setLine(i, { weight: e.target.checked ? 700 : 400 })} />
|
||||||
|
<span className="sans" style={{ fontSize: '0.82rem', color: 'var(--muted)' }}>Bold</span>
|
||||||
|
</label>
|
||||||
|
{lines.length > 1 && (
|
||||||
|
<span className="link-accent" style={{ fontSize: '0.8rem', color: '#d98b84' }} onClick={() => removeLine(i)}>Remove line</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={addLine} className="pill" style={{ fontSize: '0.82rem', alignSelf: 'flex-start' }}>+ Add line</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonsPanel({ element, onProps }) {
|
||||||
|
const items = element.props?.items || []
|
||||||
|
const setItem = (i, patch) => onProps({ items: items.map((it, idx) => (idx === i ? { ...it, ...patch } : it)) })
|
||||||
|
const addItem = () => onProps({ items: [...items, { label: 'Button', to: '/', variant: 'primary' }] })
|
||||||
|
const removeItem = (i) => onProps({ items: items.filter((_, idx) => idx !== i) })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<AlignField value={element.props?.align} onChange={(v) => onProps({ align: v })} />
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<div key={i} style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<input className="input" value={it.label} onChange={(e) => setItem(i, { label: e.target.value })} placeholder="Label" />
|
||||||
|
<input className="input" value={it.to} onChange={(e) => setItem(i, { to: e.target.value })} placeholder="/path" style={{ fontFamily: 'ui-monospace,Menlo,monospace' }} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
|
<select className="input" value={it.variant || 'primary'} onChange={(e) => setItem(i, { variant: e.target.value })} style={{ flex: 1 }}>
|
||||||
|
<option value="primary">primary</option>
|
||||||
|
<option value="ghost">ghost</option>
|
||||||
|
</select>
|
||||||
|
{items.length > 1 && (
|
||||||
|
<span className="link-accent" style={{ fontSize: '0.8rem', color: '#d98b84' }} onClick={() => removeItem(i)}>Remove</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={addItem} className="pill" style={{ fontSize: '0.82rem', alignSelf: 'flex-start' }}>+ Add button</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const swatch = { width: '100%', height: 38, padding: 2, border: '1px solid var(--line)', borderRadius: 6, background: 'var(--bg)' }
|
||||||
|
|
||||||
|
function MoonPanel({ element, onProps }) {
|
||||||
|
const p = element.props || {}
|
||||||
|
const [up, setUp] = useState(false)
|
||||||
|
// Reuses the shared admin upload endpoint (same as the image/background panels);
|
||||||
|
// a successful upload just points props.src at the returned URL.
|
||||||
|
async function onFile(e) {
|
||||||
|
const f = e.target.files?.[0]
|
||||||
|
e.target.value = ''
|
||||||
|
if (!f) return
|
||||||
|
if (tooLargeToUpload(f.size)) return
|
||||||
|
setUp(true)
|
||||||
|
try {
|
||||||
|
const { url } = await api.admin.upload(f)
|
||||||
|
onProps({ src: url })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setUp(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div>
|
||||||
|
<span className="field-label">Moon image</span>
|
||||||
|
{p.src ? (
|
||||||
|
<img src={p.src} alt="" style={{ width: '100%', maxHeight: 90, objectFit: 'contain', borderRadius: 6, border: '1px solid var(--line)', marginBottom: 8 }} />
|
||||||
|
) : (
|
||||||
|
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>Using the default moon from the hero artwork.</p>
|
||||||
|
)}
|
||||||
|
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}>
|
||||||
|
{up ? 'Uploading…' : p.src ? 'Replace' : 'Upload'}
|
||||||
|
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
|
||||||
|
</label>
|
||||||
|
{p.src && (
|
||||||
|
<span className="link-accent" style={{ fontSize: '0.8rem', marginLeft: 10 }} onClick={() => onProps({ src: '' })}>Use default</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Alt text</span>
|
||||||
|
<input className="input" value={p.alt || ''} onChange={(e) => onProps({ alt: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Size — {p.size || 96}px</span>
|
||||||
|
<input type="range" min="24" max="320" step="1" value={p.size || 96} onChange={(e) => onProps({ size: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Glow — {Math.round((p.glow ?? 0.5) * 100)}%</span>
|
||||||
|
<input type="range" min="0" max="1" step="0.01" value={p.glow ?? 0.5} onChange={(e) => onProps({ glow: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadgePanel({ element, onProps }) {
|
||||||
|
const p = element.props || {}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Text</span>
|
||||||
|
<input className="input" value={p.text || ''} onChange={(e) => onProps({ text: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<label style={{ flex: 1 }}>
|
||||||
|
<span className="field-label">Background</span>
|
||||||
|
<input type="color" value={hexOf(p.bgColor)} onChange={(e) => onProps({ bgColor: e.target.value })} style={swatch} />
|
||||||
|
</label>
|
||||||
|
<label style={{ flex: 1 }}>
|
||||||
|
<span className="field-label">Text color</span>
|
||||||
|
<input type="color" value={hexOf(p.textColor)} onChange={(e) => onProps({ textColor: e.target.value })} style={swatch} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Corner radius — {Math.min(p.borderRadius ?? 999, 24)}px</span>
|
||||||
|
<input type="range" min="0" max="24" step="1" value={Math.min(p.borderRadius ?? 999, 24)} onChange={(e) => onProps({ borderRadius: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImagePanel({ element, onProps }) {
|
||||||
|
const p = element.props || {}
|
||||||
|
const [up, setUp] = useState(false)
|
||||||
|
async function onFile(e) {
|
||||||
|
const f = e.target.files?.[0]
|
||||||
|
e.target.value = ''
|
||||||
|
if (!f) return
|
||||||
|
if (tooLargeToUpload(f.size)) return
|
||||||
|
setUp(true)
|
||||||
|
try {
|
||||||
|
const { url } = await api.admin.upload(f)
|
||||||
|
onProps({ src: url })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setUp(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div>
|
||||||
|
<span className="field-label">Image</span>
|
||||||
|
{p.src && <img src={p.src} alt="" style={{ width: '100%', maxHeight: 90, objectFit: 'contain', borderRadius: 6, border: '1px solid var(--line)', marginBottom: 8 }} />}
|
||||||
|
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}>
|
||||||
|
{up ? 'Uploading…' : p.src ? 'Replace' : 'Upload'}
|
||||||
|
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Width — {p.width || 40}%</span>
|
||||||
|
<input type="range" min="10" max="100" step="1" value={p.width || 40} onChange={(e) => onProps({ width: Number(e.target.value) })} style={{ width: '100%' }} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Alt text</span>
|
||||||
|
<input className="input" value={p.alt || ''} onChange={(e) => onProps({ alt: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
330
client/src/routes/admin/views/Moderation.jsx
Normal file
330
client/src/routes/admin/views/Moderation.jsx
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
|
import { ago, dateTime } from '../../../lib/format.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
const WINDOWS = [
|
||||||
|
{ key: '24h', label: 'Last 24h' },
|
||||||
|
{ key: '7d', label: 'Last 7 days' },
|
||||||
|
{ key: '30d', label: 'Last 30 days' },
|
||||||
|
]
|
||||||
|
const TYPES = [
|
||||||
|
{ key: null, label: 'All' },
|
||||||
|
{ key: 'ban', label: 'Bans' },
|
||||||
|
{ key: 'kick', label: 'Kicks' },
|
||||||
|
{ key: 'mute', label: 'Mutes' },
|
||||||
|
{ key: 'warn', label: 'Warnings' },
|
||||||
|
]
|
||||||
|
const MOD_TILES = [
|
||||||
|
{ key: 'ban', label: 'Bans' },
|
||||||
|
{ key: 'kick', label: 'Kicks' },
|
||||||
|
{ key: 'mute', label: 'Mutes' },
|
||||||
|
{ key: 'warn', label: 'Warnings' },
|
||||||
|
]
|
||||||
|
// Second tile row → jumps the events panel to the matching stream.
|
||||||
|
const EVENT_TILES = [
|
||||||
|
{ key: 'joins', label: 'Joins', tab: 'members' },
|
||||||
|
{ key: 'leaves', label: 'Leaves', tab: 'members' },
|
||||||
|
{ key: 'filter_hits', label: 'Filter hits', tab: 'filter' },
|
||||||
|
{ key: 'spam_hits', label: 'Spam hits', tab: 'spam' },
|
||||||
|
]
|
||||||
|
const EVENT_TABS = [
|
||||||
|
{ key: 'members', label: 'Members' },
|
||||||
|
{ key: 'filter', label: 'Filter hits' },
|
||||||
|
{ key: 'spam', label: 'Spam hits' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Moderation() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [win, setWin] = useState('24h')
|
||||||
|
const [typeFilter, setTypeFilter] = useState(null)
|
||||||
|
const [eventTab, setEventTab] = useState('members')
|
||||||
|
|
||||||
|
const { loading, error, data } = useAsync(
|
||||||
|
() =>
|
||||||
|
Promise.all([
|
||||||
|
api.admin.modSummary(),
|
||||||
|
api.admin.modRecent({ limit: 100 }),
|
||||||
|
api.admin.modMembers({ limit: 50 }),
|
||||||
|
api.admin.modFilterHits({ limit: 50 }),
|
||||||
|
api.admin.modSpamHits({ limit: 50 }),
|
||||||
|
]),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load moderation data." />
|
||||||
|
|
||||||
|
const [summary, recent, members, filterHits, spamHits] = data
|
||||||
|
const counts = summary.windows?.[win] || {}
|
||||||
|
const feed = typeFilter ? recent.filter((r) => r.action_type === typeFilter) : recent
|
||||||
|
const goUser = (id) => navigate(`/admin/moderation/user/${id}`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<UserSearch onPick={goUser} />
|
||||||
|
|
||||||
|
{/* Window selector */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, margin: '4px 0 14px' }}>
|
||||||
|
{WINDOWS.map((w) => (
|
||||||
|
<button key={w.key} onClick={() => setWin(w.key)} className="pill" style={win === w.key ? activePill : undefined}>
|
||||||
|
{w.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Moderation-action tiles (click filters the recent-actions feed) */}
|
||||||
|
<div className="grid-4" style={{ gap: 14, marginBottom: 14 }}>
|
||||||
|
{MOD_TILES.map((t) => (
|
||||||
|
<Tile
|
||||||
|
key={t.key}
|
||||||
|
value={counts[t.key] ?? 0}
|
||||||
|
label={t.label}
|
||||||
|
active={typeFilter === t.key}
|
||||||
|
onClick={() => setTypeFilter(typeFilter === t.key ? null : t.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event tiles (click jumps the events panel to that stream) */}
|
||||||
|
<div className="grid-4" style={{ gap: 14, marginBottom: 8 }}>
|
||||||
|
{EVENT_TILES.map((t) => (
|
||||||
|
<Tile
|
||||||
|
key={t.key}
|
||||||
|
value={counts[t.key] ?? 0}
|
||||||
|
label={t.label}
|
||||||
|
sub={t.key === 'joins' && counts.invite_joins ? `${counts.invite_joins} via invite` : null}
|
||||||
|
active={eventTab === t.tab}
|
||||||
|
onClick={() => setEventTab(t.tab)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 24px' }}>
|
||||||
|
Counts are for the selected window. Member, filter, and spam events are captured live by the bot.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Recent moderation actions */}
|
||||||
|
<div style={rowHead}>
|
||||||
|
<h2 className="display" style={h2}>Recent actions</h2>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{TYPES.map((t) => (
|
||||||
|
<button key={t.label} onClick={() => setTypeFilter(t.key)} className="pill" style={typeFilter === t.key ? activePill : undefined}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel-flat" style={{ marginBottom: 30 }}>
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Action</th>
|
||||||
|
<th className="adm-th">Target</th>
|
||||||
|
<th className="adm-th">Staff</th>
|
||||||
|
<th className="adm-th">Reason</th>
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{feed.length === 0 && (
|
||||||
|
<tr><td className="adm-td" colSpan={5} style={muted}>No matching actions.</td></tr>
|
||||||
|
)}
|
||||||
|
{feed.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td className="adm-td"><span className={`badge badge-${a.action_type}`}>{a.action_type}</span></td>
|
||||||
|
<td className="adm-td">
|
||||||
|
<span className="link-accent" onClick={() => goUser(a.target_user_id)}>{a.target_tag || a.target_user_id}</span>
|
||||||
|
{a.linked_account && <span className="badge badge-editor" style={{ marginLeft: 8 }}>site: {a.linked_account.username}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td">
|
||||||
|
{a.is_automated ? <span className="badge badge-auto">Automated</span> : <span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--muted)', maxWidth: 280 }}>{a.reason || '—'}</td>
|
||||||
|
<td className="adm-td dim" title={dateTime(a.created_at)}>{ago(a.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event streams panel */}
|
||||||
|
<div style={rowHead}>
|
||||||
|
<h2 className="display" style={h2}>Events</h2>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{EVENT_TABS.map((t) => (
|
||||||
|
<button key={t.key} onClick={() => setEventTab(t.key)} className="pill" style={eventTab === t.key ? activePill : undefined}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{eventTab === 'members' && <MembersTable rows={members} onUser={goUser} />}
|
||||||
|
{eventTab === 'filter' && <FilterTable rows={filterHits} onUser={goUser} />}
|
||||||
|
{eventTab === 'spam' && <SpamTable rows={spamHits} onUser={goUser} />}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tile({ value, label, sub, active, onClick }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: 20,
|
||||||
|
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'var(--panel-grad)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
|
||||||
|
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>{label}</div>
|
||||||
|
{sub && <div className="sans dim" style={{ fontSize: '0.68rem', marginTop: 4 }}>{sub}</div>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MembersTable({ rows, onUser }) {
|
||||||
|
return (
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Event</th>
|
||||||
|
<th className="adm-th">User</th>
|
||||||
|
<th className="adm-th">Invite</th>
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 && <tr><td className="adm-td" colSpan={4} style={muted}>No member events yet.</td></tr>}
|
||||||
|
{rows.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td className="adm-td"><span className={`badge ${m.event_type === 'join' ? 'badge-pub' : 'badge-ban'}`}>{m.event_type}</span></td>
|
||||||
|
<td className="adm-td"><span className="link-accent" onClick={() => onUser(m.discord_user_id)}>{m.username || m.discord_user_id}</span></td>
|
||||||
|
<td className="adm-td dim">
|
||||||
|
{m.invite_code ? (
|
||||||
|
<span>{m.invite_code}{m.inviter_tag ? ` · by ${m.inviter_tag}` : ''}</span>
|
||||||
|
) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td dim" title={dateTime(m.created_at)}>{ago(m.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterTable({ rows, onUser }) {
|
||||||
|
return (
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Type</th>
|
||||||
|
<th className="adm-th">User</th>
|
||||||
|
<th className="adm-th">Matched</th>
|
||||||
|
<th className="adm-th">Action</th>
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 && <tr><td className="adm-td" colSpan={5} style={muted}>No filter hits yet.</td></tr>}
|
||||||
|
{rows.map((f) => (
|
||||||
|
<tr key={f.id}>
|
||||||
|
<td className="adm-td"><span className={`badge ${f.hit_type === 'invite' ? 'badge-ban' : 'badge-warn'}`}>{f.hit_type}</span></td>
|
||||||
|
<td className="adm-td"><span className="link-accent" onClick={() => onUser(f.discord_user_id)}>{f.username || f.discord_user_id}</span></td>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 240 }}>{f.matched || '—'}</td>
|
||||||
|
<td className="adm-td"><span className={`badge badge-${f.action_taken === 'delete' ? 'auto' : f.action_taken}`}>{f.action_taken}</span></td>
|
||||||
|
<td className="adm-td dim" title={dateTime(f.created_at)}>{ago(f.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPAM_LABEL = { rate_limit: 'Rate limit', mass_mention: 'Mass mention', mass_emoji: 'Mass emoji' }
|
||||||
|
|
||||||
|
function SpamTable({ rows, onUser }) {
|
||||||
|
return (
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Type</th>
|
||||||
|
<th className="adm-th">User</th>
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 && <tr><td className="adm-td" colSpan={3} style={muted}>No spam hits yet.</td></tr>}
|
||||||
|
{rows.map((s) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td className="adm-td"><span className="badge badge-warn">{SPAM_LABEL[s.spam_type] || s.spam_type}</span></td>
|
||||||
|
<td className="adm-td"><span className="link-accent" onClick={() => onUser(s.discord_user_id)}>{s.username || s.discord_user_id}</span></td>
|
||||||
|
<td className="adm-td dim" title={dateTime(s.created_at)}>{ago(s.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User lookup: search by Discord id or a historical username snapshot.
|
||||||
|
function UserSearch({ onPick }) {
|
||||||
|
const [term, setTerm] = useState('')
|
||||||
|
const [results, setResults] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function run(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const q = term.trim()
|
||||||
|
if (!q) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
setResults(await api.admin.modSearch(q))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 22 }}>
|
||||||
|
<form onSubmit={run} style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input className="input" placeholder="Search by Discord ID or username…" value={term} onChange={(e) => setTerm(e.target.value)} style={{ maxWidth: 360 }} />
|
||||||
|
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>{busy ? 'Searching…' : 'Look up'}</button>
|
||||||
|
</form>
|
||||||
|
{results && results.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>No moderated users match “{term}”.</p>
|
||||||
|
)}
|
||||||
|
{results && results.length > 0 && (
|
||||||
|
<div className="panel-flat" style={{ marginTop: 10 }}>
|
||||||
|
<table className="adm-table">
|
||||||
|
<tbody>
|
||||||
|
{results.map((r) => (
|
||||||
|
<tr key={r.target_user_id} style={{ cursor: 'pointer' }} onClick={() => onPick(r.target_user_id)}>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.target_tag || '(unknown tag)'}</td>
|
||||||
|
<td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}>{r.target_user_id}</td>
|
||||||
|
<td className="adm-td dim">{r.action_count} action{Number(r.action_count) === 1 ? '' : 's'}</td>
|
||||||
|
<td className="adm-td dim">last {ago(r.last_seen)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
|
||||||
|
const rowHead = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }
|
||||||
|
const h2 = { margin: 0, fontSize: '1.25rem', color: 'var(--head)' }
|
||||||
|
const muted = { color: 'var(--muted)' }
|
||||||
245
client/src/routes/admin/views/ModerationUser.jsx
Normal file
245
client/src/routes/admin/views/ModerationUser.jsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
|
import { dateTime, ago } from '../../../lib/format.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
|
const ACTION_TABS = [
|
||||||
|
{ key: 'warn', label: 'Warnings' },
|
||||||
|
{ key: 'mute', label: 'Mutes' },
|
||||||
|
{ key: 'kick', label: 'Kicks' },
|
||||||
|
{ key: 'ban', label: 'Bans' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function fmtDuration(seconds) {
|
||||||
|
if (!seconds) return null
|
||||||
|
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`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModerationUser() {
|
||||||
|
const { discordId } = useParams()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isAdmin = user?.role === 'admin'
|
||||||
|
const [tab, setTab] = useState('warn')
|
||||||
|
const [tick, setTick] = useState(0)
|
||||||
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||||
|
|
||||||
|
const { loading, error, data } = useAsync(
|
||||||
|
() =>
|
||||||
|
Promise.all([
|
||||||
|
api.admin.modUser(discordId),
|
||||||
|
api.admin.modUserActions(discordId, { limit: 200 }),
|
||||||
|
api.admin.modUserNotes(discordId),
|
||||||
|
]),
|
||||||
|
[discordId, tick],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load this user’s history." />
|
||||||
|
|
||||||
|
const [summary, actions, notes] = data
|
||||||
|
const counts = summary.counts || {}
|
||||||
|
const tabActions = actions.filter((a) => a.action_type === tab)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<Link to="/admin/moderation" className="link-accent" style={{ fontSize: '0.85rem' }}>
|
||||||
|
← Back to moderation
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 20px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
|
||||||
|
{summary.tag || '(unknown user)'}
|
||||||
|
</span>
|
||||||
|
{summary.linked_account && (
|
||||||
|
<span className="badge badge-editor">site account: {summary.linked_account.username}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem', marginTop: 4 }}>
|
||||||
|
{discordId}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 18, marginTop: 14, flexWrap: 'wrap' }}>
|
||||||
|
{ACTION_TABS.map((t) => (
|
||||||
|
<Count key={t.key} label={t.label} value={counts[t.key] || 0} />
|
||||||
|
))}
|
||||||
|
<Count label="Notes" value={summary.notes_count || 0} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 14, borderBottom: '1px solid var(--line-soft)', paddingBottom: 12 }}>
|
||||||
|
{ACTION_TABS.map((t) => (
|
||||||
|
<TabButton key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
||||||
|
{t.label} ({counts[t.key] || 0})
|
||||||
|
</TabButton>
|
||||||
|
))}
|
||||||
|
<TabButton active={tab === 'notes'} onClick={() => setTab('notes')}>
|
||||||
|
Notes ({summary.notes_count || 0})
|
||||||
|
</TabButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'notes' ? (
|
||||||
|
<NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
|
||||||
|
) : (
|
||||||
|
<ActionTable rows={tabActions} showDuration={tab === 'mute'} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Count({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="display" style={{ fontSize: '1.4rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
|
||||||
|
<div className="card-kicker" style={{ marginTop: 4, marginBottom: 0 }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({ active, onClick, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '7px 14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
background: active ? 'var(--blue)' : 'transparent',
|
||||||
|
color: active ? 'var(--ink)' : 'var(--muted)',
|
||||||
|
borderColor: active ? 'var(--accent)' : 'var(--line)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionTable({ rows, showDuration }) {
|
||||||
|
return (
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Reason</th>
|
||||||
|
<th className="adm-th">Actor</th>
|
||||||
|
{showDuration && <th className="adm-th">Duration</th>}
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="adm-td" colSpan={showDuration ? 4 : 3} style={{ color: 'var(--muted)' }}>
|
||||||
|
Nothing here.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{rows.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--text)' }}>{a.reason || '—'}</td>
|
||||||
|
<td className="adm-td">
|
||||||
|
{a.is_automated ? (
|
||||||
|
<span className="badge badge-auto">Automated</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{showDuration && <td className="adm-td dim">{fmtDuration(a.duration_seconds) || '—'}</td>}
|
||||||
|
<td className="adm-td dim" title={dateTime(a.created_at)}>{dateTime(a.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotesTab({ discordId, notes, isAdmin, onAdded }) {
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [visibility, setVisibility] = useState('staff_only')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
async function add() {
|
||||||
|
if (!body.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
setErr('')
|
||||||
|
try {
|
||||||
|
await api.admin.addModNote(discordId, { body: body.trim(), visibility })
|
||||||
|
setBody('')
|
||||||
|
setVisibility('staff_only')
|
||||||
|
onAdded()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Could not save the note.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 18 }}>
|
||||||
|
{err && <p className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{err}</p>}
|
||||||
|
<textarea
|
||||||
|
className="textarea"
|
||||||
|
placeholder="Add a staff note about this user…"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' }}>
|
||||||
|
<select value={visibility} onChange={(e) => setVisibility(e.target.value)} className="select" style={{ maxWidth: 200 }}>
|
||||||
|
<option value="staff_only">Staff only</option>
|
||||||
|
{isAdmin && <option value="admin_only">Admin only</option>}
|
||||||
|
</select>
|
||||||
|
<button onClick={add} disabled={busy || !body.trim()} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Saving…' : 'Add note'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Note</th>
|
||||||
|
<th className="adm-th">Author</th>
|
||||||
|
<th className="adm-th">Visibility</th>
|
||||||
|
<th className="adm-th">When</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>No notes yet.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{notes.map((n) => (
|
||||||
|
<tr key={n.id}>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--text)', whiteSpace: 'pre-wrap' }}>{n.body}</td>
|
||||||
|
<td className="adm-td dim">{n.author_username || n.author_tag || '—'}</td>
|
||||||
|
<td className="adm-td">
|
||||||
|
<span className={`badge ${n.visibility === 'admin_only' ? 'badge-ban' : 'badge-editor'}`}>
|
||||||
|
{n.visibility === 'admin_only' ? 'admin only' : 'staff'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="adm-td dim" title={dateTime(n.created_at)}>{ago(n.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { lazy, Suspense, useState } from 'react'
|
||||||
import Modal from '../../../components/Modal.jsx'
|
import Modal from '../../../components/Modal.jsx'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ v: 'news', l: 'News' },
|
{ v: 'news', l: 'News' },
|
||||||
{ v: 'five-on-friday', l: 'Five on Friday' },
|
{ v: 'five-on-friday', l: 'Five on Friday' },
|
||||||
@@ -146,10 +148,16 @@ export default function PostEditor({ post, onClose, onSaved }) {
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<div>
|
||||||
<span className="field-label">Body (HTML or text)</span>
|
<span className="field-label">Body</span>
|
||||||
<textarea value={form.body} onChange={set('body')} className="textarea" />
|
<Suspense fallback={<span className="spin" />}>
|
||||||
</label>
|
<RichTextEditor
|
||||||
|
value={form.body}
|
||||||
|
onChange={(html) => setForm((f) => ({ ...f, body: html }))}
|
||||||
|
variant={isScreenshot ? 'minimal' : 'post'}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export default function UserEditor({ user, onClose, onSaved }) {
|
|||||||
<select value={form.role} onChange={set('role')} className="select">
|
<select value={form.role} onChange={set('role')} className="select">
|
||||||
<option value="admin">admin</option>
|
<option value="admin">admin</option>
|
||||||
<option value="editor">editor</option>
|
<option value="editor">editor</option>
|
||||||
|
<option value="moderator">moderator</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { dateTime } from '../../../lib/format.js'
|
|||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
import UserEditor from './UserEditor.jsx'
|
import UserEditor from './UserEditor.jsx'
|
||||||
|
|
||||||
|
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator' }
|
||||||
|
|
||||||
export default function UsersAdmin() {
|
export default function UsersAdmin() {
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||||
@@ -16,7 +18,7 @@ export default function UsersAdmin() {
|
|||||||
<section>
|
<section>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||||
Manage admin and editor accounts
|
Manage admin, editor, and moderator accounts
|
||||||
</p>
|
</p>
|
||||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
||||||
+ Add user
|
+ Add user
|
||||||
@@ -44,7 +46,7 @@ export default function UsersAdmin() {
|
|||||||
{u.username}
|
{u.username}
|
||||||
</td>
|
</td>
|
||||||
<td className="adm-td">
|
<td className="adm-td">
|
||||||
<span className={`badge ${u.role === 'admin' ? 'badge-admin' : 'badge-editor'}`}>{u.role}</span>
|
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
||||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||||
|
|||||||
@@ -4,12 +4,18 @@ import { useAsync } from '../../../lib/useAsync.js'
|
|||||||
import { shortDate } from '../../../lib/format.js'
|
import { shortDate } from '../../../lib/format.js'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
import WikiEditor from './WikiEditor.jsx'
|
import WikiEditor from './WikiEditor.jsx'
|
||||||
|
import WikiCategories from './WikiCategories.jsx'
|
||||||
|
|
||||||
export default function WikiAdmin() {
|
export default function WikiAdmin() {
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||||
const { loading, error, data } = useAsync(() => api.admin.listWiki(), [tick])
|
const [q, setQ] = useState('')
|
||||||
|
const { loading, error, data } = useAsync(
|
||||||
|
() => api.admin.listWiki(q.trim() ? `?q=${encodeURIComponent(q.trim())}` : ''),
|
||||||
|
[tick, q],
|
||||||
|
)
|
||||||
const [editing, setEditing] = useState(null) // null | 'new' | slug
|
const [editing, setEditing] = useState(null) // null | 'new' | slug
|
||||||
|
const [managingCats, setManagingCats] = useState(false)
|
||||||
const pages = data || []
|
const pages = data || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -18,10 +24,23 @@ export default function WikiAdmin() {
|
|||||||
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||||
{pages.length} page{pages.length === 1 ? '' : 's'} · edit content and structure
|
{pages.length} page{pages.length === 1 ? '' : 's'} · edit content and structure
|
||||||
</p>
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Search pages…"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
<button onClick={() => setManagingCats(true)} className="pill">
|
||||||
|
Manage sections
|
||||||
|
</button>
|
||||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
||||||
+ New page
|
+ New page
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading && <Loading />}
|
{loading && <Loading />}
|
||||||
{error && <ErrorState message="Could not load wiki pages." />}
|
{error && <ErrorState message="Could not load wiki pages." />}
|
||||||
@@ -32,7 +51,8 @@ export default function WikiAdmin() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="adm-th">Page</th>
|
<th className="adm-th">Page</th>
|
||||||
<th className="adm-th">Slug</th>
|
<th className="adm-th">Section</th>
|
||||||
|
<th className="adm-th">Status</th>
|
||||||
<th className="adm-th">Updated</th>
|
<th className="adm-th">Updated</th>
|
||||||
<th className="adm-th" />
|
<th className="adm-th" />
|
||||||
</tr>
|
</tr>
|
||||||
@@ -42,9 +62,15 @@ export default function WikiAdmin() {
|
|||||||
<tr key={w.slug}>
|
<tr key={w.slug}>
|
||||||
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
||||||
{w.title}
|
{w.title}
|
||||||
</td>
|
<span
|
||||||
<td className="adm-td" style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)' }}>
|
style={{ display: 'block', fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)', fontSize: '0.78rem' }}
|
||||||
|
>
|
||||||
{w.slug}
|
{w.slug}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="adm-td dim">{w.category_title || '—'}</td>
|
||||||
|
<td className="adm-td">
|
||||||
|
<StatusPill published={w.published} />
|
||||||
</td>
|
</td>
|
||||||
<td className="adm-td dim">{shortDate(w.updated_at)}</td>
|
<td className="adm-td dim">{shortDate(w.updated_at)}</td>
|
||||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||||
@@ -54,6 +80,13 @@ export default function WikiAdmin() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{pages.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="adm-td dim" colSpan={5}>
|
||||||
|
No wiki pages yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,6 +102,37 @@ export default function WikiAdmin() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{managingCats && (
|
||||||
|
<WikiCategories
|
||||||
|
onClose={() => {
|
||||||
|
setManagingCats(false)
|
||||||
|
reload() // section titles may have changed
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatusPill({ published }) {
|
||||||
|
const live = Boolean(published)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
padding: '2px 9px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: `1px solid ${live ? 'rgba(108,176,140,0.5)' : 'var(--line)'}`,
|
||||||
|
color: live ? '#8fc7a6' : 'var(--dim)',
|
||||||
|
background: live ? 'rgba(108,176,140,0.12)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{live ? 'Published' : 'Draft'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
182
client/src/routes/admin/views/WikiCategories.jsx
Normal file
182
client/src/routes/admin/views/WikiCategories.jsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import Modal from '../../../components/Modal.jsx'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
const EMPTY = { slug: '', title: '', description: '', sort_order: 0 }
|
||||||
|
|
||||||
|
export default function WikiCategories({ onClose }) {
|
||||||
|
const [cats, setCats] = useState([])
|
||||||
|
const [editing, setEditing] = useState(null) // null = create mode, else category id
|
||||||
|
const [form, setForm] = useState(EMPTY)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
api.admin
|
||||||
|
.listWikiCategories()
|
||||||
|
.then(setCats)
|
||||||
|
.catch(() => setError('Could not load sections.'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }))
|
||||||
|
|
||||||
|
function startEdit(c) {
|
||||||
|
setEditing(c.id)
|
||||||
|
setForm({ slug: c.slug, title: c.title, description: c.description || '', sort_order: c.sort_order })
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setEditing(null)
|
||||||
|
setForm(EMPTY)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form.title.trim()) return setError('Title is required.')
|
||||||
|
if (!editing && !/^[a-z0-9-]+$/.test(form.slug)) {
|
||||||
|
return setError('Slug must be lowercase letters, numbers, and dashes.')
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
const payload = {
|
||||||
|
title: form.title.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
sort_order: Number(form.sort_order) || 0,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editing) await api.admin.updateWikiCategory(editing, payload)
|
||||||
|
else await api.admin.createWikiCategory({ slug: form.slug, ...payload })
|
||||||
|
reset()
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not save section.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(c) {
|
||||||
|
if (!confirm(`Delete section "${c.title}"? Its ${c.page_count} page(s) become uncategorized.`)) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.admin.deleteWikiCategory(c.id)
|
||||||
|
if (editing === c.id) reset()
|
||||||
|
load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not delete section.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Wiki sections"
|
||||||
|
onClose={onClose}
|
||||||
|
width={640}
|
||||||
|
footer={
|
||||||
|
<button onClick={onClose} className="pill">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||||
|
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||||
|
|
||||||
|
{/* Create / edit form */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ flex: '1 1 160px' }}>
|
||||||
|
<span className="field-label">Slug</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.slug}
|
||||||
|
onChange={set('slug')}
|
||||||
|
disabled={Boolean(editing)}
|
||||||
|
className="input"
|
||||||
|
style={{ fontFamily: 'ui-monospace,Menlo,monospace', opacity: editing ? 0.6 : 1 }}
|
||||||
|
placeholder="guides"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ flex: '1 1 160px' }}>
|
||||||
|
<span className="field-label">Title</span>
|
||||||
|
<input type="text" value={form.title} onChange={set('title')} className="input" />
|
||||||
|
</label>
|
||||||
|
<label style={{ flex: '0 0 90px' }}>
|
||||||
|
<span className="field-label">Order</span>
|
||||||
|
<input type="number" value={form.sort_order} onChange={set('sort_order')} className="input" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Description</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.description}
|
||||||
|
onChange={set('description')}
|
||||||
|
className="input"
|
||||||
|
maxLength={400}
|
||||||
|
placeholder="Shown under the section heading on the wiki home."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||||
|
{editing ? 'Save section' : '+ Add section'}
|
||||||
|
</button>
|
||||||
|
{editing && (
|
||||||
|
<button onClick={reset} disabled={busy} className="pill">
|
||||||
|
Cancel edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing categories */}
|
||||||
|
<div className="panel-flat">
|
||||||
|
<table className="adm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="adm-th">Section</th>
|
||||||
|
<th className="adm-th">Slug</th>
|
||||||
|
<th className="adm-th">Pages</th>
|
||||||
|
<th className="adm-th" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cats.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
||||||
|
{c.title}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td" style={{ fontFamily: 'ui-monospace,Menlo,monospace', color: 'var(--accent)' }}>
|
||||||
|
{c.slug}
|
||||||
|
</td>
|
||||||
|
<td className="adm-td dim">{c.page_count}</td>
|
||||||
|
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
<span className="link-accent" onClick={() => startEdit(c)}>
|
||||||
|
Edit
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--line)', margin: '0 8px' }}>·</span>
|
||||||
|
<span className="link-accent" style={{ color: '#d98b84' }} onClick={() => remove(c)}>
|
||||||
|
Delete
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{cats.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="adm-td dim" colSpan={4}>
|
||||||
|
No sections yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +1,64 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||||
import Modal from '../../../components/Modal.jsx'
|
import Modal from '../../../components/Modal.jsx'
|
||||||
|
import WikiHistory from './WikiHistory.jsx'
|
||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Admin-only and heavy (TipTap) — load as its own chunk so the public bundle
|
||||||
|
// never pays for it.
|
||||||
|
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
|
||||||
|
|
||||||
export default function WikiEditor({ slug, onClose, onSaved }) {
|
export default function WikiEditor({ slug, onClose, onSaved }) {
|
||||||
const isEdit = Boolean(slug)
|
const isEdit = Boolean(slug)
|
||||||
const [form, setForm] = useState({ slug: '', title: '', body: '' })
|
const [form, setForm] = useState({
|
||||||
|
slug: '',
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
excerpt: '',
|
||||||
|
category_id: '',
|
||||||
|
published: true,
|
||||||
|
tags: '',
|
||||||
|
})
|
||||||
|
const [categories, setCategories] = useState([])
|
||||||
|
const [pages, setPages] = useState([])
|
||||||
const [loading, setLoading] = useState(isEdit)
|
const [loading, setLoading] = useState(isEdit)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
|
|
||||||
|
// Categories (dropdown) + pages (internal-link picker), for both new and edit.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
api.admin
|
||||||
|
.listWikiCategories()
|
||||||
|
.then((cats) => active && setCategories(cats))
|
||||||
|
.catch(() => {})
|
||||||
|
api.admin
|
||||||
|
.listWiki()
|
||||||
|
.then((list) => active && setPages(list.map((p) => ({ slug: p.slug, title: p.title }))))
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEdit) return
|
if (!isEdit) return
|
||||||
let active = true
|
let active = true
|
||||||
api.admin
|
api.admin
|
||||||
.getWiki(slug)
|
.getWiki(slug)
|
||||||
.then((p) => active && setForm({ slug: p.slug, title: p.title, body: p.body || '' }))
|
.then(
|
||||||
|
(p) =>
|
||||||
|
active &&
|
||||||
|
setForm({
|
||||||
|
slug: p.slug,
|
||||||
|
title: p.title,
|
||||||
|
body: p.body || '',
|
||||||
|
excerpt: p.excerpt || '',
|
||||||
|
category_id: p.category_id != null ? String(p.category_id) : '',
|
||||||
|
published: Boolean(p.published),
|
||||||
|
tags: (p.tags || []).map((t) => t.label).join(', '),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.catch(() => active && setError('Could not load this page.'))
|
.catch(() => active && setError('Could not load this page.'))
|
||||||
.finally(() => active && setLoading(false))
|
.finally(() => active && setLoading(false))
|
||||||
return () => {
|
return () => {
|
||||||
@@ -24,14 +68,30 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
|
|||||||
|
|
||||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }))
|
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }))
|
||||||
|
|
||||||
|
function payload() {
|
||||||
|
return {
|
||||||
|
title: form.title.trim(),
|
||||||
|
body: form.body,
|
||||||
|
excerpt: form.excerpt.trim(),
|
||||||
|
category_id: form.category_id ? Number(form.category_id) : null,
|
||||||
|
published: form.published,
|
||||||
|
tags: form.tags
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form.title.trim()) return setError('Title is required.')
|
if (!form.title.trim()) return setError('Title is required.')
|
||||||
if (!isEdit && !/^[a-z0-9-]+$/.test(form.slug)) return setError('Slug must be lowercase letters, numbers, and dashes.')
|
if (!isEdit && !/^[a-z0-9-]+$/.test(form.slug)) {
|
||||||
|
return setError('Slug must be lowercase letters, numbers, and dashes.')
|
||||||
|
}
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
if (isEdit) await api.admin.updateWiki(slug, { title: form.title.trim(), body: form.body })
|
if (isEdit) await api.admin.updateWiki(slug, payload())
|
||||||
else await api.admin.createWiki({ slug: form.slug, title: form.title.trim(), body: form.body })
|
else await api.admin.createWiki({ slug: form.slug, ...payload() })
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Could not save.')
|
setError(err.message || 'Could not save.')
|
||||||
@@ -52,6 +112,7 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
title={isEdit ? 'Edit wiki page' : 'New wiki page'}
|
title={isEdit ? 'Edit wiki page' : 'New wiki page'}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@@ -63,11 +124,16 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isEdit && (
|
||||||
|
<button onClick={() => setShowHistory(true)} disabled={busy} className="pill">
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={onClose} disabled={busy} className="pill">
|
<button onClick={onClose} disabled={busy} className="pill">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq">
|
<button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq">
|
||||||
{busy ? 'Saving…' : 'Save'}
|
{busy ? 'Saving…' : form.published ? 'Save & publish' : 'Save draft'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -93,13 +159,74 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
|
|||||||
<span className="field-label">Title</span>
|
<span className="field-label">Title</span>
|
||||||
<input type="text" value={form.title} onChange={set('title')} className="input" />
|
<input type="text" value={form.title} onChange={set('title')} className="input" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||||
<span className="field-label">Body (HTML — use <h2> for the table of contents)</span>
|
<label style={{ flex: '1 1 200px' }}>
|
||||||
<textarea value={form.body} onChange={set('body')} className="textarea" style={{ minHeight: 260 }} />
|
<span className="field-label">Section</span>
|
||||||
|
<select value={form.category_id} onChange={set('category_id')} className="input">
|
||||||
|
<option value="">— Uncategorized —</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'flex-end', gap: 8, paddingBottom: 10 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.published}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, published: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="field-label" style={{ margin: 0 }}>
|
||||||
|
Published
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Excerpt (card teaser on the wiki index)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.excerpt}
|
||||||
|
onChange={set('excerpt')}
|
||||||
|
className="input"
|
||||||
|
maxLength={400}
|
||||||
|
placeholder="One-line summary shown on the wiki home."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="field-label">Tags (comma-separated)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.tags}
|
||||||
|
onChange={set('tags')}
|
||||||
|
className="input"
|
||||||
|
placeholder="beginner, pvp, towns"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<span className="field-label">Body (use Heading 2 for table-of-contents sections)</span>
|
||||||
|
<Suspense fallback={<span className="spin" />}>
|
||||||
|
<RichTextEditor
|
||||||
|
value={form.body}
|
||||||
|
onChange={(html) => setForm((f) => ({ ...f, body: html }))}
|
||||||
|
pages={pages.filter((p) => p.slug !== form.slug)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
{showHistory && (
|
||||||
|
<WikiHistory
|
||||||
|
slug={slug}
|
||||||
|
onClose={() => setShowHistory(false)}
|
||||||
|
onRestored={() => {
|
||||||
|
setShowHistory(false)
|
||||||
|
onSaved()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
139
client/src/routes/admin/views/WikiHistory.jsx
Normal file
139
client/src/routes/admin/views/WikiHistory.jsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { diffWords } from 'diff'
|
||||||
|
import Modal from '../../../components/Modal.jsx'
|
||||||
|
import { dateTime } from '../../../lib/format.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Plain-text view of a body, for a readable word-level diff.
|
||||||
|
function toText(html) {
|
||||||
|
if (!html) return ''
|
||||||
|
if (typeof window === 'undefined' || !window.DOMParser) return html
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
return doc.body.textContent || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WikiHistory({ slug, onClose, onRestored }) {
|
||||||
|
const [revisions, setRevisions] = useState([])
|
||||||
|
const [current, setCurrent] = useState(null)
|
||||||
|
const [selected, setSelected] = useState(null) // full revision snapshot
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
Promise.all([api.admin.listWikiRevisions(slug), api.admin.getWiki(slug)])
|
||||||
|
.then(([revs, page]) => {
|
||||||
|
if (!active) return
|
||||||
|
setRevisions(revs)
|
||||||
|
setCurrent(page)
|
||||||
|
})
|
||||||
|
.catch(() => active && setError('Could not load history.'))
|
||||||
|
.finally(() => active && setLoading(false))
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
async function selectRevision(id) {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const rev = await api.admin.getWikiRevision(slug, id)
|
||||||
|
setSelected(rev)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not load revision.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restore() {
|
||||||
|
if (!selected) return
|
||||||
|
if (!confirm(`Restore the page to revision #${selected.id}? This creates a new revision.`)) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.admin.restoreWikiRevision(slug, selected.id)
|
||||||
|
onRestored()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not restore.')
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff the selected revision (old) against the current saved page (new).
|
||||||
|
const parts = useMemo(
|
||||||
|
() => (selected ? diffWords(toText(selected.body), toText(current?.body || '')) : []),
|
||||||
|
[selected, current],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`History — ${slug}`}
|
||||||
|
onClose={onClose}
|
||||||
|
width={760}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button onClick={onClose} disabled={busy} className="pill">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button onClick={restore} disabled={busy || !selected} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Restoring…' : 'Restore this revision'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="spin" />
|
||||||
|
) : error ? (
|
||||||
|
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||||
|
) : (
|
||||||
|
<div className="wiki-history">
|
||||||
|
<ul className="wiki-history-list">
|
||||||
|
{revisions.map((r, i) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`wiki-rev${selected?.id === r.id ? ' is-active' : ''}`}
|
||||||
|
onClick={() => selectRevision(r.id)}
|
||||||
|
>
|
||||||
|
<span className="wiki-rev-note">
|
||||||
|
{r.change_note || 'Edit'}
|
||||||
|
{i === 0 && <span className="wiki-rev-latest"> · latest</span>}
|
||||||
|
</span>
|
||||||
|
<span className="wiki-rev-meta">
|
||||||
|
{dateTime(r.created_at)}
|
||||||
|
{r.editor ? ` · ${r.editor}` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{revisions.length === 0 && <li className="dim sans" style={{ fontSize: '0.85rem' }}>No revisions yet.</li>}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="wiki-history-diff">
|
||||||
|
{!selected ? (
|
||||||
|
<p className="muted sans" style={{ fontSize: '0.9rem' }}>
|
||||||
|
Select a revision to see what changed between it and the current page.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.76rem' }}>
|
||||||
|
Diff: revision #{selected.id} → current
|
||||||
|
</p>
|
||||||
|
<div className="wiki-diff">
|
||||||
|
{parts.length === 0 || (parts.length === 1 && !parts[0].added && !parts[0].removed) ? (
|
||||||
|
<span className="muted">No textual differences.</span>
|
||||||
|
) : (
|
||||||
|
parts.map((p, i) => (
|
||||||
|
<span key={i} className={p.added ? 'diff-add' : p.removed ? 'diff-del' : ''}>
|
||||||
|
{p.value}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import DOMPurify from 'dompurify'
|
||||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
import PageHeader from '../../components/PageHeader.jsx'
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||||
@@ -36,7 +37,7 @@ export default function FiveOnFriday() {
|
|||||||
{it.title}
|
{it.title}
|
||||||
</h2>
|
</h2>
|
||||||
{it.body ? (
|
{it.body ? (
|
||||||
<div className="prose" dangerouslySetInnerHTML={{ __html: it.body }} />
|
<div className="prose" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(it.body) }} />
|
||||||
) : (
|
) : (
|
||||||
it.excerpt && <p style={{ margin: 0, color: 'var(--text)' }}>{it.excerpt}</p>
|
it.excerpt && <p style={{ margin: 0, color: 'var(--text)' }}>{it.excerpt}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
import { useAsync } from '../../lib/useAsync.js'
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
@@ -52,7 +53,7 @@ function Issue({ issue }) {
|
|||||||
{issue.excerpt && <p style={{ margin: '14px 0 0', color: 'var(--muted)', fontSize: '1.1rem' }}>{issue.excerpt}</p>}
|
{issue.excerpt && <p style={{ margin: '14px 0 0', color: 'var(--muted)', fontSize: '1.1rem' }}>{issue.excerpt}</p>}
|
||||||
<div style={{ height: 1, background: 'var(--line)', margin: '28px 0' }} />
|
<div style={{ height: 1, background: 'var(--line)', margin: '28px 0' }} />
|
||||||
{issue.body ? (
|
{issue.body ? (
|
||||||
<div className="prose" dangerouslySetInnerHTML={{ __html: issue.body }} />
|
<div className="prose" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(issue.body) }} />
|
||||||
) : (
|
) : (
|
||||||
<p className="muted">This issue has no content yet.</p>
|
<p className="muted">This issue has no content yet.</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,32 +1,12 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import HeroElement from '../../components/HeroElement.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
import { defaultLayout, parseLayout, heroBackground } from '../../lib/heroLayout.js'
|
||||||
|
|
||||||
const HERO_BG =
|
// Admin "Preview" opens the portal with ?preview=1 to render the unpublished draft.
|
||||||
"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('/assets/img/uomysticmoon-main-hero.png')"
|
const PREVIEW = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('preview') === '1'
|
||||||
|
|
||||||
const QUICK = [
|
|
||||||
{ label: 'News', to: '/site/news' },
|
|
||||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
|
||||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
|
||||||
{ label: 'Monthly Newsletter', to: '/site/newsletter' },
|
|
||||||
{ label: 'About', to: '/site/about' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const DESTINATIONS = [
|
|
||||||
{
|
|
||||||
kicker: 'Public portal',
|
|
||||||
title: 'Mysticmoon Website',
|
|
||||||
body: 'Updates, screenshots, newsletters, and weekly community posts from the shard.',
|
|
||||||
to: '/site',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kicker: 'Knowledge base',
|
|
||||||
title: 'Mysticmoon Wiki',
|
|
||||||
body: 'Guides, maps, systems, items, monsters, crafting, lore, and rules.',
|
|
||||||
to: '/wiki',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function Portal() {
|
export default function Portal() {
|
||||||
const { settings } = useSite()
|
const { settings } = useSite()
|
||||||
@@ -34,78 +14,56 @@ export default function Portal() {
|
|||||||
settings.homepage_teaser ||
|
settings.homepage_teaser ||
|
||||||
'Mysticmoon is still being shaped beneath a midnight sky — a quiet preview for the news, screenshots, guides, and community notes to come as the world wakes.'
|
'Mysticmoon is still being shaped beneath a midnight sky — a quiet preview for the news, screenshots, guides, and community notes to come as the world wakes.'
|
||||||
|
|
||||||
|
// Published layout (public). Falls back to the pre-populated default if missing,
|
||||||
|
// malformed, the wrong version, or empty — so the hero is never blank.
|
||||||
|
const published = useMemo(() => parseLayout(settings.hero_layout), [settings.hero_layout])
|
||||||
|
|
||||||
|
// In preview mode, pull the draft via the admin endpoint (requires a logged-in
|
||||||
|
// admin cookie); falls back silently to the published/default layout otherwise.
|
||||||
|
const [draft, setDraft] = useState(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!PREVIEW) return
|
||||||
|
let active = true
|
||||||
|
api.admin
|
||||||
|
.getSettings()
|
||||||
|
.then((s) => active && setDraft(parseLayout(s.hero_layout_draft)))
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const active = (PREVIEW && draft) || (published && published.elements.length ? published : null)
|
||||||
|
const layout = active || defaultLayout(teaser)
|
||||||
|
const isDefault = !active
|
||||||
|
const bgStyle = heroBackground(layout, { isDefault })
|
||||||
|
const elements = [...layout.elements].sort((a, b) => (a.z || 0) - (b.z || 0))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicLayout header={false}>
|
<PublicLayout header={false}>
|
||||||
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<main style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{PREVIEW && draft && (
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{ background: 'var(--accent)', color: 'var(--bg-deep)', textAlign: 'center', padding: '6px 12px', fontSize: '0.8rem', fontWeight: 700, letterSpacing: '0.04em' }}
|
||||||
|
>
|
||||||
|
Preview — showing unpublished draft
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<section
|
<section
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'grid',
|
flex: 1,
|
||||||
alignContent: 'center',
|
minHeight: '100vh',
|
||||||
minHeight: 'clamp(600px,72vh,860px)',
|
...bgStyle,
|
||||||
padding: '96px max(18px,calc((100% - 1080px)/2)) 96px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textAlign: 'center',
|
|
||||||
backgroundColor: 'var(--bg-deep)',
|
|
||||||
backgroundImage: HERO_BG,
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: 760, margin: '0 auto', textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
<div style={{ position: 'absolute', inset: 0, padding: '0 max(18px,calc((100% - 1080px)/2))' }}>
|
||||||
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.22em' }}>
|
{elements.map((el) => (
|
||||||
Private shard project
|
<HeroElement key={el.id} element={el} />
|
||||||
</p>
|
))}
|
||||||
<h1
|
|
||||||
className="display"
|
|
||||||
style={{ margin: 0, fontSize: 'clamp(3rem,8.5vw,5.75rem)', lineHeight: 1, letterSpacing: '0.02em' }}
|
|
||||||
>
|
|
||||||
UOMysticmoon
|
|
||||||
</h1>
|
|
||||||
<p style={{ margin: '22px auto 0', color: '#dbe2ea', fontSize: '1.32rem', fontStyle: 'italic' }}>
|
|
||||||
A private Ultima Online world in progress
|
|
||||||
</p>
|
|
||||||
<p style={{ maxWidth: 600, margin: '22px auto 0', color: '#c4cdd8', fontSize: '1.06rem' }}>{teaser}</p>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center', marginTop: 34 }}>
|
|
||||||
<Link to="/site" className="btn btn-primary">
|
|
||||||
Enter the Website
|
|
||||||
</Link>
|
|
||||||
<Link to="/wiki" className="btn btn-ghost">
|
|
||||||
Open the Wiki
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="shell" style={{ padding: '56px 0 12px' }}>
|
|
||||||
<nav className="grid-2" aria-label="Main destinations">
|
|
||||||
{DESTINATIONS.map((d) => (
|
|
||||||
<Link key={d.to} to={d.to} className="card" style={{ padding: 30 }}>
|
|
||||||
<span className="card-kicker" style={{ letterSpacing: '0.16em', marginBottom: 14 }}>
|
|
||||||
{d.kicker}
|
|
||||||
</span>
|
|
||||||
<strong
|
|
||||||
className="display"
|
|
||||||
style={{ fontSize: '1.7rem', color: 'var(--head)', marginBottom: 10, fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
{d.title}
|
|
||||||
</strong>
|
|
||||||
<span className="muted">{d.body}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shell" style={{ padding: '24px 0 64px' }}>
|
|
||||||
<nav style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 10 }} aria-label="Quick links">
|
|
||||||
{QUICK.map((q) => (
|
|
||||||
<Link key={q.to} to={q.to} className="pill">
|
|
||||||
{q.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</PublicLayout>
|
</PublicLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,27 +1,90 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { useState } from 'react'
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
import PageHeader from '../../components/PageHeader.jsx'
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||||
import { useAsync } from '../../lib/useAsync.js'
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
import { api } from '../../api/client.js'
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII']
|
function SearchBox({ initial, onSubmit }) {
|
||||||
|
const [term, setTerm] = useState(initial || '')
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(term.trim())
|
||||||
|
}}
|
||||||
|
style={{ display: 'flex', gap: 8, maxWidth: 460, margin: '0 auto 8px' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={term}
|
||||||
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Search the wiki…"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary btn-sq">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Short blurbs for the seeded categories (the list endpoint returns title/slug only).
|
// Group published pages under their category, preserving category sort order and
|
||||||
const BLURBS = {
|
// collecting anything uncategorized into a trailing section.
|
||||||
'new-player-guide': 'First steps, basic survival, and early goals.',
|
function groupByCategory(categories, pages) {
|
||||||
'maps-atlas': 'Regions, towns, routes, and travel notes.',
|
const byId = new Map(categories.map((c) => [c.id, { ...c, pages: [] }]))
|
||||||
systems: 'Shard mechanics and custom features.',
|
const uncategorized = []
|
||||||
items: 'Equipment, treasures, rewards, and curiosities.',
|
for (const page of pages) {
|
||||||
monsters: 'Creatures, bosses, spawns, and dangers.',
|
const bucket = page.category_id != null ? byId.get(page.category_id) : null
|
||||||
crafting: 'Professions, materials, recipes, and tools.',
|
if (bucket) bucket.pages.push(page)
|
||||||
lore: 'Stories, places, factions, and mysteries.',
|
else uncategorized.push(page)
|
||||||
rules: 'Player conduct, shard expectations, and policies.',
|
}
|
||||||
|
const sections = [...byId.values()].filter((c) => c.pages.length > 0)
|
||||||
|
if (uncategorized.length) {
|
||||||
|
sections.push({ id: 'uncategorized', title: 'Other Pages', description: '', pages: uncategorized })
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageCard({ page }) {
|
||||||
|
return (
|
||||||
|
<Link to={`/wiki/${page.slug}`} className="card" style={{ padding: 22 }}>
|
||||||
|
<h3 className="display" style={{ margin: '0 0 6px', fontSize: '1.1rem', color: 'var(--head)' }}>
|
||||||
|
{page.title}
|
||||||
|
</h3>
|
||||||
|
<p className="muted" style={{ margin: 0, fontSize: '0.92rem' }}>
|
||||||
|
{page.excerpt || 'Open the guide →'}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Wiki() {
|
export default function Wiki() {
|
||||||
const { loading, error, data } = useAsync(() => api.wiki())
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const pages = data || []
|
const activeCategory = searchParams.get('category')
|
||||||
|
const activeTag = searchParams.get('tag')
|
||||||
|
const activeQ = searchParams.get('q')
|
||||||
|
// Search / tag views fetch a filtered page list; otherwise all pages (grouped here).
|
||||||
|
const pageOpts = activeQ ? { q: activeQ } : activeTag ? { tag: activeTag } : {}
|
||||||
|
const { loading, error, data } = useAsync(
|
||||||
|
() =>
|
||||||
|
Promise.all([api.wikiCategories(), api.wiki(pageOpts)]).then(([categories, pages]) => ({
|
||||||
|
categories,
|
||||||
|
pages,
|
||||||
|
})),
|
||||||
|
[activeTag, activeQ],
|
||||||
|
)
|
||||||
|
|
||||||
|
const allSections = data ? groupByCategory(data.categories, data.pages) : []
|
||||||
|
const sections = activeCategory
|
||||||
|
? allSections.filter((s) => s.slug === activeCategory)
|
||||||
|
: allSections
|
||||||
|
const hasPages = data && data.pages.length > 0
|
||||||
|
const flat = Boolean(activeTag || activeQ) // flat-list views
|
||||||
|
const filtered = Boolean(activeCategory || activeTag || activeQ)
|
||||||
|
|
||||||
|
const runSearch = (term) => setSearchParams(term ? { q: term } : {})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicLayout section="wiki">
|
<PublicLayout section="wiki">
|
||||||
@@ -30,26 +93,61 @@ export default function Wiki() {
|
|||||||
center
|
center
|
||||||
eyebrow="Knowledge base"
|
eyebrow="Knowledge base"
|
||||||
title="Mysticmoon Wiki"
|
title="Mysticmoon Wiki"
|
||||||
lead="A calm starting point for shard guides, maps, systems, items, monsters, crafting, lore, and rules."
|
lead="A calm starting point for shard guides, the world and its lore, gameplay systems, and community rules."
|
||||||
/>
|
/>
|
||||||
|
<SearchBox initial={activeQ || ''} onSubmit={runSearch} />
|
||||||
|
|
||||||
{loading && <Loading />}
|
{loading && <Loading />}
|
||||||
{error && <ErrorState message="Could not load the wiki right now." />}
|
{error && <ErrorState message="Could not load the wiki right now." />}
|
||||||
{!loading && !error && pages.length === 0 && <EmptyState>No wiki pages yet.</EmptyState>}
|
{!loading && !error && !hasPages && !filtered && <EmptyState>No wiki pages yet.</EmptyState>}
|
||||||
<section className="grid-4">
|
|
||||||
{pages.map((p, i) => (
|
{!loading && !error && filtered && (
|
||||||
<Link key={p.slug} to={`/wiki/${p.slug}`} className="card" style={{ padding: 22 }}>
|
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.85rem' }}>
|
||||||
<span className="display" style={{ color: 'var(--accent)', fontSize: '1.4rem', marginBottom: 10 }}>
|
<Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
{ROMAN[i] || i + 1}
|
← All sections
|
||||||
</span>
|
|
||||||
<h3 className="display" style={{ margin: '0 0 6px', fontSize: '1.1rem', color: 'var(--head)' }}>
|
|
||||||
{p.title}
|
|
||||||
</h3>
|
|
||||||
<p className="muted" style={{ margin: 0, fontSize: '0.92rem' }}>
|
|
||||||
{BLURBS[p.slug] || 'Open the guide →'}
|
|
||||||
</p>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
{activeTag && <span className="muted"> · Tagged #{activeTag}</span>}
|
||||||
|
{activeQ && <span className="muted"> · Results for “{activeQ}”</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Flat list: search results or a tag filter (both cross categories). */}
|
||||||
|
{!loading && !error && flat &&
|
||||||
|
(data.pages.length === 0 ? (
|
||||||
|
<EmptyState>{activeQ ? 'No pages match that search.' : 'No pages with this tag.'}</EmptyState>
|
||||||
|
) : (
|
||||||
|
<div className="grid-4" style={{ marginTop: 12 }}>
|
||||||
|
{data.pages.map((p) => (
|
||||||
|
<PageCard key={p.slug} page={p} />
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Category / full view: grouped sections. */}
|
||||||
|
{!loading && !error && !flat && hasPages && activeCategory && sections.length === 0 && (
|
||||||
|
<EmptyState>No pages in this section yet.</EmptyState>
|
||||||
|
)}
|
||||||
|
{!flat &&
|
||||||
|
sections.map((section) => (
|
||||||
|
<section key={section.id} style={{ marginTop: 36 }}>
|
||||||
|
<h2
|
||||||
|
className="display"
|
||||||
|
style={{ margin: '0 0 4px', fontSize: '1.5rem', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
{section.description && (
|
||||||
|
<p className="muted" style={{ margin: '0 0 16px', fontSize: '0.95rem' }}>
|
||||||
|
{section.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid-4" style={{ marginTop: section.description ? 0 : 12 }}>
|
||||||
|
{section.pages.map((p) => (
|
||||||
|
<PageCard key={p.slug} page={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</PublicLayout>
|
</PublicLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
import { useAsync } from '../../lib/useAsync.js'
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
@@ -13,24 +14,36 @@ function slugify(text) {
|
|||||||
.replace(/(^-|-$)/g, '')
|
.replace(/(^-|-$)/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the stored body HTML: assign ids to <h2> headings and collect a TOC.
|
// Parse the stored body HTML: sanitize (defense in depth — the server also
|
||||||
function buildArticle(body) {
|
// sanitizes on save), assign ids to <h2> headings and collect a TOC, and mark
|
||||||
|
// internal links to pages that don't exist as "red links".
|
||||||
|
function buildArticle(body, missing) {
|
||||||
if (!body) return { html: '', toc: [] }
|
if (!body) return { html: '', toc: [] }
|
||||||
if (typeof window === 'undefined' || !window.DOMParser) return { html: body, toc: [] }
|
if (typeof window === 'undefined' || !window.DOMParser) return { html: '', toc: [] }
|
||||||
const doc = new DOMParser().parseFromString(body, 'text/html')
|
const safe = DOMPurify.sanitize(body)
|
||||||
|
const doc = new DOMParser().parseFromString(safe, 'text/html')
|
||||||
const toc = []
|
const toc = []
|
||||||
doc.querySelectorAll('h2').forEach((h, i) => {
|
doc.querySelectorAll('h2').forEach((h, i) => {
|
||||||
const id = slugify(h.textContent || '') || `section-${i}`
|
const id = slugify(h.textContent || '') || `section-${i}`
|
||||||
h.id = id
|
h.id = id
|
||||||
toc.push({ id, label: h.textContent })
|
toc.push({ id, label: h.textContent })
|
||||||
})
|
})
|
||||||
|
doc.querySelectorAll('a[href^="/wiki/"]').forEach((a) => {
|
||||||
|
const target = a.getAttribute('href').replace(/^\/wiki\//, '').replace(/[#?].*$/, '')
|
||||||
|
a.removeAttribute('target') // internal links stay in-app
|
||||||
|
if (missing.has(target)) {
|
||||||
|
a.classList.add('wiki-red-link')
|
||||||
|
a.setAttribute('title', 'This page does not exist yet')
|
||||||
|
}
|
||||||
|
})
|
||||||
return { html: doc.body.innerHTML, toc }
|
return { html: doc.body.innerHTML, toc }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WikiArticle() {
|
export default function WikiArticle() {
|
||||||
const { slug } = useParams()
|
const { slug } = useParams()
|
||||||
const { loading, error, data: page } = useAsync(() => api.wikiPage(slug), [slug])
|
const { loading, error, data: page } = useAsync(() => api.wikiPage(slug), [slug])
|
||||||
const { html, toc } = useMemo(() => buildArticle(page?.body), [page])
|
const missing = useMemo(() => new Set(page?.missing_links || []), [page])
|
||||||
|
const { html, toc } = useMemo(() => buildArticle(page?.body, missing), [page, missing])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicLayout section="wiki">
|
<PublicLayout section="wiki">
|
||||||
@@ -77,6 +90,17 @@ export default function WikiArticle() {
|
|||||||
<Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
<Link to="/wiki" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
Wiki
|
Wiki
|
||||||
</Link>
|
</Link>
|
||||||
|
{page.category_title && (
|
||||||
|
<>
|
||||||
|
<span>/</span>
|
||||||
|
<Link
|
||||||
|
to={`/wiki?category=${page.category_slug}`}
|
||||||
|
style={{ color: 'var(--accent)', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
{page.category_title}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span>{page.title}</span>
|
<span>{page.title}</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -86,6 +110,15 @@ export default function WikiArticle() {
|
|||||||
<p className="sans" style={{ margin: '18px 0 0', color: 'var(--dim)', fontSize: '0.78rem', letterSpacing: '0.04em' }}>
|
<p className="sans" style={{ margin: '18px 0 0', color: 'var(--dim)', fontSize: '0.78rem', letterSpacing: '0.04em' }}>
|
||||||
Last updated {longDate(page.updated_at) || '—'}
|
Last updated {longDate(page.updated_at) || '—'}
|
||||||
</p>
|
</p>
|
||||||
|
{page.tags && page.tags.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 14 }}>
|
||||||
|
{page.tags.map((t) => (
|
||||||
|
<Link key={t.slug} to={`/wiki?tag=${t.slug}`} className="wiki-tag">
|
||||||
|
#{t.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ height: 1, background: 'var(--line)', margin: '30px 0' }} />
|
<div style={{ height: 1, background: 'var(--line)', margin: '30px 0' }} />
|
||||||
{html ? (
|
{html ? (
|
||||||
<div className="prose" dangerouslySetInnerHTML={{ __html: html }} />
|
<div className="prose" dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
@@ -93,6 +126,28 @@ export default function WikiArticle() {
|
|||||||
<p className="muted">This page has no content yet.</p>
|
<p className="muted">This page has no content yet.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{page.backlinks && page.backlinks.length > 0 && (
|
||||||
|
<section
|
||||||
|
style={{ marginTop: 40, borderTop: '1px solid var(--line)', paddingTop: 22 }}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="sans"
|
||||||
|
style={{ margin: '0 0 12px', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase' }}
|
||||||
|
>
|
||||||
|
Linked from
|
||||||
|
</p>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: 18, fontFamily: 'var(--sans)', fontSize: '0.92rem' }}>
|
||||||
|
{page.backlinks.map((b) => (
|
||||||
|
<li key={b.slug} style={{ marginBottom: 6 }}>
|
||||||
|
<Link to={`/wiki/${b.slug}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||||
|
{b.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<nav style={{ display: 'flex', justifyContent: 'flex-start', marginTop: 40 }}>
|
<nav style={{ display: 'flex', justifyContent: 'flex-start', marginTop: 40 }}>
|
||||||
<Link to="/wiki" className="pill">
|
<Link to="/wiki" className="pill">
|
||||||
← All wiki pages
|
← All wiki pages
|
||||||
|
|||||||
@@ -312,6 +312,254 @@ button[disabled] {
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Rich text editor (TipTap) ===== */
|
||||||
|
.rte {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.rte:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.rte-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel-flat);
|
||||||
|
}
|
||||||
|
.rte-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
/* Icon (glyph) buttons render larger so picture/link/etc. read clearly. */
|
||||||
|
.rte-btn[title="Link"],
|
||||||
|
.rte-btn[title="Insert image"],
|
||||||
|
.rte-btn[title="Link to another wiki page"],
|
||||||
|
.rte-btn[title="Quote"],
|
||||||
|
.rte-btn[title="Divider"],
|
||||||
|
.rte-btn[title="Undo"],
|
||||||
|
.rte-btn[title="Redo"] {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.rte-btn:hover:not([disabled]) {
|
||||||
|
background: var(--blue);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.rte-btn.is-active {
|
||||||
|
background: var(--blue);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-bright);
|
||||||
|
}
|
||||||
|
.rte-btn[disabled] {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.rte-sep {
|
||||||
|
width: 1px;
|
||||||
|
align-self: stretch;
|
||||||
|
margin: 2px 4px;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
.rte-content {
|
||||||
|
padding: 16px 18px;
|
||||||
|
max-height: min(640px, 65vh);
|
||||||
|
/* Scroll both vertically and sideways (wide images, code blocks, tables). */
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.rte-content .ProseMirror {
|
||||||
|
min-height: 320px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.rte-content .ProseMirror p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
color: var(--dim);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* Internal-link picker popover */
|
||||||
|
.rte-linkmenu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: 60px;
|
||||||
|
left: 12px;
|
||||||
|
width: min(360px, calc(100% - 20px));
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel-a);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.rte-linkmenu-list {
|
||||||
|
margin-top: 8px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.rte-linkmenu-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rte-linkmenu-item:hover {
|
||||||
|
background: var(--blue);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.rte-linkmenu-slug {
|
||||||
|
color: var(--dim);
|
||||||
|
font-family: ui-monospace, Menlo, monospace;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Wiki connectivity (tags + red links) ===== */
|
||||||
|
.wiki-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(127, 153, 189, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.wiki-tag:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--blue);
|
||||||
|
}
|
||||||
|
.prose a.wiki-red-link {
|
||||||
|
color: #d98b84;
|
||||||
|
border-bottom: 1px dotted #d98b84;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Wiki revision history ===== */
|
||||||
|
.wiki-history {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.wiki-history {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.wiki-history-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.wiki-rev {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel-flat);
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.wiki-rev:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.wiki-rev.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--blue);
|
||||||
|
}
|
||||||
|
.wiki-rev-note {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--head);
|
||||||
|
}
|
||||||
|
.wiki-rev-latest {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
.wiki-rev-meta {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
.wiki-diff {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.diff-add {
|
||||||
|
background: rgba(95, 185, 138, 0.22);
|
||||||
|
color: #b9e6cd;
|
||||||
|
}
|
||||||
|
.diff-del {
|
||||||
|
background: rgba(176, 102, 95, 0.24);
|
||||||
|
color: #e3b0aa;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Hero canvas editor ===== */
|
||||||
|
.hero-el-editable {
|
||||||
|
outline: 1px dashed rgba(127, 153, 189, 0.45);
|
||||||
|
outline-offset: 2px;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none; /* let Pointer Events drive drag on touch */
|
||||||
|
}
|
||||||
|
.hero-el-editable:hover {
|
||||||
|
outline-color: var(--accent);
|
||||||
|
}
|
||||||
|
.hero-el-editable.is-selected {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
.hero-canvas-grid {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, rgba(127, 153, 189, 0.18) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, rgba(127, 153, 189, 0.18) 1px, transparent 1px);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Admin tables ===== */
|
/* ===== Admin tables ===== */
|
||||||
.adm-table {
|
.adm-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -365,6 +613,29 @@ button[disabled] {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
.badge-moderator {
|
||||||
|
background: rgba(224, 176, 112, 0.12);
|
||||||
|
color: #e0b070;
|
||||||
|
border: 1px solid rgba(224, 176, 112, 0.4);
|
||||||
|
}
|
||||||
|
/* Action-type badges for the moderation dashboard. */
|
||||||
|
.badge-ban {
|
||||||
|
background: rgba(217, 139, 132, 0.16);
|
||||||
|
color: #d98b84;
|
||||||
|
border: 1px solid rgba(217, 139, 132, 0.4);
|
||||||
|
}
|
||||||
|
.badge-kick,
|
||||||
|
.badge-mute,
|
||||||
|
.badge-warn {
|
||||||
|
background: rgba(224, 176, 112, 0.12);
|
||||||
|
color: #e0b070;
|
||||||
|
border: 1px solid rgba(224, 176, 112, 0.4);
|
||||||
|
}
|
||||||
|
.badge-auto {
|
||||||
|
background: rgba(127, 153, 189, 0.14);
|
||||||
|
color: #9fb0c6;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
.link-accent {
|
.link-accent {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user