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:
2026-07-17 01:18:30 +00:00
parent 476e5dba87
commit beba7301a1
203 changed files with 31304 additions and 269 deletions
+71 -48
View File
@@ -2,20 +2,20 @@
import { useEffect, useRef, useState } from 'react'
import { WowheadRefresh } from './WowheadRefresh'
import { wowheadIcon, wowheadUrl, wowheadData } from '@/lib/wowhead'
import { wowheadUrl, wowheadData } from '@/lib/wowhead'
export type ActivityEntry = { id: number; name: string; icon: string; date: number }
/**
* Navegador de logros del personaje: búsqueda en tiempo real (por nombre o ID) y
* paginación (anterior/siguiente + números). Los datos se piden a /api/armory/activity;
* la primera página llega ya renderizada del servidor.
* Pestaña de Logros: filtros por ID y por nombre, tabla Logro | Fecha, paginación.
* Mismo estilo que Misiones. Datos vía /api/armory/activity; 1ª página del servidor.
*/
export function ArmoryActivity({
guid,
locale,
initial,
total: initialTotal,
grandTotal,
pageSize,
labels,
}: {
@@ -23,21 +23,31 @@ export function ArmoryActivity({
locale: string
initial: ActivityEntry[]
total: number
grandTotal: number
pageSize: number
labels: { earned: string; search: string; empty: string }
labels: {
filterId: string
filterName: string
empty: string
logro: string
date: string
title: string
countLine: string
}
}) {
const [entries, setEntries] = useState<ActivityEntry[]>(initial)
const [total, setTotal] = useState(initialTotal)
const [page, setPage] = useState(1)
const [q, setQ] = useState('')
const [qid, setQid] = useState('')
const [qname, setQname] = useState('')
const [loading, setLoading] = useState(false)
const pages = Math.max(1, Math.ceil(total / pageSize))
const load = async (p: number, query: string) => {
const load = async (p: number, id: string, name: string) => {
setLoading(true)
try {
const res = await fetch(
`/api/armory/activity?guid=${guid}&page=${p}&q=${encodeURIComponent(query)}&locale=${locale}`,
`/api/armory/activity?guid=${guid}&page=${p}&id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}&locale=${locale}`,
)
const data = await res.json()
setEntries(Array.isArray(data.entries) ? data.entries : [])
@@ -49,7 +59,6 @@ export function ArmoryActivity({
}
}
// Búsqueda en tiempo real (con retardo para no consultar en cada tecla).
const skip = useRef(true)
useEffect(() => {
if (skip.current) {
@@ -58,21 +67,22 @@ export function ArmoryActivity({
}
const t = setTimeout(() => {
setPage(1)
load(1, q)
load(1, qid, qname)
}, 300)
return () => clearTimeout(t)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q])
}, [qid, qname])
const goto = (p: number) => {
if (p < 1 || p > pages || p === page || loading) return
setPage(p)
load(p, q)
load(p, qid, qname)
}
const fmt = (ts: number) => {
const d = new Date(ts * 1000)
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`
const p = (n: number) => String(n).padStart(2, '0')
return `${p(d.getDate())}/${p(d.getMonth() + 1)}/${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}`
}
const win = 2
@@ -81,44 +91,57 @@ export function ArmoryActivity({
const nums: number[] = []
for (let p = from; p <= to; p++) nums.push(p)
const countLine = labels.countLine
.replace('{total}', String(total))
.replace('{page}', String(page))
.replace('{pages}', String(pages))
return (
<>
<WowheadRefresh dep={`${page}-${q}-${entries.length}`} />
<input
className="armory-ach-search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={labels.search}
aria-label={labels.search}
/>
<div className={`armory-activity${loading ? ' loading' : ''}`}>
{entries.length === 0 ? (
<p className="armory-hint">{labels.empty}</p>
) : (
entries.map((a, i) => (
<div className="armory-activity-row" key={`${a.id}-${a.date}-${i}`}>
<span
className="armory-activity-icon"
style={{ backgroundImage: `url(${wowheadIcon(a.icon, 'small')})` }}
/>
<span className="armory-activity-text">
{labels.earned}{' '}
<a
className="armory-activity-link"
href={wowheadUrl('achievement', a.id, locale)}
data-wowhead={wowheadData('achievement', a.id, locale)}
target="_blank"
rel="noopener noreferrer"
>
[{a.name}]
</a>
.
</span>
<span className="armory-activity-date">{fmt(a.date)}</span>
</div>
))
)}
<WowheadRefresh dep={`${page}-${qid}-${qname}-${entries.length}`} />
<div className="armory-quest-filters armory-ach-filters">
<input className="armory-ach-search" value={qid} onChange={(e) => setQid(e.target.value)} placeholder={labels.filterId} inputMode="numeric" />
<input className="armory-ach-search" value={qname} onChange={(e) => setQname(e.target.value)} placeholder={labels.filterName} />
</div>
<p className="armory-quest-count">{countLine}</p>
<div className="armory-quest-title">
<i className="fas fa-trophy" /> {labels.title} <span className="armory-quest-title-count">({grandTotal})</span>
</div>
{entries.length === 0 ? (
<p className="armory-hint">{labels.empty}</p>
) : (
<table className={`armory-quest-table${loading ? ' loading' : ''}`}>
<thead>
<tr>
<th>{labels.logro}</th>
<th className="armory-quest-th-status">{labels.date}</th>
</tr>
</thead>
<tbody>
{entries.map((a) => (
<tr key={`${a.id}-${a.date}`}>
<td>
<a
className="armory-quest-link armory-ach-link"
href={wowheadUrl('achievement', a.id, locale)}
data-wowhead={wowheadData('achievement', a.id, locale)}
target="_blank"
rel="noopener noreferrer"
>
{a.name}
</a>
<span className="armory-quest-id"> ({a.id})</span>
</td>
<td className="armory-quest-td-status armory-ach-date">{fmt(a.date)}</td>
</tr>
))}
</tbody>
</table>
)}
{pages > 1 && (
<div className="armory-pager">
<button onClick={() => goto(page - 1)} disabled={page <= 1} type="button" aria-label="Anterior">