feat(modules): client extension slots (phase 3, slice 2) #138
24
client/src/components/ShardStatusLink.jsx
Normal file
24
client/src/components/ShardStatusLink.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
// ── Core's fill for the `site.footer.status` extension slot ────────────────
|
||||
//
|
||||
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the contract is
|
||||
// MODULE_API.md §3.7.
|
||||
//
|
||||
// This is the whole of what used to be four lines inline in SiteFooter.jsx, and
|
||||
// it is a file now for one reason: `/site/shard` is a UO page, so the link goes
|
||||
// when the client half goes, and core should be deleting a registration rather
|
||||
// than editing its footer under extraction pressure.
|
||||
//
|
||||
// Note what core kept and what it handed over. Core owns the position in the row
|
||||
// and the separator around it, and passes `linkStyle` so the row stays visually
|
||||
// one row. The label, the destination, and the decision to render at all are
|
||||
// this file's — which is exactly the division a module inherits.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function ShardStatusLink({ linkStyle }) {
|
||||
return (
|
||||
<Link to="/site/shard" style={linkStyle}>
|
||||
Shard Status
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import Slot from '../modules/Slot.jsx'
|
||||
|
||||
const FOOTER_SLOT = 'site.footer.status'
|
||||
|
||||
// Handed to the extension rather than left for it to guess. A module rendering
|
||||
// its own link in this row should look like the row, and the alternative is
|
||||
// every module restating core's colours and then drifting from them the next
|
||||
// time this footer is themed.
|
||||
const LINK_STYLE = { color: 'var(--accent)', textDecoration: 'none' }
|
||||
|
||||
export default function SiteFooter() {
|
||||
const { contactEmail, siteTitle } = useSite()
|
||||
@@ -36,10 +45,14 @@ export default function SiteFooter() {
|
||||
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
{contactEmail}
|
||||
</a>
|
||||
·
|
||||
<Link to="/site/shard" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Shard Status
|
||||
</Link>
|
||||
{/* A module's spot in the footer, and core supplies only the
|
||||
position and the styling: the label, the target and whether
|
||||
anything renders at all are the module's (MODULE_API.md §3.7).
|
||||
The separator goes through `wrap` rather than sitting beside the
|
||||
slot, so it shares the extension's fate — no module installed and
|
||||
a module whose link throws both render nothing here, rather than
|
||||
the second leaving a stray middot behind. */}
|
||||
<Slot name={FOOTER_SLOT} linkStyle={LINK_STYLE} wrap={(link) => <> · {link}</>} />
|
||||
·
|
||||
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
|
||||
Admin
|
||||
|
||||
@@ -3,8 +3,10 @@ import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import { publishSharedDependencies } from './modules/shared.js'
|
||||
import { registerFeatureProvider } from './modules/registry.js'
|
||||
import { declareSlot, registerExtension, registerFeatureProvider } from './modules/registry.js'
|
||||
import { useShardFlags } from './lib/useShardFeatures.js'
|
||||
import ShardStatusLink from './components/ShardStatusLink.jsx'
|
||||
import UserShardSections from './routes/admin/views/UserShardSections.jsx'
|
||||
import './styles/theme.css'
|
||||
|
||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||
@@ -28,6 +30,34 @@ publishSharedDependencies()
|
||||
// for them by the name they will always have had.
|
||||
registerFeatureProvider('core', 'uo', useShardFlags)
|
||||
|
||||
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
|
||||
//
|
||||
// Declared HERE, in core's own bundle, which is what makes the ordering a fact
|
||||
// rather than a hope: module chunks are deferred scripts the shell injects after
|
||||
// this one (§3.1), so a module can never reach registerExtension before the slot
|
||||
// it names exists. "Unknown slot" therefore always means a typo or a version
|
||||
// skew, never a load-order accident — which is why that case throws.
|
||||
//
|
||||
// Both slots are named for a PLACE, not for a meaning. `site.footer.status` is
|
||||
// the spot in the footer's info row, not a declaration that core knows what a
|
||||
// game server's status is; the label, the target and whether anything renders at
|
||||
// all belong to whoever fills it. A slot typed by its content would put game
|
||||
// semantics back into core, which is the thing Phase 3 takes out.
|
||||
declareSlot('site.footer.status')
|
||||
// Deliberately the same name as the server's slot (MODULE_API.md §2.4): one
|
||||
// resource, one extension point, two halves. The module with routes under
|
||||
// /api/v1/admin/users/:id is the module with something to show on that page.
|
||||
declareSlot('admin.users.detail')
|
||||
|
||||
// And core fills both itself, under owner id `core`, with the components that
|
||||
// were inline in SiteFooter.jsx and UserDetail.jsx until this slice. The page
|
||||
// renders exactly what it rendered before, and the mechanism is exercised by
|
||||
// core's own content from the day it lands rather than first proved by the
|
||||
// change that depends on it. Slice 3 deletes these three lines and the two files
|
||||
// they name, and the module registers the same two slots on its way in.
|
||||
registerExtension('core', 'site.footer.status', ShardStatusLink)
|
||||
registerExtension('core', 'admin.users.detail', UserShardSections)
|
||||
|
||||
// Render on DOMContentLoaded rather than immediately, and that is the one line
|
||||
// of core's boot the module system changes.
|
||||
//
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { dateTime, ago } from '../../../lib/format.js'
|
||||
import { dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import CharacterStats from '../../../components/CharacterStats.jsx'
|
||||
import GameAccounts from '../../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../../components/VendorSales.jsx'
|
||||
import Slot from '../../../modules/Slot.jsx'
|
||||
|
||||
// Admin read-only view of one user's shard (uo-link) footprint: linked game
|
||||
// accounts + character rosters, currently-online characters, houses (incl.
|
||||
// IDOC) and recent vendor sales — everything scoped to that user's accounts.
|
||||
// Reached from the Users table's "View" action; Edit stays a separate modal.
|
||||
// Admin view of one user: who they are, their security posture (trusted devices
|
||||
// and MFA), and then whatever the installed module contributes about them —
|
||||
// today core's own UO footprint, via the `admin.users.detail` extension slot
|
||||
// (MODULE_API.md §3.7). Reached from the Users table's "View" action; Edit stays
|
||||
// a separate modal.
|
||||
|
||||
const ROLE_BADGE = {
|
||||
admin: 'badge-admin',
|
||||
@@ -28,114 +27,6 @@ function SectionTitle({ children }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Currently-online characters on the user's accounts, with where they are. The
|
||||
// per-character Online/Offline badge lives in the roster; this adds location.
|
||||
function OnlineNow({ scope }) {
|
||||
const { data } = useAsync(() => scope.online(), [scope])
|
||||
if (!data) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Online now</SectionTitle>
|
||||
{data.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map((c) => (
|
||||
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
|
||||
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
||||
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Shard "standing": city governorships held and guilds led by this user's
|
||||
// accounts (both reliable current-state lookups). Renders nothing when empty.
|
||||
function Standing({ scope }) {
|
||||
const { data } = useAsync(() => scope.standing(), [scope])
|
||||
if (!data) return null
|
||||
const govs = data.governorOf || []
|
||||
const guilds = data.guildsLed || []
|
||||
if (govs.length === 0 && guilds.length === 0) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Standing</SectionTitle>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{govs.map((g) => (
|
||||
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
|
||||
Governor of {g.city}
|
||||
</span>
|
||||
))}
|
||||
{guilds.map((g) => (
|
||||
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
|
||||
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// 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])
|
||||
if (!data) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Houses</SectionTitle>
|
||||
{data.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{data.map((h) => (
|
||||
<HouseRow key={h.serial} house={h} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Admin security controls for one user: their trusted devices (view + revoke) and
|
||||
// an MFA reset for a locked-out user. Every action is audit-logged server-side.
|
||||
function SecurityAdmin({ userId }) {
|
||||
@@ -244,25 +135,8 @@ function SecurityAdmin({ userId }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ShardSections({ scope }) {
|
||||
return (
|
||||
<>
|
||||
<CharacterStats scope={scope} />
|
||||
<SectionTitle>Linked accounts & characters</SectionTitle>
|
||||
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
|
||||
<Standing scope={scope} />
|
||||
<OnlineNow scope={scope} />
|
||||
<Houses scope={scope} />
|
||||
<VendorSales fetchSales={scope.sales} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UserDetail() {
|
||||
const { id } = useParams()
|
||||
// Memoize so the child components' effects (keyed on `scope`) don't refetch
|
||||
// on every render.
|
||||
const scope = useMemo(() => api.admin.userShard(id), [id])
|
||||
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
|
||||
|
||||
if (loading) return <Loading />
|
||||
@@ -296,7 +170,10 @@ export default function UserDetail() {
|
||||
</div>
|
||||
|
||||
<SecurityAdmin userId={id} />
|
||||
<ShardSections scope={scope} />
|
||||
{/* Whatever the installed module has to say about this user, or nothing
|
||||
at all. Core's own UO sections fill it today (UserShardSections.jsx,
|
||||
registered in main.jsx) — MODULE_API.md §3.7. */}
|
||||
<Slot name="admin.users.detail" userId={id} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
163
client/src/routes/admin/views/UserShardSections.jsx
Normal file
163
client/src/routes/admin/views/UserShardSections.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// ── Core's fill for the `admin.users.detail` extension slot ────────────────
|
||||
//
|
||||
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1. Every section below
|
||||
// is UO, and every one of them leaves core with the client half in slice 3 —
|
||||
// this file exists so that when they do, core deletes a registration and a file
|
||||
// instead of unpicking a page.
|
||||
//
|
||||
// Core registers it through the same seam a module uses
|
||||
// (`registerExtension('core', …)` in main.jsx), which is the client twin of the
|
||||
// server's `registries.registerCore()` and the same trick `useShardFlags`
|
||||
// already uses for the feature seam. The mechanism is therefore exercised by
|
||||
// core's own content from the day it lands, rather than first proved by the
|
||||
// change that depends on it.
|
||||
//
|
||||
// The slot hands over `userId` and nothing else — deliberately, not `scope`.
|
||||
// `api.admin.userShard` is a UO binding that leaves core in slice 3, so a slot
|
||||
// that passed it would be handing a module something core is about to delete.
|
||||
// An extension builds its own client for the routes it registered at the other
|
||||
// end (MODULE_API.md §3.5), and this file does exactly what the module will.
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useAsync } from '../../../lib/useAsync.js'
|
||||
import { ago } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import CharacterStats from '../../../components/CharacterStats.jsx'
|
||||
import GameAccounts from '../../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../../components/VendorSales.jsx'
|
||||
|
||||
// Its own copy, not an export from UserDetail.jsx: six lines of presentational
|
||||
// furniture that is not in the §3.4 kit, so a module filling this slot would
|
||||
// vendor the same thing. Core's copy stays behind with core's own security
|
||||
// panel, which is the other caller.
|
||||
function SectionTitle({ children }) {
|
||||
return (
|
||||
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Currently-online characters on the user's accounts, with where they are. The
|
||||
// per-character Online/Offline badge lives in the roster; this adds location.
|
||||
function OnlineNow({ scope }) {
|
||||
const { data } = useAsync(() => scope.online(), [scope])
|
||||
if (!data) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Online now</SectionTitle>
|
||||
{data.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map((c) => (
|
||||
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
|
||||
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
||||
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Shard "standing": city governorships held and guilds led by this user's
|
||||
// accounts (both reliable current-state lookups). Renders nothing when empty.
|
||||
function Standing({ scope }) {
|
||||
const { data } = useAsync(() => scope.standing(), [scope])
|
||||
if (!data) return null
|
||||
const govs = data.governorOf || []
|
||||
const guilds = data.guildsLed || []
|
||||
if (govs.length === 0 && guilds.length === 0) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Standing</SectionTitle>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{govs.map((g) => (
|
||||
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
|
||||
Governor of {g.city}
|
||||
</span>
|
||||
))}
|
||||
{guilds.map((g) => (
|
||||
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
|
||||
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// 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])
|
||||
if (!data) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Houses</SectionTitle>
|
||||
{data.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{data.map((h) => (
|
||||
<HouseRow key={h.serial} house={h} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
export default function UserShardSections({ userId }) {
|
||||
// Memoized so the child components' effects (keyed on `scope`) don't refetch
|
||||
// on every render — the same reason UserDetail memoized it before this moved.
|
||||
const scope = useMemo(() => api.admin.userShard(userId), [userId])
|
||||
return (
|
||||
<>
|
||||
<CharacterStats scope={scope} />
|
||||
<SectionTitle>Linked accounts & characters</SectionTitle>
|
||||
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
|
||||
<Standing scope={scope} />
|
||||
<OnlineNow scope={scope} />
|
||||
<Houses scope={scope} />
|
||||
<VendorSales fetchSales={scope.sales} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user