chore(quality): resolve SonarQube code smells across website
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 11m13s

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:
2026-07-21 04:35:39 -05:00
parent 4993470fa2
commit 12d50fd615
59 changed files with 1088 additions and 848 deletions

View File

@@ -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>
)

View File

@@ -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. */}

View File

@@ -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('')

View File

@@ -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>
</>
}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>
)
}

View File

@@ -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>
)}

View File

@@ -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>
</>
}

View File

@@ -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>
</>