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>
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user