The 35 files behind twelve public pages, seven admin views, two player views and three core-page extensions, ported onto `window.__rg`. Every one of them imports exactly the seven kit members plus `lib/format.js`, which is the finding §2.7.1 predicted and this confirms. `client/src/core.js` is the port mechanism, and unlike the server's it is a plain read: `window.__rg` is published before any module chunk evaluates, so there is no gap to defer around and a ported component keeps its ordinary import shape. `client/src/api.js` rebuilds the UO namespaces over the request primitive — same URLs, because §1.2 freezes the API surface. SPA paths changed and API paths did not. `/site/shard` is `/uo/shard`, and the admin paths lost their now-redundant `shard-` prefixes (`/admin/uo/ops`), a clean break being the only moment that is free. `shim/rg.js` becomes the single reader of the global, so the "core did not publish its dependencies" message is reachable from whichever module the bundler happens to touch first rather than from whichever one is imported first — a guarantee that used to last until someone sorted the imports. Co-Authored-By: Claude <noreply@anthropic.com>
292 lines
13 KiB
JavaScript
292 lines
13 KiB
JavaScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
|
import { describe } from '../../lib/shardEvents.js'
|
|
import { ago } from '../../lib/format.js'
|
|
import api from '../../api.js'
|
|
|
|
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
|
|
// unban) and the help-page support queue, plus a live audit log. Open to admins
|
|
// and moderators. The acting staff member (`actor`) is attached server-side from
|
|
// the session — nothing here sends it — so every action is attributable.
|
|
|
|
function Flash({ ok, err }) {
|
|
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
|
|
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
|
|
return null
|
|
}
|
|
|
|
// ── Broadcast ────────────────────────────────────────────────────────────────
|
|
function Broadcast() {
|
|
const [text, setText] = useState('')
|
|
const [hue, setHue] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
const [ok, setOk] = useState('')
|
|
const [err, setErr] = useState('')
|
|
|
|
async function send() {
|
|
if (!text.trim()) return setErr('Enter a message.')
|
|
setBusy(true); setOk(''); setErr('')
|
|
try {
|
|
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
|
|
setOk('Broadcast sent.')
|
|
setText('')
|
|
} catch (e) {
|
|
setErr(e.message || 'Could not broadcast.')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
|
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
|
A system message shown to everyone online right now.
|
|
</p>
|
|
<label style={{ display: 'block' }}>
|
|
<span className="field-label">Message</span>
|
|
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
|
|
</label>
|
|
<label style={{ display: 'block', maxWidth: 140 }}>
|
|
<span className="field-label">Hue (optional)</span>
|
|
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
|
|
</label>
|
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
|
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
|
|
<Flash ok={ok} err={err} />
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
|
|
function AccountActions() {
|
|
const [account, setAccount] = useState('')
|
|
const [durationSec, setDurationSec] = useState('')
|
|
const [reason, setReason] = useState('')
|
|
const [busy, setBusy] = useState('')
|
|
const [ok, setOk] = useState('')
|
|
const [err, setErr] = useState('')
|
|
|
|
const acct = account.trim()
|
|
function guard() {
|
|
if (!acct) {
|
|
setErr('Enter an account name.')
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function run(label, fn, done) {
|
|
if (!guard()) return
|
|
setBusy(label); setOk(''); setErr('')
|
|
try {
|
|
const r = await fn()
|
|
setOk(done(r))
|
|
} catch (e) {
|
|
setErr(e.message || 'Action failed.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
const kick = () =>
|
|
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,
|
|
}),
|
|
() => {
|
|
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
|
|
return `Banned ${acct}${when}.`
|
|
},
|
|
)
|
|
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
|
|
|
|
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)' }}>Account actions</h3>
|
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
|
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
|
|
</p>
|
|
<label style={{ display: 'block' }}>
|
|
<span className="field-label">Account</span>
|
|
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
|
|
</label>
|
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
<label style={{ display: 'block', maxWidth: 200 }}>
|
|
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
|
|
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
|
|
</label>
|
|
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
|
|
<span className="field-label">Ban reason (optional)</span>
|
|
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
|
</label>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
|
|
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
|
|
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
|
|
<Flash ok={ok} err={err} />
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ── Support (help-page) queue ────────────────────────────────────────────────
|
|
function PageRow({ page, onDone }) {
|
|
const [message, setMessage] = useState('')
|
|
const [busy, setBusy] = useState('')
|
|
const [err, setErr] = useState('')
|
|
|
|
async function respond(close) {
|
|
if (!message.trim()) return setErr('Enter a reply first.')
|
|
setBusy(close ? 'respond-close' : 'respond'); setErr('')
|
|
try {
|
|
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
|
|
onDone()
|
|
} catch (e) {
|
|
setErr(e.message || 'Could not send.')
|
|
setBusy('')
|
|
}
|
|
}
|
|
async function close() {
|
|
setBusy('close'); setErr('')
|
|
try {
|
|
await api.admin.shardOps.closePage(page.pageId)
|
|
onDone()
|
|
} catch (e) {
|
|
setErr(e.message || 'Could not close.')
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
|
<div style={{ minWidth: 0 }}>
|
|
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
|
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
|
{page.sender?.name || page.pageId}
|
|
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
|
|
</div>
|
|
</div>
|
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
|
|
</div>
|
|
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
|
|
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
|
|
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
|
|
</div>
|
|
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
|
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
|
|
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
|
|
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
|
|
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SupportQueue() {
|
|
const [pages, setPages] = useState(null)
|
|
const [err, setErr] = useState('')
|
|
const pollRef = useRef(null)
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setPages(await api.admin.shardOps.pages())
|
|
} catch {
|
|
setErr('Could not load the support queue.')
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
load()
|
|
pollRef.current = setInterval(load, 7000)
|
|
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>
|
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
|
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>}
|
|
{queueBody}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ── Audit log ────────────────────────────────────────────────────────────────
|
|
// Seeded from the stored admin.audit history, then kept live from the admin SSE
|
|
// channel (which carries every kind — we filter to admin.audit here).
|
|
function AuditLog() {
|
|
const [seed, setSeed] = useState([])
|
|
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
|
|
|
|
useEffect(() => {
|
|
api.admin.shardOps
|
|
.audit(50)
|
|
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
|
|
.catch(() => setSeed([]))
|
|
}, [])
|
|
|
|
// Live events on top; fall back to the seed for anything older than the live tail.
|
|
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
|
|
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
|
|
|
|
return (
|
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
|
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
|
|
{rows.length === 0 ? (
|
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
|
|
) : (
|
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
|
|
{rows.map((e) => (
|
|
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
|
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
export default function ShardOps() {
|
|
return (
|
|
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
|
<Broadcast />
|
|
<AccountActions />
|
|
<SupportQueue />
|
|
<AuditLog />
|
|
</section>
|
|
)
|
|
}
|