The module's half of the org lead's correction: Teams is the contract, guilds are the presentation, and the presentation is this module's. Adds `/uo/guilds/:id` — the detail view the board never had — with the roster from this module's OWN board, which is the same data it answers core's Team provider from. Reading core's projection of our own answer back would be a round trip through a staler copy of it. The page declares `uo.guild.detail` and core fills it with the Team activity feed. That is the one part of this page core cannot hand over: only core can resolve whether the viewer is inside the Team, and the public/members split on that feed is a security boundary. The guild is named in OUR terms — core maps its own Team from the module id and the external id — so this module never holds core's row id or slug. `TeamOverviewStrip` is deleted with the core Team page it filled. `team.member.row` is not declared here either: the useful thing to put in a roster row is a link to the character behind it, and nothing core could supply identifies one. `GET /public/shard/guilds/:id` backs the page, gated and projected through the same `guilds` feature as the board — so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin. A roster is where those appear in bulk, which makes this the endpoint where getting the projection wrong would matter most. Co-Authored-By: Claude <noreply@anthropic.com>
171 lines
7.0 KiB
JavaScript
171 lines
7.0 KiB
JavaScript
import { useMemo, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
|
import api from '../../api.js'
|
|
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
|
|
|
// The guild board. Loaded once from /public/shard/guilds, then kept live by
|
|
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
|
|
// joined" strip on top of the board.
|
|
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
|
|
|
|
function Leader({ leader }) {
|
|
if (!leader || !leader.name) return <span className="dim">—</span>
|
|
return <span>{leader.name}</span>
|
|
}
|
|
|
|
function GuildRow({ g }) {
|
|
return (
|
|
// A link now, because the board gained a detail page: the roster and core's
|
|
// Team activity feed live there (docs/website/TEAMS.md Part 3).
|
|
<Link
|
|
to={`/uo/guilds/${encodeURIComponent(g.id)}`}
|
|
className="panel"
|
|
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14, textDecoration: 'none' }}
|
|
>
|
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
|
|
{g.abbr && (
|
|
<span
|
|
className="sans"
|
|
style={{
|
|
flex: 'none',
|
|
fontSize: '0.72rem',
|
|
letterSpacing: '0.06em',
|
|
color: 'var(--accent)',
|
|
border: '1px solid rgba(201,162,75,0.4)',
|
|
borderRadius: 5,
|
|
padding: '1px 6px',
|
|
}}
|
|
>
|
|
{g.abbr}
|
|
</span>
|
|
)}
|
|
<strong
|
|
className="display"
|
|
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
|
>
|
|
{g.name || 'A guild'}
|
|
</strong>
|
|
</div>
|
|
{g.alliance && (
|
|
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
|
{g.alliance}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
|
|
<div>
|
|
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
|
|
<span className="dim"> / {g.members ?? 0}</span>
|
|
</div>
|
|
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
|
<Leader leader={g.leader} />
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
export default function Guilds() {
|
|
const { loading, error, data } = useAsync(() => api.shard.guilds())
|
|
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
|
|
const [q, setQ] = useState('')
|
|
|
|
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
|
|
const board = useMemo(() => {
|
|
const map = new Map()
|
|
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
|
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
const ev = events[i]
|
|
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
|
|
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
|
|
}
|
|
return [...map.values()]
|
|
}, [data, events])
|
|
|
|
// Recent joins strip (newest first, deduped, capped).
|
|
const joins = useMemo(
|
|
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
|
|
[events],
|
|
)
|
|
|
|
const filtered = useMemo(() => {
|
|
const needle = q.trim().toLowerCase()
|
|
const rows = needle
|
|
? board.filter((g) =>
|
|
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
|
|
)
|
|
: board
|
|
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
|
}, [board, q])
|
|
|
|
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
|
|
|
|
return (
|
|
<PublicLayout section="website">
|
|
<div className="shell-narrow page-body">
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
|
<PageHeader eyebrow="Live" title="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, updating in real time." />
|
|
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
|
{connected ? 'Live' : 'Offline'}
|
|
</span>
|
|
</div>
|
|
|
|
{loading && <Loading />}
|
|
{error && <ErrorState message="Could not load the guild board right now." />}
|
|
|
|
{!loading && !error && (
|
|
<>
|
|
{board.length === 0 ? (
|
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
|
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
|
|
</section>
|
|
) : (
|
|
<>
|
|
{joins.length > 0 && (
|
|
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
|
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
|
|
Recently joined
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
|
{joins.map((j) => (
|
|
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
|
|
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
|
|
<span className="dim"> joined </span>
|
|
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
|
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
|
|
{board.length} guilds · {totalMembers.toLocaleString()} members
|
|
</p>
|
|
<input
|
|
className="input sans"
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="Search guilds…"
|
|
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
|
|
</div>
|
|
{filtered.length === 0 && (
|
|
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match “{q}”.</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</PublicLayout>
|
|
)
|
|
}
|