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>
229 lines
9.8 KiB
JavaScript
229 lines
9.8 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import ShardAccountActions from './ShardAccountActions.jsx'
|
||
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
|
||
import api from '../api.js'
|
||
import { useGameAccountSignup } from '../lib/useShardFeatures.js'
|
||
import { ErrorState, Loading } from '../core.js'
|
||
|
||
// Shared game-account linking + character roster, used by both the player portal
|
||
// (/player) and the staff account page (/admin/account). `scope` is the api
|
||
// object with { link, accounts, roster } (player or admin self-service); `charTo`
|
||
// maps a serial to the route for that character's sheet. `readOnly` drops the
|
||
// link forms and self-voice copy for the admin case where staff view *another*
|
||
// user's accounts (no `scope.link`) at /admin/users/:id.
|
||
|
||
function LinkForm({ scope, onLinked, compact }) {
|
||
const [code, setCode] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [msg, setMsg] = useState('')
|
||
const [error, setError] = useState('')
|
||
|
||
async function submit(e) {
|
||
e.preventDefault()
|
||
setMsg(''); setError('')
|
||
if (!code.trim()) return
|
||
setBusy(true)
|
||
try {
|
||
const { account } = await scope.link(code.trim())
|
||
setMsg(`Linked ${account}.`)
|
||
setCode('')
|
||
await onLinked()
|
||
} catch (err) {
|
||
setError(err.message || 'Could not link that code.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
|
||
<label style={{ display: 'block' }}>
|
||
{!compact && <span className="field-label">Link code</span>}
|
||
<input
|
||
type="text"
|
||
value={code}
|
||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||
className="input"
|
||
autoComplete="off"
|
||
placeholder="AB12CD"
|
||
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
|
||
/>
|
||
</label>
|
||
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||
{busy ? 'Linking…' : 'Link account'}
|
||
</button>
|
||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||
</form>
|
||
)
|
||
}
|
||
|
||
function AccountRoster({ scope, account, charTo }) {
|
||
const [roster, setRoster] = useState(null)
|
||
const [error, setError] = useState('')
|
||
const [unavailable, setUnavailable] = useState(false)
|
||
|
||
const load = useCallback(async () => {
|
||
setError(''); setUnavailable(false)
|
||
try {
|
||
setRoster(await scope.roster(account))
|
||
} catch (err) {
|
||
if (err.status === 503) setUnavailable(true)
|
||
else setError(err.message || 'Could not load this account.')
|
||
}
|
||
}, [scope, account])
|
||
useEffect(() => { load() }, [load])
|
||
|
||
if (unavailable) {
|
||
return (
|
||
<div>
|
||
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
|
||
<button className="pill" onClick={load}>Retry</button>
|
||
</div>
|
||
)
|
||
}
|
||
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
|
||
|
||
const chars = roster.chars || []
|
||
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
|
||
|
||
return (
|
||
<div className="grid-2" style={{ gap: 12 }}>
|
||
{chars.map((c) => (
|
||
<Link
|
||
key={c.serial}
|
||
to={charTo(c.serial)}
|
||
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
|
||
>
|
||
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
|
||
{(c.name || '?').charAt(0)}
|
||
</span>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
|
||
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
|
||
</div>
|
||
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
|
||
// then calls onUnlink(account) and reloads. Errors surface inline.
|
||
function UnlinkButton({ account, onUnlink }) {
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState('')
|
||
async function go() {
|
||
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
|
||
setBusy(true); setError('')
|
||
try {
|
||
await onUnlink(account)
|
||
} catch (err) {
|
||
const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' }
|
||
setError(byStatus[err.status] || err.message || 'Could not unlink.')
|
||
setBusy(false)
|
||
}
|
||
}
|
||
return (
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
|
||
{busy ? 'Unlinking…' : 'Unlink'}
|
||
</button>
|
||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
|
||
const [accounts, setAccounts] = useState(null)
|
||
const [error, setError] = useState('')
|
||
// Whether the site currently offers game-account creation. Only relevant for
|
||
// the self-service (non-readOnly) view with a createAccount scope.
|
||
//
|
||
// From OUR public features endpoint as of slice 3, not core's public settings:
|
||
// the flag derives from the `uo.game_account_signup` setting, which this module
|
||
// owns, because "the game server's own SignupMode must agree" is not a sentence
|
||
// core can own. Same cached call the nav gates use, so this costs no round-trip.
|
||
const signupOk = useGameAccountSignup()
|
||
|
||
const load = useCallback(async () => {
|
||
setError('')
|
||
try {
|
||
setAccounts(await scope.accounts())
|
||
} catch {
|
||
setError(readOnly ? 'Could not load this user’s game accounts.' : 'Could not load your game accounts.')
|
||
}
|
||
}, [scope, readOnly])
|
||
useEffect(() => { load() }, [load])
|
||
|
||
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk === true
|
||
|
||
if (error) return <ErrorState message={error} />
|
||
if (!accounts) return <Loading />
|
||
|
||
// No linked accounts. In read-only (admin viewing another user) this is just an
|
||
// empty state; otherwise it's the link-your-account prompt.
|
||
if (accounts.length === 0) {
|
||
if (readOnly) {
|
||
return (
|
||
<div className="panel" style={{ padding: 22 }}>
|
||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||
This user has not linked a game account.
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<div className="panel" style={{ padding: 22 }}>
|
||
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
|
||
one-time code, then enter it below to see your characters, stats, skills and vendors here.
|
||
</p>
|
||
<LinkForm scope={scope} onLinked={load} />
|
||
</div>
|
||
{canCreate && (
|
||
<div className="panel" style={{ padding: 22 }}>
|
||
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
|
||
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Linked — characters grouped by account.
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||
{accounts.map((a) => (
|
||
<section key={a.account}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||
{a.account}
|
||
</div>
|
||
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
|
||
</div>
|
||
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
|
||
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
|
||
</section>
|
||
))}
|
||
{!readOnly && (
|
||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
|
||
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||
<LinkForm scope={scope} onLinked={load} compact />
|
||
{canCreate && (
|
||
<div style={{ marginTop: 20 }}>
|
||
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
|
||
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|