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
+278
View File
@@ -0,0 +1,278 @@
import { notFound } from 'next/navigation'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import {
getCharacter,
getCharacterEquipment,
getCharacterCustomizations,
getCharacterStats,
getCharacterProfessions,
getItemIcons,
getAchievementPoints,
getCharacterTalents,
getCharacterSpecs,
getSpellIcons,
getActivityPage,
} from '@/lib/armory'
import { raceName, className, classColor, factionOf, classPower } from '@/lib/wow-data'
import { PageShell } from '@/components/PageShell'
import { ArmoryPaperdoll, type SheetItem } from '@/components/ArmoryPaperdoll'
import { wowheadIcon } from '@/lib/wowhead'
import { ArmoryTalents } from '@/components/ArmoryTalents'
import { ArmoryActivity } from '@/components/ArmoryActivity'
import { WowheadRefresh } from '@/components/WowheadRefresh'
export const dynamic = 'force-dynamic'
export default async function CharacterPage({
params,
}: {
params: Promise<{ locale: string; guid: string }>
}) {
const { locale, guid } = await params
setRequestLocale(locale)
const t = await getTranslations('Armory')
const id = Number(guid)
const character = await getCharacter(id)
if (!character) notFound()
const [equipment, customizations, stats, professions, achievementPoints, talents, specs, activityData] = await Promise.all([
getCharacterEquipment(id, locale),
getCharacterCustomizations(id),
getCharacterStats(id),
getCharacterProfessions(id, locale),
getAchievementPoints(id),
getCharacterTalents(id, character.class, locale),
getCharacterSpecs(id, character.class, locale),
getActivityPage(id, locale, 1, 10, ''),
])
const icons = await getItemIcons(equipment.map((e) => e.entry))
const spellIcons = talents ? await getSpellIcons(talents.trees.flatMap((tr) => tr.talents.map((c) => c.spell))) : {}
const items: SheetItem[] = equipment.map((e) => ({
slot: e.slot,
entry: e.entry,
name: e.name,
quality: e.quality,
itemLevel: e.itemLevel,
inventoryType: e.inventoryType,
displayId: e.displayId,
transmogDisplayId: e.transmogDisplayId,
ench: e.ench,
gems: e.gems,
icon: icons[e.entry] ?? null,
}))
const gear = equipment.filter((e) => e.slot !== 3 && e.slot !== 18 && e.itemLevel > 0)
const avgItemLevel = gear.length ? Math.round(gear.reduce((s, e) => s + e.itemLevel, 0) / gear.length) : 0
const faction = factionOf(character.race)
const factionLabel = faction === 'alliance' ? t('alliance') : faction === 'horde' ? t('horde') : ''
const power = classPower(character.class, locale)
const statRows = stats
? [
{ label: t('strength'), value: `${stats.strength}` },
{ label: t('agility'), value: `${stats.agility}` },
{ label: t('stamina'), value: `${stats.stamina}` },
{ label: t('intellect'), value: `${stats.intellect}` },
{ label: t('armor'), value: `${stats.armor}` },
{ label: t('attackPower'), value: `${stats.attackPower}` },
{ label: t('rangedAttackPower'), value: `${stats.rangedAttackPower}` },
{ label: t('spellPower'), value: `${stats.spellPower}` },
{ label: t('critChance'), value: `${stats.critPct.toFixed(2)}%` },
{ label: t('spellCrit'), value: `${stats.spellCritPct.toFixed(2)}%` },
{ label: t('dodge'), value: `${stats.dodgePct.toFixed(2)}%` },
{ label: t('parry'), value: `${stats.parryPct.toFixed(2)}%` },
{ label: t('block'), value: `${stats.blockPct.toFixed(2)}%` },
{ label: t('resilience'), value: `${stats.resilience}` },
]
: []
return (
<PageShell title={character.name}>
<div className="main-wide">
<p className="forum-path">
<Link href="/armory">← {t('backToArmory')}</Link>
</p>
<div className="armory-sheet">
<div className="armory-sheet-head">
<h1 className="armory-char-name" style={{ color: classColor(character.class) }}>
{character.name}
{avgItemLevel > 0 ? (
<span className="armory-char-ilvl">
{' '}
({t('itemLevel')} {avgItemLevel})
</span>
) : null}
</h1>
<div className="armory-char-sub">
{t('level')} {character.level} · {raceName(character.race, locale)} ·{' '}
<span style={{ color: classColor(character.class) }}>{className(character.class, locale)}</span>
</div>
<div className="armory-char-tags">
{factionLabel && <span className={`armory-faction ${faction}`}>{factionLabel}</span>}
<span className={`armory-status ${character.online ? 'online' : 'offline'}`}>
{character.online ? t('online') : t('offline')}
</span>
{character.guildName && character.guildId && (
<Link href={`/armory/guild/${character.guildId}`} className="armory-guild-tag">
&lt;{character.guildName}&gt;
</Link>
)}
<span className="armory-achpoints" title={t('achievementPoints')}>
<i className="fas fa-star" /> {achievementPoints.toLocaleString(locale)}
</span>
</div>
</div>
<ArmoryPaperdoll
race={character.race}
gender={character.gender}
customizations={customizations}
items={items}
health={character.health}
power={character.power}
powerLabel={power.label}
powerColor={power.color}
locale={locale}
labels={{
loading: t('modelLoading'),
unavailable: t('modelUnavailable'),
health: t('health'),
itemLevel: t('itemLevel'),
model: t('model3d'),
list: t('equipment'),
}}
/>
{specs.primary && (
<div className="armory-panel armory-spec-panel">
<h2 className="armory-section-title">{t('specialization')}</h2>
<div className="armory-specs">
<div className="armory-spec-box active">
<span
className="armory-spec-box-icon"
style={{ backgroundImage: `url(${wowheadIcon(specs.primary.icon, 'medium')})` }}
/>
<div className="armory-spec-box-info">
<div className="armory-spec-box-name">{specs.primary.name}</div>
<div className="armory-spec-box-dist">{specs.primary.dist.join(' / ')}</div>
</div>
</div>
<div className={`armory-spec-box ${specs.secondary ? '' : 'na'}`}>
{specs.secondary ? (
<>
<span
className="armory-spec-box-icon"
style={{ backgroundImage: `url(${wowheadIcon(specs.secondary.icon, 'medium')})` }}
/>
<div className="armory-spec-box-info">
<div className="armory-spec-box-name">{specs.secondary.name}</div>
<div className="armory-spec-box-dist">{specs.secondary.dist.join(' / ')}</div>
</div>
</>
) : (
<span className="armory-spec-box-na">N/A</span>
)}
</div>
</div>
</div>
)}
{talents && talents.total > 0 && (
<div className="armory-panel armory-talents-panel">
<h2 className="armory-section-title">{t('talents')}</h2>
<WowheadRefresh dep={`talents-${id}`} />
<ArmoryTalents
trees={talents.trees}
spec={talents.spec}
total={talents.total}
maxPoints={Math.max(0, character.level - 9)}
locale={locale}
spellIcons={spellIcons}
/>
</div>
)}
{activityData.entries.length > 0 && (
<div className="armory-panel armory-activity-panel">
<h2 className="armory-section-title">{t('recentActivity')}</h2>
<ArmoryActivity
guid={id}
locale={locale}
initial={activityData.entries}
total={activityData.total}
pageSize={10}
labels={{ earned: t('earned'), search: t('achSearch'), empty: t('noAchievements') }}
/>
</div>
)}
<div className="armory-panels">
<div className="armory-panel">
<h2 className="armory-section-title">{t('stats')}</h2>
{stats ? (
<div className="armory-stats-grid">
{statRows.map((r) => (
<div className="armory-stat" key={r.label}>
<span className="armory-stat-label">{r.label}</span>
<span className="armory-stat-value">{r.value}</span>
</div>
))}
</div>
) : (
<p className="armory-hint armory-stats-pending">{t('statsPending')}</p>
)}
</div>
<div className="armory-panel">
<h2 className="armory-section-title">{t('professions')}</h2>
{professions.length ? (
<div className="armory-prof-groups">
{(['primary', 'secondary'] as const).map((kind) => {
const list = professions.filter((p) => p.kind === kind)
if (!list.length) return null
return (
<div className="armory-prof-group" key={kind}>
<h3 className="armory-prof-grouptitle">
{kind === 'primary' ? t('primaryProfs') : t('secondaryProfs')}
</h3>
{list.map((p) => (
<div className="armory-prof-row" key={p.skill}>
<span
className="armory-prof-icon"
style={{ backgroundImage: `url(${wowheadIcon(p.icon, 'small')})` }}
/>
<div className="armory-prof-body">
<div className="armory-prof-head">
<span className="armory-prof-name">
{p.name}
{p.rank ? <span className="armory-prof-rank"> · {p.rank}</span> : null}
</span>
<span className="armory-prof-value">
{p.value}
<span className="armory-prof-max"> / {p.max}</span>
</span>
</div>
<div className="armory-prof-bar">
<span style={{ width: `${Math.round((p.value / Math.max(1, p.max)) * 100)}%` }} />
</div>
</div>
</div>
))}
</div>
)
})}
</div>
) : (
<p className="armory-hint">{t('noProfessions')}</p>
)}
</div>
</div>
</div>
</div>
</PageShell>
)
}
@@ -9,26 +9,42 @@ import {
getCharacterProfessions,
getItemIcons,
getAchievementPoints,
getCharacterTalents,
getCharacterTalentGroups,
getCharacterSpecs,
getSpellIcons,
getActivityPage,
getAchievementOverview,
getRaidProgress,
getDungeonProgress,
getRealmName,
getQuestCount,
getQuestPage,
getQuestStatusCounts,
getCharacterCollection,
getReputations,
getPvpRank,
getCharacterArena,
getPvpCurrency,
getBattlegroundHistory,
} from '@/lib/armory'
import { raceName, className, classColor, factionOf, classPower } from '@/lib/wow-data'
import { raceName, className, classColor, factionOf, classPower, XP_FOR_LEVEL } from '@/lib/wow-data'
import { PageShell } from '@/components/PageShell'
import { ArmoryPaperdoll, type SheetItem } from '@/components/ArmoryPaperdoll'
import { wowheadIcon } from '@/lib/wowhead'
import { ArmoryTalents } from '@/components/ArmoryTalents'
import { ArmoryActivity } from '@/components/ArmoryActivity'
import { WowheadRefresh } from '@/components/WowheadRefresh'
import { ArmoryTalentTab } from '@/components/ArmoryTalentTab'
import { ArmoryAchievements } from '@/components/ArmoryAchievements'
import { ArmoryRaids } from '@/components/ArmoryRaids'
import { ArmoryQuests } from '@/components/ArmoryQuests'
import { ArmoryTabs, type ArmoryTab } from '@/components/ArmoryTabs'
import { ArmoryReputations } from '@/components/ArmoryReputations'
import { ArmoryCollection } from '@/components/ArmoryCollection'
import { ArmoryPvpRank } from '@/components/ArmoryPvpRank'
import { ArmoryBattlegrounds } from '@/components/ArmoryBattlegrounds'
export const dynamic = 'force-dynamic'
const MAX_LEVEL = 80
export default async function CharacterPage({
params,
}: {
params: Promise<{ locale: string; guid: string }>
}) {
export default async function CharacterPage({ params }: { params: Promise<{ locale: string; guid: string }> }) {
const { locale, guid } = await params
setRequestLocale(locale)
const t = await getTranslations('Armory')
@@ -37,18 +53,36 @@ export default async function CharacterPage({
const character = await getCharacter(id)
if (!character) notFound()
const [equipment, customizations, stats, professions, achievementPoints, talents, specs, activityData] = await Promise.all([
getCharacterEquipment(id, locale),
getCharacterCustomizations(id),
getCharacterStats(id),
getCharacterProfessions(id, locale),
getAchievementPoints(id),
getCharacterTalents(id, character.class, locale),
getCharacterSpecs(id, character.class, locale),
getActivityPage(id, locale, 1, 10, ''),
const [equipment, customizations, stats, professions, achievementPoints, talentGroups, specs, activityData, questCount] =
await Promise.all([
getCharacterEquipment(id, locale),
getCharacterCustomizations(id),
getCharacterStats(id),
getCharacterProfessions(id, locale),
getAchievementPoints(id),
getCharacterTalentGroups(id, character.class, locale),
getCharacterSpecs(id, character.class, locale),
getActivityPage(id, locale, 1, 10, '', ''),
getQuestCount(id),
])
const [questData, mounts, pets, questCounts, reputations, pvpRankData, arena, pvpCurrency, bgHistory, achOverview, raidProgress, dungeonProgress, realmName] = await Promise.all([
getQuestPage(id, locale, 1, 15, '', '', 'all'),
getCharacterCollection(id, locale, 'mounts'),
getCharacterCollection(id, locale, 'pets'),
getQuestStatusCounts(id),
getReputations(id, locale),
getPvpRank(1, 30, ''),
getCharacterArena(id),
getPvpCurrency(id),
getBattlegroundHistory(id),
getAchievementOverview(id, factionOf(character.race), locale),
getRaidProgress(id, locale),
getDungeonProgress(id, locale),
getRealmName(),
])
const qGrand = questCounts.rewarded + questCounts.complete + questCounts.inprogress + questCounts.failed
const icons = await getItemIcons(equipment.map((e) => e.entry))
const spellIcons = talents ? await getSpellIcons(talents.trees.flatMap((tr) => tr.talents.map((c) => c.spell))) : {}
const spellIcons = await getSpellIcons(talentGroups.spellIds)
const items: SheetItem[] = equipment.map((e) => ({
slot: e.slot,
@@ -70,6 +104,10 @@ export default async function CharacterPage({
const factionLabel = faction === 'alliance' ? t('alliance') : faction === 'horde' ? t('horde') : ''
const power = classPower(character.class, locale)
const xpNext = character.level < MAX_LEVEL ? XP_FOR_LEVEL[character.level] ?? 0 : 0
const xpPct =
character.level >= MAX_LEVEL ? 100 : xpNext > 0 ? Math.min(100, Math.round((character.xp / xpNext) * 100)) : 0
const statRows = stats
? [
{ label: t('strength'), value: `${stats.strength}` },
@@ -89,6 +127,431 @@ export default async function CharacterPage({
]
: []
// ---- Pestaña Equipación: modelo + spec + stats/profesiones/PvP ----
const equipTab = (
<>
<ArmoryPaperdoll
race={character.race}
gender={character.gender}
customizations={customizations}
items={items}
health={character.health}
power={character.power}
powerLabel={power.label}
powerColor={power.color}
locale={locale}
labels={{
loading: t('modelLoading'),
unavailable: t('modelUnavailable'),
health: t('health'),
itemLevel: t('itemLevel'),
model: t('model3d'),
list: t('equipment'),
}}
/>
{specs.primary && (
<div className="armory-panel armory-spec-panel">
<h2 className="armory-section-title">{t('specialization')}</h2>
<div className="armory-specs">
<div className="armory-spec-box active">
<span
className="armory-spec-box-icon"
style={{ backgroundImage: `url(${wowheadIcon(specs.primary.icon, 'medium')})` }}
/>
<div className="armory-spec-box-info">
<div className="armory-spec-box-name">{specs.primary.name}</div>
<div className="armory-spec-box-dist">{specs.primary.dist.join(' / ')}</div>
</div>
</div>
<div className={`armory-spec-box ${specs.secondary ? '' : 'na'}`}>
{specs.secondary ? (
<>
<span
className="armory-spec-box-icon"
style={{ backgroundImage: `url(${wowheadIcon(specs.secondary.icon, 'medium')})` }}
/>
<div className="armory-spec-box-info">
<div className="armory-spec-box-name">{specs.secondary.name}</div>
<div className="armory-spec-box-dist">{specs.secondary.dist.join(' / ')}</div>
</div>
</>
) : (
<span className="armory-spec-box-na">N/A</span>
)}
</div>
</div>
</div>
)}
<div className="armory-panels">
<div className="armory-panel">
<h2 className="armory-section-title">{t('stats')}</h2>
{stats ? (
<div className="armory-stats-grid">
{statRows.map((r) => (
<div className="armory-stat" key={r.label}>
<span className="armory-stat-label">{r.label}</span>
<span className="armory-stat-value">{r.value}</span>
</div>
))}
</div>
) : (
<p className="armory-hint armory-stats-pending">{t('statsPending')}</p>
)}
</div>
<div className="armory-panel">
<h2 className="armory-section-title">{t('professions')}</h2>
{professions.length ? (
<div className="armory-prof-groups">
{(['primary', 'secondary'] as const).map((kind) => {
const list = professions.filter((p) => p.kind === kind)
if (!list.length) return null
return (
<div className="armory-prof-group" key={kind}>
<h3 className="armory-prof-grouptitle">
{kind === 'primary' ? t('primaryProfs') : t('secondaryProfs')}
</h3>
{list.map((p) => (
<div className="armory-prof-row" key={p.skill}>
<span
className="armory-prof-icon"
style={{ backgroundImage: `url(${wowheadIcon(p.icon, 'small')})` }}
/>
<div className="armory-prof-body">
<div className="armory-prof-head">
<span className="armory-prof-name">
{p.name}
{p.rank ? <span className="armory-prof-rank"> · {p.rank}</span> : null}
</span>
<span className="armory-prof-value">
{p.value}
<span className="armory-prof-max"> / {p.max}</span>
</span>
</div>
<div className="armory-prof-bar">
<span style={{ width: `${Math.round((p.value / Math.max(1, p.max)) * 100)}%` }} />
</div>
</div>
</div>
))}
</div>
)
})}
</div>
) : (
<p className="armory-hint">{t('noProfessions')}</p>
)}
</div>
</div>
</>
)
// ---- Pestaña PvP (sub-pestañas: Estadísticas + Rank mundo) ----
const pvpStatsSub = (
<div className="armory-panel">
<div className="armory-pvp-tiles">
<div className="armory-pvp-tile">
<span className="armory-pvp-tile-num" style={{ color: '#ff5555' }}>
{character.totalKills.toLocaleString(locale)}
</span>
<span className="armory-pvp-tile-lbl">{t('totalKills')}</span>
</div>
<div className="armory-pvp-tile">
<span className="armory-pvp-tile-num" style={{ color: '#ff8888' }}>
{character.todayKills.toLocaleString(locale)}
</span>
<span className="armory-pvp-tile-lbl">{t('todayKills')}</span>
</div>
<div className="armory-pvp-tile">
<span className="armory-pvp-tile-num" style={{ color: '#e08a3a' }}>
{character.yesterdayKills.toLocaleString(locale)}
</span>
<span className="armory-pvp-tile-lbl">{t('yesterdayKills')}</span>
</div>
<div className="armory-pvp-tile">
<span className="armory-pvp-tile-num" style={{ color: '#ffd100' }}>
{pvpCurrency.honor.toLocaleString(locale)}
</span>
<span className="armory-pvp-tile-lbl">{t('honorPoints')}</span>
</div>
<div className="armory-pvp-tile">
<span className="armory-pvp-tile-num" style={{ color: '#9b59b6' }}>
{pvpCurrency.arena.toLocaleString(locale)}
</span>
<span className="armory-pvp-tile-lbl">{t('arenaPoints')}</span>
</div>
</div>
</div>
)
const arenaSub = (
<div className="armory-panel">
<div className="armory-arena">
{arena.map((b) => {
const losses = Math.max(0, b.seasonGames - b.seasonWins)
const winRate = b.seasonGames ? Math.round((b.seasonWins / b.seasonGames) * 100) : 0
return (
<div className="armory-arena-card" key={b.slot}>
<div className="armory-arena-bracket">{b.bracket}</div>
<span className={`armory-arena-mode ${b.seasonGames > 0 ? 'rated' : 'none'}`}>
{b.seasonGames > 0 ? t('arenaRated') : t('arenaUnrated')}
</span>
<div className="armory-arena-rating">{b.personalRating}</div>
<div className="armory-arena-rating-lbl">{t('arenaRating')}</div>
{b.seasonGames > 0 ? (
<div className="armory-arena-rows">
<div className="armory-arena-row">
<span>{t('arenaSeason')}</span>
<span>
<b className="armory-arena-w">{b.seasonWins}</b> / <b className="armory-arena-l">{losses}</b> ·{' '}
{winRate}%
</span>
</div>
<div className="armory-arena-row">
<span>{t('arenaWeek')}</span>
<span>
{b.weekWins} / {Math.max(0, b.weekGames - b.weekWins)}
</span>
</div>
<div className="armory-arena-row">
<span>MMR</span>
<span>{b.mmr}</span>
</div>
{b.rank > 0 ? (
<div className="armory-arena-row">
<span>{t('arenaRank')}</span>
<span>#{b.rank}</span>
</div>
) : null}
</div>
) : (
<div className="armory-arena-nogames">{t('arenaNoGames')}</div>
)}
</div>
)
})}
</div>
</div>
)
const pvpTab = (
<ArmoryTabs
tabs={[
{ key: 'pvpstats', label: t('pvpStats'), icon: 'fas fa-skull', content: pvpStatsSub },
{ key: 'arena', label: t('arena'), icon: 'fas fa-shield-halved', content: arenaSub },
{
key: 'battlegrounds',
label: t('battlegrounds'),
icon: 'fas fa-flag-checkered',
content: (
<ArmoryBattlegrounds
locale={locale}
rows={bgHistory.rows}
matches={bgHistory.matches}
wins={bgHistory.wins}
losses={bgHistory.losses}
winRate={bgHistory.winRate}
labels={{
history: t('bgHistory'),
desc: t('bgDesc'),
matches: t('bgMatches'),
wins: t('bgWins'),
losses: t('bgLosses'),
winRate: t('bgWinRate'),
result: t('bgResult'),
win: t('bgWin'),
loss: t('bgLoss'),
kb: t('bgKb'),
hk: t('bgHk'),
deaths: t('bgDeaths'),
damage: t('bgDamage'),
healing: t('bgHealing'),
honor: t('bgHonor'),
date: t('bgDate'),
none: t('bgNone'),
battleground: t('battlegrounds'),
}}
/>
),
},
{
key: 'pvprank',
label: t('pvpRank'),
icon: 'fas fa-trophy',
content: (
<ArmoryPvpRank
locale={locale}
initial={pvpRankData.entries}
total={pvpRankData.total}
pageSize={30}
labels={{
title: t('pvpRankTitle'),
desc: t('pvpRankDesc'),
search: t('searchPlayer'),
empty: t('noPlayers'),
character: t('rankPersonaje'),
kills: t('rankKills'),
playedTime: t('playedTime'),
}}
/>
),
},
]}
/>
)
const raidsTab = (
<ArmoryRaids
locale={locale}
raids={raidProgress}
labels={{
bosses: t('raidBosses'),
empty: t('raidNoData'),
classic: t('expClassic'),
tbc: t('expTbc'),
wotlk: t('expWotlk'),
}}
/>
)
const dungeonsTab = (
<ArmoryRaids
locale={locale}
raids={dungeonProgress}
labels={{
bosses: t('raidBosses'),
empty: t('raidNoData'),
classic: t('expClassic'),
tbc: t('expTbc'),
wotlk: t('expWotlk'),
}}
/>
)
// ---- Pestaña Talentos (toggle Primaria/Secundaria + árbol + glifos) ----
const talentsTab = talentGroups.groups.length ? (
<ArmoryTalentTab
groups={talentGroups.groups}
activeIndex={talentGroups.activeIndex}
spellIcons={spellIcons}
locale={locale}
labels={{
distribution: t('distribution'),
glyphs: t('glyphs'),
majorGlyphs: t('majorGlyphs'),
minorGlyphs: t('minorGlyphs'),
primary: t('primary'),
secondary: t('secondary'),
activeSpec: t('activeSpec'),
}}
/>
) : (
<div className="armory-panel">
<p className="armory-hint">{t('noTalents')}</p>
</div>
)
// ---- Pestaña Logros ----
const logrosTab = (
<ArmoryAchievements
locale={locale}
guid={id}
categories={achOverview.categories}
earnedPoints={achOverview.earnedPoints}
totalCompleted={achOverview.totalCompleted}
labels={{
summary: t('achSummaryLine'),
back: t('achBack'),
reward: t('achReward'),
empty: t('achEmptyCat'),
of: t('achOf'),
feats: t('achFeats'),
}}
/>
)
const questsTab = (
<div className="armory-panel armory-quests-panel">
<ArmoryQuests
guid={id}
locale={locale}
initial={questData.entries}
total={questData.total}
grandTotal={qGrand}
pageSize={15}
statuses={[
{ key: 'all', label: t('allStatuses'), count: qGrand },
{ key: 'inprogress', label: t('qsInProgress'), count: questCounts.inprogress },
{ key: 'complete', label: t('qsComplete'), count: questCounts.complete },
{ key: 'failed', label: t('qsFailed'), count: questCounts.failed },
{ key: 'rewarded', label: t('qsRewarded'), count: questCounts.rewarded },
]}
badgeLabels={{
rewarded: t('bRewarded'),
complete: t('bComplete'),
inprogress: t('bInProgress'),
failed: t('bFailed'),
}}
labels={{
filterId: t('filterId'),
filterName: t('filterName'),
empty: t('noQuests'),
quest: t('quest'),
statusCol: t('statusCol'),
title: t('quests'),
pageOf: '',
}}
/>
</div>
)
const mountsTab = (
<ArmoryCollection
items={mounts}
locale={locale}
pageSize={30}
labels={{ title: t('mountsCollected', { n: mounts.length }), search: t('collSearch'), empty: t('noMounts') }}
/>
)
const petsTab = (
<ArmoryCollection
items={pets}
locale={locale}
pageSize={30}
labels={{ title: t('petsCollected', { n: pets.length }), search: t('collSearch'), empty: t('noPets') }}
/>
)
const repsTab = (
<ArmoryReputations
reputations={reputations}
pageSize={30}
labels={{ title: t('reputations'), search: t('repSearch'), empty: t('noReps') }}
/>
)
const tabs: ArmoryTab[] = [
{ key: 'equip', label: t('tabEquip'), icon: 'fas fa-shield-alt', content: equipTab },
{
key: 'talents',
label: t('talents'),
icon: 'fas fa-tree',
count: talentGroups.groups[talentGroups.activeIndex]?.total,
content: talentsTab,
},
{ key: 'achievements', label: t('achievements'), icon: 'fas fa-trophy', count: activityData.total, content: logrosTab },
{ key: 'raids', label: t('raids'), icon: 'fas fa-dragon', content: raidsTab },
{ key: 'dungeons', label: t('dungeons'), icon: 'fas fa-dungeon', content: dungeonsTab },
{ key: 'quests', label: t('quests'), icon: 'fas fa-scroll', count: questCount, content: questsTab },
{ key: 'reputations', label: t('reputations'), icon: 'fas fa-handshake', count: reputations.length, content: repsTab },
{ key: 'pvp', label: t('pvp'), icon: 'fas fa-khanda', content: pvpTab },
{ key: 'mounts', label: t('mounts'), icon: 'fas fa-horse', count: mounts.length, content: mountsTab },
{ key: 'pets', label: t('pets'), icon: 'fas fa-paw', count: pets.length, content: petsTab },
]
return (
<PageShell title={character.name}>
<div className="main-wide">
@@ -121,134 +584,26 @@ export default async function CharacterPage({
&lt;{character.guildName}&gt;
</Link>
)}
{realmName && <span className="armory-realm">{realmName}</span>}
<span className="armory-achpoints" title={t('achievementPoints')}>
<i className="fas fa-star" /> {achievementPoints.toLocaleString(locale)}
</span>
</div>
</div>
<ArmoryPaperdoll
race={character.race}
gender={character.gender}
customizations={customizations}
items={items}
health={character.health}
power={character.power}
powerLabel={power.label}
powerColor={power.color}
locale={locale}
labels={{
loading: t('modelLoading'),
unavailable: t('modelUnavailable'),
health: t('health'),
itemLevel: t('itemLevel'),
model: t('model3d'),
list: t('equipment'),
}}
/>
{specs.primary && (
<div className="armory-panel armory-spec-panel">
<h2 className="armory-section-title">{t('specialization')}</h2>
<div className="armory-specs">
<div className="armory-spec-box active">
<span
className="armory-spec-box-icon"
style={{ backgroundImage: `url(${wowheadIcon(specs.primary.icon, 'medium')})` }}
/>
<div className="armory-spec-box-info">
<div className="armory-spec-box-name">{specs.primary.name}</div>
<div className="armory-spec-box-dist">{specs.primary.dist.join(' / ')}</div>
</div>
</div>
<div className={`armory-spec-box ${specs.secondary ? '' : 'na'}`}>
{specs.secondary ? (
<>
<span
className="armory-spec-box-icon"
style={{ backgroundImage: `url(${wowheadIcon(specs.secondary.icon, 'medium')})` }}
/>
<div className="armory-spec-box-info">
<div className="armory-spec-box-name">{specs.secondary.name}</div>
<div className="armory-spec-box-dist">{specs.secondary.dist.join(' / ')}</div>
</div>
</>
) : (
<span className="armory-spec-box-na">N/A</span>
)}
</div>
</div>
<div className="armory-xpbar">
<div className="armory-xpbar-head">
<span className="armory-xpbar-label">{t('level')}</span>
<span className="armory-xpbar-value">
{character.level >= MAX_LEVEL ? `${character.level} (MAX)` : character.level}
</span>
</div>
)}
{talents && talents.total > 0 && (
<div className="armory-panel armory-talents-panel">
<h2 className="armory-section-title">{t('talents')}</h2>
<WowheadRefresh dep={`talents-${id}`} />
<ArmoryTalents
trees={talents.trees}
spec={talents.spec}
total={talents.total}
maxPoints={Math.max(0, character.level - 9)}
locale={locale}
spellIcons={spellIcons}
/>
</div>
)}
{activityData.entries.length > 0 && (
<div className="armory-panel armory-activity-panel">
<h2 className="armory-section-title">{t('recentActivity')}</h2>
<ArmoryActivity
guid={id}
locale={locale}
initial={activityData.entries}
total={activityData.total}
pageSize={10}
labels={{ earned: t('earned'), search: t('achSearch'), empty: t('noAchievements') }}
/>
</div>
)}
<div className="armory-panels">
<div className="armory-panel">
<h2 className="armory-section-title">{t('stats')}</h2>
{stats ? (
<div className="armory-stats-grid">
{statRows.map((r) => (
<div className="armory-stat" key={r.label}>
<span className="armory-stat-label">{r.label}</span>
<span className="armory-stat-value">{r.value}</span>
</div>
))}
</div>
) : (
<p className="armory-hint armory-stats-pending">{t('statsPending')}</p>
)}
</div>
<div className="armory-panel">
<h2 className="armory-section-title">{t('professions')}</h2>
{professions.length ? (
<div className="armory-prof-list">
{professions.map((p) => (
<div className="armory-prof" key={p.skill}>
<span className="armory-prof-nameblock">
<span className="armory-prof-name">{p.name}</span>
{p.rank ? <span className="armory-prof-rank">{p.rank}</span> : null}
</span>
<span className="armory-prof-value">
{p.value}
<span className="armory-prof-max"> / {p.max}</span>
</span>
</div>
))}
</div>
) : (
<p className="armory-hint">{t('noProfessions')}</p>
)}
<div className="armory-xpbar-track">
<span style={{ width: `${xpPct}%` }} />
</div>
</div>
<ArmoryTabs tabs={tabs} />
</div>
</div>
</PageShell>
@@ -1,54 +1,248 @@
import { notFound } from 'next/navigation'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import { getGuild } from '@/lib/armory'
import { raceName, className, classColor } from '@/lib/wow-data'
import {
getGuildInfo,
getGuildRoster,
getGuildRecentAchievements,
getRealmName,
getGuildAchievementOverview,
getGuildRoleBreakdown,
} from '@/lib/armory'
import { getSession } from '@/lib/session'
import { isGuildMemberByAccount } from '@/lib/armory'
import { className, classColor } from '@/lib/wow-data'
import { wowheadIcon } from '@/lib/wowhead'
import { PageShell } from '@/components/PageShell'
import { ArmoryGuildRoster } from '@/components/ArmoryGuildRoster'
import { ArmoryAchievements } from '@/components/ArmoryAchievements'
export const dynamic = 'force-dynamic'
const PAGE_SIZE = 25
export default async function GuildPage({
params,
searchParams,
}: {
params: Promise<{ locale: string; guid: string }>
searchParams: Promise<{ tab?: string }>
}) {
const { locale, guid } = await params
const { tab } = await searchParams
setRequestLocale(locale)
const t = await getTranslations('Armory')
const guild = await getGuild(Number(guid))
if (!guild) notFound()
const guildId = Number(guid)
const info = await getGuildInfo(guildId)
if (!info) notFound()
const isLogros = tab === 'logros'
const emblemHue = (info.emblem.bg * 7) % 360
return (
<PageShell title={guild.name}>
<div className="main-wide">
<PageShell title={info.name}>
<div className="main-wide armory-guild-page">
<p className="forum-path">
<Link href="/armory"> {t('backToArmory')}</Link>
</p>
<div className="armory-char-sub">
{guild.members.length} {t('members')}
{guild.leaderName && (
<>
{' · '}
{t('leader')}: {guild.leaderName}
</>
)}
{/* Cabecera de hermandad */}
<div className="armory-guild-head">
<div className="armory-guild-emblem" style={{ background: `hsl(${emblemHue} 45% 22%)` }}>
<i className="fas fa-dragon" style={{ color: `hsl(${(emblemHue + 40) % 360} 70% 65%)` }} />
</div>
<div className="armory-guild-headinfo">
<h1 className="armory-guild-title">{info.name}</h1>
<div className="armory-guild-realm">{/* reino */}</div>
<div className="armory-guild-stats">
<span className="armory-guild-stat">
<i className="fas fa-shield" /> {info.guildPoints.toLocaleString(locale)}
</span>
<span className="armory-guild-stat">
<i className="fas fa-users" /> {info.memberCount} {t('members')}
</span>
{info.createdYear > 0 && (
<span className="armory-guild-stat muted">
{t('guildFounded')} {info.createdYear}
</span>
)}
</div>
</div>
</div>
<div className="armory-list" style={{ marginTop: 16 }}>
{guild.members.map((m) => (
<Link key={m.guid} href={`/armory/character/${m.guid}`} className="armory-row">
<span className="armory-name" style={{ color: classColor(m.class) }}>
{m.name}
</span>
<span className="armory-meta">
{t('level')} {m.level} · {raceName(m.race, locale)} · {className(m.class, locale)}
</span>
</Link>
))}
<div className="armory-guild-tabbar">
<Link href={`/armory/guild/${guildId}`} className={`armory-guild-tab${!isLogros ? ' active' : ''}`}>
{t('guild')}
</Link>
<Link href={`/armory/guild/${guildId}?tab=logros`} className={`armory-guild-tab${isLogros ? ' active' : ''}`}>
{t('guildAchievements')}
</Link>
</div>
{isLogros ? (
<GuildLogros guildId={guildId} locale={locale} t={t} />
) : (
<GuildRoster guildId={guildId} locale={locale} info={info} t={t} />
)}
</div>
</PageShell>
)
}
/* ---- Vista Hermandad (roster + barra lateral) ---- */
async function GuildRoster({
guildId,
locale,
info,
t,
}: {
guildId: number
locale: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
info: any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
t: any
}) {
const session = await getSession()
const accountId = session.accountId ?? 0
const [roster, recent, realm, roleBreak, isMember] = await Promise.all([
getGuildRoster(guildId, { page: 1, pageSize: PAGE_SIZE, classes: [], roles: [], sort: 'rank' }),
getGuildRecentAchievements(guildId, locale, 5),
getRealmName(),
getGuildRoleBreakdown(guildId),
isGuildMemberByAccount(accountId, guildId),
])
const motdState: 'guest' | 'member' | 'nonmember' = !accountId ? 'guest' : isMember ? 'member' : 'nonmember'
const roleRows: { key: 'tank' | 'healer' | 'dps'; label: string; color: string; count: number }[] = [
{ key: 'tank', label: t('roleTank'), color: '#4a90d9', count: roleBreak.tank },
{ key: 'healer', label: t('roleHealer'), color: '#4ade5f', count: roleBreak.healer },
{ key: 'dps', label: t('roleDps'), color: '#d9534f', count: roleBreak.dps },
]
const maxRole = Math.max(1, roleBreak.tank, roleBreak.healer, roleBreak.dps)
const maxCount = Math.max(1, ...info.classBreakdown.map((c: { count: number }) => c.count))
const fmtDate = (unix: number) => {
if (!unix) return ''
const d = new Date(unix * 1000)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
}
return (
<div className="armory-guild-layout">
<div className="armory-guild-main">
<h2 className="armory-guild-section">
{t('guildMembers')} <span className="armory-guild-realm-inline">· {realm}</span>
</h2>
<ArmoryGuildRoster
guildid={guildId}
locale={locale}
initial={roster.members}
total={roster.total}
pageSize={PAGE_SIZE}
classes={info.classBreakdown.map((c: { class: number }) => c.class)}
labels={{
view: t('guildView'), sortRank: t('guildSortRank'), sortPoints: t('guildSortPoints'),
sortLevel: t('guildSortLevel'), sortName: t('guildSortName'), filterClass: t('guildFilterClass'),
allClasses: t('guildAllClasses'), filterRole: t('guildFilterRole'), allRoles: t('guildAllRoles'),
roleTank: t('roleTank'), roleHealer: t('roleHealer'), roleDps: t('roleDps'),
sortIlvl: t('guildSortIlvl'), ilvl: t('colIlvl'),
name: t('colName'), race: t('colRace'), class: t('colClass'), role: t('colFunction'),
level: t('colLevel'), rank: t('colRank'), points: t('guildSortPoints'),
}}
/>
</div>
<aside className="armory-guild-side">
<div className="armory-guild-card">
<h3 className="armory-guild-card-title">{t('guildMotd')}</h3>
{motdState === 'member' ? (
<div className="armory-guild-motd">{info.motd?.trim() ? info.motd : t('guildMotdEmpty')}</div>
) : motdState === 'guest' ? (
<div className="armory-guild-motd-locked">
<i className="fas fa-lock" />{' '}
{t('guildMotdGuest')} <Link href="/my-account" className="armory-guild-login">{t('login')}</Link>
</div>
) : (
<div className="armory-guild-motd-locked">
<i className="fas fa-lock" /> {t('guildMotdLocked')}
</div>
)}
</div>
{recent.length > 0 && (
<div className="armory-guild-card">
<h3 className="armory-guild-card-title">{t('guildRecent')}</h3>
<ul className="armory-guild-recent">
{recent.map((a) => (
<li key={a.id} className="armory-guild-recent-item">
<img src={wowheadIcon(a.icon, 'medium')} alt="" className="armory-guild-recent-ico" loading="lazy" />
<span className="armory-guild-recent-name">{a.name}</span>
<span className="armory-guild-recent-date">{fmtDate(a.date)}</span>
</li>
))}
</ul>
</div>
)}
{info.classBreakdown.length > 0 && (
<div className="armory-guild-card">
<h3 className="armory-guild-card-title">{t('guildOverview')}</h3>
<div className="armory-guild-chart-lbl">{t('guildClassBreak')}</div>
<div className="armory-guild-chart">
{info.classBreakdown.map((c: { class: number; count: number }) => (
<div className="armory-guild-bar-col" key={c.class} title={`${className(c.class, locale)}: ${c.count}`}>
<div className="armory-guild-bar-wrap">
<div
className="armory-guild-bar"
style={{ height: `${Math.round((c.count / maxCount) * 100)}%`, background: classColor(c.class) }}
/>
</div>
</div>
))}
</div>
<div className="armory-guild-chart-lbl" style={{ marginTop: 14 }}>{t('guildRoleBreak')}</div>
<div className="armory-guild-roles">
{roleRows.map((r) => (
<div className="armory-guild-role-row" key={r.key}>
<span className="armory-guild-role-lbl" style={{ color: r.color }}>{r.label}</span>
<div className="armory-guild-role-track">
<div className="armory-guild-role-fill" style={{ width: `${Math.round((r.count / maxRole) * 100)}%`, background: r.color }} />
</div>
<span className="armory-guild-role-n">{r.count}</span>
</div>
))}
</div>
</div>
)}
</aside>
</div>
)
}
/* ---- Vista Logros de hermandad (anillos por categoría) ---- */
async function GuildLogros({
guildId,
locale,
t,
}: {
guildId: number
locale: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
t: any
}) {
const overview = await getGuildAchievementOverview(guildId, null, locale)
return (
<ArmoryAchievements
locale={locale}
guid={guildId}
categories={overview.categories}
earnedPoints={overview.earnedPoints}
totalCompleted={overview.totalCompleted}
apiPath="/api/armory/guild-achievements"
idKey="guild"
labels={{
summary: t('achSummaryLine'), back: t('achBack'), reward: t('achReward'),
empty: t('achEmptyCat'), of: t('achOf'), feats: t('achFeats'),
}}
/>
)
}
@@ -0,0 +1,19 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getAchievementCategory } from '@/lib/armory'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const PAGE_SIZE = 20
/** Logros conseguidos de un personaje dentro de una categoría (paginado). */
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams
const guid = Number(sp.get('guid')) || 0
const cat = Number(sp.get('cat')) || 0
const page = Math.max(1, Number(sp.get('page')) || 1)
const locale = sp.get('locale') === 'en' ? 'en' : 'es'
if (!guid || !cat) return NextResponse.json({ entries: [], total: 0, pageSize: PAGE_SIZE })
const { entries, total } = await getAchievementCategory(guid, cat, locale, page, PAGE_SIZE)
return NextResponse.json({ entries, total, pageSize: PAGE_SIZE })
}
+4 -3
View File
@@ -6,14 +6,15 @@ export const dynamic = 'force-dynamic'
const PAGE_SIZE = 10
/** Actividad reciente paginada + búsqueda (por nombre o ID) para la armería. */
/** Logros paginados + filtros (por ID y por nombre) para la armería. */
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams
const guid = Number(sp.get('guid'))
const locale = sp.get('locale') === 'en' ? 'en' : 'es'
const page = Math.max(1, Number(sp.get('page')) || 1)
const q = sp.get('q') || ''
const filterId = sp.get('id') || ''
const filterName = sp.get('name') || ''
if (!guid) return NextResponse.json({ entries: [], total: 0, pageSize: PAGE_SIZE })
const { entries, total } = await getActivityPage(guid, locale, page, PAGE_SIZE, q)
const { entries, total } = await getActivityPage(guid, locale, page, PAGE_SIZE, filterId, filterName)
return NextResponse.json({ entries, total, pageSize: PAGE_SIZE })
}
@@ -0,0 +1,19 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getGuildAchievementCategory } from '@/lib/armory'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const PAGE_SIZE = 20
/** Logros de una categoría conseguidos por la hermandad (paginado). */
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams
const guild = Number(sp.get('guild')) || 0
const cat = Number(sp.get('cat')) || 0
const page = Math.max(1, Number(sp.get('page')) || 1)
const locale = sp.get('locale') === 'en' ? 'en' : 'es'
if (!guild || !cat) return NextResponse.json({ entries: [], total: 0, pageSize: PAGE_SIZE })
const { entries, total } = await getGuildAchievementCategory(guild, cat, locale, page, PAGE_SIZE)
return NextResponse.json({ entries, total, pageSize: PAGE_SIZE })
}
@@ -0,0 +1,25 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getGuildRoster } from '@/lib/armory'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/** Roster de hermandad paginado con filtro por clase y orden. */
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams
const guildid = Number(sp.get('guild')) || 0
const page = Math.max(1, Number(sp.get('page')) || 1)
const pageSize = Math.min(50, Math.max(10, Number(sp.get('pageSize')) || 25))
const sort = sp.get('sort') || 'rank'
const classes = (sp.get('classes') || '')
.split(',')
.map((n) => Number(n))
.filter((n) => Number.isInteger(n) && n > 0)
const roles = (sp.get('roles') || '')
.split(',')
.map((s) => s.trim())
.filter((s) => s === 'tank' || s === 'healer' || s === 'dps')
if (!guildid) return NextResponse.json({ members: [], total: 0, pageSize })
const { members, total } = await getGuildRoster(guildid, { page, pageSize, classes, roles, sort })
return NextResponse.json({ members, total, pageSize })
}
+16
View File
@@ -0,0 +1,16 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getPvpRank } from '@/lib/armory'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const PAGE_SIZE = 30
/** Ranking mundial de bajas (Total Kills) con búsqueda + paginación. */
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams
const page = Math.max(1, Number(sp.get('page')) || 1)
const q = sp.get('q') || ''
const { entries, total } = await getPvpRank(page, PAGE_SIZE, q)
return NextResponse.json({ entries, total, pageSize: PAGE_SIZE })
}
+21
View File
@@ -0,0 +1,21 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getQuestPage } from '@/lib/armory'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const PAGE_SIZE = 15
/** Misiones paginadas + filtros (ID, nombre, estado). */
export async function GET(req: NextRequest) {
const sp = req.nextUrl.searchParams
const guid = Number(sp.get('guid'))
const locale = sp.get('locale') === 'en' ? 'en' : 'es'
const page = Math.max(1, Number(sp.get('page')) || 1)
const filterId = sp.get('id') || ''
const filterName = sp.get('name') || ''
const status = sp.get('status') || 'all'
if (!guid) return NextResponse.json({ entries: [], total: 0, pageSize: PAGE_SIZE })
const { entries, total } = await getQuestPage(guid, locale, page, PAGE_SIZE, filterId, filterName, status)
return NextResponse.json({ entries, total, pageSize: PAGE_SIZE })
}
+522
View File
@@ -327,3 +327,525 @@ a.armory-activity-link { background-image: none !important; }
.armory-pager button.active { background: #d79602; color: #14110d; border-color: #d79602; }
.armory-pager button:disabled { opacity: .4; cursor: default; }
.armory-pager-dots { color: #6d6a5e; padding: 6px 2px; align-self: center; }
/* ===== Profesiones con barras (Primarias / Secundarias) ===== */
.armory-prof-groups { display: flex; flex-direction: column; gap: 16px; }
.armory-prof-grouptitle { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: #6d6a5e; margin: 0 0 10px; font-weight: bold; }
.armory-prof-row { display: flex; align-items: center; gap: 11px; margin-bottom: 11px; }
.armory-prof-row:last-child { margin-bottom: 0; }
.armory-prof-icon { width: 36px; height: 36px; flex: 0 0 36px; border-radius: 4px; background: #0d0b08 center/cover no-repeat; box-shadow: inset 0 0 0 1px rgba(255,255,255,.09); }
.armory-prof-body { flex: 1; min-width: 0; }
.armory-prof-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 5px; }
.armory-prof-name { font-size: 13px; font-weight: bold; color: #d4cdbb; }
.armory-prof-rank { font-size: 11px; color: #6d6a5e; font-weight: normal; }
.armory-prof-value { font-size: 13px; font-weight: bold; color: #d79602; white-space: nowrap; }
.armory-prof-max { color: #6d6a5e; font-weight: normal; }
.armory-prof-bar { height: 7px; background: rgba(255,255,255,.06); border-radius: 4px; overflow: hidden; }
.armory-prof-bar > span { display: block; height: 100%; background: linear-gradient(90deg, #a8791a, #e0b030); border-radius: 4px; }
/* ===== Barra de XP/Nivel ===== */
.armory-xpbar { margin: 8px 0 18px; }
.armory-xpbar-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
.armory-xpbar-label { font-size: 13px; color: #8a8578; }
.armory-xpbar-value { font-size: 18px; font-weight: bold; color: #d79602; }
.armory-xpbar-track { height: 10px; background: rgba(255,255,255,.05); border-radius: 5px; overflow: hidden; box-shadow: inset 0 0 0 1px rgba(255,255,255,.04); }
.armory-xpbar-track > span { display: block; height: 100%; border-radius: 5px; background: linear-gradient(90deg, #7a5cff, #b452d6 45%, #e0813a 80%, #f0b429); }
/* ===== Pestañas de la ficha ===== */
.armory-ctabbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
.armory-ctab {
display: inline-flex; align-items: center; gap: 8px; padding: 11px 16px; border-radius: 6px; cursor: pointer;
background: rgba(0,0,0,.25); border: 1px solid #2a2723; color: #d4cdbb; font-weight: bold; font-size: 14px; transition: all .12s;
}
.armory-ctab i { color: #8a8578; }
.armory-ctab:hover { border-color: rgba(215,150,2,.4); }
.armory-ctab.active { background: #d79602; color: #14110d; border-color: #d79602; }
.armory-ctab.active i { color: #14110d; }
.armory-ctab-count { font-size: 12px; padding: 1px 8px; border-radius: 10px; background: rgba(0,0,0,.3); color: #d79602; }
.armory-ctab.active .armory-ctab-count { background: rgba(0,0,0,.25); color: #14110d; }
.armory-ctab-panel { display: flex; flex-direction: column; gap: 16px; }
/* ===== PvP ===== */
.armory-pvp { display: flex; flex-direction: column; gap: 8px; }
.armory-pvp-stat { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,.03); }
.armory-pvp-label { font-size: 12px; color: #8a8578; }
.armory-pvp-value { font-size: 13px; font-weight: bold; color: #d4cdbb; }
/* ===== Glifos ===== */
.armory-glyphs { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; }
.armory-glyph { display: flex; align-items: center; gap: 10px; margin-bottom: 9px; }
.armory-glyph:last-child { margin-bottom: 0; }
.armory-glyph-icon { width: 30px; height: 30px; flex: 0 0 30px; border-radius: 4px; background: #0d0b08 center/cover no-repeat; box-shadow: inset 0 0 0 1px rgba(163,53,238,.5); }
.armory-glyph-name { font-size: 13px; color: #d4cdbb; }
/* ===== Pestaña Misiones ===== */
.armory-quest-filters { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; margin-bottom: 10px; }
.armory-quest-status { padding: 9px 12px; border-radius: 4px; background: #14110d; border: 1px solid #2a2723; color: #d4cdbb; font-size: 13px; cursor: pointer; }
.armory-quest-status:focus { outline: none; border-color: #d79602; }
.armory-quest-count { font-size: 12px; color: #8a8578; margin: 0 0 12px; }
.armory-quest-table { width: 100%; border-collapse: collapse; }
.armory-quest-table.loading { opacity: .5; }
.armory-quest-table th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: #6d6a5e; padding: 8px 6px; border-bottom: 1px solid #2a2723; }
.armory-quest-th-status, .armory-quest-td-status { text-align: right; white-space: nowrap; }
.armory-quest-table td { padding: 9px 6px; border-bottom: 1px solid rgba(255,255,255,.03); font-size: 13px; }
.armory-quest-link { color: #ffd100; text-decoration: none; font-weight: bold; }
.armory-quest-link:hover { text-decoration: underline; }
a.armory-quest-link { background-image: none !important; }
.armory-quest-badge { font-size: 11px; font-weight: bold; padding: 2px 9px; border-radius: 10px; }
.armory-quest-badge.completed { color: #1eff00; background: rgba(30,255,0,.1); }
.armory-quest-badge.inprogress { color: #d79602; background: rgba(215,150,2,.1); }
@media (max-width: 600px) { .armory-quest-filters { grid-template-columns: 1fr; } }
/* ===== Colecciones (Monturas / Mascotas) ===== */
.armory-collection-head { font-size: 14px; color: #d4cdbb; padding: 11px 14px; margin-bottom: 14px; background: rgba(0,0,0,.2); border-radius: 4px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.03); }
.armory-collection { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px; }
.armory-coll-card { display: flex; align-items: center; gap: 11px; padding: 9px 12px; border-radius: 4px; text-decoration: none; background: rgba(0,0,0,.2); box-shadow: inset 0 0 0 1px rgba(255,255,255,.04); transition: background .12s; }
.armory-coll-card:hover { background: rgba(255,255,255,.05); }
.armory-coll-icon { width: 36px; height: 36px; flex: 0 0 36px; border-radius: 4px; background: #0d0b08 center/cover no-repeat; box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); }
.armory-coll-name { font-size: 13px; font-weight: bold; color: #d4cdbb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
a.armory-coll-card { background-image: none !important; }
/* ===== Toggle Primaria/Secundaria + distribución + 4 columnas (árboles + glifos) ===== */
.armory-spec-toggle { display: flex; gap: 8px; justify-content: center; margin-bottom: 16px; }
.armory-spec-toggle button { padding: 9px 20px; border-radius: 6px; background: rgba(0,0,0,.25); border: 1px solid #2a2723; color: #d4cdbb; font-weight: bold; font-size: 14px; cursor: pointer; transition: all .12s; }
.armory-spec-toggle button:hover { border-color: rgba(215,150,2,.4); }
.armory-spec-toggle button.active { background: #d79602; color: #14110d; border-color: #d79602; }
.armory-spec-toggle-dot { font-weight: normal; font-size: 12px; opacity: .85; }
.armory-tdist { text-align: center; padding: 16px; margin-bottom: 16px; background: rgba(0,0,0,.2); border-radius: 6px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.03); }
.armory-tdist-label { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #8a8578; margin-bottom: 6px; }
.armory-tdist-value { font-size: 26px; font-weight: bold; color: #d79602; letter-spacing: .05em; }
.armory-talent-cols { display: grid; grid-template-columns: repeat(3, auto) minmax(200px, 1fr); gap: 14px; justify-content: center; align-items: start; }
.armory-glyph-col .armory-glyph-group { margin-bottom: 14px; }
@media (max-width: 980px) { .armory-talent-cols { grid-template-columns: 1fr; } }
/* ===== Misiones: título, desplegable de estado, filas con ID ===== */
.armory-quest-title { font-size: 20px; font-weight: bold; color: #d4cdbb; margin-bottom: 14px; display: flex; align-items: center; gap: 10px; }
.armory-quest-title i { color: #d79602; }
.armory-quest-title-count { font-size: 15px; font-weight: normal; color: #8a8578; }
.armory-quest-id { font-size: 11px; color: #6d6a5e; font-weight: bold; }
a.armory-quest-link { font-weight: bold; }
/* Desplegable custom de estado */
.armory-dropdown { position: relative; }
.armory-dropdown-btn { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 12px; border-radius: 4px; background: #14110d; border: 1px solid #2a2723; color: #d4cdbb; font-size: 13px; cursor: pointer; }
.armory-dropdown-btn:hover { border-color: #d79602; }
.armory-dropdown-caret { margin-left: auto; color: #6d6a5e; }
.armory-dropdown-list { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 60; background: #14110d; border: 1px solid #2a2723; border-radius: 4px; box-shadow: 0 8px 24px rgba(0,0,0,.6); overflow: hidden; }
.armory-dropdown-item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 9px 12px; background: none; border: 0; color: #d4cdbb; font-size: 13px; cursor: pointer; text-align: left; }
.armory-dropdown-item:hover { background: rgba(255,255,255,.06); }
.armory-dropdown-item.active { background: rgba(215,150,2,.12); }
.armory-dropdown-noicon { width: 14px; display: inline-block; }
/* Logros: enlace amarillo + fecha */
.armory-ach-link { color: #ffd100 !important; }
.armory-ach-date { color: #d4cdbb; font-variant-numeric: tabular-nums; }
.armory-ach-filters { grid-template-columns: 1fr 1fr; }
/* ===== Reputaciones ===== */
.armory-reps { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px 24px; margin-top: 14px; }
.armory-rep-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 5px; }
.armory-rep-name { font-size: 13px; font-weight: bold; color: #d4cdbb; }
.armory-rep-rank { font-size: 12px; font-weight: bold; }
.armory-rep-nums { font-size: 11px; color: #6d6a5e; }
.armory-rep-bar { height: 8px; background: rgba(255,255,255,.06); border-radius: 4px; overflow: hidden; }
.armory-rep-bar > span { display: block; height: 100%; border-radius: 4px; }
/* ===== PvP: tiles de estadísticas ===== */
.armory-pvp-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; }
.armory-pvp-tile { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 22px 14px; border-radius: 8px; background: rgba(0,0,0,.22); box-shadow: inset 0 0 0 1px rgba(255,255,255,.04); }
.armory-pvp-tile-num { font-size: 30px; font-weight: bold; line-height: 1; }
.armory-pvp-tile-lbl { font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: #8a8578; }
/* ===== PvP: ranking mundial ===== */
.armory-pvprank-header { text-align: center; margin-bottom: 16px; }
.armory-pvprank-header .armory-quest-title { justify-content: center; font-size: 24px; }
.armory-pvprank-desc { color: #8a8578; font-size: 14px; margin: 4px 0 0; }
.armory-rank-table th { color: #d79602; }
.armory-rank-th-pos { width: 60px; }
.armory-rank-th-kills, .armory-rank-th-time { text-align: right; }
.armory-rank-pos { font-weight: bold; color: #d4cdbb; white-space: nowrap; }
.armory-rank-medal { font-size: 15px; }
.armory-rank-name { color: #ffd100; text-decoration: none; font-weight: bold; }
.armory-rank-name:hover { text-decoration: underline; }
.armory-rank-kills { text-align: right; color: #ff5555; font-weight: bold; font-variant-numeric: tabular-nums; }
.armory-rank-time { text-align: right; color: #8a8578; white-space: nowrap; font-variant-numeric: tabular-nums; }
/* Fix alineación de la tabla de ranking (cabeceras derecha + anchos de columna) */
.armory-rank-table th.armory-rank-th-kills, .armory-rank-table td.armory-rank-kills,
.armory-rank-table th.armory-rank-th-time, .armory-rank-table td.armory-rank-time { text-align: right; }
.armory-rank-th-pos, .armory-rank-pos { width: 70px; }
.armory-rank-th-kills, .armory-rank-kills { width: 120px; }
.armory-rank-th-time, .armory-rank-time { width: 150px; }
/* ===== Arena (brackets 2v2/3v3/5v5) ===== */
.armory-arena { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
.armory-arena-card { text-align: center; padding: 20px 16px; border-radius: 8px; background: rgba(0,0,0,.22); box-shadow: inset 0 0 0 1px rgba(255,255,255,.04); }
.armory-arena-bracket { font-size: 15px; font-weight: bold; color: #d79602; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 10px; }
.armory-arena-rating { font-size: 34px; font-weight: bold; color: #d4cdbb; line-height: 1; }
.armory-arena-rating-lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: #6d6a5e; margin: 4px 0 14px; }
.armory-arena-rows { display: flex; flex-direction: column; gap: 6px; text-align: left; }
.armory-arena-row { display: flex; justify-content: space-between; font-size: 13px; color: #8a8578; padding: 3px 0; border-top: 1px solid rgba(255,255,255,.04); }
.armory-arena-row span:last-child { color: #d4cdbb; font-weight: bold; }
.armory-arena-w { color: #1eff00; }
.armory-arena-l { color: #ff5555; }
.armory-arena-nogames { font-size: 13px; color: #6d6a5e; padding: 10px 0; }
/* Badge de modo de arena (puntuado/sin partidas) */
.armory-arena-mode { display: inline-block; font-size: 10px; font-weight: bold; text-transform: uppercase; letter-spacing: .05em; padding: 2px 10px; border-radius: 10px; margin-bottom: 12px; }
.armory-arena-mode.rated { color: #d79602; background: rgba(215,150,2,.14); }
.armory-arena-mode.none { color: #6d6a5e; background: rgba(255,255,255,.04); }
/* ===== Campos de batalla (PvP) ===== */
.armory-bg-panel { padding: 18px; }
.armory-bg-header { margin-bottom: 14px; }
.armory-bg-desc { margin: 4px 0 0; color: #9a9488; font-size: 13px; }
.armory-bg-summary {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 16px;
}
.armory-bg-stat {
display: flex; flex-direction: column; align-items: center; gap: 4px;
background: #17140f; border: 1px solid #2b2620; border-radius: 8px; padding: 12px 8px;
}
.armory-bg-stat-num { font-size: 22px; font-weight: 700; }
.armory-bg-stat-lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: #8a847a; }
.armory-bg-table-wrap { overflow-x: auto; }
.armory-bg-table { width: 100%; }
.armory-bg-table th.armory-bg-c, .armory-bg-table td.armory-bg-c { text-align: center; white-space: nowrap; }
.armory-bg-name { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; white-space: nowrap; }
.armory-bg-ico {
display: inline-flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: 5px; color: #fff; font-size: 12px;
box-shadow: inset 0 0 0 1px rgba(255,255,255,.15);
}
.armory-bg-res {
display: inline-block; min-width: 68px; padding: 3px 8px; border-radius: 5px;
font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px;
}
.armory-bg-res.win { background: rgba(46,204,113,.15); color: #4ee08a; border: 1px solid rgba(46,204,113,.4); }
.armory-bg-res.loss { background: rgba(255,85,85,.13); color: #ff7a7a; border: 1px solid rgba(255,85,85,.4); }
.armory-bg-kb { color: #ffd100; font-weight: 600; }
.armory-bg-honor { color: #e0a13a; font-weight: 600; }
.armory-bg-date { color: #8a847a; font-size: 12px; }
@media (max-width: 640px) {
.armory-bg-summary { grid-template-columns: repeat(2, 1fr); }
}
/* Fecha + hora del BG (apilado) */
.armory-bg-dt { display: inline-flex; flex-direction: column; align-items: flex-end; line-height: 1.25; }
.armory-bg-dt-d { color: #cfc8bb; font-size: 12px; }
.armory-bg-dt-t { color: #8a847a; font-size: 11px; font-variant-numeric: tabular-nums; }
/* ===== Logros: rejilla de categorías (anillos) ===== */
.armory-ach2 { padding: 16px; }
.armory-ach2-summary { margin: 0 0 16px; color: #b8b0a2; font-size: 14px; }
.armory-ach-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px;
}
.armory-ach-card {
display: flex; flex-direction: column; align-items: center; gap: 10px;
background: #17140f; border: 1px solid #2b2620; border-radius: 10px;
padding: 18px 10px 14px; cursor: pointer; transition: border-color .15s, background .15s, transform .1s;
font-family: inherit; text-align: center;
}
.armory-ach-card:hover { border-color: #c8a24c; background: #1e1a13; transform: translateY(-2px); }
.armory-ach-ring {
position: relative; width: 86px; height: 86px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
}
.armory-ach-ring::before {
content: ''; position: absolute; inset: 11px; border-radius: 50%; background: #17140f;
}
.armory-ach-card:hover .armory-ach-ring::before { background: #1e1a13; }
.armory-ach-ring-pct {
position: relative; z-index: 1; font-size: 18px; font-weight: 700; color: #fff;
}
.armory-ach-ring.feats {
background: #241f19; color: #7d7568; font-size: 30px;
}
.armory-ach-ring.feats::before { inset: 4px; }
.armory-ach-ring.feats > i { position: relative; z-index: 1; }
.armory-ach-cat-name { color: #e6b53c; font-size: 14px; font-weight: 600; line-height: 1.2; }
.armory-ach-cat-pts { color: #d9c9a0; font-size: 13px; display: inline-flex; align-items: center; gap: 5px; }
.armory-ach-cat-pts > i { color: #c8a24c; }
/* ===== Logros: detalle de una categoría ===== */
.armory-ach2-head { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
.armory-ach2-back {
display: inline-flex; align-items: center; gap: 7px; background: #17140f; border: 1px solid #2b2620;
color: #d9c9a0; border-radius: 8px; padding: 8px 14px; cursor: pointer; font-family: inherit; font-size: 13px;
transition: border-color .15s, color .15s;
}
.armory-ach2-back:hover { border-color: #c8a24c; color: #fff; }
.armory-ach2-title { font-size: 20px; font-weight: 700; color: #e6b53c; display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.armory-ach2-sub { font-size: 13px; font-weight: 400; color: #8a847a; }
.armory-ach2-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; transition: opacity .15s; }
.armory-ach2-list.loading { opacity: .5; }
.armory-ach2-item {
display: flex; align-items: flex-start; gap: 14px;
background: #17140f; border: 1px solid #2b2620; border-radius: 8px; padding: 12px 14px;
}
.armory-ach2-item:hover { border-color: #3a342b; }
.armory-ach2-icon, .armory-ach2-name { background-image: none !important; }
.armory-ach2-icon {
flex: 0 0 auto; width: 48px; height: 48px; border-radius: 6px; overflow: hidden;
border: 1px solid #3a342b; display: block;
}
.armory-ach2-icon img { width: 100%; height: 100%; display: block; }
.armory-ach2-body { flex: 1 1 auto; min-width: 0; }
.armory-ach2-name { color: #4a9eff; font-weight: 600; font-size: 15px; text-decoration: none; }
.armory-ach2-name:hover { text-decoration: underline; }
.armory-ach2-desc { margin: 3px 0 0; color: #b8b0a2; font-size: 13px; line-height: 1.35; }
.armory-ach2-reward { margin: 5px 0 0; color: #7fd18a; font-size: 12px; }
.armory-ach2-reward > i { margin-right: 4px; }
.armory-ach2-meta { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.armory-ach2-pts {
display: inline-flex; align-items: center; gap: 5px; color: #ffd100; font-weight: 700; font-size: 15px;
}
.armory-ach2-pts > i { color: #c8a24c; }
.armory-ach2-date { color: #7d7568; font-size: 12px; font-variant-numeric: tabular-nums; }
@media (max-width: 560px) {
.armory-ach-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); }
.armory-ach2-meta { flex-direction: row; gap: 12px; }
}
/* Fix: los anillos se aplastaban (flex-shrink en columna) -> forzar tamaño fijo */
.armory-ach-ring { flex: 0 0 86px; box-sizing: border-box; }
.armory-ach-ring.feats { flex: 0 0 86px; }
.armory-ach-card { min-height: 168px; justify-content: flex-start; }
.armory-ach-cat-name { margin-top: 2px; }
/* Fix hueco iconos: wowhead iconiza los <a> (ins/del + padding). Neutralizar. */
.armory-ach2-item { align-items: center; }
.armory-ach2-icon {
padding: 0 !important; width: 48px !important; height: 48px !important;
margin: 0 !important;
}
.armory-ach2-icon img { width: 48px !important; height: 48px !important; }
.armory-ach2-icon ins, .armory-ach2-icon del,
.armory-ach2-name ins, .armory-ach2-name del { display: none !important; }
.armory-ach2-name { padding-left: 0 !important; background-image: none !important; }
/* Fix descuadre: nombres de 2 líneas (JcJ, Mazmorras) empujaban el escudo.
Reservar altura fija para el nombre y anclar los puntos abajo. */
.armory-ach-card { justify-content: flex-start; }
.armory-ach-cat-name {
min-height: 2.5em; display: flex; align-items: center; justify-content: center;
margin-top: 4px;
}
.armory-ach-cat-pts { margin-top: auto; padding-top: 4px; }
/* Override: el margin-top:auto sacaba los escudos fuera de la tarjeta.
Quitarlo y usar solo altura reservada del nombre para alinear los puntos. */
.armory-ach-card { min-height: 0 !important; justify-content: flex-start; gap: 8px; padding: 18px 10px 16px; }
.armory-ach-cat-name { min-height: 2.6em; margin-top: 0; }
.armory-ach-cat-pts { margin-top: 0 !important; padding-top: 0; }
/* Iconos del detalle: asegurar caja limpia sin restos de wowhead */
.armory-ach2-item { align-items: center; }
.armory-ach2-icon {
width: 48px !important; height: 48px !important; padding: 0 !important; margin: 0 !important;
overflow: hidden; border-radius: 6px;
}
.armory-ach2-icon img { width: 48px !important; height: 48px !important; display: block !important; }
.armory-ach2-icon ins, .armory-ach2-icon del,
.armory-ach2-name ins, .armory-ach2-name del { display: none !important; }
.armory-ach2-name { padding-left: 0 !important; }
/* ==== Reset definitivo de la tarjeta de categoría (deshace fixes previos) ==== */
.armory-ach-card {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: flex-start !important;
gap: 10px !important;
min-height: 196px !important;
padding: 18px 10px 16px !important;
overflow: visible !important;
}
.armory-ach-ring {
flex: 0 0 86px !important;
width: 86px !important;
height: 86px !important;
box-sizing: border-box !important;
}
.armory-ach-ring.feats { flex: 0 0 86px !important; width: 86px !important; height: 86px !important; }
.armory-ach-cat-name {
min-height: 2.6em !important;
margin: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.armory-ach-cat-pts {
margin: 0 !important;
padding-top: 2px !important;
}
/* ===== Pestaña Bandas (progreso de raids) ===== */
.armory-raids { padding: 16px; }
.armory-raid-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 14px;
}
.armory-raid-card {
background: #17140f; border: 1px solid #2b2620; border-radius: 10px; overflow: visible;
display: flex; flex-direction: column;
}
.armory-raid-banner {
border-radius: 10px 10px 0 0; padding: 14px 14px 12px; min-height: 62px;
display: flex; flex-direction: column; justify-content: center; gap: 3px;
box-shadow: inset 0 -40px 40px -30px rgba(0,0,0,.6);
}
.armory-raid-name { color: #fff; font-weight: 700; font-size: 15px; line-height: 1.2; text-shadow: 0 1px 3px rgba(0,0,0,.7); }
.armory-raid-count { color: rgba(255,255,255,.75); font-size: 12px; text-shadow: 0 1px 2px rgba(0,0,0,.7); }
.armory-raid-diffs { padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 7px; }
.armory-raid-row { position: relative; display: flex; align-items: center; gap: 10px; }
.armory-raid-diff-lbl {
flex: 0 0 68px; color: #d9a441; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px;
}
.armory-raid-bar {
position: relative; flex: 1 1 auto; height: 22px; border-radius: 5px; overflow: hidden;
background: #241f19; border: 1px solid #322c24; cursor: default;
}
.armory-raid-bar-fill { position: absolute; inset: 0 auto 0 0; height: 100%; border-radius: 4px 0 0 4px; transition: width .3s; }
.armory-raid-bar.full .armory-raid-bar-fill { background: linear-gradient(90deg, #2f9e4f, #37c15f); width: 100% !important; }
.armory-raid-bar.partial .armory-raid-bar-fill { background: linear-gradient(90deg, #c8781f, #e0902f); }
.armory-raid-bar.empty .armory-raid-bar-fill { background: transparent; }
.armory-raid-bar-txt {
position: relative; z-index: 1; display: block; text-align: center; line-height: 22px;
font-size: 12px; font-weight: 700; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,.6);
}
/* Tooltip de jefes */
.armory-raid-tip {
position: absolute; left: 78px; bottom: calc(100% + 8px); z-index: 30;
min-width: 220px; max-width: 280px;
background: #0d0b08; border: 1px solid #c8a24c; border-radius: 8px; padding: 10px 12px;
box-shadow: 0 8px 24px rgba(0,0,0,.6);
opacity: 0; visibility: hidden; transform: translateY(4px); transition: opacity .12s, transform .12s;
pointer-events: none;
}
.armory-raid-row:hover .armory-raid-tip { opacity: 1; visibility: visible; transform: translateY(0); }
.armory-raid-tip-head { color: #e6b53c; font-weight: 700; font-size: 13px; margin-bottom: 6px; border-bottom: 1px solid #2b2620; padding-bottom: 5px; }
.armory-raid-tip ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.armory-raid-tip li { font-size: 12px; color: #9a948a; display: flex; gap: 6px; }
.armory-raid-tip li.killed { color: #7fd18a; }
.armory-raid-tip li.alive { color: #8a847a; }
.armory-raid-tip-x { color: inherit; font-weight: 700; opacity: .85; min-width: 24px; }
@media (max-width: 560px) {
.armory-raid-grid { grid-template-columns: 1fr; }
}
/* Cabeceras de expansión en Bandas */
.armory-raid-exp { margin-bottom: 22px; }
.armory-raid-exp:last-child { margin-bottom: 0; }
.armory-raid-exp-title {
margin: 0 0 12px; font-size: 15px; font-weight: 700; color: #e6b53c;
text-transform: uppercase; letter-spacing: 1px;
padding-bottom: 8px; border-bottom: 1px solid #2b2620;
}
/* ===== Página de hermandad ===== */
.armory-guild-page { max-width: 1200px; margin: 0 auto; }
.armory-guild-head { display: flex; align-items: center; gap: 18px; margin: 8px 0 4px; }
.armory-guild-emblem {
flex: 0 0 auto; width: 76px; height: 76px; border-radius: 50%;
display: flex; align-items: center; justify-content: center; font-size: 34px;
border: 2px solid #c8a24c; box-shadow: inset 0 0 20px rgba(0,0,0,.5);
}
.armory-guild-title { margin: 0; font-size: 28px; font-weight: 800; color: #fff; line-height: 1.1; }
.armory-guild-realm { color: #9a948a; font-size: 13px; margin-top: 2px; }
.armory-guild-stats { display: flex; gap: 18px; margin-top: 8px; flex-wrap: wrap; }
.armory-guild-stat { display: inline-flex; align-items: center; gap: 6px; color: #d9c9a0; font-size: 14px; }
.armory-guild-stat > i { color: #c8a24c; }
.armory-guild-stat.muted { color: #8a847a; }
.armory-guild-tabbar { display: flex; gap: 8px; border-bottom: 1px solid #2b2620; margin: 16px 0 18px; }
.armory-guild-tab { padding: 8px 16px; border-radius: 6px 6px 0 0; font-weight: 700; font-size: 14px; color: #9a948a; }
.armory-guild-tab.active { background: #c8a24c; color: #1a1710; }
.armory-guild-layout { display: grid; grid-template-columns: 1fr 340px; gap: 22px; align-items: start; }
.armory-guild-section { font-size: 14px; text-transform: uppercase; letter-spacing: .5px; color: #b8b0a2; margin: 0 0 12px; font-weight: 700; }
/* Filtros */
.armory-guild-filters { display: flex; gap: 16px; margin-bottom: 14px; flex-wrap: wrap; }
.armory-guild-filter { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #8a847a; }
.armory-guild-filter select {
background: #17140f; border: 1px solid #3a342b; color: #e6ddc9; border-radius: 6px;
padding: 8px 10px; font-family: inherit; font-size: 13px; min-width: 190px; cursor: pointer;
}
.armory-guild-filter select:focus { outline: none; border-color: #c8a24c; }
/* Tabla roster */
.armory-guild-table { width: 100%; border-collapse: collapse; transition: opacity .15s; }
.armory-guild-table.loading { opacity: .5; }
.armory-guild-table th {
text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #c8a24c;
padding: 8px 10px; border-bottom: 1px solid #2b2620; font-weight: 700;
}
.armory-guild-table th.armory-gt-c, .armory-guild-table td.armory-gt-c { text-align: center; }
.armory-guild-table th.armory-gt-pts, .armory-guild-table td.armory-gt-pts { text-align: right; }
.armory-guild-table td { padding: 7px 10px; border-bottom: 1px solid #201c16; }
.armory-guild-table tbody tr:hover { background: #17140f; }
.armory-gt-name { display: inline-flex; align-items: center; gap: 9px; font-weight: 600; text-decoration: none; }
.armory-gt-name:hover { text-decoration: underline; }
.armory-gt-avatar { width: 26px; height: 26px; border-radius: 50%; border: 1px solid #3a342b; object-fit: cover; }
.armory-gt-ico { width: 24px; height: 24px; border-radius: 5px; vertical-align: middle; }
.armory-gt-level { color: #cfc8bb; font-weight: 600; }
.armory-gt-rank { color: #b8b0a2; font-size: 13px; }
.armory-gt-points { color: #ffd100; font-weight: 700; display: inline-flex; align-items: center; gap: 5px; }
.armory-gt-points > i { color: #c8a24c; }
/* Barra lateral */
.armory-guild-side { display: flex; flex-direction: column; gap: 16px; }
.armory-guild-card { background: #17140f; border: 1px solid #2b2620; border-radius: 10px; padding: 14px 16px; }
.armory-guild-card-title { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; letter-spacing: .5px; color: #c8a24c; font-weight: 700; }
.armory-guild-motd-locked { color: #8a847a; font-size: 13px; line-height: 1.4; }
.armory-guild-motd-locked > i { margin-right: 6px; color: #6a6459; }
.armory-guild-recent { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.armory-guild-recent-item { display: flex; align-items: center; gap: 10px; }
.armory-guild-recent-ico { width: 34px; height: 34px; border-radius: 6px; border: 1px solid #3a342b; }
.armory-guild-recent-name { flex: 1 1 auto; color: #e6b53c; font-size: 13px; font-weight: 600; }
.armory-guild-recent-date { color: #7d7568; font-size: 11px; }
.armory-guild-chart-lbl { font-size: 12px; color: #9a948a; margin-bottom: 8px; }
.armory-guild-chart { display: flex; align-items: flex-end; gap: 5px; height: 96px; }
.armory-guild-bar-col { flex: 1 1 0; height: 100%; display: flex; align-items: flex-end; }
.armory-guild-bar-wrap { width: 100%; height: 100%; display: flex; align-items: flex-end; }
.armory-guild-bar { width: 100%; min-height: 3px; border-radius: 3px 3px 0 0; }
@media (max-width: 900px) {
.armory-guild-layout { grid-template-columns: 1fr; }
}
/* Tag de reino en la cabecera del personaje */
.armory-realm {
display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 12px;
background: #17140f; border: 1px solid #2b2620; color: #9a948a; font-size: 12px;
}
/* Logros de hermandad + realm inline */
.armory-guild-realm-inline { color: #7d7568; font-weight: 400; text-transform: none; letter-spacing: 0; }
.armory-guild-tab { text-decoration: none; }
.armory-guild-page .armory-ach2 { margin-top: 4px; }
/* Columna Función + desglose de funciones */
.armory-gt-role {
display: inline-flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: 50%; background: #17140f;
border: 1px solid currentColor; font-size: 12px;
}
.armory-guild-roles { display: flex; flex-direction: column; gap: 8px; }
.armory-guild-role-row { display: grid; grid-template-columns: 62px 1fr 24px; align-items: center; gap: 8px; }
.armory-guild-role-lbl { font-size: 12px; font-weight: 700; }
.armory-guild-role-track { height: 16px; background: #241f19; border: 1px solid #322c24; border-radius: 4px; overflow: hidden; }
.armory-guild-role-fill { height: 100%; min-width: 2px; border-radius: 3px; transition: width .3s; }
.armory-guild-role-n { font-size: 12px; color: #cfc8bb; text-align: right; font-variant-numeric: tabular-nums; }
/* Fix botón azul de pestaña + mensaje diario + ilvl */
.armory-guild-tab { color: #c9c2b5 !important; }
.armory-guild-tab:hover { color: #fff !important; }
.armory-guild-tab.active { color: #1a1710 !important; }
.armory-guild-motd { color: #cfc8bb; font-size: 13px; line-height: 1.45; white-space: pre-wrap; }
.armory-guild-login { color: #e6b53c !important; text-decoration: underline; font-weight: 600; }
.armory-gt-ilvl { color: #7fb4ff; font-weight: 700; }
+229
View File
@@ -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>
)
}
+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">
+155
View File
@@ -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>
)
}
+103
View File
@@ -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>
)
}
+245
View File
@@ -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>
)
}
+160
View File
@@ -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>
)
}
+206
View File
@@ -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>
)}
</>
)
}
+120
View File
@@ -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>
)
}
+118
View File
@@ -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>
)
}
+38
View File
@@ -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>
)
}
+67
View File
@@ -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>
)
}
+66 -26
View File
@@ -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>
)
+1200 -10
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"mounts":[458,459,468,470,471,472,578,579,580,581,3363,5784,6648,6653,6654,6777,6896,6897,6898,6899,8394,8395,8980,10789,10793,10795,10796,10798,10799,10873,10969,13819,15779,15780,15781,16055,16056,16058,16059,16060,16080,16081,16082,16083,16084,17229,17450,17453,17454,17455,17456,17458,17459,17460,17461,17462,17463,17464,17465,17481,18363,18989,18990,18991,18992,22717,22718,22719,22720,22721,22722,22723,22724,23161,23214,23219,23220,23221,23222,23223,23225,23227,23228,23229,23238,23239,23240,23241,23242,23243,23246,23247,23248,23249,23250,23251,23252,23338,23509,23510,24242,24252,25953,26054,26055,26056,26656,28828,29059,30174,32235,32239,32240,32242,32243,32244,32245,32246,32289,32290,32292,32295,32296,32297,32345,33630,33660,34406,34407,34767,34769,34790,34795,34896,34897,34898,34899,35018,35020,35022,35025,35027,35028,35710,35711,35712,35713,35714,36702,37015,39315,39316,39317,39318,39319,39798,39800,39801,39802,39803,40192,41252,41513,41514,41515,41516,41517,41518,42776,42777,42781,43688,43810,43899,43900,43927,44151,44153,44317,44744,46197,46199,46628,47037,48025,48027,48778,48954,49193,49322,49378,49379,50869,50870,51412,51960,54729,54753,55531,58615,58983,59567,59568,59569,59570,59571,59572,59573,59650,59785,59788,59791,59793,59797,59799,59802,59804,59961,59976,59996,60002,60021,60024,60025,60114,60116,60118,60119,60136,60140,60424,61229,61230,61294,61309,61425,61442,61444,61446,61447,61451,61465,61467,61469,61470,61996,61997,62048,62140,63232,63635,63636,63637,63638,63639,63640,63641,63642,63643,63796,63844,63956,63963,64656,64657,64658,64659,64731,64927,64977,65439,65637,65638,65639,65640,65641,65642,65643,65644,65645,65646,65917,66087,66088,66090,66091,66122,66123,66124,66846,66847,66906,66907,67336,67466,68056,68057,68187,68188,69395,71342,71810,72286,72807,72808,73313,74856,74918,75596,75614,75973,348459,372677,387308,387311,387319,387320,387321,387323,388516,394209,416158,423869,440915,446902],"pets":[4055,10673,10674,10675,10676,10677,10678,10679,10680,10681,10682,10683,10684,10685,10686,10687,10688,10695,10696,10697,10698,10699,10700,10701,10702,10703,10704,10705,10706,10707,10708,10709,10710,10711,10712,10713,10714,10715,10716,10717,10718,10719,10720,10721,12243,13548,15048,15049,15067,15648,15999,16450,17468,17469,17567,17707,17708,17709,19363,19772,23428,23429,23430,23431,23432,23530,23531,23811,24696,24985,24986,24987,24988,24989,24990,25018,25162,25849,26010,26045,26067,26391,26529,26533,26541,27241,27570,28487,28505,28738,28739,28740,28871,30152,30156,32298,33050,33057,35156,35157,35239,35907,35909,35910,35911,36027,36028,36029,36031,36034,39181,39709,40319,40405,40549,40613,40614,40634,40990,42609,43697,43698,43918,44369,45082,45125,45127,45174,45175,45890,46425,46426,46599,48406,48408,49964,51716,51851,52615,53082,53316,53768,54187,55068,59250,61348,61349,61350,61351,61357,61472,61725,61773,61855,61991,62491,62508,62510,62513,62514,62516,62542,62561,62562,62564,62609,62674,62746,63318,63712,64351,65358,65381,65382,65682,66030,66096,66175,66520,67413,67414,67415,67416,67417,67418,67419,67420,67527,68767,68810,69002,69452,69535,69536,69539,69541,69677,70613,71840,74932,75134,75613,75906,75936,78381,330659,359755,376324,384796,387325,387326,387328,387329,387330,387331,387332,388541,407786,423843,423868,428053,446916]}
File diff suppressed because one or more lines are too long
+19 -16
View File
@@ -77,22 +77,22 @@ export const PAPERDOLL_RIGHT = [9, 5, 6, 7, 10, 11, 12, 13]
export const PAPERDOLL_WEAPONS = [15, 16, 17]
// Skill lines de profesiones (primarias + secundarias) de WotLK. IDs estándar.
export const PROFESSION_SKILLS: Record<number, { es: string; en: string }> = {
171: { es: 'Alquimia', en: 'Alchemy' },
164: { es: 'Herrería', en: 'Blacksmithing' },
333: { es: 'Encantamiento', en: 'Enchanting' },
202: { es: 'Ingeniería', en: 'Engineering' },
182: { es: 'Herboristería', en: 'Herbalism' },
773: { es: 'Inscripción', en: 'Inscription' },
755: { es: 'Joyería', en: 'Jewelcrafting' },
165: { es: 'Peletería', en: 'Leatherworking' },
186: { es: 'Minería', en: 'Mining' },
393: { es: 'Desuello', en: 'Skinning' },
197: { es: 'Sastrería', en: 'Tailoring' },
129: { es: 'Primeros auxilios', en: 'First Aid' },
185: { es: 'Cocina', en: 'Cooking' },
356: { es: 'Pesca', en: 'Fishing' },
762: { es: 'Equitación', en: 'Riding' },
export const PROFESSION_SKILLS: Record<number, { es: string; en: string; icon: string; kind: 'primary' | 'secondary' }> = {
171: { es: 'Alquimia', en: 'Alchemy', icon: 'trade_alchemy', kind: 'primary' },
164: { es: 'Herrería', en: 'Blacksmithing', icon: 'trade_blacksmithing', kind: 'primary' },
333: { es: 'Encantamiento', en: 'Enchanting', icon: 'trade_engraving', kind: 'primary' },
202: { es: 'Ingeniería', en: 'Engineering', icon: 'trade_engineering', kind: 'primary' },
182: { es: 'Herboristería', en: 'Herbalism', icon: 'trade_herbalism', kind: 'primary' },
773: { es: 'Inscripción', en: 'Inscription', icon: 'inv_inscription_tradeskill01', kind: 'primary' },
755: { es: 'Joyería', en: 'Jewelcrafting', icon: 'inv_misc_gem_01', kind: 'primary' },
165: { es: 'Peletería', en: 'Leatherworking', icon: 'trade_leatherworking', kind: 'primary' },
186: { es: 'Minería', en: 'Mining', icon: 'trade_mining', kind: 'primary' },
393: { es: 'Desuello', en: 'Skinning', icon: 'inv_misc_pelt_wolf_01', kind: 'primary' },
197: { es: 'Sastrería', en: 'Tailoring', icon: 'trade_tailoring', kind: 'primary' },
129: { es: 'Primeros auxilios', en: 'First Aid', icon: 'spell_holy_sealofsacrifice', kind: 'secondary' },
185: { es: 'Cocina', en: 'Cooking', icon: 'inv_misc_food_15', kind: 'secondary' },
356: { es: 'Pesca', en: 'Fishing', icon: 'trade_fishing', kind: 'secondary' },
762: { es: 'Equitación', en: 'Riding', icon: 'ability_mount_ridinghorse', kind: 'secondary' },
}
// Pestañas de talento de WotLK: tabId -> { clase, índice de árbol 0/1/2, nombre }.
@@ -162,3 +162,6 @@ export const SPEC_ICONS: Record<number, string> = {
302: 'spell_shadow_deathcoil', 303: 'spell_shadow_metamorphosis', 301: 'spell_shadow_rainoffire',
283: 'spell_nature_starfall', 281: 'ability_druid_catform', 282: 'spell_nature_healingtouch',
}
// XP necesaria para pasar de nivel N a N+1 (WotLK 3.3.5). Nivel 80 = máximo.
export const XP_FOR_LEVEL: Record<number, number> = { 1: 400, 2: 900, 3: 1400, 4: 2100, 5: 2800, 6: 3600, 7: 4500, 8: 5400, 9: 6500, 10: 7600, 11: 8700, 12: 9800, 13: 11000, 14: 12300, 15: 13600, 16: 15000, 17: 16400, 18: 17800, 19: 19300, 20: 20800, 21: 22400, 22: 24000, 23: 25500, 24: 27200, 25: 28900, 26: 30500, 27: 32200, 28: 33900, 29: 36300, 30: 38800, 31: 41600, 32: 44600, 33: 48000, 34: 51400, 35: 55000, 36: 58700, 37: 62400, 38: 66200, 39: 70200, 40: 74300, 41: 78500, 42: 82800, 43: 87100, 44: 91600, 45: 96300, 46: 101000, 47: 105800, 48: 110700, 49: 115700, 50: 120900, 51: 126100, 52: 131500, 53: 137000, 54: 142500, 55: 148200, 56: 154000, 57: 159900, 58: 165800, 59: 172000, 60: 290000, 61: 317000, 62: 349000, 63: 386000, 64: 428000, 65: 475000, 66: 527000, 67: 585000, 68: 648000, 69: 717000, 70: 1523800, 71: 1539600, 72: 1555700, 73: 1571800, 74: 1587900, 75: 1604200, 76: 1620700, 77: 1637400, 78: 1653900, 79: 1670800 }
+143 -1
View File
@@ -2062,6 +2062,148 @@
"earned": "Earned",
"viewFirstActivity": "View first activity",
"achSearch": "Search achievement by name or ID…",
"noAchievements": "No achievements"
"noAchievements": "No achievements",
"primaryProfs": "Primary",
"secondaryProfs": "Secondary",
"tabEquip": "Equipment",
"achievements": "Achievements",
"quests": "Quests",
"mounts": "Mounts",
"pets": "Pets",
"comingSoon": "Coming soon",
"kills": "Kills",
"honor": "Honor",
"glyphs": "Glyphs",
"majorGlyphs": "Major",
"minorGlyphs": "Minor",
"filterId": "Filter by ID…",
"filterName": "Filter by name…",
"allStatuses": "All statuses",
"completed": "Completed",
"inProgress": "In progress",
"noQuests": "No quests",
"quest": "Quest",
"statusCol": "Status",
"questCountLine": "{total} quests · Page {page} of {pages}",
"mountsCollected": "{n} mounts collected",
"petsCollected": "{n} pets collected",
"noMounts": "No mounts",
"noPets": "No pets",
"distribution": "Talent distribution",
"primary": "Primary",
"secondary": "Secondary",
"activeSpec": "active",
"qsInProgress": "In Progress",
"qsComplete": "Complete",
"qsFailed": "Failed",
"qsRewarded": "Turned in",
"bInProgress": "In progress",
"bComplete": "Complete",
"bFailed": "Failed",
"bRewarded": "Turned in",
"achCompleted": "Completed achievements",
"achLogro": "Achievement",
"achDate": "Date",
"achCountLine": "{total} achievements · Page {page} of {pages}",
"reputations": "Reputations",
"repSearch": "Search faction…",
"noReps": "No reputations",
"collSearch": "Search…",
"pvp": "PvP",
"pvpStats": "Statistics",
"pvpRank": "World rank",
"totalKills": "Total kills",
"todayKills": "Today",
"playedTime": "Played time",
"rankPersonaje": "Character",
"rankKills": "Kills",
"pvpRankTitle": "World PvP Rank",
"pvpRankDesc": "Server kill (Total Kills) ranking.",
"searchPlayer": "Player name…",
"noPlayers": "No results",
"arena": "Arena",
"arenaRating": "Rating",
"arenaSeason": "Season",
"arenaWeek": "Week",
"arenaWins": "Won",
"arenaLosses": "Lost",
"arenaGames": "Games",
"arenaWinRate": "Win %",
"arenaNoGames": "No games",
"arenaRated": "Rated",
"arenaUnrated": "No games",
"arenaRank": "Rank",
"yesterdayKills": "Yesterday",
"arenaPoints": "Arena points",
"honorPoints": "Honor points",
"battlegrounds": "Battlegrounds",
"bgHistory": "Match history",
"bgDesc": "Latest recorded battleground matches.",
"bgMatches": "Matches",
"bgWins": "Wins",
"bgLosses": "Losses",
"bgWinRate": "Win %",
"bgResult": "Result",
"bgWin": "Win",
"bgLoss": "Loss",
"bgKb": "Killing blows",
"bgHk": "Honorable kills",
"bgDeaths": "Deaths",
"bgDamage": "Damage",
"bgHealing": "Healing",
"bgHonor": "Honor",
"bgDate": "Date",
"bgNone": "No battleground matches recorded.",
"achCategories": "Categories",
"achPoints2": "points",
"achCompleted2": "earned",
"achOf": "of",
"achBack": "Back to categories",
"achFeats": "earned",
"achReward": "Reward",
"achEmptyCat": "No achievements earned in this category.",
"achSummaryLine": "{done} of {total} achievements · {points} points",
"raids": "Raids",
"raidBosses": "bosses",
"raidNoData": "No raid progress.",
"expClassic": "Classic",
"expTbc": "The Burning Crusade",
"expWotlk": "Wrath of the Lich King",
"dungeons": "Dungeons",
"guildAchievements": "Achievements",
"guildMembers": "Guild members",
"guildFounded": "Founded",
"guildLeader": "Guild Master",
"guildPoints": "Guild points",
"guildView": "View",
"guildSortRank": "Guild rank",
"guildSortPoints": "Achievement points",
"guildSortLevel": "Level",
"guildSortName": "Name",
"guildFilterClass": "Filter by class",
"guildAllClasses": "All classes",
"colRace": "Race",
"colClass": "Class",
"colLevel": "Level",
"colRank": "Guild rank",
"colName": "Name",
"guildMotd": "Message of the day",
"guildMotdLocked": "You must be a guild member to see the message of the day.",
"guildRecent": "Recent achievements",
"guildOverview": "Member overview",
"guildClassBreak": "Class breakdown",
"guildRealm": "Realm",
"colFunction": "Role",
"guildFilterRole": "Filter by role",
"guildAllRoles": "All roles",
"roleTank": "Tank",
"roleHealer": "Healer",
"roleDps": "Damage",
"guildRoleBreak": "Role breakdown",
"guildSortIlvl": "Item level",
"colIlvl": "Item level",
"guildMotdGuest": "Do you belong to this guild? Sign in to see its message of the day.",
"guildMotdEmpty": "This guild has no message of the day.",
"login": "Sign in"
}
}
+143 -1
View File
@@ -2062,6 +2062,148 @@
"earned": "Logrado",
"viewFirstActivity": "Ver primera actividad",
"achSearch": "Buscar logro por nombre o ID…",
"noAchievements": "No hay logros"
"noAchievements": "No hay logros",
"primaryProfs": "Primarias",
"secondaryProfs": "Secundarias",
"tabEquip": "Equipación",
"achievements": "Logros",
"quests": "Misiones",
"mounts": "Monturas",
"pets": "Mascotas",
"comingSoon": "Próximamente",
"kills": "Muertes",
"honor": "Honor",
"glyphs": "Glifos",
"majorGlyphs": "Mayores",
"minorGlyphs": "Menores",
"filterId": "Filtrar por ID…",
"filterName": "Filtrar por nombre…",
"allStatuses": "Todos los estados",
"completed": "Completada",
"inProgress": "En progreso",
"noQuests": "Sin misiones",
"quest": "Misión",
"statusCol": "Estado",
"questCountLine": "{total} misiones · Página {page} de {pages}",
"mountsCollected": "{n} monturas coleccionadas",
"petsCollected": "{n} mascotas coleccionadas",
"noMounts": "Sin monturas",
"noPets": "Sin mascotas",
"distribution": "Distribución de talentos",
"primary": "Primaria",
"secondary": "Secundaria",
"activeSpec": "activa",
"qsInProgress": "En Progreso",
"qsComplete": "Completadas",
"qsFailed": "Fallidas",
"qsRewarded": "Entregadas",
"bInProgress": "En progreso",
"bComplete": "Completada",
"bFailed": "Fallida",
"bRewarded": "Entregada",
"achCompleted": "Logros completados",
"achLogro": "Logro",
"achDate": "Fecha",
"achCountLine": "{total} logros · Página {page} de {pages}",
"reputations": "Reputaciones",
"repSearch": "Buscar facción…",
"noReps": "Sin reputaciones",
"collSearch": "Buscar…",
"pvp": "PvP",
"pvpStats": "Estadísticas",
"pvpRank": "Rank mundo",
"totalKills": "Total kills",
"todayKills": "Hoy",
"playedTime": "Tiempo jugado",
"rankPersonaje": "Personaje",
"rankKills": "Kills",
"pvpRankTitle": "Rank PvP Mundo",
"pvpRankDesc": "Ranking de bajas (Total Kills) del servidor.",
"searchPlayer": "Nombre del jugador…",
"noPlayers": "Sin resultados",
"arena": "Arena",
"arenaRating": "Rating",
"arenaSeason": "Temporada",
"arenaWeek": "Semana",
"arenaWins": "Ganadas",
"arenaLosses": "Perdidas",
"arenaGames": "Partidas",
"arenaWinRate": "% victorias",
"arenaNoGames": "Sin partidas",
"arenaRated": "Puntuado",
"arenaUnrated": "Sin partidas",
"arenaRank": "Rango",
"yesterdayKills": "Ayer",
"arenaPoints": "Puntos de arena",
"honorPoints": "Puntos de honor",
"battlegrounds": "Campos de batalla",
"bgHistory": "Historial de combate",
"bgDesc": "Últimas partidas de campo de batalla registradas.",
"bgMatches": "Partidas",
"bgWins": "Victorias",
"bgLosses": "Derrotas",
"bgWinRate": "% victorias",
"bgResult": "Resultado",
"bgWin": "Victoria",
"bgLoss": "Derrota",
"bgKb": "Golpes mortales",
"bgHk": "Muertes honorables",
"bgDeaths": "Muertes",
"bgDamage": "Daño",
"bgHealing": "Curación",
"bgHonor": "Honor",
"bgDate": "Fecha",
"bgNone": "Sin partidas de campo de batalla registradas.",
"achCategories": "Categorías",
"achPoints2": "puntos",
"achCompleted2": "conseguidos",
"achOf": "de",
"achBack": "Volver a categorías",
"achFeats": "conseguidas",
"achReward": "Recompensa",
"achEmptyCat": "Sin logros conseguidos en esta categoría.",
"achSummaryLine": "{done} de {total} logros · {points} puntos",
"raids": "Bandas",
"raidBosses": "jefes",
"raidNoData": "Sin progreso de banda.",
"expClassic": "Clásico",
"expTbc": "The Burning Crusade",
"expWotlk": "Wrath of the Lich King",
"dungeons": "Mazmorras",
"guildAchievements": "Logros",
"guildMembers": "Miembros de la hermandad",
"guildFounded": "Fundada en",
"guildLeader": "Maestro de hermandad",
"guildPoints": "Puntos de hermandad",
"guildView": "Ver",
"guildSortRank": "Rango en la hermandad",
"guildSortPoints": "Puntos de logros",
"guildSortLevel": "Nivel",
"guildSortName": "Nombre",
"guildFilterClass": "Filtrar por clase",
"guildAllClasses": "Todas las clases",
"colRace": "Raza",
"colClass": "Clase",
"colLevel": "Nivel",
"colRank": "Rango en la hermandad",
"colName": "Nombre",
"guildMotd": "Mensaje diario",
"guildMotdLocked": "Tienes que ser miembro de la hermandad para ver el mensaje diario.",
"guildRecent": "Logros recientes",
"guildOverview": "Información general de los miembros",
"guildClassBreak": "Desglose de clases",
"guildRealm": "Reino",
"colFunction": "Función",
"guildFilterRole": "Filtrar por función",
"guildAllRoles": "Todas las funciones",
"roleTank": "Tanque",
"roleHealer": "Sanador",
"roleDps": "Daño",
"guildRoleBreak": "Desglose de funciones",
"guildSortIlvl": "Nivel de objeto",
"colIlvl": "Nivel de objeto",
"guildMotdGuest": "¿Perteneces a esta hermandad? Inicia sesión para ver su mensaje diario.",
"guildMotdEmpty": "Esta hermandad no tiene mensaje diario.",
"login": "Iniciar sesión"
}
}
+1
View File
@@ -0,0 +1 @@
inv_boots_01
+1
View File
@@ -0,0 +1 @@
inv_pants_02
+1
View File
@@ -0,0 +1 @@
inv_shirt_01
+1
View File
@@ -0,0 +1 @@
inv_mace_107
+1
View File
@@ -0,0 +1 @@
inv_shirt_guildtabard_01
Binary file not shown.
Binary file not shown.
+79
View File
@@ -0,0 +1,79 @@
{
"Model": 0,
"Textures": null,
"Textures2": null,
"TextureFiles": {
"44168": [
{
"FileDataId": 165326,
"Gender": 1,
"Class": 0,
"Race": 0,
"ExtraData": 0
},
{
"FileDataId": 165327,
"Gender": 0,
"Class": 0,
"Race": 0,
"ExtraData": 0
}
],
"45052": [
{
"FileDataId": 162632,
"Gender": 1,
"Class": 0,
"Race": 0,
"ExtraData": 0
},
{
"FileDataId": 162633,
"Gender": 0,
"Class": 0,
"Race": 0,
"ExtraData": 0
}
]
},
"ModelFiles": null,
"Item": {
"Flags": 1,
"InventoryType": 19,
"ItemClass": 0,
"ItemSubClass": 0,
"HideGeosetMale": null,
"HideGeosetFemale": null,
"GeosetGroup": [
1,
0,
0,
0,
0,
0
],
"AttachGeosetGroup": [
0,
0,
0,
0,
0,
0
],
"GeosetGroupOverride": 0,
"ItemVisual": 0,
"ParticleColor": null
},
"Creature": null,
"Character": null,
"ItemEffects": null,
"Equipment": null,
"ComponentTextures": {
"3": 44168,
"4": 45052
},
"ComponentModels": null,
"StateKits": null,
"Decor": null,
"Scale": 1
}
+103
View File
@@ -0,0 +1,103 @@
{
"Model": 0,
"Textures": null,
"Textures2": null,
"TextureFiles": {
"27853": [
{
"FileDataId": 151149,
"Gender": 1,
"Class": 0,
"Race": 0,
"ExtraData": 0
},
{
"FileDataId": 151150,
"Gender": 0,
"Class": 0,
"Race": 0,
"ExtraData": 0
},
{
"FileDataId": 151151,
"Gender": 3,
"Class": 0,
"Race": 0,
"ExtraData": 0
}
],
"43842": [
{
"FileDataId": 162708,
"Gender": 1,
"Class": 0,
"Race": 0,
"ExtraData": 0
},
{
"FileDataId": 162709,
"Gender": 0,
"Class": 0,
"Race": 0,
"ExtraData": 0
}
],
"32971": [
{
"FileDataId": 160057,
"Gender": 1,
"Class": 0,
"Race": 0,
"ExtraData": 0
},
{
"FileDataId": 160058,
"Gender": 0,
"Class": 0,
"Race": 0,
"ExtraData": 0
}
]
},
"ModelFiles": null,
"Item": {
"Flags": 0,
"InventoryType": 4,
"ItemClass": 4,
"ItemSubClass": 0,
"HideGeosetMale": null,
"HideGeosetFemale": null,
"GeosetGroup": [
0,
0,
0,
0,
0,
0
],
"AttachGeosetGroup": [
0,
0,
0,
0,
0,
0
],
"GeosetGroupOverride": 0,
"ItemVisual": 0,
"ParticleColor": null
},
"Creature": null,
"Character": null,
"ItemEffects": null,
"Equipment": null,
"ComponentTextures": {
"0": 27853,
"3": 43842,
"4": 32971
},
"ComponentModels": null,
"StateKits": null,
"Decor": null,
"Scale": 1
}
+93
View File
@@ -0,0 +1,93 @@
{
"Model": 0,
"Textures": null,
"Textures2": null,
"TextureFiles": {
"34391": [
{
"FileDataId": 157363,
"Gender": 1,
"Class": 0,
"Race": 0,
"ExtraData": 0
},
{
"FileDataId": 157364,
"Gender": 0,
"Class": 0,
"Race": 0,
"ExtraData": 0
},
{
"FileDataId": 157365,
"Gender": 3,
"Class": 0,
"Race": 0,
"ExtraData": 0
}
],
"31483": [
{
"FileDataId": 154745,
"Gender": 1,
"Class": 0,
"Race": 0,
"ExtraData": 0
},
{
"FileDataId": 154746,
"Gender": 0,
"Class": 0,
"Race": 0,
"ExtraData": 0
},
{
"FileDataId": 154747,
"Gender": 3,
"Class": 0,
"Race": 0,
"ExtraData": 0
}
]
},
"ModelFiles": null,
"Item": {
"Flags": 0,
"InventoryType": 7,
"ItemClass": 4,
"ItemSubClass": 4,
"HideGeosetMale": null,
"HideGeosetFemale": null,
"GeosetGroup": [
0,
0,
0,
0,
0,
0
],
"AttachGeosetGroup": [
0,
0,
0,
0,
0,
0
],
"GeosetGroupOverride": 0,
"ItemVisual": 0,
"ParticleColor": null
},
"Creature": null,
"Character": null,
"ItemEffects": null,
"Equipment": null,
"ComponentTextures": {
"5": 34391,
"6": 31483
},
"ComponentModels": null,
"StateKits": null,
"Decor": null,
"Scale": 1
}
+65
View File
@@ -0,0 +1,65 @@
{
"Model": 0,
"Textures": null,
"Textures2": null,
"TextureFiles": {
"31715": [
{
"FileDataId": 154786,
"Gender": 3,
"Class": 0,
"Race": 0,
"ExtraData": 0
}
],
"25002": [
{
"FileDataId": 152659,
"Gender": 3,
"Class": 0,
"Race": 0,
"ExtraData": 0
}
]
},
"ModelFiles": null,
"Item": {
"Flags": 0,
"InventoryType": 8,
"ItemClass": 4,
"ItemSubClass": 4,
"HideGeosetMale": null,
"HideGeosetFemale": null,
"GeosetGroup": [
1,
0,
0,
0,
0,
0
],
"AttachGeosetGroup": [
0,
0,
0,
0,
0,
0
],
"GeosetGroupOverride": 0,
"ItemVisual": 0,
"ParticleColor": null
},
"Creature": null,
"Character": null,
"ItemEffects": null,
"Equipment": null,
"ComponentTextures": {
"6": 31715,
"7": 25002
},
"ComponentModels": null,
"StateKits": null,
"Decor": null,
"Scale": 1
}
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
{
"Model": 0,
"Textures": {
"2": 305187
},
"Textures2": null,
"TextureFiles": null,
"ModelFiles": {
"2989": [
{
"FileDataId": 305165,
"Gender": 2,
"Class": 0,
"Race": 0,
"ExtraData": -1
}
]
},
"Item": {
"Flags": 0,
"InventoryType": 21,
"ItemClass": 2,
"ItemSubClass": 4,
"HideGeosetMale": null,
"HideGeosetFemale": null,
"GeosetGroup": [
0,
0,
0,
0,
0,
0
],
"AttachGeosetGroup": [
0,
0,
0,
0,
0,
0
],
"GeosetGroupOverride": 0,
"ItemVisual": 0,
"ParticleColor": null
},
"Creature": null,
"Character": null,
"ItemEffects": null,
"Equipment": null,
"ComponentTextures": null,
"ComponentModels": {
"0": 2989
},
"StateKits": null,
"Decor": null,
"Scale": 1
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+1
View File
@@ -0,0 +1 @@
A través de El Portal Oscuro
+1
View File
@@ -0,0 +1 @@
Llegada a Terrallende
+1
View File
@@ -0,0 +1 @@
Habla con Gryan Mantorrecio
+1
View File
@@ -0,0 +1 @@
Recompensas por los gnolls Zarparrío
+1
View File
@@ -0,0 +1 @@
El infierno se ha congelado...
+1
View File
@@ -0,0 +1 @@
Si Valgarde cae...
+1
View File
@@ -0,0 +1 @@
Rescatar a los rescatadores
+1
View File
@@ -0,0 +1 @@
La liga de los humanos
+1
View File
@@ -0,0 +1 @@
Honrar la llama
+1
View File
@@ -0,0 +1 @@
¡Profana este fuego!
+1
View File
@@ -0,0 +1 @@
¡Profana este fuego!
+1
View File
@@ -0,0 +1 @@
¡Profana este fuego!
+1
View File
@@ -0,0 +1 @@
Honrar la llama
+1
View File
@@ -0,0 +1 @@
Honrar la llama
+1
View File
@@ -0,0 +1 @@
Honrar la llama
+1
View File
@@ -0,0 +1 @@
Honrar la llama
+1
View File
@@ -0,0 +1 @@
Honrar la llama
+1
View File
@@ -0,0 +1 @@
Jugando con fuego
+1
View File
@@ -0,0 +1 @@
Incienso para los agostizos de verano
+1
View File
@@ -0,0 +1 @@
El maestro en tradición de verano
+1
View File
@@ -0,0 +1 @@
Cubo de caramelos
+1
View File
@@ -0,0 +1 @@
Aprender a ir y a volver: la manera mágica
+1
View File
@@ -0,0 +1 @@
Pequeño huérfano Lito de los Oráculos
+1
View File
@@ -0,0 +1 @@
Investigar la Mina del Eco
+1
View File
@@ -0,0 +1 @@
Los enanos modistos
+1
View File
@@ -0,0 +1 @@
Hermandad de ladrones
+1
View File
@@ -0,0 +1 @@
Escaramuza en la Mina del Eco
+1
View File
@@ -0,0 +1 @@
El Cuartel de Arroyoeste necesita ayuda
+1
View File
@@ -0,0 +1 @@
Una carta consagrada
+1
View File
@@ -0,0 +1 @@
Los lobos cruzan la frontera
+1
View File
@@ -0,0 +1 @@
Publicidad de la bodega
+1
View File
@@ -0,0 +1 @@
SE BUSCA: ¡Viscoso!
+1
View File
@@ -0,0 +1 @@
Guardianes del altar
+1
View File
@@ -0,0 +1 @@
Eagan Desollapelambres
+1
View File
@@ -0,0 +1 @@
Informa en Villadorada

Some files were not shown because too many files have changed in this diff Show More