diff --git a/client/src/components/CharacterStats.jsx b/client/src/components/CharacterStats.jsx new file mode 100644 index 0000000..0f7fe3b --- /dev/null +++ b/client/src/components/CharacterStats.jsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react' + +// A small stat-tile row for a "My Characters" page: total characters, how many +// are online right now, and how many game accounts are linked. `scope` is the +// shard api object (admin or player self-service). Renders nothing until an +// account is linked, so the empty/link-prompt state below it stands alone. +// +// It fetches the same rosters GameAccounts loads; for a personal page that's at +// most a couple of extra live round-trips, and keeps this presentational bit +// decoupled from GameAccounts' per-account roster loading. + +function Tile({ value, label }) { + return ( +
+
{value}
+
+ {label} +
+
+ ) +} + +export default function CharacterStats({ scope }) { + const [stats, setStats] = useState(null) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const accounts = await scope.accounts() + const linked = accounts.length + if (linked === 0) { + if (!cancelled) setStats({ linked: 0 }) + return + } + // Roster is a live round-trip and can be unavailable (503); tolerate a + // partial result so a restarting shard doesn't blank the whole row. + const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account))) + let chars = 0 + let online = 0 + let complete = true + for (const r of rosters) { + if (r.status === 'fulfilled') { + const cs = r.value.chars || [] + chars += cs.length + online += cs.filter((c) => c.online).length + } else { + complete = false + } + } + if (!cancelled) setStats({ linked, chars, online, complete }) + } catch { + if (!cancelled) setStats({ error: true }) + } + })() + return () => { cancelled = true } + }, [scope]) + + // Hidden until we know an account is linked (or while first loading). + if (!stats || stats.error || stats.linked === 0) return null + + // Counts depend on live rosters; show a dash if none came back. + const count = (n) => (stats.complete || stats.chars > 0 ? n : '—') + + return ( +
+ + + +
+ ) +} diff --git a/client/src/routes/admin/views/AdminCharacters.jsx b/client/src/routes/admin/views/AdminCharacters.jsx index 7167603..8a33933 100644 --- a/client/src/routes/admin/views/AdminCharacters.jsx +++ b/client/src/routes/admin/views/AdminCharacters.jsx @@ -1,15 +1,16 @@ +import CharacterStats from '../../../components/CharacterStats.jsx' import GameAccounts from '../../../components/GameAccounts.jsx' import VendorSales from '../../../components/VendorSales.jsx' import { api } from '../../../api/client.js' // Staff link their OWN in-game account and view their characters — the same // shared component players use, pointed at the staff self-service endpoints. +// Sits inside the Admin shell, which supplies the "My Characters" page header; +// stat tiles bring it to parity with the Player Portal's Characters page. export default function AdminCharacters() { return (
-

- Link your own game account to view your characters, stats, skills and vendors. -

+ `/admin/characters/${serial}`} />
diff --git a/client/src/routes/player/PlayerAccount.jsx b/client/src/routes/player/PlayerAccount.jsx index ee240bd..e9984cb 100644 --- a/client/src/routes/player/PlayerAccount.jsx +++ b/client/src/routes/player/PlayerAccount.jsx @@ -335,7 +335,6 @@ export default function PlayerAccount() { return (
-

Account

{loading && } {error && } {!loading && !error && account && ( diff --git a/client/src/routes/player/PlayerCharacters.jsx b/client/src/routes/player/PlayerCharacters.jsx index 65617b7..27711cd 100644 --- a/client/src/routes/player/PlayerCharacters.jsx +++ b/client/src/routes/player/PlayerCharacters.jsx @@ -8,7 +8,6 @@ import { api } from '../../api/client.js' export default function PlayerCharacters() { return (
-

Your characters

`/player/char/${serial}`} />
diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx index 4c6a878..0d4318d 100644 --- a/client/src/routes/player/PlayerPortalLayout.jsx +++ b/client/src/routes/player/PlayerPortalLayout.jsx @@ -1,22 +1,65 @@ -import { NavLink, Link, Outlet, useNavigate } from 'react-router-dom' +import { NavLink, Outlet, useNavigate, useLocation } 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 . Matches the -// site's dark theme vocabulary. -const tab = ({ isActive }) => ({ - textDecoration: 'none', +// Shared shell for the logged-in player portal. Uses the same sidebar shell as +// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in +// experiences read as one app — the portal just carries fewer nav rows. + +// Small inline stroke icons (16px, currentColor) — same frame as AdminLayout. +function Icon({ children, size = 16 }) { + return ( + + ) +} +const IconUser = () => +const IconGear = () => + +const NAV = [ + { to: '/player', label: 'Characters', end: true, icon: IconUser }, + { to: '/account', label: 'Account', icon: IconGear }, +] + +// The sticky content header mirrors the active page. Character sheets live under +// /player/char/:serial and keep their own in-page back link. +const TITLES = { + '/player': 'Characters', + '/account': 'Account', +} + +const navBtnBase = { + textAlign: 'left', + borderRadius: 8, + padding: '10px 14px', fontFamily: 'var(--sans)', - fontSize: '0.9rem', - padding: '8px 4px', - color: isActive ? 'var(--head)' : 'var(--muted)', - borderBottom: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`, -}) + fontSize: '0.92rem', + textDecoration: 'none', + display: 'flex', + alignItems: 'center', + gap: 10, + transition: 'background .15s,color .15s', +} export default function PlayerPortalLayout() { const { user, logout } = useAuth() const navigate = useNavigate() + const location = useLocation() + const title = + TITLES[location.pathname] || + (location.pathname.startsWith('/player/char/') ? 'Character' : 'Player Portal') async function signOut() { await logout() @@ -24,31 +67,94 @@ export default function PlayerPortalLayout() { } return ( -
-
-
-
- -
-
UOMysticmoon
-
Player Portal
+
+ {/* Sidebar */} +
-
- -
-
+ + +
+
+ + Signed in as {user?.username} +
+ +
+ + + {/* Main */} +
+
+

+ {title} +

+ + ← Site + +
+ +
+ +
+
+
) }