From 12d50fd6156014997c3517183738068a5f2dd94e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 21 Jul 2026 04:35:39 -0500 Subject: [PATCH] chore(quality): resolve SonarQube code smells across website MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client, and bot). All changes are behaviour-preserving refactors — no route, protocol, schema, or config changes — verified against the full server (381) and client (43) test suites plus a clean client build. By rule: - S3776 (20, cognitive complexity): extract helpers/handlers so each function drops under the threshold — shard model upsert builders, page/wiki update, block validation, notification stream mapping (dispatch table), SSO mobile login, shard ingest deps, uo-link socket backfill/connect, the bot slash- command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/ CharacterStats React components. - S4624 (34, nested template literals): pull inner templates into locals / a withQs() helper; rewrite shardEvents.describe() as a formatter table. - S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small components, or guarded JSX expressions. - S6479 (12, array-index React keys): key by stable content instead of index (two in-editor lists left as-is; index matches their by-index edit model). - S6353 (6): [0-9]/[^0-9] -> \d/\D. S125 (5): reword state-shape comments that parsed as code. S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples. - S6481 (2): memoize Auth/Site context values (and SiteContext brand). - S4144: dedupe HeroEditor upload handler into useImageUpload(). - S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex -> prefix list): assorted one-liners. Co-Authored-By: Claude --- bot/src/discord/commands/invite.command.js | 84 +++++---- bot/src/discord/commands/schedule.command.js | 138 +++++++------- bot/src/discord/discordManager.js | 71 +++---- bot/src/discord/messageFilter.js | 3 +- bot/src/filter/inviteFilter.js | 2 +- bot/src/utils/duration.js | 2 +- client/src/api/client.js | 36 ++-- client/src/components/CharacterStats.jsx | 33 ++-- client/src/components/GameAccounts.jsx | 3 +- client/src/components/HeroElement.jsx | 20 +- client/src/components/RichTextEditor.jsx | 2 +- client/src/components/ShardAccountActions.jsx | 12 +- client/src/components/SiteHeader.jsx | 10 +- client/src/components/VendorSales.jsx | 4 +- client/src/contexts/AuthContext.jsx | 11 +- client/src/contexts/SiteContext.jsx | 31 +-- client/src/data/regionBuckets.js | 18 +- client/src/lib/shardEvents.js | 150 ++++++++------- client/src/routes/admin/AdminLayout.jsx | 22 +-- client/src/routes/admin/AdminLogin.jsx | 6 +- .../src/routes/admin/views/AccountAdmin.jsx | 2 +- client/src/routes/admin/views/Appeals.jsx | 6 +- .../routes/admin/views/AuthProvidersAdmin.jsx | 16 +- .../routes/admin/views/BotActivityAdmin.jsx | 4 +- client/src/routes/admin/views/Dashboard.jsx | 5 +- .../src/routes/admin/views/EmailDelivery.jsx | 2 +- client/src/routes/admin/views/HeroEditor.jsx | 101 +++++----- .../src/routes/admin/views/InvitesAdmin.jsx | 18 +- .../src/routes/admin/views/ModerationUser.jsx | 17 +- client/src/routes/admin/views/PageBuilder.jsx | 9 +- .../src/routes/admin/views/SettingsAdmin.jsx | 26 ++- client/src/routes/admin/views/ShardOps.jsx | 44 +++-- client/src/routes/admin/views/UserDetail.jsx | 55 +++--- client/src/routes/admin/views/WikiEditor.jsx | 5 +- client/src/routes/admin/views/WikiHistory.jsx | 23 ++- client/src/routes/player/AcceptInvite.jsx | 2 +- client/src/routes/player/PlayerAccount.jsx | 5 +- client/src/routes/player/PlayerLogin.jsx | 6 +- client/src/routes/player/PlayerRegister.jsx | 8 +- client/src/routes/public/ChampSpawns.jsx | 13 +- client/src/routes/public/Governors.jsx | 7 +- client/src/routes/public/Shard.jsx | 155 ++++++++------- client/src/routes/wiki/Wiki.jsx | 4 +- server/src/blocks/validateBlocks.js | 108 ++++++----- server/src/config/notificationStreams.js | 111 +++++------ server/src/middleware/botScore.js | 1 + server/src/model/activity/activity.model.js | 4 +- .../src/model/moderation/moderation.pure.js | 2 +- server/src/model/pages/pages.model.js | 123 ++++++------ .../model/shardEvents/shardEvents.model.js | 3 +- server/src/model/shardState/shardState.db.js | 10 +- .../src/model/shardState/shardState.model.js | 100 +++++----- server/src/model/wiki/wiki.model.js | 17 +- server/src/router/v1/admin/admin.routes.js | 10 +- .../router/v1/admin/discordBot.controller.js | 3 +- server/src/router/v1/auth/sso.controller.js | 63 +++++-- server/src/utils/shardIngest.js | 10 +- server/src/utils/uoLinkClient.js | 3 +- server/src/utils/uoLinkSocket.js | 177 +++++++++--------- 59 files changed, 1088 insertions(+), 848 deletions(-) diff --git a/bot/src/discord/commands/invite.command.js b/bot/src/discord/commands/invite.command.js index e72ca31..95cd6ad 100644 --- a/bot/src/discord/commands/invite.command.js +++ b/bot/src/discord/commands/invite.command.js @@ -4,6 +4,46 @@ const guildConfig = require('../../model/guildConfig') const inviteLog = require('../../model/inviteLog') const inviteRotator = require('../../invites/inviteRotator') +// Per-subcommand handlers, split out of execute() so the dispatch stays flat. +async function handleChannel(interaction) { + 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 }) +} + +async function handleRotate(interaction) { + 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}` }) + } +} + +async function handleLog(interaction) { + 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 }) +} + module.exports = { data: { name: 'invite', @@ -40,46 +80,8 @@ module.exports = { }, 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 }) - } + if (sub === 'channel') return handleChannel(interaction) + if (sub === 'rotate') return handleRotate(interaction) + if (sub === 'log') return handleLog(interaction) }, } diff --git a/bot/src/discord/commands/schedule.command.js b/bot/src/discord/commands/schedule.command.js index bf9e3b6..25d95ae 100644 --- a/bot/src/discord/commands/schedule.command.js +++ b/bot/src/discord/commands/schedule.command.js @@ -5,6 +5,72 @@ const scheduledMessages = require('../../model/scheduledMessages') const scheduler = require('../../scheduler/scheduler') const { parseDuration } = require('../../utils/duration') +// Per-subcommand handlers, split out of execute() so the dispatch stays flat. +async function handleRecurring(interaction) { + 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 }) +} + +async function handleOnce(interaction) { + 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 }) +} + +async function handleRemove(interaction) { + 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 }) +} + +async function handleList(interaction) { + 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) => { + let kind + if (r.cron_expression) kind = `cron \`${r.cron_expression}\`` + else if (r.sent_at) kind = `sent ${new Date(r.sent_at).toLocaleString()}` + else kind = `due ${new Date(r.run_at).toLocaleString()}` + const suffix = r.enabled ? '' : ' (disabled)' + return `**#${r.id}** <#${r.channel_id}> — ${kind}${suffix}` + }) + await interaction.reply({ content: lines.join('\n'), ephemeral: true }) +} + module.exports = { data: { name: 'schedule', @@ -47,73 +113,9 @@ module.exports = { }, 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 }) - } + if (sub === 'recurring') return handleRecurring(interaction) + if (sub === 'once') return handleOnce(interaction) + if (sub === 'remove') return handleRemove(interaction) + if (sub === 'list') return handleList(interaction) }, } diff --git a/bot/src/discord/discordManager.js b/bot/src/discord/discordManager.js index 49f3bae..5edc42a 100644 --- a/bot/src/discord/discordManager.js +++ b/bot/src/discord/discordManager.js @@ -50,6 +50,42 @@ async function stop() { log.info('discord client disconnected') } +// Post-login startup: register commands and start the background workers. A +// failure here leaves the client connected but flags an error status. +async function onReady() { + 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 }) + } +} + +// Route an interaction: role-menu handler first, then chat-input slash commands. +async function onInteractionCreate(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) + } +} + // 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 }) { @@ -72,39 +108,8 @@ async function start({ token, guildId: gid }) { ], }) - 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.once('ready', onReady) + client.on('interactionCreate', onInteractionCreate) client.on('messageCreate', messageFilter.handleMessageCreate) client.on('guildMemberAdd', handleGuildMemberAdd) client.on('guildMemberRemove', handleGuildMemberRemove) diff --git a/bot/src/discord/messageFilter.js b/bot/src/discord/messageFilter.js index 5c4a085..00b2540 100644 --- a/bot/src/discord/messageFilter.js +++ b/bot/src/discord/messageFilter.js @@ -66,8 +66,7 @@ function detectSpam(message) { 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 + return Boolean(memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id))) } async function applyWarnAction(message, reason) { diff --git a/bot/src/filter/inviteFilter.js b/bot/src/filter/inviteFilter.js index 70c9925..30e198c 100644 --- a/bot/src/filter/inviteFilter.js +++ b/bot/src/filter/inviteFilter.js @@ -2,7 +2,7 @@ // 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 +const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-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 diff --git a/bot/src/utils/duration.js b/bot/src/utils/duration.js index 3bfab3b..2679db0 100644 --- a/bot/src/utils/duration.js +++ b/bot/src/utils/duration.js @@ -7,7 +7,7 @@ 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()) + const match = /^(\d+)\s*([smhd])$/i.exec(input.trim()) if (!match) return null const [, amount, unit] = match return Number(amount) * UNIT_MS[unit.toLowerCase()] diff --git a/client/src/api/client.js b/client/src/api/client.js index d227a3b..b88a920 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -2,6 +2,10 @@ // same-origin API (/api/v1) — proxied to the Express server in dev. const BASE = '/api/v1' +// Prefix a non-empty query string with "?" (and nothing when it is empty), so +// callers can append it to a path without a dangling "?". +const withQs = (s) => (s ? `?${s}` : '') + class ApiError extends Error { constructor(status, message, body) { super(message) @@ -84,7 +88,7 @@ export const api = { 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}` : ''}`) + return req(`/public/wiki${withQs(s)}`) }, wikiCategories: () => req('/public/wiki/categories'), wikiTags: () => req('/public/wiki/tags'), @@ -105,17 +109,22 @@ export const api = { if (opts.kind) qs.set('kind', opts.kind) if (opts.limit) qs.set('limit', opts.limit) const s = qs.toString() - return req(`/public/shard/feed${s ? `?${s}` : ''}`) + return req(`/public/shard/feed${withQs(s)}`) + }, + economy: (limit) => { + const q = limit ? `limit=${limit}` : '' + return req(`/public/shard/economy${withQs(q)}`) }, - economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`), online: () => req('/public/shard/online'), idoc: () => req('/public/shard/idoc'), champs: () => req('/public/shard/champs'), // Protocol 2.0 boards. guilds: () => req('/public/shard/guilds'), governors: () => req('/public/shard/governors'), - governorHistory: (city, limit) => - req(`/public/shard/governors/${encodeURIComponent(city)}/history${limit ? `?limit=${limit}` : ''}`), + governorHistory: (city, limit) => { + const q = limit ? `limit=${limit}` : '' + return req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(q)}`) + }, presence: () => req('/public/shard/presence'), houses: () => req('/public/shard/houses'), }, @@ -129,7 +138,10 @@ export const api = { admin: { dashboard: () => req('/admin/dashboard'), setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }), - listPosts: (category) => req(`/admin/posts${category ? `?category=${category}` : ''}`), + listPosts: (category) => { + const q = category ? `category=${category}` : '' + return req(`/admin/posts${withQs(q)}`) + }, getPost: (id) => req(`/admin/posts/${id}`), createPost: (data) => req('/admin/posts', { method: 'POST', body: data }), updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }), @@ -216,7 +228,7 @@ export const api = { 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}` : ''}`) + return req(`/admin/moderation/recent${withQs(s)}`) }, modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`), modMembers: (params = {}) => { @@ -225,21 +237,21 @@ export const api = { 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}` : ''}`) + return req(`/admin/moderation/members${withQs(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}` : ''}`) + return req(`/admin/moderation/filter-hits${withQs(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}` : ''}`) + return req(`/admin/moderation/spam-hits${withQs(s)}`) }, modUser: (discordId) => req(`/admin/moderation/user/${discordId}`), modUserActions: (discordId, params = {}) => { @@ -248,7 +260,7 @@ export const api = { 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}` : ''}`) + return req(`/admin/moderation/user/${discordId}/actions${withQs(s)}`) }, modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`), addModNote: (discordId, data) => @@ -261,7 +273,7 @@ export const api = { if (params.limit) qs.set('limit', params.limit) if (params.offset) qs.set('offset', params.offset) const s = qs.toString() - return req(`/admin/moderation/appeals${s ? `?${s}` : ''}`) + return req(`/admin/moderation/appeals${withQs(s)}`) }, getAppeal: (id) => req(`/admin/moderation/appeals/${id}`), claimAppeal: (id) => req(`/admin/moderation/appeals/${id}/claim`, { method: 'POST' }), diff --git a/client/src/components/CharacterStats.jsx b/client/src/components/CharacterStats.jsx index 0f7fe3b..12142ed 100644 --- a/client/src/components/CharacterStats.jsx +++ b/client/src/components/CharacterStats.jsx @@ -20,6 +20,25 @@ function Tile({ value, label }) { ) } +// Fold the settled roster results into totals. `complete` is false when any +// account's roster failed (a partial result — shown as a dash rather than a +// misleadingly low count). +function summarizeRosters(rosters) { + let chars = 0 + let online = 0 + let complete = true + for (const r of rosters) { + if (r.status !== 'fulfilled') { + complete = false + continue + } + const cs = r.value.chars || [] + chars += cs.length + online += cs.filter((c) => c.online).length + } + return { chars, online, complete } +} + export default function CharacterStats({ scope }) { const [stats, setStats] = useState(null) @@ -36,19 +55,7 @@ export default function CharacterStats({ scope }) { // Roster is a live round-trip and can be unavailable (503); tolerate a // partial result so a restarting shard doesn't blank the whole row. const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account))) - let chars = 0 - let online = 0 - let complete = true - for (const r of rosters) { - if (r.status === 'fulfilled') { - const cs = r.value.chars || [] - chars += cs.length - online += cs.filter((c) => c.online).length - } else { - complete = false - } - } - if (!cancelled) setStats({ linked, chars, online, complete }) + if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) }) } catch { if (!cancelled) setStats({ error: true }) } diff --git a/client/src/components/GameAccounts.jsx b/client/src/components/GameAccounts.jsx index ff1539d..ef9d2ed 100644 --- a/client/src/components/GameAccounts.jsx +++ b/client/src/components/GameAccounts.jsx @@ -121,7 +121,8 @@ function UnlinkButton({ account, onUnlink }) { try { await onUnlink(account) } catch (err) { - setError(err.status === 403 ? 'Protected account — refused.' : err.status === 404 ? 'Not linked.' : (err.message || 'Could not unlink.')) + const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' } + setError(byStatus[err.status] || err.message || 'Could not unlink.') setBusy(false) } } diff --git a/client/src/components/HeroElement.jsx b/client/src/components/HeroElement.jsx index 75583f7..ee0f4eb 100644 --- a/client/src/components/HeroElement.jsx +++ b/client/src/components/HeroElement.jsx @@ -34,14 +34,15 @@ function TextBlock({ props }) { const align = props.align || 'center' return (
- {(props.lines || []).map((line, i) => { + {(props.lines || []).map((line) => { const Tag = /^(h1|h2|h3|p|span|div)$/.test(line.tag) ? line.tag : 'p' + const key = `${line.tag}:${(line.text || '').slice(0, 40)}` // A rich-text line (e.g. the homepage teaser) carries sanitized HTML; // sanitize again on render as defense in depth. Others render as text. if (line.html) { return ( + {line.text} ) @@ -59,11 +60,11 @@ function TextBlock({ props }) { } function Buttons({ props }) { - const justify = props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center' + const justify = { left: 'flex-start', right: 'flex-end' }[props.align] || 'center' return (
- {(props.items || []).map((b, i) => ( - + {(props.items || []).map((b) => ( + {b.label} ))} @@ -160,12 +161,7 @@ export default function HeroElement({ children, }) { const anchor = element.anchor || 'center' - const transform = - anchor === 'center' - ? 'translate(-50%, -50%)' - : anchor === 'top-right' - ? 'translateX(-100%)' - : undefined + const transform = { center: 'translate(-50%, -50%)', 'top-right': 'translateX(-100%)' }[anchor] // 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 = diff --git a/client/src/components/RichTextEditor.jsx b/client/src/components/RichTextEditor.jsx index 9d7d233..f2381fc 100644 --- a/client/src/components/RichTextEditor.jsx +++ b/client/src/components/RichTextEditor.jsx @@ -36,7 +36,7 @@ function AlignIcon({ align }) { return ( ) diff --git a/client/src/components/ShardAccountActions.jsx b/client/src/components/ShardAccountActions.jsx index 10510a5..3db37c2 100644 --- a/client/src/components/ShardAccountActions.jsx +++ b/client/src/components/ShardAccountActions.jsx @@ -36,9 +36,12 @@ export default function ShardAccountActions({ account, style }) { } const kick = () => - run('kick', () => api.admin.shardOps.kick({ account }), (r) => - `Kicked${r && r.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`, - ) + run('kick', () => api.admin.shardOps.kick({ account }), (r) => { + const n = r && r.sessions != null ? r.sessions : null + const plural = n === 1 ? '' : 's' + const sessions = n != null ? ` (${n} session${plural})` : '' + return `Kicked${sessions}.` + }) const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.') const ban = () => run('ban', () => @@ -49,7 +52,8 @@ export default function ShardAccountActions({ account, style }) { }), () => { setBanOpen(false) - return `Banned${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.` + const when = durationSec ? ` for ${durationSec}s` : ' indefinitely' + return `Banned${when}.` }) const btn = { fontSize: '0.72rem', padding: '4px 10px' } diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 4e60bfa..4394419 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -31,12 +31,10 @@ export default function SiteHeader() { const { siteTitle } = useSite() // Where the auth entry points: staff → admin, player → portal, else sign in. - const account = - user && user.role && user.role !== 'player' - ? { label: 'Admin', to: '/admin' } - : user - ? { label: 'My Account', to: '/player' } - : { label: 'Sign in', to: '/account/login' } + let account + if (user && user.role && user.role !== 'player') account = { label: 'Admin', to: '/admin' } + else if (user) account = { label: 'My Account', to: '/player' } + else account = { label: 'Sign in', to: '/account/login' } return (
No vendor sales recorded yet.

) : (
    - {sales.map((s, i) => ( -
  • + {sales.map((s) => ( +
  • {s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp diff --git a/client/src/contexts/AuthContext.jsx b/client/src/contexts/AuthContext.jsx index 298ecff..665fc13 100644 --- a/client/src/contexts/AuthContext.jsx +++ b/client/src/contexts/AuthContext.jsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useState, useCallback } from 'react' +import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react' import { api } from '../api/client.js' const AuthContext = createContext(null) @@ -61,8 +61,15 @@ export function AuthProvider({ children }) { } }, []) + // Memoized so consumers don't re-render on every provider render (the callbacks + // are already stable via useCallback). + const value = useMemo( + () => ({ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }), + [user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh], + ) + return ( - + {children} ) diff --git a/client/src/contexts/SiteContext.jsx b/client/src/contexts/SiteContext.jsx index 781d97f..958556a 100644 --- a/client/src/contexts/SiteContext.jsx +++ b/client/src/contexts/SiteContext.jsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useState, useCallback } from 'react' +import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react' import { api } from '../api/client.js' const SiteContext = createContext(null) @@ -23,7 +23,7 @@ export function SiteProvider({ children }) { refresh() }, [refresh]) - const brand = settings.brand || {} + const brand = useMemo(() => settings.brand || {}, [settings]) // Apply the instance accent color to the CSS variable the theme is built on, // so branding flows to every `var(--accent)` at runtime (no rebuild). @@ -31,17 +31,22 @@ export function SiteProvider({ children }) { if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent) }, [brand.accent]) - const value = { - settings, - loading, - refresh, - brand, - mode: settings.site_mode || 'live', - siteTitle: brand.name || settings.site_title || 'Runic Gateway', - siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway', - contactEmail: brand.contactEmail || settings.contact_email || '', - heroImage: brand.hero || '/assets/img/runic-emblem.png', - } + // Memoized so consumers don't re-render on every provider render (brand is a + // fresh object each render, which would otherwise churn the context value). + const value = useMemo( + () => ({ + settings, + loading, + refresh, + brand, + mode: settings.site_mode || 'live', + siteTitle: brand.name || settings.site_title || 'Runic Gateway', + siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway', + contactEmail: brand.contactEmail || settings.contact_email || '', + heroImage: brand.hero || '/assets/img/runic-emblem.png', + }), + [settings, loading, refresh, brand], + ) return {children} } diff --git a/client/src/data/regionBuckets.js b/client/src/data/regionBuckets.js index 7d901af..ffbc1dd 100644 --- a/client/src/data/regionBuckets.js +++ b/client/src/data/regionBuckets.js @@ -4,6 +4,16 @@ // membership) and the widget follows. Anything not matched lands in "Wilderness" // so the bucket counts always reconcile to the true total. +// Named cities/towns, matched as a prefix on the (space/apostrophe-stripped) +// region name so "skara brae", "serpent's hold", etc. all resolve. Kept as a +// list rather than one giant alternation regex (simpler to read and retune). +const TOWN_PREFIXES = [ + 'moonglow', 'minoc', 'trinsic', 'jhelom', 'yew', 'skarabrae', 'magincia', + 'newmagincia', 'vesper', 'nujelm', 'cove', 'ocllo', 'serpenthold', 'serpentshold', + 'wind', 'delucia', 'papua', +] +const normalizeRegion = (r) => String(r).toLowerCase().replace(/['’\s]/g, '') + // Ordered list of buckets. `label` shows in the widget; `match(region)` decides // membership. First matching bucket wins; the last bucket is the catch-all. export const BUCKETS = [ @@ -17,10 +27,10 @@ export const BUCKETS = [ id: 'towns', label: 'Towns', // The other named cities/towns. - match: (r) => - /^(moonglow|minoc|trinsic|jhelom|yew|skara ?brae|magincia|new ?magincia|vesper|nujelm|cove|ocllo|serpent'?s? hold|wind|delucia|papua)/i.test( - r, - ), + match: (r) => { + const norm = normalizeRegion(r) + return TOWN_PREFIXES.some((t) => norm.startsWith(t)) + }, }, { id: 'dungeons', diff --git a/client/src/lib/shardEvents.js b/client/src/lib/shardEvents.js index 00c70ec..5ad5786 100644 --- a/client/src/lib/shardEvents.js +++ b/client/src/lib/shardEvents.js @@ -10,73 +10,95 @@ function nameOf(who) { const n = (v) => Number(v || 0).toLocaleString() +// A one-line human description of each event kind, keyed by kind. Each formatter +// takes the payload and returns a string. Conditional suffixes are pulled into +// locals so no template literal is nested inside another. +const DESCRIBERS = { + 'vendor.sale': (p) => { + const qty = p.amount > 1 ? ` ×${p.amount}` : '' + return `${p.itemType || 'An item'}${qty} sold for ${n(p.price)}gp` + }, + 'player.death': (p) => { + const by = p.killer ? ` by ${nameOf(p.killer)}` : '' + return `${nameOf(p.who)} was slain${by}` + }, + 'player.murdered': (p) => { + const by = p.murderer ? ` by ${nameOf(p.murderer)}` : '' + return `${nameOf(p.victim)} was murdered${by}` + }, + 'mob.killed': (p) => `${nameOf(p.killer)} killed ${nameOf(p.killed)}`, + 'skill.gain': (p) => { + const base = p.base != null ? ` (${p.base})` : '' + return `${nameOf(p.who)} gained ${p.skill}${base}` + }, + 'fame.change': (p) => `${nameOf(p.who)}’s fame changed to ${n(p.new)}`, + 'karma.change': (p) => `${nameOf(p.who)}’s karma changed to ${n(p.new)}`, + 'quest.complete': (p) => `${nameOf(p.who)} completed “${p.quest}”`, + 'house.decay': (p) => { + const region = p.region ? ` — ${p.region}` : '' + return `${p.name || 'A house'} is now ${p.to || p.stage}${region}` + }, + 'mob.login': (p) => `${nameOf(p.who)} entered the world`, + 'mob.logout': (p) => `${nameOf(p.who)} left the world`, + 'economy.supply': (p) => `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`, + 'server.hello': (p) => `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`, + 'server.shutdown': () => 'Shard shut down', + 'server.crashed': (p) => { + const err = p.error ? `: ${p.error}` : '' + return `Shard crashed${err}` + }, + 'champ.update': (p) => { + const where = p.name || p.type || 'A champion spawn' + if (p.status === 'active' && p.bossUp) { + const boss = p.boss ? ` (${p.boss})` : '' + return `${where}: boss is up${boss}` + } + if (p.status === 'active') { + const level = p.level != null ? ` — level ${p.level}` : '' + return `${where} is active${level}` + } + if (p.status === 'cooldown') return `${where} is on cooldown` + return `${where} is ${p.status || 'idle'}` + }, + 'champ.remove': () => `A champion spawn ended`, + // Support (help-page) queue + in-game moderation (admin channel only) + 'page.new': (p) => `New ${p.type || 'help'} page from ${nameOf(p.sender)}`, + 'page.updated': (p) => { + const claimed = p.handled ? ' (claimed)' : '' + return `Help page from ${nameOf(p.sender)} updated${claimed}` + }, + 'page.closed': (p) => `Help page ${p.pageId || ''} closed`, + 'admin.audit': (p) => { + const on = p.target ? ` on ${p.target}` : '' + const origin = p.origin ? ` [${p.origin}]` : '' + return `${p.actor || 'Staff'} ${p.action || 'acted'}${on}${origin}` + }, + // Staff / sensitive (admin channel only) + 'audit.set': (p) => + `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`, + 'audit.command': (p) => { + const args = p.args ? ` ${p.args}` : '' + return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${args}` + }, + 'cheat.fastwalk': (p) => { + const ip = p.ip ? ` (${p.ip})` : '' + return `Fast-walk flagged: ${nameOf(p.who)}${ip}` + }, + 'account.login.attempt': (p) => { + const ip = p.ip ? ` from ${p.ip}` : '' + return `Login attempt: ${p.acct}${ip}` + }, + 'gold.change': (p) => { + const sign = p.delta >= 0 ? '+' : '' + return `${p.acct}: gold ${sign}${n(p.delta)} → ${n(p.new)}` + }, +} + // A one-line human description of an event. Accepts either a stored event // (with .payload) or a raw live frame (fields at top level). export function describe(ev) { - const p = ev.payload || ev - switch (ev.kind) { - case 'vendor.sale': - return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${n(p.price)}gp` - case 'player.death': - return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}` - case 'player.murdered': - return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}` - case 'mob.killed': - return `${nameOf(p.killer)} killed ${nameOf(p.killed)}` - case 'skill.gain': - return `${nameOf(p.who)} gained ${p.skill}${p.base != null ? ` (${p.base})` : ''}` - case 'fame.change': - return `${nameOf(p.who)}’s fame changed to ${n(p.new)}` - case 'karma.change': - return `${nameOf(p.who)}’s karma changed to ${n(p.new)}` - case 'quest.complete': - return `${nameOf(p.who)} completed “${p.quest}”` - case 'house.decay': - return `${p.name || 'A house'} is now ${p.to || p.stage}${p.region ? ` — ${p.region}` : ''}` - case 'mob.login': - return `${nameOf(p.who)} entered the world` - case 'mob.logout': - return `${nameOf(p.who)} left the world` - case 'economy.supply': - return `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts` - case 'server.hello': - return `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles` - case 'server.shutdown': - return 'Shard shut down' - case 'server.crashed': - return `Shard crashed${p.error ? `: ${p.error}` : ''}` - case 'champ.update': { - const where = p.name || p.type || 'A champion spawn' - if (p.status === 'active' && p.bossUp) return `${where}: boss is up${p.boss ? ` (${p.boss})` : ''}` - if (p.status === 'active') return `${where} is active${p.level != null ? ` — level ${p.level}` : ''}` - if (p.status === 'cooldown') return `${where} is on cooldown` - return `${where} is ${p.status || 'idle'}` - } - case 'champ.remove': - return `A champion spawn ended` - // Support (help-page) queue + in-game moderation (admin channel only) - case 'page.new': - return `New ${p.type || 'help'} page from ${nameOf(p.sender)}` - case 'page.updated': - return `Help page from ${nameOf(p.sender)} updated${p.handled ? ' (claimed)' : ''}` - case 'page.closed': - return `Help page ${p.pageId || ''} closed` - case 'admin.audit': - return `${p.actor || 'Staff'} ${p.action || 'acted'}${p.target ? ` on ${p.target}` : ''}${p.origin ? ` [${p.origin}]` : ''}` - // Staff / sensitive (admin channel only) - case 'audit.set': - return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})` - case 'audit.command': - return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${p.args ? ` ${p.args}` : ''}` - case 'cheat.fastwalk': - return `Fast-walk flagged: ${nameOf(p.who)}${p.ip ? ` (${p.ip})` : ''}` - case 'account.login.attempt': - return `Login attempt: ${p.acct}${p.ip ? ` from ${p.ip}` : ''}` - case 'gold.change': - return `${p.acct}: gold ${p.delta >= 0 ? '+' : ''}${n(p.delta)} → ${n(p.new)}` - default: - return ev.kind - } + const fmt = DESCRIBERS[ev.kind] + return fmt ? fmt(ev.payload || ev) : ev.kind } // Category grouping for the filter tabs. diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 6c3826f..f54811c 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -113,6 +113,14 @@ const TITLES = { '/admin/account': 'Account Security', } +// Fallback page title for dynamic sub-routes not in the exact-match TITLES map. +function sectionTitle(pathname) { + if (pathname.startsWith('/admin/moderation')) return 'Moderation' + if (pathname.startsWith('/admin/characters')) return 'My Characters' + if (pathname.startsWith('/admin/users/')) return 'User' + return 'Admin' +} + const navBtnBase = { textAlign: 'left', borderRadius: 8, @@ -131,15 +139,7 @@ export default function AdminLayout() { const { mode, siteTitle } = useSite() const navigate = useNavigate() const location = useLocation() - const title = - TITLES[location.pathname] || - (location.pathname.startsWith('/admin/moderation') - ? 'Moderation' - : location.pathname.startsWith('/admin/characters') - ? 'My Characters' - : location.pathname.startsWith('/admin/users/') - ? 'User' - : 'Admin') + const title = TITLES[location.pathname] || sectionTitle(location.pathname) // 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)' @@ -236,7 +236,7 @@ export default function AdminLayout() {
@@ -277,7 +280,7 @@ export default function PageBuilder() { {error} {details.length > 0 && ( )} @@ -386,7 +389,7 @@ export default function PageBuilder() { Show in navigation - setSetting('navOrder')(v === '' ? null : v.replace(/[^0-9]/g, ''))} hint="Lower numbers appear first." /> + setSetting('navOrder')(v === '' ? null : v.replace(/\D/g, ''))} hint="Lower numbers appear first." /> diff --git a/client/src/routes/admin/views/SettingsAdmin.jsx b/client/src/routes/admin/views/SettingsAdmin.jsx index ca59eb8..583dd86 100644 --- a/client/src/routes/admin/views/SettingsAdmin.jsx +++ b/client/src/routes/admin/views/SettingsAdmin.jsx @@ -109,14 +109,15 @@ export default function SettingsAdmin() { // A rich field can't live inside a