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,229 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { wowheadIcon, wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
import { WowheadRefresh } from './WowheadRefresh'
|
||||
|
||||
export type AchCat = {
|
||||
id: number
|
||||
name: string
|
||||
total: number
|
||||
completed: number
|
||||
totalPoints: number
|
||||
earnedPoints: number
|
||||
pct: number
|
||||
isFeats: boolean
|
||||
}
|
||||
export type AchDetailItem = {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
reward: string
|
||||
icon: string
|
||||
points: number
|
||||
date: number
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
function ringColor(pct: number) {
|
||||
if (pct >= 90) return '#4ade5f'
|
||||
if (pct >= 50) return '#e0902f'
|
||||
return '#d97b28'
|
||||
}
|
||||
|
||||
export function ArmoryAchievements({
|
||||
locale,
|
||||
guid,
|
||||
categories,
|
||||
earnedPoints,
|
||||
totalCompleted,
|
||||
apiPath = '/api/armory/achievements',
|
||||
idKey = 'guid',
|
||||
labels,
|
||||
}: {
|
||||
locale: string
|
||||
guid: number
|
||||
categories: AchCat[]
|
||||
earnedPoints: number
|
||||
totalCompleted: number
|
||||
apiPath?: string
|
||||
idKey?: string
|
||||
labels: {
|
||||
summary: string
|
||||
back: string
|
||||
reward: string
|
||||
empty: string
|
||||
of: string
|
||||
feats: string
|
||||
}
|
||||
}) {
|
||||
const [cat, setCat] = useState<AchCat | null>(null)
|
||||
const [entries, setEntries] = useState<AchDetailItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
const open = async (c: AchCat, p = 1) => {
|
||||
setCat(c)
|
||||
setPage(p)
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${apiPath}?${idKey}=${guid}&cat=${c.id}&page=${p}&locale=${locale}`)
|
||||
const data = await res.json()
|
||||
setEntries(Array.isArray(data.entries) ? data.entries : [])
|
||||
setTotal(Number(data.total) || 0)
|
||||
} catch {
|
||||
setEntries([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goto = (p: number) => {
|
||||
if (!cat || p < 1 || p > pages || p === page || loading) return
|
||||
open(cat, p)
|
||||
}
|
||||
|
||||
const fmtDate = (unix: number) => {
|
||||
if (!unix) return ''
|
||||
const d = new Date(unix * 1000)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
// ---- Vista detalle de una categoría ----
|
||||
if (cat) {
|
||||
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)
|
||||
return (
|
||||
<div className="armory-panel armory-ach2">
|
||||
<WowheadRefresh dep={`${cat.id}:${page}:${entries.length}`} />
|
||||
<div className="armory-ach2-head">
|
||||
<button type="button" className="armory-ach2-back" onClick={() => setCat(null)}>
|
||||
<i className="fas fa-arrow-left" /> {labels.back}
|
||||
</button>
|
||||
<div className="armory-ach2-title">
|
||||
{cat.name}
|
||||
<span className="armory-ach2-sub">
|
||||
{cat.completed} {labels.of} {cat.total}
|
||||
{cat.isFeats ? '' : ` · ${cat.earnedPoints} / ${cat.totalPoints}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 && !loading ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
<ul className={`armory-ach2-list${loading ? ' loading' : ''}`}>
|
||||
{entries.map((a) => (
|
||||
<li className="armory-ach2-item" key={a.id}>
|
||||
<span className="armory-ach2-icon">
|
||||
<img src={wowheadIcon(a.icon, 'large')} alt="" loading="lazy" />
|
||||
</span>
|
||||
<div className="armory-ach2-body">
|
||||
<a
|
||||
className="armory-ach2-name"
|
||||
href={wowheadUrl('achievement', a.id, locale)}
|
||||
data-wowhead={wowheadData('achievement', a.id, locale)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{a.name}
|
||||
</a>
|
||||
{a.description ? <p className="armory-ach2-desc">{a.description}</p> : null}
|
||||
{a.reward ? (
|
||||
<p className="armory-ach2-reward">
|
||||
<i className="fas fa-gift" /> {labels.reward}: {a.reward}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="armory-ach2-meta">
|
||||
{a.points > 0 ? (
|
||||
<span className="armory-ach2-pts">
|
||||
<i className="fas fa-shield" /> {a.points}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="armory-ach2-date">{fmtDate(a.date)}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Vista rejilla de categorías ----
|
||||
const totalAch = categories.reduce((s, c) => s + c.total, 0)
|
||||
const summary = labels.summary
|
||||
.replace('{done}', String(totalCompleted))
|
||||
.replace('{total}', String(totalAch))
|
||||
.replace('{points}', String(earnedPoints))
|
||||
|
||||
return (
|
||||
<div className="armory-panel armory-ach2">
|
||||
<p className="armory-ach2-summary">{summary}</p>
|
||||
<div className="armory-ach-grid">
|
||||
{categories.map((c) => {
|
||||
const col = ringColor(c.pct)
|
||||
return (
|
||||
<button type="button" className="armory-ach-card" key={c.id} onClick={() => open(c)}>
|
||||
{c.isFeats ? (
|
||||
<div className="armory-ach-ring feats">
|
||||
<i className="fas fa-award" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="armory-ach-ring"
|
||||
style={{ background: `conic-gradient(${col} ${c.pct * 3.6}deg, #241f19 0deg)` }}
|
||||
>
|
||||
<span className="armory-ach-ring-pct">{c.pct}%</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="armory-ach-cat-name">{c.name}</span>
|
||||
<span className="armory-ach-cat-pts">
|
||||
<i className="fas fa-shield" /> {c.isFeats ? c.completed : c.earnedPoints}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { BgMatch } from '@/lib/armory'
|
||||
|
||||
/** Nombre + color por BattlegroundTypeId (3.4.3 WotLK). */
|
||||
const BG_TYPES: Record<number, { es: string; en: string; color: string; icon: string }> = {
|
||||
1: { es: 'Valle de Alterac', en: 'Alterac Valley', color: '#4a90d9', icon: 'fas fa-mountain' },
|
||||
2: { es: 'Garganta Grito de Guerra', en: 'Warsong Gulch', color: '#2ecc71', icon: 'fas fa-flag' },
|
||||
3: { es: 'Cuenca de Arathi', en: 'Arathi Basin', color: '#e0a13a', icon: 'fas fa-tower-observation' },
|
||||
7: { es: 'Ojo de la Tormenta', en: 'Eye of the Storm', color: '#9b59b6', icon: 'fas fa-bolt' },
|
||||
9: { es: 'Costa de los Ancestros', en: 'Strand of the Ancients', color: '#c0703a', icon: 'fas fa-ship' },
|
||||
30: { es: 'Isla de la Conquista', en: 'Isle of Conquest', color: '#5dade2', icon: 'fas fa-anchor' },
|
||||
32: { es: 'Campo de batalla aleatorio', en: 'Random Battleground', color: '#888888', icon: 'fas fa-dice' },
|
||||
}
|
||||
|
||||
export type BgLabels = {
|
||||
history: string
|
||||
desc: string
|
||||
matches: string
|
||||
wins: string
|
||||
losses: string
|
||||
winRate: string
|
||||
result: string
|
||||
win: string
|
||||
loss: string
|
||||
kb: string
|
||||
hk: string
|
||||
deaths: string
|
||||
damage: string
|
||||
healing: string
|
||||
honor: string
|
||||
date: string
|
||||
none: string
|
||||
battleground: string
|
||||
}
|
||||
|
||||
export function ArmoryBattlegrounds({
|
||||
locale,
|
||||
rows,
|
||||
matches,
|
||||
wins,
|
||||
losses,
|
||||
winRate,
|
||||
labels,
|
||||
}: {
|
||||
locale: string
|
||||
rows: BgMatch[]
|
||||
matches: number
|
||||
wins: number
|
||||
losses: number
|
||||
winRate: number
|
||||
labels: BgLabels
|
||||
}) {
|
||||
const fmt = (n: number) => n.toLocaleString(locale)
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return '—'
|
||||
const day = d.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
const time = d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
||||
return (
|
||||
<span className="armory-bg-dt">
|
||||
<span className="armory-bg-dt-d">{day}</span>
|
||||
<span className="armory-bg-dt-t">{time}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const bgOf = (type: number) =>
|
||||
BG_TYPES[type] || { es: `BG #${type}`, en: `BG #${type}`, color: '#777', icon: 'fas fa-khanda' }
|
||||
|
||||
return (
|
||||
<div className="armory-panel armory-bg-panel">
|
||||
<div className="armory-bg-header">
|
||||
<div className="armory-quest-title">
|
||||
<i className="fas fa-flag-checkered" /> {labels.history}
|
||||
</div>
|
||||
<p className="armory-bg-desc">{labels.desc}</p>
|
||||
</div>
|
||||
|
||||
<div className="armory-bg-summary">
|
||||
<div className="armory-bg-stat">
|
||||
<span className="armory-bg-stat-num" style={{ color: '#ffd100' }}>
|
||||
{fmt(matches)}
|
||||
</span>
|
||||
<span className="armory-bg-stat-lbl">{labels.matches}</span>
|
||||
</div>
|
||||
<div className="armory-bg-stat">
|
||||
<span className="armory-bg-stat-num" style={{ color: '#2ecc71' }}>
|
||||
{fmt(wins)}
|
||||
</span>
|
||||
<span className="armory-bg-stat-lbl">{labels.wins}</span>
|
||||
</div>
|
||||
<div className="armory-bg-stat">
|
||||
<span className="armory-bg-stat-num" style={{ color: '#ff5555' }}>
|
||||
{fmt(losses)}
|
||||
</span>
|
||||
<span className="armory-bg-stat-lbl">{labels.losses}</span>
|
||||
</div>
|
||||
<div className="armory-bg-stat">
|
||||
<span className="armory-bg-stat-num" style={{ color: '#e0a13a' }}>
|
||||
{winRate}%
|
||||
</span>
|
||||
<span className="armory-bg-stat-lbl">{labels.winRate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="armory-hint">{labels.none}</p>
|
||||
) : (
|
||||
<div className="armory-bg-table-wrap">
|
||||
<table className="armory-quest-table armory-bg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{labels.battleground}</th>
|
||||
<th className="armory-bg-c">{labels.result}</th>
|
||||
<th className="armory-bg-c">{labels.kb}</th>
|
||||
<th className="armory-bg-c">{labels.hk}</th>
|
||||
<th className="armory-bg-c">{labels.deaths}</th>
|
||||
<th className="armory-bg-c">{labels.damage}</th>
|
||||
<th className="armory-bg-c">{labels.healing}</th>
|
||||
<th className="armory-bg-c">{labels.honor}</th>
|
||||
<th className="armory-bg-c">{labels.date}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((m) => {
|
||||
const bg = bgOf(m.type)
|
||||
const name = locale === 'en' ? bg.en : bg.es
|
||||
return (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
<span className="armory-bg-name">
|
||||
<span className="armory-bg-ico" style={{ background: bg.color }}>
|
||||
<i className={bg.icon} />
|
||||
</span>
|
||||
{name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="armory-bg-c">
|
||||
<span className={`armory-bg-res ${m.won ? 'win' : 'loss'}`}>{m.won ? labels.win : labels.loss}</span>
|
||||
</td>
|
||||
<td className="armory-bg-c armory-bg-kb">{fmt(m.killingBlows)}</td>
|
||||
<td className="armory-bg-c">{fmt(m.honorableKills)}</td>
|
||||
<td className="armory-bg-c">{fmt(m.deaths)}</td>
|
||||
<td className="armory-bg-c">{fmt(m.damage)}</td>
|
||||
<td className="armory-bg-c">{fmt(m.healing)}</td>
|
||||
<td className="armory-bg-c armory-bg-honor">{fmt(m.bonusHonor)}</td>
|
||||
<td className="armory-bg-c armory-bg-date">{fmtDate(m.date)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { WowheadRefresh } from './WowheadRefresh'
|
||||
import { wowheadIcon, wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
|
||||
export type CollectionEntry = { id: number; name: string; icon: string }
|
||||
|
||||
/** Colección (monturas/mascotas) con búsqueda en tiempo real y paginación en cliente. */
|
||||
export function ArmoryCollection({
|
||||
items,
|
||||
locale,
|
||||
pageSize,
|
||||
labels,
|
||||
}: {
|
||||
items: CollectionEntry[]
|
||||
locale: string
|
||||
pageSize: number
|
||||
labels: { title: string; search: string; empty: string }
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const query = q.trim().toLowerCase()
|
||||
const filtered = query ? items.filter((i) => i.name.toLowerCase().includes(query)) : items
|
||||
const pages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
||||
const p = Math.min(page, pages)
|
||||
const pageItems = filtered.slice((p - 1) * pageSize, (p - 1) * pageSize + pageSize)
|
||||
|
||||
const win = 2
|
||||
const from = Math.max(1, p - win)
|
||||
const to = Math.min(pages, p + win)
|
||||
const nums: number[] = []
|
||||
for (let i = from; i <= to; i++) nums.push(i)
|
||||
|
||||
return (
|
||||
<div className="armory-panel">
|
||||
<WowheadRefresh dep={`${p}-${q}-${pageItems.length}`} />
|
||||
<div className="armory-collection-head">{labels.title}</div>
|
||||
<input
|
||||
className="armory-ach-search"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder={labels.search}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
<div className="armory-collection">
|
||||
{pageItems.map((c) => (
|
||||
<a
|
||||
className="armory-coll-card"
|
||||
key={c.id}
|
||||
href={wowheadUrl('spell', c.id, locale)}
|
||||
data-wowhead={wowheadData('spell', c.id, locale)}
|
||||
data-wh-icon-size="false"
|
||||
data-wh-rename-link="false"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="armory-coll-icon" style={{ backgroundImage: `url(${wowheadIcon(c.icon, 'small')})` }} />
|
||||
<span className="armory-coll-name">{c.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<div className="armory-pager">
|
||||
<button onClick={() => setPage(p - 1)} disabled={p <= 1} type="button" aria-label="Anterior">
|
||||
‹
|
||||
</button>
|
||||
{from > 1 && (
|
||||
<>
|
||||
<button onClick={() => setPage(1)} type="button">
|
||||
1
|
||||
</button>
|
||||
{from > 2 && <span className="armory-pager-dots">…</span>}
|
||||
</>
|
||||
)}
|
||||
{nums.map((n) => (
|
||||
<button key={n} className={n === p ? 'active' : ''} onClick={() => setPage(n)} type="button">
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
{to < pages && (
|
||||
<>
|
||||
{to < pages - 1 && <span className="armory-pager-dots">…</span>}
|
||||
<button onClick={() => setPage(pages)} type="button">
|
||||
{pages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => setPage(p + 1)} disabled={p >= pages} type="button" aria-label="Siguiente">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type PvpRankEntry = { rank: number; guid: number; name: string; race: number; class: number; kills: number; playedTime: number }
|
||||
|
||||
const MEDAL = ['🥇', '🥈', '🥉']
|
||||
|
||||
/** Ranking mundial de bajas: búsqueda por nombre + paginación (API). */
|
||||
export function ArmoryPvpRank({
|
||||
locale,
|
||||
initial,
|
||||
total: initialTotal,
|
||||
pageSize,
|
||||
labels,
|
||||
}: {
|
||||
locale: string
|
||||
initial: PvpRankEntry[]
|
||||
total: number
|
||||
pageSize: number
|
||||
labels: {
|
||||
title: string
|
||||
desc: string
|
||||
search: string
|
||||
empty: string
|
||||
character: string
|
||||
kills: string
|
||||
playedTime: string
|
||||
}
|
||||
}) {
|
||||
const [entries, setEntries] = useState<PvpRankEntry[]>(initial)
|
||||
const [total, setTotal] = useState(initialTotal)
|
||||
const [page, setPage] = useState(1)
|
||||
const [q, setQ] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
const load = async (p: number, search: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/armory/pvprank?page=${p}&q=${encodeURIComponent(search)}&locale=${locale}`)
|
||||
const data = await res.json()
|
||||
setEntries(Array.isArray(data.entries) ? data.entries : [])
|
||||
setTotal(Number(data.total) || 0)
|
||||
} catch {
|
||||
setEntries([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const skip = useRef(true)
|
||||
useEffect(() => {
|
||||
if (skip.current) {
|
||||
skip.current = false
|
||||
return
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
load(1, q)
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q])
|
||||
|
||||
const goto = (p: number) => {
|
||||
if (p < 1 || p > pages || p === page || loading) return
|
||||
setPage(p)
|
||||
load(p, q)
|
||||
}
|
||||
|
||||
const fmtTime = (sec: number) => {
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
return `${d}d ${h}h ${m}m`
|
||||
}
|
||||
|
||||
const win = 2
|
||||
const from = Math.max(1, page - win)
|
||||
const to = Math.min(pages, page + win)
|
||||
const nums: number[] = []
|
||||
for (let p = from; p <= to; p++) nums.push(p)
|
||||
|
||||
return (
|
||||
<div className="armory-panel armory-pvprank-panel">
|
||||
<div className="armory-pvprank-header">
|
||||
<div className="armory-quest-title">
|
||||
<i className="fas fa-trophy" /> {labels.title}
|
||||
</div>
|
||||
<p className="armory-pvprank-desc">{labels.desc}</p>
|
||||
</div>
|
||||
|
||||
<input className="armory-ach-search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={labels.search} />
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
<table className={`armory-quest-table armory-rank-table${loading ? ' loading' : ''}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="armory-rank-th-pos">#</th>
|
||||
<th>{labels.character}</th>
|
||||
<th className="armory-rank-th-kills">{labels.kills}</th>
|
||||
<th className="armory-rank-th-time">{labels.playedTime}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.guid}>
|
||||
<td className="armory-rank-pos">
|
||||
{e.rank <= 3 ? <span className="armory-rank-medal">{MEDAL[e.rank - 1]}</span> : null} {e.rank}
|
||||
</td>
|
||||
<td>
|
||||
<a className="armory-rank-name" href={`/${locale}/armory/character/${e.guid}`}>
|
||||
{e.name}
|
||||
</a>
|
||||
</td>
|
||||
<td className="armory-rank-kills">{e.kills.toLocaleString(locale)}</td>
|
||||
<td className="armory-rank-time">{fmtTime(e.playedTime)}</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { WowheadRefresh } from './WowheadRefresh'
|
||||
import { wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
|
||||
export type QuestStatus = 'rewarded' | 'complete' | 'inprogress' | 'failed'
|
||||
export type QuestEntry = { id: number; name: string; status: QuestStatus }
|
||||
|
||||
// Color + icono por estado (mismo criterio para nombre, texto de estado e icono del filtro).
|
||||
const STATUS: Record<string, { color: string; icon: string }> = {
|
||||
all: { color: '#d4cdbb', icon: '' },
|
||||
inprogress: { color: '#d79602', icon: 'fas fa-scroll' },
|
||||
complete: { color: '#1eff00', icon: 'fas fa-check' },
|
||||
failed: { color: '#ff5555', icon: 'fas fa-times' },
|
||||
rewarded: { color: '#a335ee', icon: 'fas fa-gift' },
|
||||
}
|
||||
|
||||
export function ArmoryQuests({
|
||||
guid,
|
||||
locale,
|
||||
initial,
|
||||
total: initialTotal,
|
||||
grandTotal,
|
||||
pageSize,
|
||||
statuses,
|
||||
badgeLabels,
|
||||
labels,
|
||||
}: {
|
||||
guid: number
|
||||
locale: string
|
||||
initial: QuestEntry[]
|
||||
total: number
|
||||
grandTotal: number
|
||||
pageSize: number
|
||||
statuses: { key: string; label: string; count: number }[]
|
||||
badgeLabels: Record<string, string>
|
||||
labels: { filterId: string; filterName: string; empty: string; quest: string; statusCol: string; title: string; pageOf: string }
|
||||
}) {
|
||||
const [entries, setEntries] = useState<QuestEntry[]>(initial)
|
||||
const [total, setTotal] = useState(initialTotal)
|
||||
const [page, setPage] = useState(1)
|
||||
const [qid, setQid] = useState('')
|
||||
const [qname, setQname] = useState('')
|
||||
const [status, setStatus] = useState('all')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
const load = async (p: number, id: string, name: string, st: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/armory/quests?guid=${guid}&page=${p}&id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}&status=${st}&locale=${locale}`,
|
||||
)
|
||||
const data = await res.json()
|
||||
setEntries(Array.isArray(data.entries) ? data.entries : [])
|
||||
setTotal(Number(data.total) || 0)
|
||||
} catch {
|
||||
setEntries([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const skip = useRef(true)
|
||||
useEffect(() => {
|
||||
if (skip.current) {
|
||||
skip.current = false
|
||||
return
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
load(1, qid, qname, status)
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qid, qname, status])
|
||||
|
||||
const goto = (p: number) => {
|
||||
if (p < 1 || p > pages || p === page || loading) return
|
||||
setPage(p)
|
||||
load(p, qid, qname, status)
|
||||
}
|
||||
const pick = (key: string) => {
|
||||
setOpen(false)
|
||||
setStatus(key)
|
||||
}
|
||||
|
||||
const win = 2
|
||||
const from = Math.max(1, page - win)
|
||||
const to = Math.min(pages, page + win)
|
||||
const nums: number[] = []
|
||||
for (let p = from; p <= to; p++) nums.push(p)
|
||||
|
||||
const current = statuses.find((s) => s.key === status) ?? statuses[0]
|
||||
const optLabel = (s: { key: string; label: string; count: number }) =>
|
||||
s.key === 'all' ? `${s.label}` : `${s.label} (${s.count})`
|
||||
|
||||
return (
|
||||
<>
|
||||
<WowheadRefresh dep={`${page}-${qid}-${qname}-${status}-${entries.length}`} />
|
||||
|
||||
<div className="armory-quest-title">
|
||||
<i className="fas fa-scroll" /> {labels.title} <span className="armory-quest-title-count">({grandTotal})</span>
|
||||
</div>
|
||||
|
||||
<div className="armory-quest-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 className="armory-dropdown">
|
||||
<button className="armory-dropdown-btn" onClick={() => setOpen((o) => !o)} type="button">
|
||||
{current && STATUS[current.key]?.icon ? <i className={STATUS[current.key].icon} style={{ color: STATUS[current.key].color }} /> : null}
|
||||
<span>{current ? optLabel(current) : ''}</span>
|
||||
<i className="fas fa-caret-down armory-dropdown-caret" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="armory-dropdown-list">
|
||||
{statuses.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
className={`armory-dropdown-item${s.key === status ? ' active' : ''}`}
|
||||
onClick={() => pick(s.key)}
|
||||
type="button"
|
||||
>
|
||||
{STATUS[s.key]?.icon ? <i className={STATUS[s.key].icon} style={{ color: STATUS[s.key].color }} /> : <span className="armory-dropdown-noicon" />}
|
||||
<span>{optLabel(s)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
<table className={`armory-quest-table${loading ? ' loading' : ''}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{labels.quest}</th>
|
||||
<th className="armory-quest-th-status">{labels.statusCol}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((q) => {
|
||||
const color = STATUS[q.status]?.color ?? '#d4cdbb'
|
||||
return (
|
||||
<tr key={q.id}>
|
||||
<td>
|
||||
<a
|
||||
className="armory-quest-link"
|
||||
style={{ color }}
|
||||
href={wowheadUrl('quest', q.id, locale)}
|
||||
data-wowhead={wowheadData('quest', q.id, locale)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{q.name}
|
||||
</a>
|
||||
<span className="armory-quest-id"> ({q.id})</span>
|
||||
</td>
|
||||
<td className="armory-quest-td-status" style={{ color }}>
|
||||
{badgeLabels[q.status] ?? q.status}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
export type RaidBossState = { name: string; killed: boolean }
|
||||
export type RaidDifficulty = { diff: number; label: string; killed: number; total: number; bosses: RaidBossState[] }
|
||||
export type RaidProgressItem = { map: number; name: string; exp: number; total: number; difficulties: RaidDifficulty[] }
|
||||
|
||||
/** Gradiente de cabecera por banda (sin arte del cliente). */
|
||||
const RAID_THEME: Record<number, string> = {
|
||||
// Classic
|
||||
409: 'linear-gradient(135deg, #8a3a1a, #2e1208)',
|
||||
469: 'linear-gradient(135deg, #7a2a2a, #250d0d)',
|
||||
531: 'linear-gradient(135deg, #8a7a2a, #2a2408)',
|
||||
509: 'linear-gradient(135deg, #6b7a2a, #202808)',
|
||||
309: 'linear-gradient(135deg, #2f7a5a, #0d281c)',
|
||||
// TBC
|
||||
532: 'linear-gradient(135deg, #4a2f6b, #1a1028)',
|
||||
565: 'linear-gradient(135deg, #6b3a2f, #281410)',
|
||||
544: 'linear-gradient(135deg, #7a2f4a, #2a1018)',
|
||||
548: 'linear-gradient(135deg, #2a6b6b, #0d2828)',
|
||||
550: 'linear-gradient(135deg, #3a5c8a, #101f2f)',
|
||||
534: 'linear-gradient(135deg, #2f6b46, #0d281a)',
|
||||
564: 'linear-gradient(135deg, #5a2f6b, #1c1028)',
|
||||
580: 'linear-gradient(135deg, #8a7a2f, #2a2410)',
|
||||
568: 'linear-gradient(135deg, #6b5a2a, #282008)',
|
||||
// WotLK
|
||||
249: 'linear-gradient(135deg, #7a2d1a, #3a1710)',
|
||||
533: 'linear-gradient(135deg, #2f6b46, #10281a)',
|
||||
616: 'linear-gradient(135deg, #2a5c8a, #10202f)',
|
||||
615: 'linear-gradient(135deg, #4a2f6b, #1a1028)',
|
||||
624: 'linear-gradient(135deg, #6b5a2f, #282010)',
|
||||
603: 'linear-gradient(135deg, #35566b, #101c28)',
|
||||
649: 'linear-gradient(135deg, #6b4a2f, #281a10)',
|
||||
631: 'linear-gradient(135deg, #3a6b8a, #0f2230)',
|
||||
724: 'linear-gradient(135deg, #7a1f2f, #2a0f14)',
|
||||
}
|
||||
|
||||
function barClass(killed: number, total: number) {
|
||||
if (total > 0 && killed >= total) return 'full'
|
||||
if (killed > 0) return 'partial'
|
||||
return 'empty'
|
||||
}
|
||||
|
||||
/** Gradiente determinista por mapId cuando no hay tema fijo (mazmorras). */
|
||||
function themeFor(map: number) {
|
||||
if (RAID_THEME[map]) return RAID_THEME[map]
|
||||
const h = (map * 47) % 360
|
||||
return `linear-gradient(135deg, hsl(${h} 42% 30%), hsl(${h} 48% 11%))`
|
||||
}
|
||||
|
||||
export function ArmoryRaids({
|
||||
locale,
|
||||
raids,
|
||||
labels,
|
||||
}: {
|
||||
locale: string
|
||||
raids: RaidProgressItem[]
|
||||
labels: { bosses: string; empty: string; classic: string; tbc: string; wotlk: string }
|
||||
}) {
|
||||
if (raids.length === 0) {
|
||||
return (
|
||||
<div className="armory-panel armory-raids">
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const groups: { exp: number; title: string; items: RaidProgressItem[] }[] = [
|
||||
{ exp: 2, title: labels.wotlk, items: raids.filter((r) => r.exp === 2) },
|
||||
{ exp: 1, title: labels.tbc, items: raids.filter((r) => r.exp === 1) },
|
||||
{ exp: 0, title: labels.classic, items: raids.filter((r) => r.exp === 0) },
|
||||
].filter((g) => g.items.length > 0)
|
||||
|
||||
return (
|
||||
<div className="armory-panel armory-raids">
|
||||
{groups.map((g) => (
|
||||
<section className="armory-raid-exp" key={g.exp}>
|
||||
<h3 className="armory-raid-exp-title">{g.title}</h3>
|
||||
<div className="armory-raid-grid">
|
||||
{g.items.map((r) => (
|
||||
<div className="armory-raid-card" key={r.map}>
|
||||
<div className="armory-raid-banner" style={{ background: themeFor(r.map) }}>
|
||||
<span className="armory-raid-name">{r.name}</span>
|
||||
<span className="armory-raid-count">
|
||||
{r.total} {labels.bosses}
|
||||
</span>
|
||||
</div>
|
||||
<div className="armory-raid-diffs">
|
||||
{r.difficulties.map((d) => (
|
||||
<div className="armory-raid-row" key={d.diff}>
|
||||
<span className="armory-raid-diff-lbl">{d.label}</span>
|
||||
<div className={`armory-raid-bar ${barClass(d.killed, d.total)}`}>
|
||||
<div
|
||||
className="armory-raid-bar-fill"
|
||||
style={{ width: `${d.total ? Math.round((d.killed / d.total) * 100) : 0}%` }}
|
||||
/>
|
||||
<span className="armory-raid-bar-txt">
|
||||
{d.killed}/{d.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="armory-raid-tip" role="tooltip">
|
||||
<div className="armory-raid-tip-head">
|
||||
{r.name} · {d.label}
|
||||
</div>
|
||||
<ul>
|
||||
{d.bosses.map((b, i) => (
|
||||
<li key={i} className={b.killed ? 'killed' : 'alive'}>
|
||||
<span className="armory-raid-tip-x">{b.killed ? '1' : '0'} ×</span> {b.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export type ReputationEntry = {
|
||||
faction: number
|
||||
name: string
|
||||
standing: number
|
||||
rank: string
|
||||
rankIndex: number
|
||||
color: string
|
||||
cur: number
|
||||
max: number
|
||||
}
|
||||
|
||||
/** Pestaña de Reputaciones: búsqueda en tiempo real + paginación + barra de rango/progreso. */
|
||||
export function ArmoryReputations({
|
||||
reputations,
|
||||
pageSize,
|
||||
labels,
|
||||
}: {
|
||||
reputations: ReputationEntry[]
|
||||
pageSize: number
|
||||
labels: { title: string; search: string; empty: string }
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const query = q.trim().toLowerCase()
|
||||
const filtered = query ? reputations.filter((r) => r.name.toLowerCase().includes(query)) : reputations
|
||||
const pages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
||||
const p = Math.min(page, pages)
|
||||
const pageItems = filtered.slice((p - 1) * pageSize, (p - 1) * pageSize + pageSize)
|
||||
|
||||
const win = 2
|
||||
const from = Math.max(1, p - win)
|
||||
const to = Math.min(pages, p + win)
|
||||
const nums: number[] = []
|
||||
for (let i = from; i <= to; i++) nums.push(i)
|
||||
|
||||
return (
|
||||
<div className="armory-panel armory-reps-panel">
|
||||
<div className="armory-quest-title">
|
||||
<i className="fas fa-handshake" /> {labels.title}{' '}
|
||||
<span className="armory-quest-title-count">({reputations.length})</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="armory-ach-search"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder={labels.search}
|
||||
/>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
<div className="armory-reps">
|
||||
{pageItems.map((r) => (
|
||||
<div className="armory-rep" key={r.faction}>
|
||||
<div className="armory-rep-head">
|
||||
<span className="armory-rep-name">{r.name}</span>
|
||||
<span className="armory-rep-standing">
|
||||
<span className="armory-rep-rank" style={{ color: r.color }}>
|
||||
{r.rank}
|
||||
</span>
|
||||
{r.rankIndex < 7 ? (
|
||||
<span className="armory-rep-nums">
|
||||
{' '}
|
||||
{r.cur.toLocaleString()} / {r.max.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="armory-rep-bar">
|
||||
<span style={{ width: `${Math.round((r.cur / Math.max(1, r.max)) * 100)}%`, background: r.color }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<div className="armory-pager">
|
||||
<button onClick={() => setPage(p - 1)} disabled={p <= 1} type="button" aria-label="Anterior">
|
||||
‹
|
||||
</button>
|
||||
{from > 1 && (
|
||||
<>
|
||||
<button onClick={() => setPage(1)} type="button">
|
||||
1
|
||||
</button>
|
||||
{from > 2 && <span className="armory-pager-dots">…</span>}
|
||||
</>
|
||||
)}
|
||||
{nums.map((n) => (
|
||||
<button key={n} className={n === p ? 'active' : ''} onClick={() => setPage(n)} type="button">
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
{to < pages && (
|
||||
<>
|
||||
{to < pages - 1 && <span className="armory-pager-dots">…</span>}
|
||||
<button onClick={() => setPage(pages)} type="button">
|
||||
{pages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => setPage(p + 1)} disabled={p >= pages} type="button" aria-label="Siguiente">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
|
||||
export type ArmoryTab = { key: string; label: string; icon?: string; count?: number; content: ReactNode }
|
||||
|
||||
/**
|
||||
* Pestañas de la ficha de armería (Equipación / Talentos / Logros / …). Cada pestaña
|
||||
* se renderiza en el servidor y se pasa como contenido; aquí solo se conmuta cuál se
|
||||
* muestra. Se mantienen todas montadas (hidden) para no reinicializar el visor 3D.
|
||||
*/
|
||||
export function ArmoryTabs({ tabs }: { tabs: ArmoryTab[] }) {
|
||||
const [active, setActive] = useState(tabs[0]?.key)
|
||||
|
||||
return (
|
||||
<div className="armory-ctabs">
|
||||
<div className="armory-ctabbar">
|
||||
{tabs.map((tb) => (
|
||||
<button
|
||||
key={tb.key}
|
||||
type="button"
|
||||
className={`armory-ctab${tb.key === active ? ' active' : ''}`}
|
||||
onClick={() => setActive(tb.key)}
|
||||
>
|
||||
{tb.icon ? <i className={tb.icon} /> : null}
|
||||
<span>{tb.label}</span>
|
||||
{typeof tb.count === 'number' ? <span className="armory-ctab-count">{tb.count}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tabs.map((tb) => (
|
||||
<div key={tb.key} className="armory-ctab-panel" hidden={tb.key !== active}>
|
||||
{tb.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { WowheadRefresh } from './WowheadRefresh'
|
||||
import { ArmoryTalents, type TalentGroupView } from './ArmoryTalents'
|
||||
|
||||
/**
|
||||
* Pestaña de talentos con conmutador Primaria/Secundaria (doble especialización).
|
||||
* Cada grupo trae su propio árbol y sus propios glifos, así que al conmutar cambian
|
||||
* ambos. Marca cuál es la spec activa del personaje.
|
||||
*/
|
||||
export function ArmoryTalentTab({
|
||||
groups,
|
||||
activeIndex,
|
||||
spellIcons,
|
||||
locale,
|
||||
labels,
|
||||
}: {
|
||||
groups: TalentGroupView[]
|
||||
activeIndex: number
|
||||
spellIcons: Record<number, string>
|
||||
locale: string
|
||||
labels: {
|
||||
distribution: string
|
||||
glyphs: string
|
||||
majorGlyphs: string
|
||||
minorGlyphs: string
|
||||
primary: string
|
||||
secondary: string
|
||||
activeSpec: string
|
||||
}
|
||||
}) {
|
||||
const [active, setActive] = useState(activeIndex)
|
||||
const groupLabel = (i: number) => (i === 0 ? labels.primary : labels.secondary)
|
||||
const g = groups[active] ?? groups[0]
|
||||
if (!g) return null
|
||||
|
||||
return (
|
||||
<div className="armory-panel armory-talents-panel">
|
||||
<WowheadRefresh dep={`talents-${active}`} />
|
||||
|
||||
{groups.length > 1 && (
|
||||
<div className="armory-spec-toggle">
|
||||
{groups.map((_, i) => (
|
||||
<button key={i} type="button" className={i === active ? 'active' : ''} onClick={() => setActive(i)}>
|
||||
{groupLabel(i)}
|
||||
{i === activeIndex ? <span className="armory-spec-toggle-dot"> • {labels.activeSpec}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ArmoryTalents
|
||||
group={g}
|
||||
spellIcons={spellIcons}
|
||||
locale={locale}
|
||||
labels={{
|
||||
distribution: labels.distribution,
|
||||
groupLabel: groupLabel(active),
|
||||
glyphs: labels.glyphs,
|
||||
majorGlyphs: labels.majorGlyphs,
|
||||
minorGlyphs: labels.minorGlyphs,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +1,43 @@
|
||||
import { wowheadUrl, wowheadData, wowheadIcon } from '@/lib/wowhead'
|
||||
import { wowheadIcon, wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
|
||||
export type TalentCell = { tier: number; col: number; rank: number; max: number; spell: number }
|
||||
export type TalentTreeData = { index: number; name: string; points: number; talents: TalentCell[] }
|
||||
|
||||
/**
|
||||
* Árbol de talentos estilo calculador: 3 árboles, cada uno una rejilla de casillas
|
||||
* posicionadas por fila (tier) y columna. Cada casilla muestra el icono del talento
|
||||
* (del hechizo de rango 1) y su rango actual/máximo; las no aprendidas van atenuadas.
|
||||
* El icono va en un <span> hijo porque wowhead sobrescribe el background del <a>.
|
||||
*/
|
||||
export function ArmoryTalents({
|
||||
trees,
|
||||
spec,
|
||||
total,
|
||||
maxPoints,
|
||||
locale,
|
||||
spellIcons,
|
||||
}: {
|
||||
trees: TalentTreeData[]
|
||||
export type GlyphInfo = { name: string; icon: string }
|
||||
export type TalentGroupView = {
|
||||
spec: string | null
|
||||
total: number
|
||||
maxPoints: number
|
||||
locale: string
|
||||
dist: number[]
|
||||
trees: TalentTreeData[]
|
||||
glyphs: { major: GlyphInfo[]; minor: GlyphInfo[] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderiza UN grupo de talentos: cabecera de distribución + los 3 árboles (rejilla de
|
||||
* iconos) + una 4ª columna con los Glifos (mayores/menores). Componente puro sin estado,
|
||||
* usado por ArmoryTalentTab (que conmuta Primaria/Secundaria).
|
||||
*/
|
||||
export function ArmoryTalents({
|
||||
group,
|
||||
spellIcons,
|
||||
locale,
|
||||
labels,
|
||||
}: {
|
||||
group: TalentGroupView
|
||||
spellIcons: Record<number, string>
|
||||
locale: string
|
||||
labels: { distribution: string; groupLabel: string; glyphs: string; majorGlyphs: string; minorGlyphs: string }
|
||||
}) {
|
||||
const { trees, glyphs, dist } = group
|
||||
return (
|
||||
<div className="armory-talents">
|
||||
<div className="armory-spec">
|
||||
<span className="armory-spec-name">{spec}</span>
|
||||
<span className="armory-spec-dist">{trees.map((tr) => tr.points).join(' / ')}</span>
|
||||
<span className="armory-spec-total">
|
||||
{total} / {maxPoints}
|
||||
</span>
|
||||
<div className="armory-tdist">
|
||||
<div className="armory-tdist-label">
|
||||
{labels.distribution} · {labels.groupLabel.toUpperCase()}
|
||||
</div>
|
||||
<div className="armory-tdist-value">{dist.join(' / ')}</div>
|
||||
</div>
|
||||
<div className="armory-talent-trees">
|
||||
|
||||
<div className="armory-talent-cols">
|
||||
{trees.map((tree) => {
|
||||
const maxTier = tree.talents.reduce((m, t) => Math.max(m, t.tier), 0)
|
||||
return (
|
||||
@@ -76,6 +80,42 @@ export function ArmoryTalents({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="armory-talent-tree armory-glyph-col">
|
||||
<div className="armory-talent-tree-head">
|
||||
<span>{labels.glyphs}</span>
|
||||
<span className="armory-talent-tree-pts">
|
||||
{glyphs.major.length} · {glyphs.minor.length}
|
||||
</span>
|
||||
</div>
|
||||
{glyphs.major.length > 0 && (
|
||||
<div className="armory-glyph-group">
|
||||
<h3 className="armory-prof-grouptitle">{labels.majorGlyphs}</h3>
|
||||
{glyphs.major.map((g, i) => (
|
||||
<div className="armory-glyph" key={i}>
|
||||
<span className="armory-glyph-icon" style={{ backgroundImage: `url(${wowheadIcon(g.icon, 'small')})` }} />
|
||||
<span className="armory-glyph-name">{g.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{glyphs.minor.length > 0 && (
|
||||
<div className="armory-glyph-group">
|
||||
<h3 className="armory-prof-grouptitle">{labels.minorGlyphs}</h3>
|
||||
{glyphs.minor.map((g, i) => (
|
||||
<div className="armory-glyph" key={i}>
|
||||
<span className="armory-glyph-icon" style={{ backgroundImage: `url(${wowheadIcon(g.icon, 'small')})` }} />
|
||||
<span className="armory-glyph-name">{g.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{glyphs.major.length === 0 && glyphs.minor.length === 0 && (
|
||||
<p className="armory-hint" style={{ padding: '8px 0' }}>
|
||||
—
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user