From 1c9a9d26e1f23f158b4315118757b58c2ae15c87 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 11 Jul 2026 02:17:45 -0500
Subject: [PATCH] Add public Shard page + player Game Accounts UI (phase 4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
---
client/src/App.jsx | 2 +
client/src/api/client.js | 28 +++
client/src/components/SiteHeader.jsx | 1 +
client/src/lib/useShardFeed.js | 53 +++++
client/src/routes/player/PlayerAccount.jsx | 170 ++++++++++++++++
client/src/routes/public/Shard.jsx | 226 +++++++++++++++++++++
6 files changed, 480 insertions(+)
create mode 100644 client/src/lib/useShardFeed.js
create mode 100644 client/src/routes/public/Shard.jsx
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 99a54dc..591df74 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -16,6 +16,7 @@ import Newsletter from './routes/public/Newsletter.jsx'
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx'
+import Shard from './routes/public/Shard.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
@@ -66,6 +67,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
{/* CMS pages: top-level /:slug, matched only after the named routes
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 3357c1a..b3021dd 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -79,6 +79,26 @@ export const api = {
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
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: {
dashboard: () => req('/admin/dashboard'),
@@ -225,6 +245,14 @@ export const api = {
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
linkedIdentities: () => req('/player/account/identities'),
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)}`),
+ },
},
}
diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx
index 2d9ae4d..16fea82 100644
--- a/client/src/components/SiteHeader.jsx
+++ b/client/src/components/SiteHeader.jsx
@@ -3,6 +3,7 @@ import MoonDot from './MoonDot.jsx'
const NAV = {
website: [
+ { label: 'Shard', to: '/site/shard' },
{ label: 'News', to: '/site/news' },
{ label: 'Screenshots', to: '/site/screenshots' },
{ label: 'Five on Friday', to: '/site/five-on-friday' },
diff --git a/client/src/lib/useShardFeed.js b/client/src/lib/useShardFeed.js
new file mode 100644
index 0000000..d4069e8
--- /dev/null
+++ b/client/src/lib/useShardFeed.js
@@ -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 }
+}
diff --git a/client/src/routes/player/PlayerAccount.jsx b/client/src/routes/player/PlayerAccount.jsx
index bb567c0..6d0ea7d 100644
--- a/client/src/routes/player/PlayerAccount.jsx
+++ b/client/src/routes/player/PlayerAccount.jsx
@@ -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
+ if (!accounts) return null
+
+ return (
+
+
+ Link your in-game account to see your characters and player vendors here. In game, type{' '}
+ [link to get a one-time code, then enter it below.
+
+
+
+
+
+ {accounts.length > 0 && (
+
+ {accounts.map((a) => (
+
+
+
+
{a.account}
+
Linked {new Date(a.linkedAt).toLocaleDateString()}
+
+
setSelected(selected === a.account ? null : a.account)}
+ >
+ {selected === a.account ? 'Hide' : 'View'}
+
+
+ {selected === a.account &&
}
+
+ ))}
+
+ )}
+ {accounts.length === 0 && (
+ No game accounts linked yet.
+ )}
+
+ )
+}
+
+// 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 (
+
+
+ The game server is restarting — try again shortly.
+
+
Retry
+
+ )
+ }
+ if (error) return {error}
+ if (!roster) return Loading…
+
+ const chars = roster.chars || []
+ const shops = (vendors && vendors.vendors) || []
+
+ return (
+
+
+
Characters
+ {chars.length === 0 ? (
+
No characters found.
+ ) : (
+
+ {chars.map((c) => (
+
+ {c.name}
+ {c.online ? 'Online' : 'Offline'}
+
+ ))}
+
+ )}
+
+ {shops.length > 0 && (
+
+
Player vendors
+
+ {shops.map((s) => (
+
+ {s.shopName || 'Vendor'}
+ {Number(s.holdGold || 0).toLocaleString()}gp
+
+ ))}
+
+
+ )}
+
+ )
+}
+
// ── Shared bits ────────────────────────────────────────────────────────────
function Section({ title, children }) {
return (
@@ -363,6 +532,7 @@ export default function PlayerAccount() {
{account.email ? ` · ${account.email}` : ''}
+
diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx
new file mode 100644
index 0000000..d6e768a
--- /dev/null
+++ b/client/src/routes/public/Shard.jsx
@@ -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 (
+
+
+
+ )
+}
+
+// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
+function Stat({ value, label }) {
+ return (
+
+
{value}
+
+ {label}
+
+
+ )
+}
+
+// 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 (
+
+
+
+
+ {loading &&
}
+ {error &&
}
+
+ {!loading && !error && data && (
+ <>
+ {/* Connection banner */}
+
+
+
+
+ {online ? 'The shard is online' : 'The shard is offline'}
+
+
+ {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.'}
+
+
+
+
+ {/* Stat tiles */}
+
+
+ {/* Economy sparkline */}
+ {data.economy && data.economy.length > 1 && (
+
+
+ Gold supply over time
+
+
+
+ )}
+
+
+ {/* Recent vendor sales */}
+ ({ id: s.id, text: describe(s), when: s.t }))}
+ />
+ {/* Latest IDOC */}
+ ({
+ id: h.serial,
+ text: `${h.name || 'A house'}${h.region ? ` — ${h.region}` : ''}`,
+ when: h.updatedAt,
+ }))}
+ />
+
+
+ {/* Live ticker */}
+
+
+
+ Live feed
+
+
+
+ {connected ? 'Live' : 'Offline'}
+
+
+ {events.length === 0 ? (
+
+ Waiting for something to happen in the world…
+
+ ) : (
+
+ {events.map((ev) => (
+
+ {describe(ev)}
+ {ago(ev.t)}
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+
+ )
+}
+
+function FeedList({ title, items, empty }) {
+ return (
+
+
+ {title}
+
+ {items.length === 0 ? (
+ {empty}
+ ) : (
+
+ {items.map((it) => (
+
+ {it.text}
+ {ago(it.when)}
+
+ ))}
+
+ )}
+
+ )
+}