Files
NightSpire/web-next/components/ArmoryGuildRoster.tsx
T
Inna beba7301a1 Armería: PvP campos de batalla, logros por categoría, bandas/mazmorras y página de hermandad
- PvP: pestaña Campos de batalla (pvpstats) con resultado, stats por partida y fecha/hora.
- Logros: rejilla por categoría con anillos de progreso + detalle por categoría
  (datos de Achievement.db2 + Achievement_Category.db2, iconos vía wowhead).
- Bandas: progreso por dificultad (10/25/heroico) con jefes reales de DungeonEncounter.db2
  y tooltip de jefes; agrupado por expansión (Clásico/TBC/WotLK).
- Mazmorras: mismo sistema, Normal + Heroico, Clásico/TBC/WotLK.
- Página de hermandad estilo armería: cabecera (emblema, puntos = unión de logros de
  miembros sin duplicar, nº miembros, fundada), pestañas Hermandad/Logros, roster con
  iconos de clase/raza, columna Función (rol por spec), filtros clase/función,
  orden por rango/puntos/nivel de objeto/nivel/nombre, paginación.
- Barra lateral: mensaje diario con login (iron-session, gated por pertenencia),
  logros recientes y desgloses de clases y funciones.
- Cabecera de personaje: enlace a la hermandad + nombre del reino.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 01:18:30 +00:00

246 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useRef, useState } from 'react'
import { className, classColor, raceName } from '@/lib/wow-data'
export type GuildMemberRow = {
guid: number
name: string
race: number
class: number
gender: number
level: number
rank: number
rankName: string
achPoints: number
role: string
ilvl: number
}
const ROLE_META: Record<string, { icon: string; color: string }> = {
tank: { icon: 'fas fa-shield-halved', color: '#4a90d9' },
healer: { icon: 'fas fa-plus', color: '#4ade5f' },
dps: { icon: 'fas fa-gavel', color: '#d9534f' },
}
const CLASS_SLUG: Record<number, string> = {
1: 'warrior', 2: 'paladin', 3: 'hunter', 4: 'rogue', 5: 'priest',
6: 'deathknight', 7: 'shaman', 8: 'mage', 9: 'warlock', 11: 'druid',
}
const RACE_SLUG: Record<number, string> = {
1: 'human', 2: 'orc', 3: 'dwarf', 4: 'nightelf', 5: 'scourge', 6: 'tauren',
7: 'gnome', 8: 'troll', 9: 'goblin', 10: 'bloodelf', 11: 'draenei', 22: 'worgen',
}
const ICON = 'https://wow.zamimg.com/images/wow/icons/large'
const classIcon = (c: number) => `${ICON}/classicon_${CLASS_SLUG[c] || 'warrior'}.jpg`
const raceIcon = (r: number, g: number) => `${ICON}/race_${RACE_SLUG[r] || 'human'}_${g === 1 ? 'female' : 'male'}.jpg`
export function ArmoryGuildRoster({
guildid,
locale,
initial,
total: initialTotal,
pageSize,
classes,
labels,
}: {
guildid: number
locale: string
initial: GuildMemberRow[]
total: number
pageSize: number
classes: number[]
labels: {
view: string
sortRank: string
sortPoints: string
sortLevel: string
sortName: string
sortIlvl: string
ilvl: string
filterClass: string
allClasses: string
filterRole: string
allRoles: string
roleTank: string
roleHealer: string
roleDps: string
name: string
race: string
class: string
role: string
level: string
rank: string
points: string
}
}) {
const [rows, setRows] = useState<GuildMemberRow[]>(initial)
const [total, setTotal] = useState(initialTotal)
const [page, setPage] = useState(1)
const [sort, setSort] = useState('rank')
const [cls, setCls] = useState(0) // 0 = todas
const [role, setRole] = useState('') // '' = todas
const [loading, setLoading] = useState(false)
const pages = Math.max(1, Math.ceil(total / pageSize))
const load = async (p: number, s: string, c: number, r: string) => {
setLoading(true)
try {
const q = new URLSearchParams({ guild: String(guildid), page: String(p), sort: s, locale })
if (c) q.set('classes', String(c))
if (r) q.set('roles', r)
const res = await fetch(`/api/armory/guild-roster?${q.toString()}`)
const data = await res.json()
setRows(Array.isArray(data.members) ? data.members : [])
setTotal(Number(data.total) || 0)
} catch {
setRows([])
} finally {
setLoading(false)
}
}
const skip = useRef(true)
useEffect(() => {
if (skip.current) {
skip.current = false
return
}
setPage(1)
load(1, sort, cls, role)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sort, cls, role])
const goto = (p: number) => {
if (p < 1 || p > pages || p === page || loading) return
setPage(p)
load(p, sort, cls, role)
}
const from = Math.max(1, page - 2)
const to = Math.min(pages, page + 2)
const nums: number[] = []
for (let p = from; p <= to; p++) nums.push(p)
const sortField = sort === 'points' ? labels.sortPoints : sort === 'level' ? labels.sortLevel : sort === 'name' ? labels.sortName : labels.sortRank
return (
<div className="armory-guild-roster">
<div className="armory-guild-filters">
<label className="armory-guild-filter">
<span>{labels.view}</span>
<select value={sort} onChange={(e) => setSort(e.target.value)}>
<option value="rank">{labels.sortRank}</option>
<option value="points">{labels.sortPoints}</option>
<option value="ilvl">{labels.sortIlvl}</option>
<option value="level">{labels.sortLevel}</option>
<option value="name">{labels.sortName}</option>
</select>
</label>
<label className="armory-guild-filter">
<span>{labels.filterClass}</span>
<select value={cls} onChange={(e) => setCls(Number(e.target.value))}>
<option value={0}>{labels.allClasses}</option>
{classes.map((c) => (
<option key={c} value={c}>
{className(c, locale)}
</option>
))}
</select>
</label>
<label className="armory-guild-filter">
<span>{labels.filterRole}</span>
<select value={role} onChange={(e) => setRole(e.target.value)}>
<option value="">{labels.allRoles}</option>
<option value="tank">{labels.roleTank}</option>
<option value="healer">{labels.roleHealer}</option>
<option value="dps">{labels.roleDps}</option>
</select>
</label>
</div>
<table className={`armory-guild-table${loading ? ' loading' : ''}`}>
<thead>
<tr>
<th>{labels.name}</th>
<th className="armory-gt-c">{labels.race}</th>
<th className="armory-gt-c">{labels.class}</th>
<th className="armory-gt-c">{labels.role}</th>
<th className="armory-gt-c">{labels.level}</th>
<th className="armory-gt-pts">{sort === 'points' ? labels.points : sort === 'ilvl' ? labels.ilvl : labels.rank}</th>
</tr>
</thead>
<tbody>
{rows.map((m) => (
<tr key={m.guid}>
<td>
<a className="armory-gt-name" href={`/${locale}/armory/character/${m.guid}`} style={{ color: classColor(m.class) }}>
<img className="armory-gt-avatar" src={raceIcon(m.race, m.gender)} alt="" loading="lazy" />
{m.name}
</a>
</td>
<td className="armory-gt-c">
<img className="armory-gt-ico" src={raceIcon(m.race, m.gender)} alt={raceName(m.race, locale)} title={raceName(m.race, locale)} loading="lazy" />
</td>
<td className="armory-gt-c">
<img className="armory-gt-ico" src={classIcon(m.class)} alt={className(m.class, locale)} title={className(m.class, locale)} loading="lazy" />
</td>
<td className="armory-gt-c">
{(() => {
const rm = ROLE_META[m.role] || ROLE_META.dps
const label = m.role === 'tank' ? labels.roleTank : m.role === 'healer' ? labels.roleHealer : labels.roleDps
return (
<span className="armory-gt-role" style={{ color: rm.color }} title={label}>
<i className={rm.icon} />
</span>
)
})()}
</td>
<td className="armory-gt-c armory-gt-level">{m.level}</td>
<td className="armory-gt-pts">
{sort === 'points' ? (
<span className="armory-gt-points">
<i className="fas fa-shield" /> {m.achPoints.toLocaleString(locale)}
</span>
) : sort === 'ilvl' ? (
<span className="armory-gt-ilvl">{m.ilvl || '—'}</span>
) : (
<span className="armory-gt-rank">{m.rankName || `#${m.rank}`}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{pages > 1 && (
<div className="armory-pager">
<button onClick={() => goto(page - 1)} disabled={page <= 1} type="button" aria-label="Anterior">
</button>
{from > 1 && (
<>
<button onClick={() => goto(1)} type="button">1</button>
{from > 2 && <span className="armory-pager-dots"></span>}
</>
)}
{nums.map((p) => (
<button key={p} className={p === page ? 'active' : ''} onClick={() => goto(p)} type="button">
{p}
</button>
))}
{to < pages && (
<>
{to < pages - 1 && <span className="armory-pager-dots"></span>}
<button onClick={() => goto(pages)} type="button">{pages}</button>
</>
)}
<button onClick={() => goto(page + 1)} disabled={page >= pages} type="button" aria-label="Siguiente">
</button>
</div>
)}
</div>
)
}