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:
@@ -1,6 +1,4 @@
|
||||
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 { Loading, ErrorState } from '../../components/PageState.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 ────────────────────────────────────────────────────────────
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
@@ -482,7 +311,7 @@ function Note({ msg, error }) {
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
export default function PlayerAccount() {
|
||||
const { logout, refresh } = useAuth()
|
||||
const { refresh } = useAuth()
|
||||
const [account, setAccount] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -505,41 +334,22 @@ export default function PlayerAccount() {
|
||||
}, [load, refresh])
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', background: 'var(--bg-deep)', color: 'var(--ink)' }}>
|
||||
<header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '18px 20px', borderBottom: '1px solid var(--line)', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<MoonDot size={12} glow={0.5} />
|
||||
<span className="display" style={{ color: 'var(--head)', fontSize: '1.1rem', letterSpacing: '0.04em' }}>
|
||||
My Account
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
|
||||
← Site
|
||||
</Link>
|
||||
<button onClick={logout} className="pill">Sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ maxWidth: 620, margin: '0 auto', padding: '10px 20px 60px' }}>
|
||||
{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>
|
||||
<div>
|
||||
<h1 className="display" style={{ margin: '0 0 4px', fontSize: '1.6rem', color: 'var(--head)' }}>Account</h1>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message={error} />}
|
||||
{!loading && !error && account && (
|
||||
<>
|
||||
<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>
|
||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||
<ChangePassword account={account} />
|
||||
<TwoFactor account={account} reload={load} />
|
||||
<LinkedAccounts />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 navigate = useNavigate()
|
||||
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.
|
||||
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([])
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.role === 'player') navigate('/account', { replace: true })
|
||||
if (user && user.role === 'player') navigate('/player', { replace: true })
|
||||
}, [user, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,7 +51,7 @@ export default function PlayerRegister() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await register(username.trim(), password, { email: email.trim() || undefined, company })
|
||||
navigate('/account', { replace: true })
|
||||
navigate('/player', { replace: true })
|
||||
} catch (err) {
|
||||
if (err.status === 409) setError('That username is already taken.')
|
||||
else if (err.status === 403) setError('Registration is not open right now.')
|
||||
|
||||
Reference in New Issue
Block a user