diff --git a/bot/src/brand.js b/bot/src/brand.js index bfa72e3..a9e5b41 100644 --- a/bot/src/brand.js +++ b/bot/src/brand.js @@ -1,13 +1,83 @@ // Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and // logs carry the instance identity. Kept minimal — the bot only needs the name // and the accent color (as an int for discord.js embeds). +// +// The accent additionally tracks ADMIN THEMING. An admin who re-themes the site +// changes `theme_visual`, which the server resolves into the effective +// `brand.accent` on GET /public/settings (docs/website/THEMING_AND_NAV.md +// §4.5). This process boots from env and then follows that value, so embeds +// don't stay the old color until someone restarts the container. +// +// Design constraints this satisfies: +// • env is always a working answer — a site that is down, unconfigured or +// mid-restart never costs the bot its accent, it just keeps the last known +// good one; +// • reading `brand.accentInt` never awaits and never throws, because it is +// read inline while building an embed; +// • at most one refresh is ever in flight. require('dotenv').config() -const name = process.env.BRAND_NAME || 'Runic Gateway' -const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd' -const accentInt = (() => { - const n = parseInt(String(accentHex).replace('#', ''), 16) - return Number.isNaN(n) ? 0x7f99bd : n -})() +const siteApi = require('./site/siteApiClient') +const createLogger = require('./utils/logger') -module.exports = { name, accentHex, accentInt } +const log = createLogger('brand') + +const name = process.env.BRAND_NAME || 'Runic Gateway' +const ENV_ACCENT = process.env.BRAND_ACCENT_COLOR || '#7f99bd' + +function toInt(hex) { + const n = parseInt(String(hex).replace('#', ''), 16) + return Number.isNaN(n) ? 0x7f99bd : n +} + +// How long a fetched accent is trusted before the next read triggers a refresh. +// A theme change reaching Discord within ten minutes is fine; a network call per +// embed is not. +const TTL_MS = 10 * 60 * 1000 + +let accentHex = ENV_ACCENT +let accentInt = toInt(ENV_ACCENT) +let fetchedAt = 0 +let inFlight = null + +async function fetchAccent() { + const res = await siteApi.getPublicSettings() + // Any failure — site down, maintenance, malformed body — leaves the current + // value in place. Stamping fetchedAt regardless is deliberate: it stops a + // persistently unreachable site from firing a request on every single read. + fetchedAt = Date.now() + const accent = res.ok ? res.data?.brand?.accent : null + if (typeof accent !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(accent)) return + if (accent === accentHex) return + accentHex = accent + accentInt = toInt(accent) + log.info('embed accent updated from the site', { accent }) +} + +// Kick off a refresh if the cached value is stale. Never awaited by a reader — +// the current value is returned immediately and the next read sees the new one. +function refreshIfStale() { + if (inFlight || Date.now() - fetchedAt < TTL_MS) return inFlight + inFlight = fetchAccent() + .catch((err) => log.warn('accent refresh failed — keeping the current value', { message: err.message })) + .finally(() => { + inFlight = null + }) + return inFlight +} + +module.exports = { + name, + // Getters, not values: consumers already read `brand.accentInt` inline when + // building an embed, so this keeps the accent current with no call-site change. + get accentHex() { + refreshIfStale() + return accentHex + }, + get accentInt() { + refreshIfStale() + return accentInt + }, + // Awaited once at startup so the first embed of a process is already correct. + refreshAccent: () => refreshIfStale() || Promise.resolve(), +} diff --git a/bot/src/server.js b/bot/src/server.js index 582826b..c204c41 100644 --- a/bot/src/server.js +++ b/bot/src/server.js @@ -21,6 +21,11 @@ async function start() { log.info(`internal API listening on http://${HOST}:${PORT}`) }) + // Pick up the site's effective accent before the first embed can be built. + // Best-effort by design: it never rejects, and a site that is not up yet just + // leaves the bot on its BRAND_ACCENT_COLOR default until the next read. + await brand.refreshAccent() + await bootstrap() setupShutdown(server) diff --git a/bot/src/site/siteApiClient.js b/bot/src/site/siteApiClient.js index 0fe8bfe..0c7a524 100644 --- a/bot/src/site/siteApiClient.js +++ b/bot/src/site/siteApiClient.js @@ -31,6 +31,15 @@ async function call(path) { } } +// The site's public settings, including the brand block. Used for the embed +// accent (see brand.js): the admin can theme the site at runtime, and the +// server resolves the effective accent into brand.accent, so this is how the +// bot's embeds track a theme change instead of being stuck on the value +// BRAND_ACCENT_COLOR had when the container started. +function getPublicSettings() { + return call('/settings') +} + function getNewsPost(idOrSlug) { return call(`/posts/news/${encodeURIComponent(idOrSlug)}`) } @@ -39,4 +48,4 @@ function searchWiki(query) { return call(`/wiki?q=${encodeURIComponent(query)}`) } -module.exports = { getNewsPost, searchWiki } +module.exports = { getPublicSettings, getNewsPost, searchWiki } diff --git a/client/index.html b/client/index.html index d92425b..a45f2d3 100644 --- a/client/index.html +++ b/client/index.html @@ -7,7 +7,16 @@ - + +
diff --git a/client/package-lock.json b/client/package-lock.json index 7118c69..b41b713 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,9 @@ "name": "runic-gateway-client", "version": "1.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tiptap/extension-image": "^2.27.2", "@tiptap/extension-link": "^2.27.2", "@tiptap/extension-text-align": "^2.27.2", @@ -306,6 +309,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -2488,6 +2544,12 @@ "@popperjs/core": "^2.9.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", diff --git a/client/package.json b/client/package.json index 907923e..97468f9 100644 --- a/client/package.json +++ b/client/package.json @@ -10,6 +10,9 @@ "test": "node --test" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tiptap/extension-image": "^2.27.2", "@tiptap/extension-link": "^2.27.2", "@tiptap/extension-text-align": "^2.27.2", diff --git a/client/src/App.jsx b/client/src/App.jsx index 3d46f16..3cacb15 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -41,6 +41,8 @@ import PagesAdmin from './routes/admin/views/PagesAdmin.jsx' import PageBuilder from './routes/admin/views/PageBuilder.jsx' import WikiAdmin from './routes/admin/views/WikiAdmin.jsx' import HeroEditor from './routes/admin/views/HeroEditor.jsx' +import AppearanceAdmin from './routes/admin/views/AppearanceAdmin.jsx' +import NavEditor from './routes/admin/views/NavEditor.jsx' import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx' import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' @@ -139,6 +141,28 @@ export default function App() { } /> } /> } /> + {/* Theme editing writes an admin-only settings key; the route sits + behind the same RoleGate as the sidebar entry that reaches it, + and PUT/DELETE /admin/settings is admin-only server-side too. */} + + + + } + /> + {/* Same reasoning as Appearance: the nav overrides are an admin-only + settings key, so the route carries the same RoleGate as the + sidebar entry that reaches it. */} + + + + } + /> } /> req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }), + // ----- settings (any authenticated account) ----- + // Nav overrides for the layouts the caller's own role renders, and the theme + // catalog the appearance form is built from. A fifth group, not part of + // /admin, because AdminLayout renders for editors and moderators too — see + // docs/website/THEMING_AND_NAV.md §4.2. + navSettings: () => req('/settings/nav'), + themeOptions: () => req('/settings/theme/options'), + // ----- public ----- publicSettings: () => req('/public/settings'), status: () => req('/public/status'), @@ -280,6 +288,20 @@ export const api = { deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }), getSettings: () => req('/admin/settings'), updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }), + // Reset one setting to its default by deleting the row — the theming/nav + // keys and the hero draft only (the server holds the allowlist). Idempotent, + // so the caller need not know whether a row exists. + resetSetting: (key) => req(`/admin/settings/${encodeURIComponent(key)}`, { method: 'DELETE' }), + // Upload one brand asset (logo | hero | favicon) and set it as the override + // in the same call → { url, brand_assets }. A separate endpoint from the + // generic upload above because the server applies per-slot rules (favicons + // are PNG-only and capped small) and writes the settings row itself, so an + // upload never leaves a file nothing points at. + uploadBrandAsset: (slot, file) => { + const fd = new FormData() + fd.append('image', file) + return req(`/admin/settings/brand-asset/${encodeURIComponent(slot)}`, { method: 'POST', body: fd, raw: true }) + }, activity: (limit = 50) => req(`/admin/activity?limit=${limit}`), botActivity: () => req('/admin/bot-activity'), unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }), diff --git a/client/src/components/BrandLogo.jsx b/client/src/components/BrandLogo.jsx new file mode 100644 index 0000000..f91ed53 --- /dev/null +++ b/client/src/components/BrandLogo.jsx @@ -0,0 +1,33 @@ +import { useSite } from '../contexts/SiteContext.jsx' + +// The instance logo, shown beside the MoonDot wherever the site says its own +// name (docs/website/THEMING_AND_NAV.md phase 5). +// +// Renders NOTHING unless this instance has a logo — `brand.logo` is the uploaded +// override or BRAND_LOGO, and its default is the empty string. That is what +// keeps an untouched instance byte-for-byte as today: the MoonDot stands alone +// exactly as it does now, and the logo is an addition an operator opts into. +// +// It sits beside the moon rather than replacing it. The moon is the app's own +// mark and appears on surfaces (maintenance, login) that must render before the +// settings fetch resolves; swapping it out would leave those momentarily blank. +// +// Deliberately not used for the footer's "powered by Runic Gateway" emblem +// (SiteFooter.jsx) — that badge is the project's mark, not the instance's, and +// must not follow brand_assets (§4.11). +export default function BrandLogo({ height = 22, alt = '', style }) { + const { brand, siteTitle } = useSite() + if (!brand.logo) return null + return ( + + ) +} diff --git a/client/src/components/NavDropdown.jsx b/client/src/components/NavDropdown.jsx new file mode 100644 index 0000000..8ea42f7 --- /dev/null +++ b/client/src/components/NavDropdown.jsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from 'react' +import { NavLink, useLocation } from 'react-router-dom' + +// One dropdown section in the public header — a menu an admin created from +// Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10). +// +// It **opens on click, never on hover**. Hover menus are unusable on touch, and +// the alternative (make the trigger a link too) means tapping to open navigates +// away instead. A section is a container, not a destination, so the trigger has +// no `to` at all. +// +// Everything else here is the keyboard and dismissal contract a menu needs: +// Escape closes and returns focus to the trigger, an outside press closes, +// navigating closes, and Arrow Up/Down walk the items. `aria-haspopup` + +// `aria-expanded` are what let a screen reader announce it as a menu rather than +// as a button that mysteriously changes the page. +export default function NavDropdown({ label, items, linkStyle }) { + const [open, setOpen] = useState(false) + const wrapRef = useRef(null) + const triggerRef = useRef(null) + const location = useLocation() + + // The trigger shows the active treatment when the page you are on lives in + // this menu — otherwise entering a section makes the header look like nothing + // is selected. + const holdsActive = items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to))) + + // Close on navigation. The menu is rendered inside a sticky header that + // survives route changes, so nothing else would dismiss it. + useEffect(() => setOpen(false), [location.pathname]) + + useEffect(() => { + if (!open) return undefined + const onKey = (e) => { + if (e.key !== 'Escape') return + setOpen(false) + triggerRef.current?.focus() + } + // `mousedown`, not `click`: closing on the press means a press that lands on + // another trigger opens that one in the same gesture. + const onOutside = (e) => { + if (!wrapRef.current?.contains(e.target)) setOpen(false) + } + document.addEventListener('keydown', onKey) + document.addEventListener('mousedown', onOutside) + return () => { + document.removeEventListener('keydown', onKey) + document.removeEventListener('mousedown', onOutside) + } + }, [open]) + + // Roving focus with the arrow keys, wrapping at both ends. + const onMenuKeyDown = (e) => { + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return + e.preventDefault() + const links = [...(wrapRef.current?.querySelectorAll('[data-menu-item]') || [])] + if (links.length === 0) return + const at = links.indexOf(document.activeElement) + const next = e.key === 'ArrowDown' ? (at + 1) % links.length : (at - 1 + links.length) % links.length + links[at === -1 ? 0 : next].focus() + } + + return ( +
+ + + {open && ( +
+ {items.map((item) => ( + setOpen(false)} + className="sans" + style={({ isActive }) => ({ + padding: '7px 10px', + borderRadius: 'var(--radius-input)', + fontSize: '0.85rem', + textDecoration: 'none', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + ...linkStyle({ isActive }), + ...(isActive ? {} : { color: 'var(--muted)' }), + })} + > + {item.label} + + ))} +
+ )} +
+ ) +} diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index a3c0341..e9e142d 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -1,8 +1,13 @@ +import { useMemo } from 'react' import { Link, NavLink } from 'react-router-dom' import MoonDot from './MoonDot.jsx' +import BrandLogo from './BrandLogo.jsx' import { useAuth } from '../contexts/AuthContext.jsx' import { useSite } from '../contexts/SiteContext.jsx' import { useShardFeatures, canSee } from '../lib/useShardFeatures.js' +import NavDropdown from './NavDropdown.jsx' +import { buildPublicNav, pruneNav } from '../lib/navOverrides.js' +import { parseJsonSetting } from '../lib/settingsJson.js' // One consistent top nav for the whole public site. Every page gets the same // main links plus an auth-aware entry on the right (Sign in / My Account / Admin). @@ -11,7 +16,11 @@ import { useShardFeatures, canSee } from '../lib/useShardFeatures.js' // to a higher audience (Admin -> Shard Visibility). They are hidden when this // viewer can't reach them, so we never render a link that would 403. The gate // itself is server-side; this is only about not advertising a dead end. -const NAV = [ +// +// Exported because Admin -> Navigation edits this list. It stays declared here, +// with this component as its owner: the editor may only relabel, reorder and +// hide what it finds, and `to`/`feature` are never its to change (§7). +export const NAV = [ { label: 'Home', to: '/', end: true }, { label: 'News', to: '/site/news' }, { label: 'Screenshots', to: '/site/screenshots' }, @@ -38,9 +47,24 @@ const linkStyle = ({ isActive }) => ({ export default function SiteHeader() { const { user, loading } = useAuth() - const { siteTitle } = useSite() + const { siteTitle, settings } = useSite() const shardFeatures = useShardFeatures() - const nav = NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)) + + // An admin may relabel, reorder and hide these entries from Admin → + // Navigation, and may group them into dropdown sections alongside links of + // their own (THEMING_AND_NAV.md §7). Two things about the order here: + // + // • the override merge runs FIRST and the feature filter after it, so the + // filter stays the boundary — an override cannot un-hide a shard surface + // this viewer may not see, whatever it says. `pruneNav` applies the same + // check inside a section and drops one it leaves empty, so a dropdown + // never opens onto nothing; + // • with no stored row this is the coded NAV, in code order, so an + // untouched instance renders exactly what it renders today. + const nav = useMemo(() => { + const tree = buildPublicNav(NAV, parseJsonSetting(settings.nav_public)) + return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature)) + }, [settings.nav_public, shardFeatures]) // Where the auth entry points: staff → admin, player → portal, else sign in. let account @@ -68,15 +92,20 @@ export default function SiteHeader() { className="display" style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }} > + {siteTitle}