Add public Shard page + player Game Accounts UI (phase 4)
Frontend for the uo-link integration, matching the existing site styling. - api/client.js: api.shard.* (status/feed/economy/idoc/char), the shardStreamUrl SSE endpoint, and api.player.shard.* (link/accounts/roster/ vendors). - lib/useShardFeed.js: EventSource hook over /public/shard/stream with a rolling buffer and a connected flag (browser never touches the sidecar WS). - routes/public/Shard.jsx: connection banner, stat tiles (online / gold supply / link), a gold-supply sparkline, "recent vendor sales" and "IDOC houses" lists, and a live event ticker — built from the shared panel/grid/format vocabulary. Registered at /site/shard under the maintenance gate and linked from the site header. - routes/player/PlayerAccount.jsx: a "Game accounts" section — enter a [link code to link an account, then expand it to see characters and player vendors on demand (503 shows a retry banner). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
This commit is contained in:
@@ -16,6 +16,7 @@ import Newsletter from './routes/public/Newsletter.jsx'
|
|||||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||||
import About from './routes/public/About.jsx'
|
import About from './routes/public/About.jsx'
|
||||||
import Status from './routes/public/Status.jsx'
|
import Status from './routes/public/Status.jsx'
|
||||||
|
import Shard from './routes/public/Shard.jsx'
|
||||||
import Wiki from './routes/wiki/Wiki.jsx'
|
import Wiki from './routes/wiki/Wiki.jsx'
|
||||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||||
import CmsPage from './routes/public/CmsPage.jsx'
|
import CmsPage from './routes/public/CmsPage.jsx'
|
||||||
@@ -66,6 +67,7 @@ export default function App() {
|
|||||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||||
<Route path="/site/about" element={<About />} />
|
<Route path="/site/about" element={<About />} />
|
||||||
<Route path="/site/status" element={<Status />} />
|
<Route path="/site/status" element={<Status />} />
|
||||||
|
<Route path="/site/shard" element={<Shard />} />
|
||||||
<Route path="/wiki" element={<Wiki />} />
|
<Route path="/wiki" element={<Wiki />} />
|
||||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||||
|
|||||||
@@ -79,6 +79,26 @@ export const api = {
|
|||||||
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
|
||||||
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
|
||||||
|
|
||||||
|
// ----- shard live data (uo-link) -----
|
||||||
|
// Token-free, same-origin reads backed by the ingested feed + a cached live
|
||||||
|
// character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed.
|
||||||
|
shard: {
|
||||||
|
status: () => req('/public/shard/status'),
|
||||||
|
feed: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.kind) qs.set('kind', opts.kind)
|
||||||
|
if (opts.limit) qs.set('limit', opts.limit)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/public/shard/feed${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
|
||||||
|
idoc: () => req('/public/shard/idoc'),
|
||||||
|
char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`),
|
||||||
|
},
|
||||||
|
// Full path (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
||||||
|
// fetch-only, so SSE subscribers build the URL from here.
|
||||||
|
shardStreamUrl: `${BASE}/public/shard/stream`,
|
||||||
|
|
||||||
// ----- admin -----
|
// ----- admin -----
|
||||||
admin: {
|
admin: {
|
||||||
dashboard: () => req('/admin/dashboard'),
|
dashboard: () => req('/admin/dashboard'),
|
||||||
@@ -225,6 +245,14 @@ export const api = {
|
|||||||
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
|
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
|
||||||
linkedIdentities: () => req('/player/account/identities'),
|
linkedIdentities: () => req('/player/account/identities'),
|
||||||
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// ----- game account linking (uo-link) -----
|
||||||
|
shard: {
|
||||||
|
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
|
||||||
|
accounts: () => req('/player/shard/accounts'),
|
||||||
|
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
|
||||||
|
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import MoonDot from './MoonDot.jsx'
|
|||||||
|
|
||||||
const NAV = {
|
const NAV = {
|
||||||
website: [
|
website: [
|
||||||
|
{ label: 'Shard', to: '/site/shard' },
|
||||||
{ label: 'News', to: '/site/news' },
|
{ label: 'News', to: '/site/news' },
|
||||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||||
|
|||||||
53
client/src/lib/useShardFeed.js
Normal file
53
client/src/lib/useShardFeed.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
|
// Subscribe to the public shard live-event SSE stream and keep a rolling buffer
|
||||||
|
// of the most recent events. The browser talks to our own /public/shard/stream
|
||||||
|
// route (plain HTTP EventSource) — never the sidecar's WebSocket — so the token
|
||||||
|
// stays server-side and it works through any reverse proxy.
|
||||||
|
//
|
||||||
|
// EventSource auto-reconnects on drop, so there is no manual retry loop here; a
|
||||||
|
// `connected` flag is exposed for a small live/offline indicator. `filter` (a
|
||||||
|
// Set of kinds, optional) limits which events are buffered. `max` caps the
|
||||||
|
// buffer length.
|
||||||
|
export function useShardFeed({ filter, max = 40 } = {}) {
|
||||||
|
const [events, setEvents] = useState([])
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
// Keep the latest filter in a ref so re-renders don't tear down the stream.
|
||||||
|
const filterRef = useRef(filter)
|
||||||
|
filterRef.current = filter
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// EventSource isn't available during SSR / very old browsers — degrade to
|
||||||
|
// "no live feed" rather than throwing.
|
||||||
|
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined
|
||||||
|
|
||||||
|
const es = new EventSource(api.shardStreamUrl, { withCredentials: true })
|
||||||
|
|
||||||
|
es.onopen = () => setConnected(true)
|
||||||
|
es.onerror = () => setConnected(false) // EventSource will retry on its own
|
||||||
|
|
||||||
|
es.onmessage = (msg) => {
|
||||||
|
let event
|
||||||
|
try {
|
||||||
|
event = JSON.parse(msg.data)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!event || !event.kind) return
|
||||||
|
const f = filterRef.current
|
||||||
|
if (f && !f.has(event.kind)) return
|
||||||
|
setEvents((prev) => {
|
||||||
|
// Tag with a stable-ish local id for React keys (events carry t but can
|
||||||
|
// collide within a ms) and cap the buffer.
|
||||||
|
const next = [{ ...event, _id: `${event.kind}-${event.t}-${prev.length}` }, ...prev]
|
||||||
|
return next.slice(0, max)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => es.close()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [max])
|
||||||
|
|
||||||
|
return { events, connected }
|
||||||
|
}
|
||||||
@@ -297,6 +297,175 @@ function LinkedAccounts() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Game accounts (uo-link) ────────────────────────────────────────────────
|
||||||
|
function GameAccounts() {
|
||||||
|
const [accounts, setAccounts] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [linkError, setLinkError] = useState('')
|
||||||
|
const [selected, setSelected] = useState(null) // account being inspected
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setAccounts(await api.player.shard.accounts())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load your linked game accounts.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
async function link(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setMsg('')
|
||||||
|
setLinkError('')
|
||||||
|
if (!code.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const { account } = await api.player.shard.link(code.trim())
|
||||||
|
setMsg(`Linked ${account}.`)
|
||||||
|
setCode('')
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setLinkError(err.message || 'Could not link that code.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!accounts) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Game accounts">
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
Link your in-game account to see your characters and player vendors here. In game, type{' '}
|
||||||
|
<code style={{ color: 'var(--head)' }}>[link</code> to get a one-time code, then enter it below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={link} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', margin: '14px 0 4px' }}>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
<Note msg={msg} error={linkError} />
|
||||||
|
|
||||||
|
{accounts.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '18px 0 0' }}>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<div key={a.account} style={{ border: '1px solid var(--line)', borderRadius: 8, padding: '12px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{a.account}</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.76rem' }}>Linked {new Date(a.linkedAt).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="pill"
|
||||||
|
onClick={() => setSelected(selected === a.account ? null : a.account)}
|
||||||
|
>
|
||||||
|
{selected === a.account ? 'Hide' : 'View'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{selected === a.account && <AccountDetail account={a.account} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{accounts.length === 0 && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.86rem', marginBottom: 0 }}>No game accounts linked yet.</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roster + vendors for one linked account, loaded on demand. Handles the shard
|
||||||
|
// restart (503) path with a retry-able banner.
|
||||||
|
function AccountDetail({ account }) {
|
||||||
|
const [roster, setRoster] = useState(null)
|
||||||
|
const [vendors, setVendors] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [unavailable, setUnavailable] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError('')
|
||||||
|
setUnavailable(false)
|
||||||
|
try {
|
||||||
|
const [r, v] = await Promise.all([
|
||||||
|
api.player.shard.roster(account),
|
||||||
|
api.player.shard.vendors(account).catch(() => null),
|
||||||
|
])
|
||||||
|
setRoster(r)
|
||||||
|
setVendors(v)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 503) setUnavailable(true)
|
||||||
|
else setError(err.message || 'Could not load this account.')
|
||||||
|
}
|
||||||
|
}, [account])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
if (unavailable) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<p className="sans" style={{ margin: 0, color: '#e0b070', fontSize: '0.85rem' }}>
|
||||||
|
The game server is restarting — try again shortly.
|
||||||
|
</p>
|
||||||
|
<button className="pill" style={{ marginTop: 8 }} onClick={load}>Retry</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (error) return <p className="sans" style={{ margin: '12px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||||
|
if (!roster) return <p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.82rem' }}>Loading…</p>
|
||||||
|
|
||||||
|
const chars = roster.chars || []
|
||||||
|
const shops = (vendors && vendors.vendors) || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div>
|
||||||
|
<div className="field-label" style={{ marginBottom: 6 }}>Characters</div>
|
||||||
|
{chars.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>No characters found.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{chars.map((c) => (
|
||||||
|
<div key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.86rem', color: 'var(--ink)' }}>
|
||||||
|
<span>{c.name}</span>
|
||||||
|
<span className="dim" style={{ fontSize: '0.76rem' }}>{c.online ? 'Online' : 'Offline'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{shops.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="field-label" style={{ marginBottom: 6 }}>Player vendors</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{shops.map((s) => (
|
||||||
|
<div key={s.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.86rem', color: 'var(--ink)' }}>
|
||||||
|
<span>{s.shopName || 'Vendor'}</span>
|
||||||
|
<span className="dim" style={{ fontSize: '0.76rem' }}>{Number(s.holdGold || 0).toLocaleString()}gp</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Shared bits ────────────────────────────────────────────────────────────
|
// ── Shared bits ────────────────────────────────────────────────────────────
|
||||||
function Section({ title, children }) {
|
function Section({ title, children }) {
|
||||||
return (
|
return (
|
||||||
@@ -363,6 +532,7 @@ export default function PlayerAccount() {
|
|||||||
{account.email ? ` · ${account.email}` : ''}
|
{account.email ? ` · ${account.email}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<GameAccounts />
|
||||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||||
<ChangePassword account={account} />
|
<ChangePassword account={account} />
|
||||||
<TwoFactor account={account} reload={load} />
|
<TwoFactor account={account} reload={load} />
|
||||||
|
|||||||
226
client/src/routes/public/Shard.jsx
Normal file
226
client/src/routes/public/Shard.jsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { ago } from '../../lib/format.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
||||||
|
function Sparkline({ series }) {
|
||||||
|
if (!series || series.length < 2) return null
|
||||||
|
const w = 320
|
||||||
|
const h = 56
|
||||||
|
const golds = series.map((s) => Number(s.gold) || 0)
|
||||||
|
const min = Math.min(...golds)
|
||||||
|
const max = Math.max(...golds)
|
||||||
|
const span = max - min || 1
|
||||||
|
const pts = series
|
||||||
|
.map((s, i) => {
|
||||||
|
const x = (i / (series.length - 1)) * w
|
||||||
|
const y = h - ((Number(s.gold) || 0) - min) / span * h
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
|
||||||
|
function Stat({ value, label }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||||
|
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A one-line human description of a feed event.
|
||||||
|
function describe(ev) {
|
||||||
|
const p = ev.payload || ev
|
||||||
|
switch (ev.kind) {
|
||||||
|
case 'vendor.sale':
|
||||||
|
return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${Number(p.price || 0).toLocaleString()}gp`
|
||||||
|
case 'player.death':
|
||||||
|
return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}`
|
||||||
|
case 'player.murdered':
|
||||||
|
return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}`
|
||||||
|
case 'mob.killed':
|
||||||
|
return `${nameOf(p.killer)} killed ${nameOf(p.killed)}`
|
||||||
|
case 'house.decay':
|
||||||
|
return `${p.name || 'A house'} is now ${p.to || p.stage}`
|
||||||
|
case 'quest.complete':
|
||||||
|
return `${nameOf(p.who)} completed “${p.quest}”`
|
||||||
|
case 'skill.gain':
|
||||||
|
return `${nameOf(p.who)} gained ${p.skill}`
|
||||||
|
case 'mob.login':
|
||||||
|
return `${nameOf(p.who)} entered the world`
|
||||||
|
case 'mob.logout':
|
||||||
|
return `${nameOf(p.who)} left the world`
|
||||||
|
default:
|
||||||
|
return ev.kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function nameOf(who) {
|
||||||
|
if (!who) return 'Someone'
|
||||||
|
if (typeof who === 'string') return who
|
||||||
|
return who.name || who.acct || 'Someone'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Shard() {
|
||||||
|
const { loading, error, data } = useAsync(() =>
|
||||||
|
Promise.all([api.shard.status(), api.shard.feed({ kind: 'vendor.sale', limit: 8 }), api.shard.idoc(), api.shard.economy(60)]).then(
|
||||||
|
([status, sales, idoc, economy]) => ({ status, sales, idoc, economy }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { events, connected } = useShardFeed({ max: 30 })
|
||||||
|
|
||||||
|
const status = data?.status
|
||||||
|
const online = status?.pluginConnected
|
||||||
|
const gold = status?.economy?.gold
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader eyebrow="Live" title="Shard" />
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load shard data right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
{/* Connection banner */}
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
padding: '24px 26px',
|
||||||
|
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: online
|
||||||
|
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
|
||||||
|
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flex: 'none',
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
|
||||||
|
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
|
||||||
|
{online ? 'The shard is online' : 'The shard is offline'}
|
||||||
|
</strong>
|
||||||
|
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
||||||
|
{online
|
||||||
|
? 'The gate to Britannia stands open.'
|
||||||
|
: status?.enabled
|
||||||
|
? 'The link to the game world is down — checking back automatically.'
|
||||||
|
: 'Live shard data is not configured yet.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Stat tiles */}
|
||||||
|
<section className="grid-3" style={{ gap: 14, marginBottom: 24 }}>
|
||||||
|
<Stat value={status?.onlineCount ?? '—'} label="Players online" />
|
||||||
|
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
||||||
|
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Economy sparkline */}
|
||||||
|
{data.economy && data.economy.length > 1 && (
|
||||||
|
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||||
|
Gold supply over time
|
||||||
|
</div>
|
||||||
|
<Sparkline series={data.economy} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid-2" style={{ gap: 18, marginBottom: 24 }}>
|
||||||
|
{/* Recent vendor sales */}
|
||||||
|
<FeedList
|
||||||
|
title="Recent vendor sales"
|
||||||
|
empty="No sales recorded yet."
|
||||||
|
items={data.sales.map((s) => ({ id: s.id, text: describe(s), when: s.t }))}
|
||||||
|
/>
|
||||||
|
{/* Latest IDOC */}
|
||||||
|
<FeedList
|
||||||
|
title="Houses in danger (IDOC)"
|
||||||
|
empty="No houses are collapsing right now."
|
||||||
|
items={data.idoc.map((h) => ({
|
||||||
|
id: h.serial,
|
||||||
|
text: `${h.name || 'A house'}${h.region ? ` — ${h.region}` : ''}`,
|
||||||
|
when: h.updatedAt,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live ticker */}
|
||||||
|
<section className="panel" style={{ padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||||
|
Live feed
|
||||||
|
</div>
|
||||||
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||||
|
Waiting for something to happen in the world…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{events.map((ev) => (
|
||||||
|
<li key={ev._id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(ev)}</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(ev.t)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedList({ title, items, empty }) {
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 20 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>{empty}</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{items.map((it) => (
|
||||||
|
<li key={it.id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.text}</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(it.when)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user