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()}
+
+ +
+ {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. +

+ +
+ ) + } + 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)} +
  • + ))} +
+ )} +
+ ) +}