Add player portal + character-sheet front end (phase 4 follow-up)
Turns the raw shard endpoints into proper, navigable pages in the site's visual language. - components/CharacterSheet.jsx: reusable sheet — attribute tiles, vitals bars, resistances, skills (with bars), and equipment — styled with the shared panel/grid vocabulary. - Player portal with a nav bar: PlayerPortalLayout (Characters / Account tabs + sign-out) wraps /player and /account. /player (PlayerCharacters) tells the logged-in player if they haven't linked a game account (with the [link code prompt) or, once linked, shows their characters grouped by account; each character opens its sheet at /player/char/:serial. Account security moved into the same shell (the buried "Game accounts" block was removed from it). Login/register now land on /player. - Public: GET /public/shard/online (redacted name+serial+map) drives an "Online now" list on /site/shard that links to public character sheets at /site/shard/char/:serial (ShardChar). Swagger: ShardOnlinePlayer + regenerated. - api.shard.online added. Verified live against the running shard: Darrow's full sheet (STR 120, 58 skills, 3 equipment) renders through the browser-facing proxy; the online list returns the live roster; player routes 401 without a session. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
This commit is contained in:
@@ -17,6 +17,7 @@ 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 Shard from './routes/public/Shard.jsx'
|
||||||
|
import ShardChar from './routes/public/ShardChar.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'
|
||||||
@@ -44,6 +45,9 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
|||||||
// Player portal
|
// Player portal
|
||||||
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
||||||
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
import PlayerRegister from './routes/player/PlayerRegister.jsx'
|
||||||
|
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
|
||||||
|
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||||
|
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||||
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -69,6 +73,7 @@ export default function App() {
|
|||||||
<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="/site/shard" element={<Shard />} />
|
||||||
|
<Route path="/site/shard/char/:serial" element={<ShardChar />} />
|
||||||
<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
|
||||||
@@ -123,13 +128,16 @@ export default function App() {
|
|||||||
<Route path="/account/login" element={<PlayerLogin />} />
|
<Route path="/account/login" element={<PlayerLogin />} />
|
||||||
<Route path="/account/register" element={<PlayerRegister />} />
|
<Route path="/account/register" element={<PlayerRegister />} />
|
||||||
<Route
|
<Route
|
||||||
path="/account"
|
|
||||||
element={
|
element={
|
||||||
<RequirePlayer>
|
<RequirePlayer>
|
||||||
<PlayerAccount />
|
<PlayerPortalLayout />
|
||||||
</RequirePlayer>
|
</RequirePlayer>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
<Route path="/player" element={<PlayerCharacters />} />
|
||||||
|
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
|
||||||
|
<Route path="/account" element={<PlayerAccount />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const api = {
|
|||||||
return req(`/public/shard/feed${s ? `?${s}` : ''}`)
|
return req(`/public/shard/feed${s ? `?${s}` : ''}`)
|
||||||
},
|
},
|
||||||
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
|
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
|
||||||
|
online: () => req('/public/shard/online'),
|
||||||
idoc: () => req('/public/shard/idoc'),
|
idoc: () => req('/public/shard/idoc'),
|
||||||
char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`),
|
char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`),
|
||||||
},
|
},
|
||||||
|
|||||||
141
client/src/components/CharacterSheet.jsx
Normal file
141
client/src/components/CharacterSheet.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Reusable character-sheet renderer for the char.profile shape returned by
|
||||||
|
// /public/shard/char/:serial. Presentational only — the parent handles loading
|
||||||
|
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
|
||||||
|
|
||||||
|
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||||
|
|
||||||
|
function StatTile({ value, label }) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
|
||||||
|
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Vital({ label, cur, max }) {
|
||||||
|
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: '12px 14px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||||
|
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
|
||||||
|
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? '—'}</span></span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CharacterSheet({ char }) {
|
||||||
|
if (!char) return null
|
||||||
|
const stats = char.stats || {}
|
||||||
|
const resist = stats.resist || {}
|
||||||
|
// Skills the character actually has, best first.
|
||||||
|
const skills = (char.skills || [])
|
||||||
|
.filter((s) => (s.value || s.base || 0) > 0)
|
||||||
|
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
||||||
|
const equipment = char.equipment || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||||
|
{/* Identity */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
|
||||||
|
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
|
||||||
|
border: '1px solid var(--line)', fontSize: '0.74rem',
|
||||||
|
color: char.online ? '#7fd0a4' : 'var(--muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{char.online ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Core stats */}
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
|
||||||
|
<div className="grid-3" style={{ gap: 12 }}>
|
||||||
|
<StatTile value={stats.str ?? '—'} label="Strength" />
|
||||||
|
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
|
||||||
|
<StatTile value={stats.int ?? '—'} label="Intelligence" />
|
||||||
|
</div>
|
||||||
|
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
|
||||||
|
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
|
||||||
|
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
|
||||||
|
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Resistances */}
|
||||||
|
{Object.keys(resist).length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
|
||||||
|
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
|
||||||
|
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills */}
|
||||||
|
{skills.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
|
||||||
|
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||||
|
{skills.map((s) => {
|
||||||
|
const cap = s.cap || 100
|
||||||
|
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
|
||||||
|
return (
|
||||||
|
<div key={s.n}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
|
||||||
|
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
|
||||||
|
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Equipment */}
|
||||||
|
{equipment.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{equipment.map((it) => (
|
||||||
|
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||||
|
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{it.layer || 'Item'}</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
{it.mods && Object.keys(it.mods).length > 0 && (
|
||||||
|
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
|
||||||
|
{Object.entries(it.mods).map(([k, v]) => (
|
||||||
|
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import MoonDot from '../../components/MoonDot.jsx'
|
|
||||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
@@ -297,175 +295,6 @@ 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 (
|
||||||
@@ -482,7 +311,7 @@ function Note({ msg, error }) {
|
|||||||
|
|
||||||
// ── Page ───────────────────────────────────────────────────────────────────
|
// ── Page ───────────────────────────────────────────────────────────────────
|
||||||
export default function PlayerAccount() {
|
export default function PlayerAccount() {
|
||||||
const { logout, refresh } = useAuth()
|
const { refresh } = useAuth()
|
||||||
const [account, setAccount] = useState(null)
|
const [account, setAccount] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -505,41 +334,22 @@ export default function PlayerAccount() {
|
|||||||
}, [load, refresh])
|
}, [load, refresh])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ minHeight: '100vh', background: 'var(--bg-deep)', color: 'var(--ink)' }}>
|
<div>
|
||||||
<header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '18px 20px', borderBottom: '1px solid var(--line)', flexWrap: 'wrap' }}>
|
<h1 className="display" style={{ margin: '0 0 4px', fontSize: '1.6rem', color: 'var(--head)' }}>Account</h1>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
{loading && <Loading />}
|
||||||
<MoonDot size={12} glow={0.5} />
|
{error && <ErrorState message={error} />}
|
||||||
<span className="display" style={{ color: 'var(--head)', fontSize: '1.1rem', letterSpacing: '0.04em' }}>
|
{!loading && !error && account && (
|
||||||
My Account
|
<>
|
||||||
</span>
|
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||||
</div>
|
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
{account.email ? ` · ${account.email}` : ''}
|
||||||
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
|
</p>
|
||||||
← Site
|
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||||
</Link>
|
<ChangePassword account={account} />
|
||||||
<button onClick={logout} className="pill">Sign out</button>
|
<TwoFactor account={account} reload={load} />
|
||||||
</div>
|
<LinkedAccounts />
|
||||||
</header>
|
</>
|
||||||
|
)}
|
||||||
<div style={{ maxWidth: 620, margin: '0 auto', padding: '10px 20px 60px' }}>
|
</div>
|
||||||
{loading && <Loading />}
|
|
||||||
{error && <ErrorState message={error} />}
|
|
||||||
{!loading && !error && account && (
|
|
||||||
<>
|
|
||||||
<div style={{ paddingTop: 24 }}>
|
|
||||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
|
||||||
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
|
|
||||||
{account.email ? ` · ${account.email}` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<GameAccounts />
|
|
||||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
|
||||||
<ChangePassword account={account} />
|
|
||||||
<TwoFactor account={account} reload={load} />
|
|
||||||
<LinkedAccounts />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
27
client/src/routes/player/PlayerCharacter.jsx
Normal file
27
client/src/routes/player/PlayerCharacter.jsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// A player's character sheet inside the portal. Character data is public MMO
|
||||||
|
// data, so it uses the same cached public endpoint the site does.
|
||||||
|
export default function PlayerCharacter() {
|
||||||
|
const { serial } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
|
||||||
|
const restarting = error && error.status === 503
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p style={{ margin: '0 0 18px' }}>
|
||||||
|
<Link to="/player" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||||
|
← Back to characters
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{loading && <Loading />}
|
||||||
|
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||||
|
{error && !restarting && <ErrorState message="Could not load that character right now." />}
|
||||||
|
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
client/src/routes/player/PlayerCharacters.jsx
Normal file
166
client/src/routes/player/PlayerCharacters.jsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// One-time-code linking form (shared by the empty state and "add another").
|
||||||
|
function LinkForm({ onLinked, compact }) {
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setMsg(''); setError('')
|
||||||
|
if (!code.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const { account } = await api.player.shard.link(code.trim())
|
||||||
|
setMsg(`Linked ${account}.`)
|
||||||
|
setCode('')
|
||||||
|
await onLinked()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not link that code.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
{!compact && <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>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roster of one linked account → character cards linking to the sheet.
|
||||||
|
function AccountRoster({ account }) {
|
||||||
|
const [roster, setRoster] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [unavailable, setUnavailable] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(''); setUnavailable(false)
|
||||||
|
try {
|
||||||
|
setRoster(await api.player.shard.roster(account))
|
||||||
|
} 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>
|
||||||
|
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
|
||||||
|
<button className="pill" onClick={load}>Retry</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||||
|
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
|
||||||
|
|
||||||
|
const chars = roster.chars || []
|
||||||
|
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid-2" style={{ gap: 12 }}>
|
||||||
|
{chars.map((c) => (
|
||||||
|
<Link
|
||||||
|
key={c.serial}
|
||||||
|
to={`/player/char/${c.serial}`}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
|
||||||
|
{(c.name || '?').charAt(0)}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
|
||||||
|
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
|
||||||
|
</div>
|
||||||
|
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerCharacters() {
|
||||||
|
const [accounts, setAccounts] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setAccounts(await api.player.shard.accounts())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load your game accounts.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!accounts) return <Loading />
|
||||||
|
|
||||||
|
// Not linked yet — prompt to link.
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="display" style={{ margin: '0 0 6px', fontSize: '1.6rem', color: 'var(--head)' }}>Your characters</h1>
|
||||||
|
<p className="sans" style={{ margin: '0 0 22px', color: 'var(--muted)', fontSize: '0.92rem', lineHeight: 1.6 }}>
|
||||||
|
You haven’t linked a game account yet. Link one to see your characters, stats, skills and vendors here.
|
||||||
|
</p>
|
||||||
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a one-time code, then enter it below.
|
||||||
|
</p>
|
||||||
|
<LinkForm onLinked={load} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linked — show characters grouped by account.
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
|
||||||
|
<h1 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>Your characters</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<section key={a.account}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
|
{a.account}
|
||||||
|
</div>
|
||||||
|
<AccountRoster account={a.account} />
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||||||
|
<LinkForm onLinked={load} compact />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export default function PlayerLogin() {
|
|||||||
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const dest = location.state?.from?.pathname || '/account'
|
const dest = location.state?.from?.pathname || '/player'
|
||||||
// A staff member who signs in here belongs in the admin shell, not the portal.
|
// A staff member who signs in here belongs in the admin shell, not the portal.
|
||||||
const destFor = (u) => (u && u.role !== 'player' ? '/admin' : dest)
|
const destFor = (u) => (u && u.role !== 'player' ? '/admin' : dest)
|
||||||
|
|
||||||
|
|||||||
54
client/src/routes/player/PlayerPortalLayout.jsx
Normal file
54
client/src/routes/player/PlayerPortalLayout.jsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { NavLink, Link, Outlet, useNavigate } from 'react-router-dom'
|
||||||
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
|
// Shared shell for the logged-in player portal: a header with a nav bar
|
||||||
|
// (Characters / Account) and the page content in an <Outlet />. Matches the
|
||||||
|
// site's dark theme vocabulary.
|
||||||
|
const tab = ({ isActive }) => ({
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontFamily: 'var(--sans)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
padding: '8px 4px',
|
||||||
|
color: isActive ? 'var(--head)' : 'var(--muted)',
|
||||||
|
borderBottom: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function PlayerPortalLayout() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await logout()
|
||||||
|
navigate('/account/login', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ minHeight: '100vh', background: 'var(--bg-deep)', color: 'var(--ink)' }}>
|
||||||
|
<header style={{ borderBottom: '1px solid var(--line)' }}>
|
||||||
|
<div style={{ maxWidth: 820, margin: '0 auto', padding: '16px 20px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<MoonDot size={12} glow={0.5} />
|
||||||
|
<div>
|
||||||
|
<div className="display" style={{ color: 'var(--head)', fontSize: '1.05rem', letterSpacing: '0.04em' }}>UOMysticmoon</div>
|
||||||
|
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.64rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>Player Portal</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.82rem' }}>{user?.username}</span>
|
||||||
|
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>← Site</Link>
|
||||||
|
<button onClick={signOut} className="pill">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav style={{ maxWidth: 820, margin: '0 auto', padding: '0 20px', display: 'flex', gap: 22 }}>
|
||||||
|
<NavLink to="/player" end style={tab}>Characters</NavLink>
|
||||||
|
<NavLink to="/account" style={tab}>Account</NavLink>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ maxWidth: 820, margin: '0 auto', padding: '28px 20px 60px' }}>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export default function PlayerRegister() {
|
|||||||
const [providers, setProviders] = useState([])
|
const [providers, setProviders] = useState([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && user.role === 'player') navigate('/account', { replace: true })
|
if (user && user.role === 'player') navigate('/player', { replace: true })
|
||||||
}, [user, navigate])
|
}, [user, navigate])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,7 +51,7 @@ export default function PlayerRegister() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await register(username.trim(), password, { email: email.trim() || undefined, company })
|
await register(username.trim(), password, { email: email.trim() || undefined, company })
|
||||||
navigate('/account', { replace: true })
|
navigate('/player', { replace: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 409) setError('That username is already taken.')
|
if (err.status === 409) setError('That username is already taken.')
|
||||||
else if (err.status === 403) setError('Registration is not open right now.')
|
else if (err.status === 403) setError('Registration is not open right now.')
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
import PageHeader from '../../components/PageHeader.jsx'
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
@@ -75,9 +76,13 @@ function nameOf(who) {
|
|||||||
|
|
||||||
export default function Shard() {
|
export default function Shard() {
|
||||||
const { loading, error, data } = useAsync(() =>
|
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(
|
Promise.all([
|
||||||
([status, sales, idoc, economy]) => ({ status, sales, idoc, economy }),
|
api.shard.status(),
|
||||||
),
|
api.shard.feed({ kind: 'vendor.sale', limit: 8 }),
|
||||||
|
api.shard.idoc(),
|
||||||
|
api.shard.economy(60),
|
||||||
|
api.shard.online(),
|
||||||
|
]).then(([status, sales, idoc, economy, online]) => ({ status, sales, idoc, economy, online })),
|
||||||
)
|
)
|
||||||
const { events, connected } = useShardFeed({ max: 30 })
|
const { events, connected } = useShardFeed({ max: 30 })
|
||||||
|
|
||||||
@@ -141,6 +146,31 @@ export default function Shard() {
|
|||||||
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Online now */}
|
||||||
|
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
|
Online now
|
||||||
|
</div>
|
||||||
|
{(!data.online || data.online.length === 0) ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No one is online right now.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||||
|
{data.online.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p.serial}
|
||||||
|
to={`/site/shard/char/${p.serial}`}
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 14px', border: '1px solid var(--line)', borderRadius: 999, color: 'var(--ink)', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
||||||
|
{p.name || p.serial}
|
||||||
|
{p.map && <span className="dim" style={{ fontSize: '0.76rem' }}>· {p.map}</span>}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Economy sparkline */}
|
{/* Economy sparkline */}
|
||||||
{data.economy && data.economy.length > 1 && (
|
{data.economy && data.economy.length > 1 && (
|
||||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||||
|
|||||||
37
client/src/routes/public/ShardChar.jsx
Normal file
37
client/src/routes/public/ShardChar.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// Public character viewer: /site/shard/char/:serial. Renders the live sheet from
|
||||||
|
// the sidecar (cached server-side). A 503 means the shard is restarting.
|
||||||
|
export default function ShardChar() {
|
||||||
|
const { serial } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
|
||||||
|
|
||||||
|
const restarting = error && error.status === 503
|
||||||
|
const notFound = error && error.status === 404
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader eyebrow="Character" title={data?.name || 'Character'} />
|
||||||
|
|
||||||
|
<p style={{ marginTop: -8, marginBottom: 20 }}>
|
||||||
|
<Link to="/site/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||||
|
← Back to shard
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||||
|
{notFound && <ErrorState message="No character with that serial." />}
|
||||||
|
{error && !restarting && !notFound && <ErrorState message="Could not load that character right now." />}
|
||||||
|
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -162,6 +162,13 @@ publicRouter.get(
|
|||||||
validate,
|
validate,
|
||||||
shard.getEconomy,
|
shard.getEconomy,
|
||||||
)
|
)
|
||||||
|
publicRouter.get(
|
||||||
|
'/shard/online',
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Players online now (name + serial + map only)'
|
||||||
|
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
|
||||||
|
shard.getOnline,
|
||||||
|
)
|
||||||
publicRouter.get(
|
publicRouter.get(
|
||||||
'/shard/idoc',
|
'/shard/idoc',
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
|||||||
@@ -69,6 +69,19 @@ async function getEconomy(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/online — who is online now (redacted: name + serial + map,
|
||||||
|
// no coordinates, vitals or account). Feeds the public "online now" list, which
|
||||||
|
// links to the public character sheet.
|
||||||
|
async function getOnline(req, res) {
|
||||||
|
try {
|
||||||
|
const rows = await shardState.listOnline()
|
||||||
|
return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map })))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getOnline', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GET /public/shard/idoc — houses currently in danger (stage IDOC).
|
// GET /public/shard/idoc — houses currently in danger (stage IDOC).
|
||||||
async function getIdoc(req, res) {
|
async function getIdoc(req, res) {
|
||||||
try {
|
try {
|
||||||
@@ -118,4 +131,4 @@ function stream(req, res) {
|
|||||||
broadcast.subscribe(req, res, 'public')
|
broadcast.subscribe(req, res, 'public')
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getStatus, getFeed, getEconomy, getIdoc, getChar, stream }
|
module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChar, stream }
|
||||||
|
|||||||
@@ -1410,6 +1410,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/public/shard/online": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Public · Shard"
|
||||||
|
],
|
||||||
|
"summary": "Players online now (name + serial + map only)",
|
||||||
|
"description": "",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Online players",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ShardOnlinePlayer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/public/shard/idoc": {
|
"/api/v1/public/shard/idoc": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -10307,6 +10334,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ShardOnlinePlayer": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "object"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "A player online now (redacted for the public list)."
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"serial": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "0x24C"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Darrow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"map": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "string"
|
||||||
|
},
|
||||||
|
"nullable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Trammel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ShardHouse": {
|
"ShardHouse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -544,6 +544,15 @@ const doc = {
|
|||||||
t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 },
|
t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ShardOnlinePlayer: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'A player online now (redacted for the public list).',
|
||||||
|
properties: {
|
||||||
|
serial: { type: 'string', example: '0x24C' },
|
||||||
|
name: { type: 'string', example: 'Darrow' },
|
||||||
|
map: { type: 'string', nullable: true, example: 'Trammel' },
|
||||||
|
},
|
||||||
|
},
|
||||||
ShardHouse: {
|
ShardHouse: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: 'A house at its current decay stage.',
|
description: 'A house at its current decay stage.',
|
||||||
|
|||||||
Reference in New Issue
Block a user