chore(quality): resolve SonarQube code smells across website
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
</div>
|
||||
|
||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
||||
{navGroups.map((group, gi) => {
|
||||
{navGroups.map((group) => {
|
||||
const links = group.items.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
@@ -258,7 +258,7 @@ export default function AdminLayout() {
|
||||
// Untitled groups (Dashboard, Account) render their links directly.
|
||||
if (!group.title) {
|
||||
return (
|
||||
<div key={`g${gi}`} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div key={group.items[0]?.to || 'group'} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{links}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -140,6 +140,10 @@ export default function AdminLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
let submitLabel = 'Sign in'
|
||||
if (busy) submitLabel = 'Signing in…'
|
||||
else if (stage === 'totp') submitLabel = 'Verify'
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
@@ -249,7 +253,7 @@ export default function AdminLogin() {
|
||||
className="btn btn-primary"
|
||||
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
|
||||
>
|
||||
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
|
||||
{submitLabel}
|
||||
</button>
|
||||
|
||||
{/* SSO providers — only on the credentials step, only if any are enabled. */}
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function AccountAdmin() {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Enrollment state.
|
||||
const [setup, setSetup] = useState(null) // { qr, otpauthUrl }
|
||||
const [setup, setSetup] = useState(null) // fields qr and otpauthUrl once enrolling
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function Appeals() {
|
||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||
const [busyId, setBusyId] = useState('')
|
||||
const [resolving, setResolving] = useState(null) // the appeal being resolved
|
||||
const [notice, setNotice] = useState(null) // { text, tone }
|
||||
const [notice, setNotice] = useState(null) // fields text and tone
|
||||
|
||||
const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0]
|
||||
const { loading, error, data } = useAsync(
|
||||
@@ -210,6 +210,8 @@ function ResolveModal({ appeal, onClose, onResolved }) {
|
||||
}
|
||||
}
|
||||
|
||||
const verb = status === 'approved' ? 'approved' : 'denied'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Resolve appeal — ${appeal.action_target_tag || appeal.discord_user_id}`}
|
||||
@@ -221,7 +223,7 @@ function ResolveModal({ appeal, onClose, onResolved }) {
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={submit} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : `Mark ${status === 'approved' ? 'approved' : 'denied'}`}
|
||||
{busy ? 'Saving…' : `Mark ${verb}`}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -44,6 +44,14 @@ function CallbackHint({ id }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Live = enabled and healthy; Incomplete = enabled but missing/invalid config;
|
||||
// Disabled otherwise.
|
||||
function ProviderStatus({ provider: p }) {
|
||||
if (p.enabled && p.health.valid) return <span className="sans" style={{ color: '#7fd0a4' }}>Live</span>
|
||||
if (p.enabled) return <span className="sans" style={{ color: '#e0b070' }}>Incomplete</span>
|
||||
return <span className="sans dim">Disabled</span>
|
||||
}
|
||||
|
||||
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)' }}>
|
||||
@@ -292,13 +300,7 @@ function CustomProviders({ items, onChanged }) {
|
||||
<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>
|
||||
)}
|
||||
<ProviderStatus provider={p} />
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<span className="link-accent" onClick={() => setEditing(p)}>Edit</span>
|
||||
|
||||
@@ -115,8 +115,8 @@ export default function BotActivityAdmin() {
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{events.map((ev, i) => (
|
||||
<tr key={`${ev.ts}-${ev.ip}-${i}`}>
|
||||
{events.map((ev) => (
|
||||
<tr key={`${ev.ts}-${ev.ip}-${ev.type}`}>
|
||||
<td className="adm-td dim">{dateTime(ev.ts)}</td>
|
||||
<td className="adm-td" style={{ ...mono, color: 'var(--text)' }}>
|
||||
{ev.ip}
|
||||
|
||||
@@ -45,6 +45,9 @@ export default function Dashboard() {
|
||||
|
||||
const changed = dash.last_change || {}
|
||||
|
||||
let modeLabel = isLive ? 'Switch to Maintenance' : 'Switch to Live'
|
||||
if (busy) modeLabel = 'Saving…'
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div
|
||||
@@ -82,7 +85,7 @@ export default function Dashboard() {
|
||||
className="sans"
|
||||
style={{ border: '1px solid var(--accent)', borderRadius: 999, padding: '11px 24px', background: 'rgba(127,153,189,0.14)', color: '#d8e2ef', fontWeight: 600, fontSize: '0.9rem', cursor: 'pointer' }}
|
||||
>
|
||||
{busy ? 'Saving…' : isLive ? 'Switch to Maintenance' : 'Switch to Live'}
|
||||
{modeLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function EmailDelivery() {
|
||||
const [busy, setBusy] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [banner, setBanner] = useState(null) // { kind: 'ok'|'err', text }
|
||||
const [banner, setBanner] = useState(null) // fields kind ('ok' or 'err') and text
|
||||
|
||||
const load = useCallback(async (seedForm = false) => {
|
||||
try {
|
||||
|
||||
@@ -23,6 +23,34 @@ function tooLargeToUpload(size) {
|
||||
)
|
||||
}
|
||||
|
||||
// Label for an image-upload button: busy, replace-existing, or first upload.
|
||||
function uploadLabel(up, hasSrc) {
|
||||
if (up) return 'Uploading…'
|
||||
return hasSrc ? 'Replace' : 'Upload'
|
||||
}
|
||||
|
||||
// Shared image-upload behaviour for the element panels that point props.src at
|
||||
// the uploaded URL (moon + image). Returns the busy flag and file <input> handler.
|
||||
function useImageUpload(onProps) {
|
||||
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 { up, onFile }
|
||||
}
|
||||
|
||||
function newElement(type, z) {
|
||||
const base = { id: genId(), type, x: 50, y: 50, z, anchor: 'center' }
|
||||
if (type === 'text_block') {
|
||||
@@ -53,6 +81,23 @@ function scaleFontSize(v, ratio) {
|
||||
return v
|
||||
}
|
||||
|
||||
// The props patch for a resize drag, per element type: image width is a % of the
|
||||
// canvas, moon size is px, and a text_block resizes its box and scales every
|
||||
// line's font proportionally. `ctx` carries the drag origin + measured geometry.
|
||||
function resizePatch(el, ctx) {
|
||||
const { orig, dxPx, dxLogical, rectWidth, baseWidth, baseLines } = ctx
|
||||
if (el.type === 'image') {
|
||||
return { width: Math.round(clamp(orig + (dxPx / rectWidth) * 100, 5, 100)) } // %
|
||||
}
|
||||
if (el.type === 'moon') {
|
||||
return { size: Math.round(clamp(orig + dxLogical, 24, 400)) } // px
|
||||
}
|
||||
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) }))
|
||||
return { width, lines }
|
||||
}
|
||||
|
||||
export default function HeroEditor() {
|
||||
const [layout, setLayout] = useState(null)
|
||||
const [live, setLive] = useState(null)
|
||||
@@ -203,7 +248,9 @@ export default function HeroEditor() {
|
||||
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)
|
||||
let defaultDim = 64
|
||||
if (dim === 'width') defaultDim = el.type === 'image' ? 40 : 600
|
||||
const orig = el.props?.[dim] ?? defaultDim
|
||||
// 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
|
||||
@@ -217,17 +264,7 @@ export default function HeroEditor() {
|
||||
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 })
|
||||
}
|
||||
updateProps(el.id, resizePatch(el, { orig, dxPx, dxLogical, rectWidth: rect.width, baseWidth, baseLines }))
|
||||
}
|
||||
const up = () => {
|
||||
node.removeEventListener('pointermove', move)
|
||||
@@ -534,24 +571,7 @@ const swatch = { width: '100%', height: 38, padding: 2, border: '1px solid var(-
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
const { up, onFile } = useImageUpload(onProps)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
@@ -562,7 +582,7 @@ function MoonPanel({ element, onProps }) {
|
||||
<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'}
|
||||
{uploadLabel(up, !!p.src)}
|
||||
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
|
||||
</label>
|
||||
{p.src && (
|
||||
@@ -613,29 +633,14 @@ function BadgePanel({ element, onProps }) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
const { up, onFile } = useImageUpload(onProps)
|
||||
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'}
|
||||
{uploadLabel(up, !!p.src)}
|
||||
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ function CreateInvite({ onCreated }) {
|
||||
const [sendEmail, setSendEmail] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [result, setResult] = useState(null) // { emailed, acceptUrl, emailError }
|
||||
const [result, setResult] = useState(null) // fields emailed, acceptUrl, emailError
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
@@ -62,6 +62,16 @@ function CreateInvite({ onCreated }) {
|
||||
}
|
||||
}
|
||||
|
||||
const submitLabel = sendEmail ? 'Create & email' : 'Create link'
|
||||
|
||||
let resultText
|
||||
if (result?.emailed) {
|
||||
resultText = 'Invitation emailed. You can also share this single-use link:'
|
||||
} else {
|
||||
const emailNote = result?.emailError ? ` (email not sent: ${result.emailError})` : ''
|
||||
resultText = `Invite created${emailNote}. Share this single-use link:`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: 22, marginBottom: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Invite someone</div>
|
||||
@@ -77,7 +87,7 @@ function CreateInvite({ onCreated }) {
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Creating…' : (sendEmail ? 'Create & email' : 'Create link')}
|
||||
{busy ? 'Creating…' : submitLabel}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -90,9 +100,7 @@ function CreateInvite({ onCreated }) {
|
||||
{result && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.84rem', color: result.emailed ? '#7fd0a4' : 'var(--muted)' }}>
|
||||
{result.emailed
|
||||
? 'Invitation emailed. You can also share this single-use link:'
|
||||
: `Invite created${result.emailError ? ` (email not sent: ${result.emailError})` : ''}. Share this single-use link:`}
|
||||
{resultText}
|
||||
</p>
|
||||
<CopyLink url={result.acceptUrl} />
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,15 @@ export default function ModerationUser() {
|
||||
const counts = summary.counts || {}
|
||||
const tabActions = actions.filter((a) => a.action_type === tab)
|
||||
|
||||
let tabBody
|
||||
if (tab === 'notes') {
|
||||
tabBody = <NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
|
||||
} else if (tab === 'appeals') {
|
||||
tabBody = <AppealsTab rows={appeals} />
|
||||
} else {
|
||||
tabBody = <ActionTable rows={tabActions} showDuration={tab === 'mute'} />
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Link to="/admin/moderation" className="link-accent" style={{ fontSize: '0.85rem' }}>
|
||||
@@ -89,13 +98,7 @@ export default function ModerationUser() {
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{tab === 'notes' ? (
|
||||
<NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
|
||||
) : tab === 'appeals' ? (
|
||||
<AppealsTab rows={appeals} />
|
||||
) : (
|
||||
<ActionTable rows={tabActions} showDuration={tab === 'mute'} />
|
||||
)}
|
||||
{tabBody}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -252,6 +252,9 @@ export default function PageBuilder() {
|
||||
|
||||
const published = form.status === 'published'
|
||||
|
||||
let saveLabel = isEdit ? 'Save' : 'Create'
|
||||
if (busy) saveLabel = 'Saving…'
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* Toolbar */}
|
||||
@@ -268,7 +271,7 @@ export default function PageBuilder() {
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary btn-sq" onClick={() => save()} disabled={busy}>
|
||||
{busy ? 'Saving…' : isEdit ? 'Save' : 'Create'}
|
||||
{saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -277,7 +280,7 @@ export default function PageBuilder() {
|
||||
{error}
|
||||
{details.length > 0 && (
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
|
||||
{details.map((d, i) => <li key={i}>{d}</li>)}
|
||||
{details.map((d) => <li key={d}>{d}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
@@ -386,7 +389,7 @@ export default function PageBuilder() {
|
||||
<span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>Show in navigation</span>
|
||||
</label>
|
||||
<SelectField label="Nav group" value={form.settings.navGroup} onChange={setSetting('navGroup')} options={NAV_GROUPS} />
|
||||
<TextField label="Nav order" value={form.settings.navOrder ?? ''} onChange={(v) => setSetting('navOrder')(v === '' ? null : v.replace(/[^0-9]/g, ''))} hint="Lower numbers appear first." />
|
||||
<TextField label="Nav order" value={form.settings.navOrder ?? ''} onChange={(v) => setSetting('navOrder')(v === '' ? null : v.replace(/\D/g, ''))} hint="Lower numbers appear first." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -109,14 +109,15 @@ export default function SettingsAdmin() {
|
||||
// A rich field can't live inside a <label> (nested toolbar buttons +
|
||||
// contenteditable), so it uses a plain <div> wrapper instead.
|
||||
const Wrap = f.rich ? 'div' : 'label'
|
||||
return (
|
||||
<Wrap key={f.key} style={{ display: 'block' }}>
|
||||
<span className="field-label">{f.label}</span>
|
||||
{f.rich ? (
|
||||
let field
|
||||
if (f.rich) {
|
||||
field = (
|
||||
<Suspense fallback={<span className="spin" />}>
|
||||
<RichTextEditor value={values[f.key]} onChange={setRaw(f.key)} variant="post" />
|
||||
</Suspense>
|
||||
) : f.options ? (
|
||||
)
|
||||
} else if (f.options) {
|
||||
field = (
|
||||
<select value={values[f.key]} onChange={set(f.key)} className="select">
|
||||
{f.options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
@@ -124,11 +125,16 @@ export default function SettingsAdmin() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : f.long ? (
|
||||
<textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} />
|
||||
) : (
|
||||
<input type="text" value={values[f.key]} onChange={set(f.key)} className="input" />
|
||||
)}
|
||||
)
|
||||
} else if (f.long) {
|
||||
field = <textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} />
|
||||
} else {
|
||||
field = <input type="text" value={values[f.key]} onChange={set(f.key)} className="input" />
|
||||
}
|
||||
return (
|
||||
<Wrap key={f.key} style={{ display: 'block' }}>
|
||||
<span className="field-label">{f.label}</span>
|
||||
{field}
|
||||
{f.help && (
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||
{f.help}
|
||||
|
||||
@@ -91,9 +91,26 @@ function AccountActions() {
|
||||
}
|
||||
|
||||
const kick = () =>
|
||||
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => `Kicked ${acct}${r?.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`)
|
||||
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => {
|
||||
const n = r?.sessions != null ? r.sessions : null
|
||||
const plural = n === 1 ? '' : 's'
|
||||
const sessions = n != null ? ` (${n} session${plural})` : ''
|
||||
return `Kicked ${acct}${sessions}.`
|
||||
})
|
||||
const ban = () =>
|
||||
run('ban', () => api.admin.shardOps.ban({ account: acct, durationSec: durationSec === '' ? undefined : Number(durationSec), reason: reason.trim() || undefined }), () => `Banned ${acct}${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`)
|
||||
run(
|
||||
'ban',
|
||||
() =>
|
||||
api.admin.shardOps.ban({
|
||||
account: acct,
|
||||
durationSec: durationSec === '' ? undefined : Number(durationSec),
|
||||
reason: reason.trim() || undefined,
|
||||
}),
|
||||
() => {
|
||||
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
|
||||
return `Banned ${acct}${when}.`
|
||||
},
|
||||
)
|
||||
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
|
||||
|
||||
return (
|
||||
@@ -200,6 +217,19 @@ function SupportQueue() {
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [load])
|
||||
|
||||
let queueBody
|
||||
if (pages == null) {
|
||||
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading…</p>
|
||||
} else if (pages.length === 0) {
|
||||
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
|
||||
} else {
|
||||
queueBody = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
|
||||
@@ -207,15 +237,7 @@ function SupportQueue() {
|
||||
Open help pages from players. A reply reaches them in game (or on their next login).
|
||||
</p>
|
||||
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
|
||||
{pages == null ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading…</p>
|
||||
) : pages.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
|
||||
</div>
|
||||
)}
|
||||
{queueBody}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,38 @@ function Standing({ scope }) {
|
||||
)
|
||||
}
|
||||
|
||||
// One house row — the many optional detail fields are gathered here so the
|
||||
// Houses list stays a simple map.
|
||||
function HouseRow({ house: h }) {
|
||||
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
|
||||
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
|
||||
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
|
||||
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
|
||||
return (
|
||||
<li
|
||||
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||||
{h.name || 'Unnamed house'}
|
||||
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
|
||||
{location}
|
||||
{coords}
|
||||
{owner}
|
||||
{shares}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
||||
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
|
||||
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
|
||||
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Houses owned by the user's accounts, IDOC first (flagged).
|
||||
function Houses({ scope }) {
|
||||
const { data } = useAsync(() => scope.houses(), [scope])
|
||||
@@ -96,28 +128,7 @@ function Houses({ scope }) {
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{data.map((h) => (
|
||||
<li
|
||||
key={h.serial}
|
||||
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||||
{h.name || 'Unnamed house'}
|
||||
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
|
||||
{h.region || (h.map != null ? `map ${h.map}` : 'unknown')}
|
||||
{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||
{h.ownerAcct ? ` · ${h.ownerAcct}` : ''}
|
||||
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
||||
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
|
||||
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
|
||||
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
||||
</div>
|
||||
</li>
|
||||
<HouseRow key={h.serial} house={h} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -111,6 +111,9 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
|
||||
}
|
||||
}
|
||||
|
||||
let saveLabel = form.published ? 'Save & publish' : 'Save draft'
|
||||
if (busy) saveLabel = 'Saving…'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -133,7 +136,7 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : form.published ? 'Save & publish' : 'Save draft'}
|
||||
{saveLabel}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -80,11 +80,11 @@ export default function WikiHistory({ slug, onClose, onRestored }) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="spin" />
|
||||
) : error ? (
|
||||
{loading && <span className="spin" />}
|
||||
{!loading && error && (
|
||||
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||
) : (
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="wiki-history">
|
||||
<ul className="wiki-history-list">
|
||||
{revisions.map((r, i) => (
|
||||
@@ -122,11 +122,16 @@ export default function WikiHistory({ slug, onClose, onRestored }) {
|
||||
{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>
|
||||
))
|
||||
parts.map((p, i) => {
|
||||
let cls = ''
|
||||
if (p.added) cls = 'diff-add'
|
||||
else if (p.removed) cls = 'diff-del'
|
||||
return (
|
||||
<span key={`${i}:${p.value}`} className={cls}>
|
||||
{p.value}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user