Files
website/client/src/routes/player/PlayerPortalLayout.jsx
wtclaude b458c1f46f feat(teams): the web surface — a notifications screen that did not exist
This is phase 6's first finding, and it changed the phase's shape.

TEAMS.md §6.3 says the per-Team mute list is surfaced "under the existing
notification settings screen". There was no such screen. `/auth/me/notifications/*`
was built for the Android app in M7 and had ZERO web consumers — a browser could
not see the stream catalog or its own subscriptions at all. That is tolerable
while push is the only sink, because push needs the app anyway. It is not
tolerable for email, whose entire argument is the web-only user who runs neither
the app nor Discord, so the sink and the screen to configure it had to ship
together.

`/account/notifications` carries all three: what to be told about, which Teams,
and whether any of it reaches a mailbox — in the order a user actually reasons
about them.

The mute toggle goes in a THIRD module-declared slot, above the roster, because
muting is an action ON the guild page while the feed and forum are content IN it.
It renders nothing for a viewer with no preference available, which is a privacy
property rather than a tidiness one: whether a preference EXISTS for a Team
answers "is this person in it", and the guild page is public.

`/unsubscribe/:token` is public and POSTs on mount — the link the user clicked was
a GET, and a GET that mutated would be triggered by every mail-client link scanner.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:35:08 -05:00

226 lines
9.0 KiB
JavaScript

import { useMemo } from 'react'
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
import { firstDestinationFor } from '../../lib/adminNav.js'
import { useNavOverrides } from '../../lib/useNavOverrides.js'
import { withModuleNav } from '../../modules/nav.js'
import { useFeatureGate } from '../../modules/features.jsx'
// 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 (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
)
}
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
// Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
// carries a gate — every player sees both — but an installed module's rows join
// this list before the merge and may carry a `feature`, so the filter after it
// is not dead code.
//
// "Characters" was the first row and left with the client half in slice 3; the
// UO module registers it again at `/player/uo/characters`, in this position,
// with `order: 0`.
export const NAV = [
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account/notifications', label: 'Notifications', icon: IconBell },
{ to: '/account', label: 'Account', end: true, icon: IconGear },
]
// The sticky content header mirrors the active page. A module's pages are not
// here and cannot be — core does not know what they are called — so they title
// from their own nav row, the same rule AdminLayout's `moduleTitle` follows.
const TITLES = {
'/account': 'Account',
'/account/appeals': 'Appeals',
'/account/notifications': 'Notifications',
}
function moduleTitle(baseNav, pathname) {
return baseNav
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
.sort((a, b) => b.to.length - a.to.length)[0]?.label
}
const navBtnBase = {
textAlign: 'left',
borderRadius: 8,
padding: '10px 14px',
fontFamily: 'var(--sans)',
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 { siteTitle } = useSite()
const navOverrides = useNavOverrides()
const isVisible = useFeatureGate()
const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
const nav = useMemo(
() => applyNavOverrides(baseNav, navOverrides.nav_player).filter(isVisible),
[baseNav, navOverrides.nav_player, isVisible],
)
const navigate = useNavigate()
const location = useLocation()
const title = TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || 'Player Portal'
async function signOut() {
await logout()
navigate('/account/login', { replace: true })
}
return (
<div className="admin-grid">
{/* Sidebar */}
<aside
style={{
borderRight: '1px solid var(--line)',
background: 'var(--bg)',
display: 'flex',
flexDirection: 'column',
position: 'sticky',
top: 0,
height: '100vh',
}}
>
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
<BrandLogo height={24} />
<MoonDot />
<div>
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
{siteTitle}
</div>
<div className="sans" style={{ color: 'var(--dim)', fontSize: '0.66rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
Player Portal
</div>
</div>
</div>
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
{nav.map((n) => (
<NavLink
key={n.to}
to={n.to}
end={n.end}
className="admin-nav-link"
style={({ isActive }) => ({
...navBtnBase,
background: isActive ? 'var(--blue)' : 'transparent',
color: isActive ? 'var(--ink)' : 'var(--muted)',
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
})}
>
{/* Guarded, like AdminLayout's. `icon` is optional in the nav
contract (§3.3) and every CORE row here has always had one, so
an unguarded `<n.icon />` was fine right up until a module
registered a row without — and then it was not a missing glyph,
it was React error #130 and a blank portal. Found by the §7.7
browser smoke; no DOM-less test can see it. */}
{n.icon && <n.icon />}
<span>{n.label}</span>
</NavLink>
))}
</nav>
<div style={{ padding: '14px 16px', borderTop: '1px solid var(--line-soft)' }}>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: '0.78rem', color: 'var(--muted)' }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: 'var(--mode-live)', boxShadow: '0 0 8px var(--mode-live)' }} />
Signed in as&nbsp;<strong style={{ color: 'var(--ink)' }}>{user?.username}</strong>
</div>
<button
onClick={signOut}
className="sans"
style={{ display: 'block', width: '100%', textAlign: 'center', border: '1px solid var(--line)', borderRadius: 8, padding: 9, color: 'var(--muted)', background: 'transparent', fontSize: '0.84rem', cursor: 'pointer' }}
>
Sign out
</button>
</div>
</aside>
{/* Main */}
<main style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '20px 32px',
borderBottom: '1px solid var(--line-soft)',
background: 'var(--bg)',
position: 'sticky',
top: 0,
zIndex: 10,
}}
>
<h1 className="display" style={{ margin: 0, fontSize: '1.5rem', color: 'var(--head)' }}>
{title}
</h1>
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
← Site
</a>
</header>
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 900, width: '100%' }}>
<Outlet />
</div>
</main>
</div>
)
}
/**
* What `/player` renders.
*
* It used to be `PlayerCharacters`, a UO page, which left the portal with no
* index at all when the client half was extracted (slice 3). Rather than pick a
* fixed destination or invent a core landing page, the index resolves to the
* first row of the portal nav this viewer can actually reach — so with the UO
* module installed a player still arrives at their characters, exactly as
* before, and with nothing installed they arrive at Account.
*
* Resolved from the BASE nav, before overrides: where everybody lands is
* behaviour, and an override is presentation (`firstDestinationFor`). `replace`
* so the back button leaves the portal rather than bouncing off this redirect.
*
* The same question exists one area over — the admin index is a hardcoded
* Dashboard — and if the two logged-in areas ever become one, this is the shape
* that answers for both. Nothing here assumes a portal separate from admin.
*/
export function PlayerIndex() {
const { user } = useAuth()
const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
const to = firstDestinationFor(baseNav, user?.role, '/account')
return <Navigate to={to} replace />
}