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>
326 lines
12 KiB
JavaScript
326 lines
12 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
||
import api from '../../api.js'
|
||
import { ErrorState, Loading } from '../../core.js'
|
||
|
||
// ── Admin · Shard visibility ────────────────────────────────────────────────
|
||
//
|
||
// Who may see which shard surface, and which sensitive fields within it.
|
||
// Admin-only, because this decides what ANONYMOUS visitors get.
|
||
//
|
||
// Two things the UI must communicate honestly, because they are not negotiable
|
||
// server-side (see docs/link/v3.md §3.4):
|
||
// • acct / webId are admin-only always and are not listed as editable fields.
|
||
// • an event kind the server doesn't know about never reaches anyone below
|
||
// admin, whatever is set here.
|
||
//
|
||
// Defaults reproduce the behavior the site had before this panel existed, so a
|
||
// fresh install shows "everything as it was" rather than an empty form.
|
||
|
||
const RUNG_LABEL = {
|
||
anonymous: 'Everyone',
|
||
logged_in: 'Signed in',
|
||
player: 'Linked players',
|
||
staff: 'Staff',
|
||
admin: 'Admins only',
|
||
}
|
||
|
||
const RUNG_HINT = {
|
||
anonymous: 'Visible to anyone, signed in or not.',
|
||
logged_in: 'Any signed-in account, linked or not.',
|
||
player: 'Accounts with a linked game account. Staff always qualify.',
|
||
staff: 'Admins and moderators.',
|
||
admin: 'Admins only.',
|
||
}
|
||
|
||
const FEATURE_LABEL = {
|
||
status: 'Shard status',
|
||
activity: 'Activity feed',
|
||
champs: 'Champion spawns',
|
||
guilds: 'Guilds',
|
||
governors: 'Town governors',
|
||
houses: 'Houses / IDOC',
|
||
presence: 'Players online',
|
||
ruleset: 'Shard rules',
|
||
atlas: 'Spawn atlas',
|
||
leaderboards: 'Leaderboards',
|
||
market: 'Marketplace',
|
||
}
|
||
|
||
const FEATURE_HINT = {
|
||
status: 'Connection state, online count, gold-supply series.',
|
||
activity: 'Deaths, kills, skill gains, quests, logins.',
|
||
champs: 'The live champion / mini-champ / sea-boss board.',
|
||
guilds: 'Guild rosters, alliances and leaders.',
|
||
governors: 'City Loyalty governors, elections and term history.',
|
||
houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
|
||
presence: 'Population aggregate and the staff-online widget.',
|
||
ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
|
||
atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
|
||
leaderboards: 'Point and loyalty standings across every points system.',
|
||
market: 'The shard-wide player-vendor index.',
|
||
}
|
||
|
||
const FIELD_LABEL = {
|
||
owner: 'House owner',
|
||
price: 'House price',
|
||
location: 'In-game location (map + coordinates)',
|
||
connect: 'Server connect address',
|
||
// Keyed on the WIRE field, which for a leaderboard entry is `name` — the
|
||
// projection matches literal JSON keys, so the rule cannot be spelled after the
|
||
// field's meaning. The label is what carries the meaning to the admin.
|
||
name: 'Character names on leaderboards',
|
||
ownerName: 'Vendor owner name',
|
||
// One rule, one key — `location` is a nested object on both the wire frame and
|
||
// the stored read model precisely so that hiding it takes the facet, the
|
||
// coordinates, the region and the house together.
|
||
ownerSerial: 'Vendor owner character id',
|
||
}
|
||
|
||
function RungSelect({ value, onChange, ladder, disabled }) {
|
||
return (
|
||
<select
|
||
className="input"
|
||
value={value}
|
||
disabled={disabled}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
style={{ maxWidth: 200 }}
|
||
>
|
||
{ladder.map((rung) => (
|
||
<option key={rung} value={rung}>
|
||
{RUNG_LABEL[rung] || rung}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)
|
||
}
|
||
|
||
function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
|
||
const fields = Object.entries(settings.fields || {})
|
||
const changed =
|
||
defaults &&
|
||
(settings.enabled !== defaults.enabled ||
|
||
settings.audience !== defaults.audience ||
|
||
settings.stream !== defaults.stream ||
|
||
JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
border: '1px solid var(--line)',
|
||
borderRadius: 10,
|
||
padding: 16,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 12,
|
||
opacity: settings.enabled ? 1 : 0.62,
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||
<div style={{ minWidth: 0 }}>
|
||
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||
{FEATURE_LABEL[name] || name}
|
||
{changed && (
|
||
<span
|
||
className="sans"
|
||
style={{ marginLeft: 8, fontSize: '0.62rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)' }}
|
||
>
|
||
changed
|
||
</span>
|
||
)}
|
||
</h3>
|
||
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--muted)', lineHeight: 1.5 }}>
|
||
{FEATURE_HINT[name]}
|
||
</p>
|
||
</div>
|
||
<label
|
||
className="sans"
|
||
style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)' }}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.enabled}
|
||
onChange={(e) => onPatch(name, { enabled: e.target.checked })}
|
||
/>
|
||
Enabled
|
||
</label>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end' }}>
|
||
<label style={{ display: 'block' }}>
|
||
<span className="field-label">Who can see it</span>
|
||
<RungSelect
|
||
value={settings.audience}
|
||
ladder={ladder}
|
||
disabled={!settings.enabled}
|
||
onChange={(audience) => onPatch(name, { audience })}
|
||
/>
|
||
<span className="sans dim" style={{ display: 'block', marginTop: 4, fontSize: '0.75rem' }}>
|
||
{RUNG_HINT[settings.audience]}
|
||
</span>
|
||
</label>
|
||
<label
|
||
className="sans"
|
||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)', paddingBottom: 22 }}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.stream}
|
||
disabled={!settings.enabled}
|
||
onChange={(e) => onPatch(name, { stream: e.target.checked })}
|
||
/>
|
||
Live updates
|
||
</label>
|
||
</div>
|
||
|
||
{fields.length > 0 && (
|
||
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12 }}>
|
||
<span className="field-label" style={{ display: 'block', marginBottom: 8 }}>
|
||
Sensitive fields
|
||
</span>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
|
||
{fields.map(([field, rung]) => (
|
||
<label key={field} style={{ display: 'block' }}>
|
||
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginBottom: 4 }}>
|
||
{FIELD_LABEL[field] || field}
|
||
</span>
|
||
<RungSelect
|
||
value={rung}
|
||
ladder={ladder}
|
||
disabled={!settings.enabled}
|
||
onChange={(level) =>
|
||
onPatch(name, { fieldRules: { ...settings.fields, [field]: level } })
|
||
}
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function ShardVisibility() {
|
||
const [config, setConfig] = useState(null)
|
||
const [defaults, setDefaults] = useState(null)
|
||
const [ladder, setLadder] = useState([])
|
||
const [lockedFields, setLockedFields] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState('')
|
||
const [saving, setSaving] = useState(false)
|
||
const [msg, setMsg] = useState('')
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true)
|
||
setError('')
|
||
try {
|
||
const data = await api.admin.getShardVisibility()
|
||
setConfig(data.features)
|
||
setDefaults(data.defaults)
|
||
setLadder(data.ladder || [])
|
||
setLockedFields(data.lockedFields || [])
|
||
} catch (err) {
|
||
setError(err.message || 'Could not load visibility settings.')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
load()
|
||
}, [load])
|
||
|
||
function patch(name, changes) {
|
||
setMsg('')
|
||
setConfig((prev) => {
|
||
const next = { ...prev[name], ...changes }
|
||
// `fieldRules` in the API is `fields` in the effective config.
|
||
if (changes.fieldRules) {
|
||
next.fields = changes.fieldRules
|
||
delete next.fieldRules
|
||
}
|
||
return { ...prev, [name]: next }
|
||
})
|
||
}
|
||
|
||
async function save() {
|
||
setSaving(true)
|
||
setMsg('')
|
||
setError('')
|
||
try {
|
||
const body = {}
|
||
for (const [name, s] of Object.entries(config)) {
|
||
body[name] = {
|
||
enabled: s.enabled,
|
||
audience: s.audience,
|
||
stream: s.stream,
|
||
fieldRules: s.fields || {},
|
||
}
|
||
}
|
||
const data = await api.admin.saveShardVisibility(body)
|
||
setConfig(data.features)
|
||
setMsg('Saved. Changes take effect within a few seconds, including on open live streams.')
|
||
} catch (err) {
|
||
setError(err.message || 'Could not save.')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
function resetToDefaults() {
|
||
setMsg('')
|
||
setConfig(structuredClone(defaults))
|
||
}
|
||
|
||
if (loading) return <Loading />
|
||
if (error && !config) return <ErrorState message={error} onRetry={load} />
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||
<header>
|
||
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||
Shard visibility
|
||
</h2>
|
||
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||
Choose who can see each shard surface on the public site, and how much detail they get.
|
||
Turning a feature off hides it entirely — its pages return “not found” rather than
|
||
revealing that it exists. “Live updates” controls whether the feature streams changes in
|
||
real time; the pages still work without it, they just refresh on load.
|
||
</p>
|
||
{lockedFields.length > 0 && (
|
||
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.82rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||
Not configurable: <strong style={{ color: 'var(--ink)' }}>{lockedFields.join(', ')}</strong> —
|
||
game account names and website user ids are never shown below admin, on any surface. They
|
||
aren’t visible in game either, so publishing them would disclose something the shard
|
||
itself doesn’t.
|
||
</p>
|
||
)}
|
||
</header>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
{Object.entries(config).map(([name, settings]) => (
|
||
<FeatureRow
|
||
key={name}
|
||
name={name}
|
||
settings={settings}
|
||
defaults={defaults?.[name]}
|
||
ladder={ladder}
|
||
onPatch={patch}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
|
||
{saving ? 'Saving…' : 'Save changes'}
|
||
</button>
|
||
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
|
||
Restore defaults
|
||
</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>}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|