Armería: PvP campos de batalla, logros por categoría, bandas/mazmorras y página de hermandad
- PvP: pestaña Campos de batalla (pvpstats) con resultado, stats por partida y fecha/hora. - Logros: rejilla por categoría con anillos de progreso + detalle por categoría (datos de Achievement.db2 + Achievement_Category.db2, iconos vía wowhead). - Bandas: progreso por dificultad (10/25/heroico) con jefes reales de DungeonEncounter.db2 y tooltip de jefes; agrupado por expansión (Clásico/TBC/WotLK). - Mazmorras: mismo sistema, Normal + Heroico, Clásico/TBC/WotLK. - Página de hermandad estilo armería: cabecera (emblema, puntos = unión de logros de miembros sin duplicar, nº miembros, fundada), pestañas Hermandad/Logros, roster con iconos de clase/raza, columna Función (rol por spec), filtros clase/función, orden por rango/puntos/nivel de objeto/nivel/nombre, paginación. - Barra lateral: mensaje diario con login (iron-session, gated por pertenencia), logros recientes y desgloses de clases y funciones. - Cabecera de personaje: enlace a la hermandad + nombre del reino. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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">
|
||||
<{character.guildName}>
|
||||
</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({
|
||||
<{character.guildName}>
|
||||
</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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { wowheadIcon, wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
import { WowheadRefresh } from './WowheadRefresh'
|
||||
|
||||
export type AchCat = {
|
||||
id: number
|
||||
name: string
|
||||
total: number
|
||||
completed: number
|
||||
totalPoints: number
|
||||
earnedPoints: number
|
||||
pct: number
|
||||
isFeats: boolean
|
||||
}
|
||||
export type AchDetailItem = {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
reward: string
|
||||
icon: string
|
||||
points: number
|
||||
date: number
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
function ringColor(pct: number) {
|
||||
if (pct >= 90) return '#4ade5f'
|
||||
if (pct >= 50) return '#e0902f'
|
||||
return '#d97b28'
|
||||
}
|
||||
|
||||
export function ArmoryAchievements({
|
||||
locale,
|
||||
guid,
|
||||
categories,
|
||||
earnedPoints,
|
||||
totalCompleted,
|
||||
apiPath = '/api/armory/achievements',
|
||||
idKey = 'guid',
|
||||
labels,
|
||||
}: {
|
||||
locale: string
|
||||
guid: number
|
||||
categories: AchCat[]
|
||||
earnedPoints: number
|
||||
totalCompleted: number
|
||||
apiPath?: string
|
||||
idKey?: string
|
||||
labels: {
|
||||
summary: string
|
||||
back: string
|
||||
reward: string
|
||||
empty: string
|
||||
of: string
|
||||
feats: string
|
||||
}
|
||||
}) {
|
||||
const [cat, setCat] = useState<AchCat | null>(null)
|
||||
const [entries, setEntries] = useState<AchDetailItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
const open = async (c: AchCat, p = 1) => {
|
||||
setCat(c)
|
||||
setPage(p)
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${apiPath}?${idKey}=${guid}&cat=${c.id}&page=${p}&locale=${locale}`)
|
||||
const data = await res.json()
|
||||
setEntries(Array.isArray(data.entries) ? data.entries : [])
|
||||
setTotal(Number(data.total) || 0)
|
||||
} catch {
|
||||
setEntries([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goto = (p: number) => {
|
||||
if (!cat || p < 1 || p > pages || p === page || loading) return
|
||||
open(cat, p)
|
||||
}
|
||||
|
||||
const fmtDate = (unix: number) => {
|
||||
if (!unix) return ''
|
||||
const d = new Date(unix * 1000)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
// ---- Vista detalle de una categoría ----
|
||||
if (cat) {
|
||||
const from = Math.max(1, page - 2)
|
||||
const to = Math.min(pages, page + 2)
|
||||
const nums: number[] = []
|
||||
for (let p = from; p <= to; p++) nums.push(p)
|
||||
return (
|
||||
<div className="armory-panel armory-ach2">
|
||||
<WowheadRefresh dep={`${cat.id}:${page}:${entries.length}`} />
|
||||
<div className="armory-ach2-head">
|
||||
<button type="button" className="armory-ach2-back" onClick={() => setCat(null)}>
|
||||
<i className="fas fa-arrow-left" /> {labels.back}
|
||||
</button>
|
||||
<div className="armory-ach2-title">
|
||||
{cat.name}
|
||||
<span className="armory-ach2-sub">
|
||||
{cat.completed} {labels.of} {cat.total}
|
||||
{cat.isFeats ? '' : ` · ${cat.earnedPoints} / ${cat.totalPoints}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 && !loading ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
<ul className={`armory-ach2-list${loading ? ' loading' : ''}`}>
|
||||
{entries.map((a) => (
|
||||
<li className="armory-ach2-item" key={a.id}>
|
||||
<span className="armory-ach2-icon">
|
||||
<img src={wowheadIcon(a.icon, 'large')} alt="" loading="lazy" />
|
||||
</span>
|
||||
<div className="armory-ach2-body">
|
||||
<a
|
||||
className="armory-ach2-name"
|
||||
href={wowheadUrl('achievement', a.id, locale)}
|
||||
data-wowhead={wowheadData('achievement', a.id, locale)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{a.name}
|
||||
</a>
|
||||
{a.description ? <p className="armory-ach2-desc">{a.description}</p> : null}
|
||||
{a.reward ? (
|
||||
<p className="armory-ach2-reward">
|
||||
<i className="fas fa-gift" /> {labels.reward}: {a.reward}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="armory-ach2-meta">
|
||||
{a.points > 0 ? (
|
||||
<span className="armory-ach2-pts">
|
||||
<i className="fas fa-shield" /> {a.points}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="armory-ach2-date">{fmtDate(a.date)}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<div className="armory-pager">
|
||||
<button onClick={() => goto(page - 1)} disabled={page <= 1} type="button" aria-label="Anterior">
|
||||
‹
|
||||
</button>
|
||||
{from > 1 && (
|
||||
<>
|
||||
<button onClick={() => goto(1)} type="button">
|
||||
1
|
||||
</button>
|
||||
{from > 2 && <span className="armory-pager-dots">…</span>}
|
||||
</>
|
||||
)}
|
||||
{nums.map((p) => (
|
||||
<button key={p} className={p === page ? 'active' : ''} onClick={() => goto(p)} type="button">
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{to < pages && (
|
||||
<>
|
||||
{to < pages - 1 && <span className="armory-pager-dots">…</span>}
|
||||
<button onClick={() => goto(pages)} type="button">
|
||||
{pages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => goto(page + 1)} disabled={page >= pages} type="button" aria-label="Siguiente">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Vista rejilla de categorías ----
|
||||
const totalAch = categories.reduce((s, c) => s + c.total, 0)
|
||||
const summary = labels.summary
|
||||
.replace('{done}', String(totalCompleted))
|
||||
.replace('{total}', String(totalAch))
|
||||
.replace('{points}', String(earnedPoints))
|
||||
|
||||
return (
|
||||
<div className="armory-panel armory-ach2">
|
||||
<p className="armory-ach2-summary">{summary}</p>
|
||||
<div className="armory-ach-grid">
|
||||
{categories.map((c) => {
|
||||
const col = ringColor(c.pct)
|
||||
return (
|
||||
<button type="button" className="armory-ach-card" key={c.id} onClick={() => open(c)}>
|
||||
{c.isFeats ? (
|
||||
<div className="armory-ach-ring feats">
|
||||
<i className="fas fa-award" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="armory-ach-ring"
|
||||
style={{ background: `conic-gradient(${col} ${c.pct * 3.6}deg, #241f19 0deg)` }}
|
||||
>
|
||||
<span className="armory-ach-ring-pct">{c.pct}%</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="armory-ach-cat-name">{c.name}</span>
|
||||
<span className="armory-ach-cat-pts">
|
||||
<i className="fas fa-shield" /> {c.isFeats ? c.completed : c.earnedPoints}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,20 +2,20 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { WowheadRefresh } from './WowheadRefresh'
|
||||
import { wowheadIcon, wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
import { wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
|
||||
export type ActivityEntry = { id: number; name: string; icon: string; date: number }
|
||||
|
||||
/**
|
||||
* Navegador de logros del personaje: búsqueda en tiempo real (por nombre o ID) y
|
||||
* paginación (anterior/siguiente + números). Los datos se piden a /api/armory/activity;
|
||||
* la primera página llega ya renderizada del servidor.
|
||||
* Pestaña de Logros: filtros por ID y por nombre, tabla Logro | Fecha, paginación.
|
||||
* Mismo estilo que Misiones. Datos vía /api/armory/activity; 1ª página del servidor.
|
||||
*/
|
||||
export function ArmoryActivity({
|
||||
guid,
|
||||
locale,
|
||||
initial,
|
||||
total: initialTotal,
|
||||
grandTotal,
|
||||
pageSize,
|
||||
labels,
|
||||
}: {
|
||||
@@ -23,21 +23,31 @@ export function ArmoryActivity({
|
||||
locale: string
|
||||
initial: ActivityEntry[]
|
||||
total: number
|
||||
grandTotal: number
|
||||
pageSize: number
|
||||
labels: { earned: string; search: string; empty: string }
|
||||
labels: {
|
||||
filterId: string
|
||||
filterName: string
|
||||
empty: string
|
||||
logro: string
|
||||
date: string
|
||||
title: string
|
||||
countLine: string
|
||||
}
|
||||
}) {
|
||||
const [entries, setEntries] = useState<ActivityEntry[]>(initial)
|
||||
const [total, setTotal] = useState(initialTotal)
|
||||
const [page, setPage] = useState(1)
|
||||
const [q, setQ] = useState('')
|
||||
const [qid, setQid] = useState('')
|
||||
const [qname, setQname] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
const load = async (p: number, query: string) => {
|
||||
const load = async (p: number, id: string, name: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/armory/activity?guid=${guid}&page=${p}&q=${encodeURIComponent(query)}&locale=${locale}`,
|
||||
`/api/armory/activity?guid=${guid}&page=${p}&id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}&locale=${locale}`,
|
||||
)
|
||||
const data = await res.json()
|
||||
setEntries(Array.isArray(data.entries) ? data.entries : [])
|
||||
@@ -49,7 +59,6 @@ export function ArmoryActivity({
|
||||
}
|
||||
}
|
||||
|
||||
// Búsqueda en tiempo real (con retardo para no consultar en cada tecla).
|
||||
const skip = useRef(true)
|
||||
useEffect(() => {
|
||||
if (skip.current) {
|
||||
@@ -58,21 +67,22 @@ export function ArmoryActivity({
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
load(1, q)
|
||||
load(1, qid, qname)
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q])
|
||||
}, [qid, qname])
|
||||
|
||||
const goto = (p: number) => {
|
||||
if (p < 1 || p > pages || p === page || loading) return
|
||||
setPage(p)
|
||||
load(p, q)
|
||||
load(p, qid, qname)
|
||||
}
|
||||
|
||||
const fmt = (ts: number) => {
|
||||
const d = new Date(ts * 1000)
|
||||
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${p(d.getDate())}/${p(d.getMonth() + 1)}/${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
}
|
||||
|
||||
const win = 2
|
||||
@@ -81,44 +91,57 @@ export function ArmoryActivity({
|
||||
const nums: number[] = []
|
||||
for (let p = from; p <= to; p++) nums.push(p)
|
||||
|
||||
const countLine = labels.countLine
|
||||
.replace('{total}', String(total))
|
||||
.replace('{page}', String(page))
|
||||
.replace('{pages}', String(pages))
|
||||
|
||||
return (
|
||||
<>
|
||||
<WowheadRefresh dep={`${page}-${q}-${entries.length}`} />
|
||||
<input
|
||||
className="armory-ach-search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={labels.search}
|
||||
aria-label={labels.search}
|
||||
/>
|
||||
<div className={`armory-activity${loading ? ' loading' : ''}`}>
|
||||
{entries.length === 0 ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
entries.map((a, i) => (
|
||||
<div className="armory-activity-row" key={`${a.id}-${a.date}-${i}`}>
|
||||
<span
|
||||
className="armory-activity-icon"
|
||||
style={{ backgroundImage: `url(${wowheadIcon(a.icon, 'small')})` }}
|
||||
/>
|
||||
<span className="armory-activity-text">
|
||||
{labels.earned}{' '}
|
||||
<a
|
||||
className="armory-activity-link"
|
||||
href={wowheadUrl('achievement', a.id, locale)}
|
||||
data-wowhead={wowheadData('achievement', a.id, locale)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
[{a.name}]
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
<span className="armory-activity-date">{fmt(a.date)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<WowheadRefresh dep={`${page}-${qid}-${qname}-${entries.length}`} />
|
||||
|
||||
<div className="armory-quest-filters armory-ach-filters">
|
||||
<input className="armory-ach-search" value={qid} onChange={(e) => setQid(e.target.value)} placeholder={labels.filterId} inputMode="numeric" />
|
||||
<input className="armory-ach-search" value={qname} onChange={(e) => setQname(e.target.value)} placeholder={labels.filterName} />
|
||||
</div>
|
||||
<p className="armory-quest-count">{countLine}</p>
|
||||
|
||||
<div className="armory-quest-title">
|
||||
<i className="fas fa-trophy" /> {labels.title} <span className="armory-quest-title-count">({grandTotal})</span>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
<table className={`armory-quest-table${loading ? ' loading' : ''}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{labels.logro}</th>
|
||||
<th className="armory-quest-th-status">{labels.date}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((a) => (
|
||||
<tr key={`${a.id}-${a.date}`}>
|
||||
<td>
|
||||
<a
|
||||
className="armory-quest-link armory-ach-link"
|
||||
href={wowheadUrl('achievement', a.id, locale)}
|
||||
data-wowhead={wowheadData('achievement', a.id, locale)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{a.name}
|
||||
</a>
|
||||
<span className="armory-quest-id"> ({a.id})</span>
|
||||
</td>
|
||||
<td className="armory-quest-td-status armory-ach-date">{fmt(a.date)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<div className="armory-pager">
|
||||
<button onClick={() => goto(page - 1)} disabled={page <= 1} type="button" aria-label="Anterior">
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { BgMatch } from '@/lib/armory'
|
||||
|
||||
/** Nombre + color por BattlegroundTypeId (3.4.3 WotLK). */
|
||||
const BG_TYPES: Record<number, { es: string; en: string; color: string; icon: string }> = {
|
||||
1: { es: 'Valle de Alterac', en: 'Alterac Valley', color: '#4a90d9', icon: 'fas fa-mountain' },
|
||||
2: { es: 'Garganta Grito de Guerra', en: 'Warsong Gulch', color: '#2ecc71', icon: 'fas fa-flag' },
|
||||
3: { es: 'Cuenca de Arathi', en: 'Arathi Basin', color: '#e0a13a', icon: 'fas fa-tower-observation' },
|
||||
7: { es: 'Ojo de la Tormenta', en: 'Eye of the Storm', color: '#9b59b6', icon: 'fas fa-bolt' },
|
||||
9: { es: 'Costa de los Ancestros', en: 'Strand of the Ancients', color: '#c0703a', icon: 'fas fa-ship' },
|
||||
30: { es: 'Isla de la Conquista', en: 'Isle of Conquest', color: '#5dade2', icon: 'fas fa-anchor' },
|
||||
32: { es: 'Campo de batalla aleatorio', en: 'Random Battleground', color: '#888888', icon: 'fas fa-dice' },
|
||||
}
|
||||
|
||||
export type BgLabels = {
|
||||
history: string
|
||||
desc: string
|
||||
matches: string
|
||||
wins: string
|
||||
losses: string
|
||||
winRate: string
|
||||
result: string
|
||||
win: string
|
||||
loss: string
|
||||
kb: string
|
||||
hk: string
|
||||
deaths: string
|
||||
damage: string
|
||||
healing: string
|
||||
honor: string
|
||||
date: string
|
||||
none: string
|
||||
battleground: string
|
||||
}
|
||||
|
||||
export function ArmoryBattlegrounds({
|
||||
locale,
|
||||
rows,
|
||||
matches,
|
||||
wins,
|
||||
losses,
|
||||
winRate,
|
||||
labels,
|
||||
}: {
|
||||
locale: string
|
||||
rows: BgMatch[]
|
||||
matches: number
|
||||
wins: number
|
||||
losses: number
|
||||
winRate: number
|
||||
labels: BgLabels
|
||||
}) {
|
||||
const fmt = (n: number) => n.toLocaleString(locale)
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return '—'
|
||||
const day = d.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
const time = d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
||||
return (
|
||||
<span className="armory-bg-dt">
|
||||
<span className="armory-bg-dt-d">{day}</span>
|
||||
<span className="armory-bg-dt-t">{time}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const bgOf = (type: number) =>
|
||||
BG_TYPES[type] || { es: `BG #${type}`, en: `BG #${type}`, color: '#777', icon: 'fas fa-khanda' }
|
||||
|
||||
return (
|
||||
<div className="armory-panel armory-bg-panel">
|
||||
<div className="armory-bg-header">
|
||||
<div className="armory-quest-title">
|
||||
<i className="fas fa-flag-checkered" /> {labels.history}
|
||||
</div>
|
||||
<p className="armory-bg-desc">{labels.desc}</p>
|
||||
</div>
|
||||
|
||||
<div className="armory-bg-summary">
|
||||
<div className="armory-bg-stat">
|
||||
<span className="armory-bg-stat-num" style={{ color: '#ffd100' }}>
|
||||
{fmt(matches)}
|
||||
</span>
|
||||
<span className="armory-bg-stat-lbl">{labels.matches}</span>
|
||||
</div>
|
||||
<div className="armory-bg-stat">
|
||||
<span className="armory-bg-stat-num" style={{ color: '#2ecc71' }}>
|
||||
{fmt(wins)}
|
||||
</span>
|
||||
<span className="armory-bg-stat-lbl">{labels.wins}</span>
|
||||
</div>
|
||||
<div className="armory-bg-stat">
|
||||
<span className="armory-bg-stat-num" style={{ color: '#ff5555' }}>
|
||||
{fmt(losses)}
|
||||
</span>
|
||||
<span className="armory-bg-stat-lbl">{labels.losses}</span>
|
||||
</div>
|
||||
<div className="armory-bg-stat">
|
||||
<span className="armory-bg-stat-num" style={{ color: '#e0a13a' }}>
|
||||
{winRate}%
|
||||
</span>
|
||||
<span className="armory-bg-stat-lbl">{labels.winRate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="armory-hint">{labels.none}</p>
|
||||
) : (
|
||||
<div className="armory-bg-table-wrap">
|
||||
<table className="armory-quest-table armory-bg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{labels.battleground}</th>
|
||||
<th className="armory-bg-c">{labels.result}</th>
|
||||
<th className="armory-bg-c">{labels.kb}</th>
|
||||
<th className="armory-bg-c">{labels.hk}</th>
|
||||
<th className="armory-bg-c">{labels.deaths}</th>
|
||||
<th className="armory-bg-c">{labels.damage}</th>
|
||||
<th className="armory-bg-c">{labels.healing}</th>
|
||||
<th className="armory-bg-c">{labels.honor}</th>
|
||||
<th className="armory-bg-c">{labels.date}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((m) => {
|
||||
const bg = bgOf(m.type)
|
||||
const name = locale === 'en' ? bg.en : bg.es
|
||||
return (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
<span className="armory-bg-name">
|
||||
<span className="armory-bg-ico" style={{ background: bg.color }}>
|
||||
<i className={bg.icon} />
|
||||
</span>
|
||||
{name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="armory-bg-c">
|
||||
<span className={`armory-bg-res ${m.won ? 'win' : 'loss'}`}>{m.won ? labels.win : labels.loss}</span>
|
||||
</td>
|
||||
<td className="armory-bg-c armory-bg-kb">{fmt(m.killingBlows)}</td>
|
||||
<td className="armory-bg-c">{fmt(m.honorableKills)}</td>
|
||||
<td className="armory-bg-c">{fmt(m.deaths)}</td>
|
||||
<td className="armory-bg-c">{fmt(m.damage)}</td>
|
||||
<td className="armory-bg-c">{fmt(m.healing)}</td>
|
||||
<td className="armory-bg-c armory-bg-honor">{fmt(m.bonusHonor)}</td>
|
||||
<td className="armory-bg-c armory-bg-date">{fmtDate(m.date)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { WowheadRefresh } from './WowheadRefresh'
|
||||
import { wowheadIcon, wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
|
||||
export type CollectionEntry = { id: number; name: string; icon: string }
|
||||
|
||||
/** Colección (monturas/mascotas) con búsqueda en tiempo real y paginación en cliente. */
|
||||
export function ArmoryCollection({
|
||||
items,
|
||||
locale,
|
||||
pageSize,
|
||||
labels,
|
||||
}: {
|
||||
items: CollectionEntry[]
|
||||
locale: string
|
||||
pageSize: number
|
||||
labels: { title: string; search: string; empty: string }
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const query = q.trim().toLowerCase()
|
||||
const filtered = query ? items.filter((i) => i.name.toLowerCase().includes(query)) : items
|
||||
const pages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
||||
const p = Math.min(page, pages)
|
||||
const pageItems = filtered.slice((p - 1) * pageSize, (p - 1) * pageSize + pageSize)
|
||||
|
||||
const win = 2
|
||||
const from = Math.max(1, p - win)
|
||||
const to = Math.min(pages, p + win)
|
||||
const nums: number[] = []
|
||||
for (let i = from; i <= to; i++) nums.push(i)
|
||||
|
||||
return (
|
||||
<div className="armory-panel">
|
||||
<WowheadRefresh dep={`${p}-${q}-${pageItems.length}`} />
|
||||
<div className="armory-collection-head">{labels.title}</div>
|
||||
<input
|
||||
className="armory-ach-search"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder={labels.search}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
<div className="armory-collection">
|
||||
{pageItems.map((c) => (
|
||||
<a
|
||||
className="armory-coll-card"
|
||||
key={c.id}
|
||||
href={wowheadUrl('spell', c.id, locale)}
|
||||
data-wowhead={wowheadData('spell', c.id, locale)}
|
||||
data-wh-icon-size="false"
|
||||
data-wh-rename-link="false"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="armory-coll-icon" style={{ backgroundImage: `url(${wowheadIcon(c.icon, 'small')})` }} />
|
||||
<span className="armory-coll-name">{c.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<div className="armory-pager">
|
||||
<button onClick={() => setPage(p - 1)} disabled={p <= 1} type="button" aria-label="Anterior">
|
||||
‹
|
||||
</button>
|
||||
{from > 1 && (
|
||||
<>
|
||||
<button onClick={() => setPage(1)} type="button">
|
||||
1
|
||||
</button>
|
||||
{from > 2 && <span className="armory-pager-dots">…</span>}
|
||||
</>
|
||||
)}
|
||||
{nums.map((n) => (
|
||||
<button key={n} className={n === p ? 'active' : ''} onClick={() => setPage(n)} type="button">
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
{to < pages && (
|
||||
<>
|
||||
{to < pages - 1 && <span className="armory-pager-dots">…</span>}
|
||||
<button onClick={() => setPage(pages)} type="button">
|
||||
{pages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => setPage(p + 1)} disabled={p >= pages} type="button" aria-label="Siguiente">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { className, classColor, raceName } from '@/lib/wow-data'
|
||||
|
||||
export type GuildMemberRow = {
|
||||
guid: number
|
||||
name: string
|
||||
race: number
|
||||
class: number
|
||||
gender: number
|
||||
level: number
|
||||
rank: number
|
||||
rankName: string
|
||||
achPoints: number
|
||||
role: string
|
||||
ilvl: number
|
||||
}
|
||||
|
||||
const ROLE_META: Record<string, { icon: string; color: string }> = {
|
||||
tank: { icon: 'fas fa-shield-halved', color: '#4a90d9' },
|
||||
healer: { icon: 'fas fa-plus', color: '#4ade5f' },
|
||||
dps: { icon: 'fas fa-gavel', color: '#d9534f' },
|
||||
}
|
||||
|
||||
const CLASS_SLUG: Record<number, string> = {
|
||||
1: 'warrior', 2: 'paladin', 3: 'hunter', 4: 'rogue', 5: 'priest',
|
||||
6: 'deathknight', 7: 'shaman', 8: 'mage', 9: 'warlock', 11: 'druid',
|
||||
}
|
||||
const RACE_SLUG: Record<number, string> = {
|
||||
1: 'human', 2: 'orc', 3: 'dwarf', 4: 'nightelf', 5: 'scourge', 6: 'tauren',
|
||||
7: 'gnome', 8: 'troll', 9: 'goblin', 10: 'bloodelf', 11: 'draenei', 22: 'worgen',
|
||||
}
|
||||
const ICON = 'https://wow.zamimg.com/images/wow/icons/large'
|
||||
const classIcon = (c: number) => `${ICON}/classicon_${CLASS_SLUG[c] || 'warrior'}.jpg`
|
||||
const raceIcon = (r: number, g: number) => `${ICON}/race_${RACE_SLUG[r] || 'human'}_${g === 1 ? 'female' : 'male'}.jpg`
|
||||
|
||||
export function ArmoryGuildRoster({
|
||||
guildid,
|
||||
locale,
|
||||
initial,
|
||||
total: initialTotal,
|
||||
pageSize,
|
||||
classes,
|
||||
labels,
|
||||
}: {
|
||||
guildid: number
|
||||
locale: string
|
||||
initial: GuildMemberRow[]
|
||||
total: number
|
||||
pageSize: number
|
||||
classes: number[]
|
||||
labels: {
|
||||
view: string
|
||||
sortRank: string
|
||||
sortPoints: string
|
||||
sortLevel: string
|
||||
sortName: string
|
||||
sortIlvl: string
|
||||
ilvl: string
|
||||
filterClass: string
|
||||
allClasses: string
|
||||
filterRole: string
|
||||
allRoles: string
|
||||
roleTank: string
|
||||
roleHealer: string
|
||||
roleDps: string
|
||||
name: string
|
||||
race: string
|
||||
class: string
|
||||
role: string
|
||||
level: string
|
||||
rank: string
|
||||
points: string
|
||||
}
|
||||
}) {
|
||||
const [rows, setRows] = useState<GuildMemberRow[]>(initial)
|
||||
const [total, setTotal] = useState(initialTotal)
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('rank')
|
||||
const [cls, setCls] = useState(0) // 0 = todas
|
||||
const [role, setRole] = useState('') // '' = todas
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
const load = async (p: number, s: string, c: number, r: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const q = new URLSearchParams({ guild: String(guildid), page: String(p), sort: s, locale })
|
||||
if (c) q.set('classes', String(c))
|
||||
if (r) q.set('roles', r)
|
||||
const res = await fetch(`/api/armory/guild-roster?${q.toString()}`)
|
||||
const data = await res.json()
|
||||
setRows(Array.isArray(data.members) ? data.members : [])
|
||||
setTotal(Number(data.total) || 0)
|
||||
} catch {
|
||||
setRows([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const skip = useRef(true)
|
||||
useEffect(() => {
|
||||
if (skip.current) {
|
||||
skip.current = false
|
||||
return
|
||||
}
|
||||
setPage(1)
|
||||
load(1, sort, cls, role)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sort, cls, role])
|
||||
|
||||
const goto = (p: number) => {
|
||||
if (p < 1 || p > pages || p === page || loading) return
|
||||
setPage(p)
|
||||
load(p, sort, cls, role)
|
||||
}
|
||||
|
||||
const from = Math.max(1, page - 2)
|
||||
const to = Math.min(pages, page + 2)
|
||||
const nums: number[] = []
|
||||
for (let p = from; p <= to; p++) nums.push(p)
|
||||
|
||||
const sortField = sort === 'points' ? labels.sortPoints : sort === 'level' ? labels.sortLevel : sort === 'name' ? labels.sortName : labels.sortRank
|
||||
|
||||
return (
|
||||
<div className="armory-guild-roster">
|
||||
<div className="armory-guild-filters">
|
||||
<label className="armory-guild-filter">
|
||||
<span>{labels.view}</span>
|
||||
<select value={sort} onChange={(e) => setSort(e.target.value)}>
|
||||
<option value="rank">{labels.sortRank}</option>
|
||||
<option value="points">{labels.sortPoints}</option>
|
||||
<option value="ilvl">{labels.sortIlvl}</option>
|
||||
<option value="level">{labels.sortLevel}</option>
|
||||
<option value="name">{labels.sortName}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="armory-guild-filter">
|
||||
<span>{labels.filterClass}</span>
|
||||
<select value={cls} onChange={(e) => setCls(Number(e.target.value))}>
|
||||
<option value={0}>{labels.allClasses}</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{className(c, locale)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="armory-guild-filter">
|
||||
<span>{labels.filterRole}</span>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="">{labels.allRoles}</option>
|
||||
<option value="tank">{labels.roleTank}</option>
|
||||
<option value="healer">{labels.roleHealer}</option>
|
||||
<option value="dps">{labels.roleDps}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<table className={`armory-guild-table${loading ? ' loading' : ''}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{labels.name}</th>
|
||||
<th className="armory-gt-c">{labels.race}</th>
|
||||
<th className="armory-gt-c">{labels.class}</th>
|
||||
<th className="armory-gt-c">{labels.role}</th>
|
||||
<th className="armory-gt-c">{labels.level}</th>
|
||||
<th className="armory-gt-pts">{sort === 'points' ? labels.points : sort === 'ilvl' ? labels.ilvl : labels.rank}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((m) => (
|
||||
<tr key={m.guid}>
|
||||
<td>
|
||||
<a className="armory-gt-name" href={`/${locale}/armory/character/${m.guid}`} style={{ color: classColor(m.class) }}>
|
||||
<img className="armory-gt-avatar" src={raceIcon(m.race, m.gender)} alt="" loading="lazy" />
|
||||
{m.name}
|
||||
</a>
|
||||
</td>
|
||||
<td className="armory-gt-c">
|
||||
<img className="armory-gt-ico" src={raceIcon(m.race, m.gender)} alt={raceName(m.race, locale)} title={raceName(m.race, locale)} loading="lazy" />
|
||||
</td>
|
||||
<td className="armory-gt-c">
|
||||
<img className="armory-gt-ico" src={classIcon(m.class)} alt={className(m.class, locale)} title={className(m.class, locale)} loading="lazy" />
|
||||
</td>
|
||||
<td className="armory-gt-c">
|
||||
{(() => {
|
||||
const rm = ROLE_META[m.role] || ROLE_META.dps
|
||||
const label = m.role === 'tank' ? labels.roleTank : m.role === 'healer' ? labels.roleHealer : labels.roleDps
|
||||
return (
|
||||
<span className="armory-gt-role" style={{ color: rm.color }} title={label}>
|
||||
<i className={rm.icon} />
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
<td className="armory-gt-c armory-gt-level">{m.level}</td>
|
||||
<td className="armory-gt-pts">
|
||||
{sort === 'points' ? (
|
||||
<span className="armory-gt-points">
|
||||
<i className="fas fa-shield" /> {m.achPoints.toLocaleString(locale)}
|
||||
</span>
|
||||
) : sort === 'ilvl' ? (
|
||||
<span className="armory-gt-ilvl">{m.ilvl || '—'}</span>
|
||||
) : (
|
||||
<span className="armory-gt-rank">{m.rankName || `#${m.rank}`}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{pages > 1 && (
|
||||
<div className="armory-pager">
|
||||
<button onClick={() => goto(page - 1)} disabled={page <= 1} type="button" aria-label="Anterior">
|
||||
‹
|
||||
</button>
|
||||
{from > 1 && (
|
||||
<>
|
||||
<button onClick={() => goto(1)} type="button">1</button>
|
||||
{from > 2 && <span className="armory-pager-dots">…</span>}
|
||||
</>
|
||||
)}
|
||||
{nums.map((p) => (
|
||||
<button key={p} className={p === page ? 'active' : ''} onClick={() => goto(p)} type="button">
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{to < pages && (
|
||||
<>
|
||||
{to < pages - 1 && <span className="armory-pager-dots">…</span>}
|
||||
<button onClick={() => goto(pages)} type="button">{pages}</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => goto(page + 1)} disabled={page >= pages} type="button" aria-label="Siguiente">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type PvpRankEntry = { rank: number; guid: number; name: string; race: number; class: number; kills: number; playedTime: number }
|
||||
|
||||
const MEDAL = ['🥇', '🥈', '🥉']
|
||||
|
||||
/** Ranking mundial de bajas: búsqueda por nombre + paginación (API). */
|
||||
export function ArmoryPvpRank({
|
||||
locale,
|
||||
initial,
|
||||
total: initialTotal,
|
||||
pageSize,
|
||||
labels,
|
||||
}: {
|
||||
locale: string
|
||||
initial: PvpRankEntry[]
|
||||
total: number
|
||||
pageSize: number
|
||||
labels: {
|
||||
title: string
|
||||
desc: string
|
||||
search: string
|
||||
empty: string
|
||||
character: string
|
||||
kills: string
|
||||
playedTime: string
|
||||
}
|
||||
}) {
|
||||
const [entries, setEntries] = useState<PvpRankEntry[]>(initial)
|
||||
const [total, setTotal] = useState(initialTotal)
|
||||
const [page, setPage] = useState(1)
|
||||
const [q, setQ] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
const load = async (p: number, search: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/armory/pvprank?page=${p}&q=${encodeURIComponent(search)}&locale=${locale}`)
|
||||
const data = await res.json()
|
||||
setEntries(Array.isArray(data.entries) ? data.entries : [])
|
||||
setTotal(Number(data.total) || 0)
|
||||
} catch {
|
||||
setEntries([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const skip = useRef(true)
|
||||
useEffect(() => {
|
||||
if (skip.current) {
|
||||
skip.current = false
|
||||
return
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
load(1, q)
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q])
|
||||
|
||||
const goto = (p: number) => {
|
||||
if (p < 1 || p > pages || p === page || loading) return
|
||||
setPage(p)
|
||||
load(p, q)
|
||||
}
|
||||
|
||||
const fmtTime = (sec: number) => {
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
return `${d}d ${h}h ${m}m`
|
||||
}
|
||||
|
||||
const win = 2
|
||||
const from = Math.max(1, page - win)
|
||||
const to = Math.min(pages, page + win)
|
||||
const nums: number[] = []
|
||||
for (let p = from; p <= to; p++) nums.push(p)
|
||||
|
||||
return (
|
||||
<div className="armory-panel armory-pvprank-panel">
|
||||
<div className="armory-pvprank-header">
|
||||
<div className="armory-quest-title">
|
||||
<i className="fas fa-trophy" /> {labels.title}
|
||||
</div>
|
||||
<p className="armory-pvprank-desc">{labels.desc}</p>
|
||||
</div>
|
||||
|
||||
<input className="armory-ach-search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={labels.search} />
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
<table className={`armory-quest-table armory-rank-table${loading ? ' loading' : ''}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="armory-rank-th-pos">#</th>
|
||||
<th>{labels.character}</th>
|
||||
<th className="armory-rank-th-kills">{labels.kills}</th>
|
||||
<th className="armory-rank-th-time">{labels.playedTime}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.guid}>
|
||||
<td className="armory-rank-pos">
|
||||
{e.rank <= 3 ? <span className="armory-rank-medal">{MEDAL[e.rank - 1]}</span> : null} {e.rank}
|
||||
</td>
|
||||
<td>
|
||||
<a className="armory-rank-name" href={`/${locale}/armory/character/${e.guid}`}>
|
||||
{e.name}
|
||||
</a>
|
||||
</td>
|
||||
<td className="armory-rank-kills">{e.kills.toLocaleString(locale)}</td>
|
||||
<td className="armory-rank-time">{fmtTime(e.playedTime)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<div className="armory-pager">
|
||||
<button onClick={() => goto(page - 1)} disabled={page <= 1} type="button" aria-label="Anterior">
|
||||
‹
|
||||
</button>
|
||||
{from > 1 && (
|
||||
<>
|
||||
<button onClick={() => goto(1)} type="button">
|
||||
1
|
||||
</button>
|
||||
{from > 2 && <span className="armory-pager-dots">…</span>}
|
||||
</>
|
||||
)}
|
||||
{nums.map((p) => (
|
||||
<button key={p} className={p === page ? 'active' : ''} onClick={() => goto(p)} type="button">
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{to < pages && (
|
||||
<>
|
||||
{to < pages - 1 && <span className="armory-pager-dots">…</span>}
|
||||
<button onClick={() => goto(pages)} type="button">
|
||||
{pages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => goto(page + 1)} disabled={page >= pages} type="button" aria-label="Siguiente">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { WowheadRefresh } from './WowheadRefresh'
|
||||
import { wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
|
||||
export type QuestStatus = 'rewarded' | 'complete' | 'inprogress' | 'failed'
|
||||
export type QuestEntry = { id: number; name: string; status: QuestStatus }
|
||||
|
||||
// Color + icono por estado (mismo criterio para nombre, texto de estado e icono del filtro).
|
||||
const STATUS: Record<string, { color: string; icon: string }> = {
|
||||
all: { color: '#d4cdbb', icon: '' },
|
||||
inprogress: { color: '#d79602', icon: 'fas fa-scroll' },
|
||||
complete: { color: '#1eff00', icon: 'fas fa-check' },
|
||||
failed: { color: '#ff5555', icon: 'fas fa-times' },
|
||||
rewarded: { color: '#a335ee', icon: 'fas fa-gift' },
|
||||
}
|
||||
|
||||
export function ArmoryQuests({
|
||||
guid,
|
||||
locale,
|
||||
initial,
|
||||
total: initialTotal,
|
||||
grandTotal,
|
||||
pageSize,
|
||||
statuses,
|
||||
badgeLabels,
|
||||
labels,
|
||||
}: {
|
||||
guid: number
|
||||
locale: string
|
||||
initial: QuestEntry[]
|
||||
total: number
|
||||
grandTotal: number
|
||||
pageSize: number
|
||||
statuses: { key: string; label: string; count: number }[]
|
||||
badgeLabels: Record<string, string>
|
||||
labels: { filterId: string; filterName: string; empty: string; quest: string; statusCol: string; title: string; pageOf: string }
|
||||
}) {
|
||||
const [entries, setEntries] = useState<QuestEntry[]>(initial)
|
||||
const [total, setTotal] = useState(initialTotal)
|
||||
const [page, setPage] = useState(1)
|
||||
const [qid, setQid] = useState('')
|
||||
const [qname, setQname] = useState('')
|
||||
const [status, setStatus] = useState('all')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
const load = async (p: number, id: string, name: string, st: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/armory/quests?guid=${guid}&page=${p}&id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}&status=${st}&locale=${locale}`,
|
||||
)
|
||||
const data = await res.json()
|
||||
setEntries(Array.isArray(data.entries) ? data.entries : [])
|
||||
setTotal(Number(data.total) || 0)
|
||||
} catch {
|
||||
setEntries([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const skip = useRef(true)
|
||||
useEffect(() => {
|
||||
if (skip.current) {
|
||||
skip.current = false
|
||||
return
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
setPage(1)
|
||||
load(1, qid, qname, status)
|
||||
}, 300)
|
||||
return () => clearTimeout(t)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qid, qname, status])
|
||||
|
||||
const goto = (p: number) => {
|
||||
if (p < 1 || p > pages || p === page || loading) return
|
||||
setPage(p)
|
||||
load(p, qid, qname, status)
|
||||
}
|
||||
const pick = (key: string) => {
|
||||
setOpen(false)
|
||||
setStatus(key)
|
||||
}
|
||||
|
||||
const win = 2
|
||||
const from = Math.max(1, page - win)
|
||||
const to = Math.min(pages, page + win)
|
||||
const nums: number[] = []
|
||||
for (let p = from; p <= to; p++) nums.push(p)
|
||||
|
||||
const current = statuses.find((s) => s.key === status) ?? statuses[0]
|
||||
const optLabel = (s: { key: string; label: string; count: number }) =>
|
||||
s.key === 'all' ? `${s.label}` : `${s.label} (${s.count})`
|
||||
|
||||
return (
|
||||
<>
|
||||
<WowheadRefresh dep={`${page}-${qid}-${qname}-${status}-${entries.length}`} />
|
||||
|
||||
<div className="armory-quest-title">
|
||||
<i className="fas fa-scroll" /> {labels.title} <span className="armory-quest-title-count">({grandTotal})</span>
|
||||
</div>
|
||||
|
||||
<div className="armory-quest-filters">
|
||||
<input className="armory-ach-search" value={qid} onChange={(e) => setQid(e.target.value)} placeholder={labels.filterId} inputMode="numeric" />
|
||||
<input className="armory-ach-search" value={qname} onChange={(e) => setQname(e.target.value)} placeholder={labels.filterName} />
|
||||
<div className="armory-dropdown">
|
||||
<button className="armory-dropdown-btn" onClick={() => setOpen((o) => !o)} type="button">
|
||||
{current && STATUS[current.key]?.icon ? <i className={STATUS[current.key].icon} style={{ color: STATUS[current.key].color }} /> : null}
|
||||
<span>{current ? optLabel(current) : ''}</span>
|
||||
<i className="fas fa-caret-down armory-dropdown-caret" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="armory-dropdown-list">
|
||||
{statuses.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
className={`armory-dropdown-item${s.key === status ? ' active' : ''}`}
|
||||
onClick={() => pick(s.key)}
|
||||
type="button"
|
||||
>
|
||||
{STATUS[s.key]?.icon ? <i className={STATUS[s.key].icon} style={{ color: STATUS[s.key].color }} /> : <span className="armory-dropdown-noicon" />}
|
||||
<span>{optLabel(s)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
<table className={`armory-quest-table${loading ? ' loading' : ''}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{labels.quest}</th>
|
||||
<th className="armory-quest-th-status">{labels.statusCol}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((q) => {
|
||||
const color = STATUS[q.status]?.color ?? '#d4cdbb'
|
||||
return (
|
||||
<tr key={q.id}>
|
||||
<td>
|
||||
<a
|
||||
className="armory-quest-link"
|
||||
style={{ color }}
|
||||
href={wowheadUrl('quest', q.id, locale)}
|
||||
data-wowhead={wowheadData('quest', q.id, locale)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{q.name}
|
||||
</a>
|
||||
<span className="armory-quest-id"> ({q.id})</span>
|
||||
</td>
|
||||
<td className="armory-quest-td-status" style={{ color }}>
|
||||
{badgeLabels[q.status] ?? q.status}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<div className="armory-pager">
|
||||
<button onClick={() => goto(page - 1)} disabled={page <= 1} type="button" aria-label="Anterior">
|
||||
‹
|
||||
</button>
|
||||
{from > 1 && (
|
||||
<>
|
||||
<button onClick={() => goto(1)} type="button">
|
||||
1
|
||||
</button>
|
||||
{from > 2 && <span className="armory-pager-dots">…</span>}
|
||||
</>
|
||||
)}
|
||||
{nums.map((p) => (
|
||||
<button key={p} className={p === page ? 'active' : ''} onClick={() => goto(p)} type="button">
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{to < pages && (
|
||||
<>
|
||||
{to < pages - 1 && <span className="armory-pager-dots">…</span>}
|
||||
<button onClick={() => goto(pages)} type="button">
|
||||
{pages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => goto(page + 1)} disabled={page >= pages} type="button" aria-label="Siguiente">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
export type RaidBossState = { name: string; killed: boolean }
|
||||
export type RaidDifficulty = { diff: number; label: string; killed: number; total: number; bosses: RaidBossState[] }
|
||||
export type RaidProgressItem = { map: number; name: string; exp: number; total: number; difficulties: RaidDifficulty[] }
|
||||
|
||||
/** Gradiente de cabecera por banda (sin arte del cliente). */
|
||||
const RAID_THEME: Record<number, string> = {
|
||||
// Classic
|
||||
409: 'linear-gradient(135deg, #8a3a1a, #2e1208)',
|
||||
469: 'linear-gradient(135deg, #7a2a2a, #250d0d)',
|
||||
531: 'linear-gradient(135deg, #8a7a2a, #2a2408)',
|
||||
509: 'linear-gradient(135deg, #6b7a2a, #202808)',
|
||||
309: 'linear-gradient(135deg, #2f7a5a, #0d281c)',
|
||||
// TBC
|
||||
532: 'linear-gradient(135deg, #4a2f6b, #1a1028)',
|
||||
565: 'linear-gradient(135deg, #6b3a2f, #281410)',
|
||||
544: 'linear-gradient(135deg, #7a2f4a, #2a1018)',
|
||||
548: 'linear-gradient(135deg, #2a6b6b, #0d2828)',
|
||||
550: 'linear-gradient(135deg, #3a5c8a, #101f2f)',
|
||||
534: 'linear-gradient(135deg, #2f6b46, #0d281a)',
|
||||
564: 'linear-gradient(135deg, #5a2f6b, #1c1028)',
|
||||
580: 'linear-gradient(135deg, #8a7a2f, #2a2410)',
|
||||
568: 'linear-gradient(135deg, #6b5a2a, #282008)',
|
||||
// WotLK
|
||||
249: 'linear-gradient(135deg, #7a2d1a, #3a1710)',
|
||||
533: 'linear-gradient(135deg, #2f6b46, #10281a)',
|
||||
616: 'linear-gradient(135deg, #2a5c8a, #10202f)',
|
||||
615: 'linear-gradient(135deg, #4a2f6b, #1a1028)',
|
||||
624: 'linear-gradient(135deg, #6b5a2f, #282010)',
|
||||
603: 'linear-gradient(135deg, #35566b, #101c28)',
|
||||
649: 'linear-gradient(135deg, #6b4a2f, #281a10)',
|
||||
631: 'linear-gradient(135deg, #3a6b8a, #0f2230)',
|
||||
724: 'linear-gradient(135deg, #7a1f2f, #2a0f14)',
|
||||
}
|
||||
|
||||
function barClass(killed: number, total: number) {
|
||||
if (total > 0 && killed >= total) return 'full'
|
||||
if (killed > 0) return 'partial'
|
||||
return 'empty'
|
||||
}
|
||||
|
||||
/** Gradiente determinista por mapId cuando no hay tema fijo (mazmorras). */
|
||||
function themeFor(map: number) {
|
||||
if (RAID_THEME[map]) return RAID_THEME[map]
|
||||
const h = (map * 47) % 360
|
||||
return `linear-gradient(135deg, hsl(${h} 42% 30%), hsl(${h} 48% 11%))`
|
||||
}
|
||||
|
||||
export function ArmoryRaids({
|
||||
locale,
|
||||
raids,
|
||||
labels,
|
||||
}: {
|
||||
locale: string
|
||||
raids: RaidProgressItem[]
|
||||
labels: { bosses: string; empty: string; classic: string; tbc: string; wotlk: string }
|
||||
}) {
|
||||
if (raids.length === 0) {
|
||||
return (
|
||||
<div className="armory-panel armory-raids">
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const groups: { exp: number; title: string; items: RaidProgressItem[] }[] = [
|
||||
{ exp: 2, title: labels.wotlk, items: raids.filter((r) => r.exp === 2) },
|
||||
{ exp: 1, title: labels.tbc, items: raids.filter((r) => r.exp === 1) },
|
||||
{ exp: 0, title: labels.classic, items: raids.filter((r) => r.exp === 0) },
|
||||
].filter((g) => g.items.length > 0)
|
||||
|
||||
return (
|
||||
<div className="armory-panel armory-raids">
|
||||
{groups.map((g) => (
|
||||
<section className="armory-raid-exp" key={g.exp}>
|
||||
<h3 className="armory-raid-exp-title">{g.title}</h3>
|
||||
<div className="armory-raid-grid">
|
||||
{g.items.map((r) => (
|
||||
<div className="armory-raid-card" key={r.map}>
|
||||
<div className="armory-raid-banner" style={{ background: themeFor(r.map) }}>
|
||||
<span className="armory-raid-name">{r.name}</span>
|
||||
<span className="armory-raid-count">
|
||||
{r.total} {labels.bosses}
|
||||
</span>
|
||||
</div>
|
||||
<div className="armory-raid-diffs">
|
||||
{r.difficulties.map((d) => (
|
||||
<div className="armory-raid-row" key={d.diff}>
|
||||
<span className="armory-raid-diff-lbl">{d.label}</span>
|
||||
<div className={`armory-raid-bar ${barClass(d.killed, d.total)}`}>
|
||||
<div
|
||||
className="armory-raid-bar-fill"
|
||||
style={{ width: `${d.total ? Math.round((d.killed / d.total) * 100) : 0}%` }}
|
||||
/>
|
||||
<span className="armory-raid-bar-txt">
|
||||
{d.killed}/{d.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="armory-raid-tip" role="tooltip">
|
||||
<div className="armory-raid-tip-head">
|
||||
{r.name} · {d.label}
|
||||
</div>
|
||||
<ul>
|
||||
{d.bosses.map((b, i) => (
|
||||
<li key={i} className={b.killed ? 'killed' : 'alive'}>
|
||||
<span className="armory-raid-tip-x">{b.killed ? '1' : '0'} ×</span> {b.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export type ReputationEntry = {
|
||||
faction: number
|
||||
name: string
|
||||
standing: number
|
||||
rank: string
|
||||
rankIndex: number
|
||||
color: string
|
||||
cur: number
|
||||
max: number
|
||||
}
|
||||
|
||||
/** Pestaña de Reputaciones: búsqueda en tiempo real + paginación + barra de rango/progreso. */
|
||||
export function ArmoryReputations({
|
||||
reputations,
|
||||
pageSize,
|
||||
labels,
|
||||
}: {
|
||||
reputations: ReputationEntry[]
|
||||
pageSize: number
|
||||
labels: { title: string; search: string; empty: string }
|
||||
}) {
|
||||
const [q, setQ] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const query = q.trim().toLowerCase()
|
||||
const filtered = query ? reputations.filter((r) => r.name.toLowerCase().includes(query)) : reputations
|
||||
const pages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
||||
const p = Math.min(page, pages)
|
||||
const pageItems = filtered.slice((p - 1) * pageSize, (p - 1) * pageSize + pageSize)
|
||||
|
||||
const win = 2
|
||||
const from = Math.max(1, p - win)
|
||||
const to = Math.min(pages, p + win)
|
||||
const nums: number[] = []
|
||||
for (let i = from; i <= to; i++) nums.push(i)
|
||||
|
||||
return (
|
||||
<div className="armory-panel armory-reps-panel">
|
||||
<div className="armory-quest-title">
|
||||
<i className="fas fa-handshake" /> {labels.title}{' '}
|
||||
<span className="armory-quest-title-count">({reputations.length})</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="armory-ach-search"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder={labels.search}
|
||||
/>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="armory-hint">{labels.empty}</p>
|
||||
) : (
|
||||
<div className="armory-reps">
|
||||
{pageItems.map((r) => (
|
||||
<div className="armory-rep" key={r.faction}>
|
||||
<div className="armory-rep-head">
|
||||
<span className="armory-rep-name">{r.name}</span>
|
||||
<span className="armory-rep-standing">
|
||||
<span className="armory-rep-rank" style={{ color: r.color }}>
|
||||
{r.rank}
|
||||
</span>
|
||||
{r.rankIndex < 7 ? (
|
||||
<span className="armory-rep-nums">
|
||||
{' '}
|
||||
{r.cur.toLocaleString()} / {r.max.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="armory-rep-bar">
|
||||
<span style={{ width: `${Math.round((r.cur / Math.max(1, r.max)) * 100)}%`, background: r.color }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<div className="armory-pager">
|
||||
<button onClick={() => setPage(p - 1)} disabled={p <= 1} type="button" aria-label="Anterior">
|
||||
‹
|
||||
</button>
|
||||
{from > 1 && (
|
||||
<>
|
||||
<button onClick={() => setPage(1)} type="button">
|
||||
1
|
||||
</button>
|
||||
{from > 2 && <span className="armory-pager-dots">…</span>}
|
||||
</>
|
||||
)}
|
||||
{nums.map((n) => (
|
||||
<button key={n} className={n === p ? 'active' : ''} onClick={() => setPage(n)} type="button">
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
{to < pages && (
|
||||
<>
|
||||
{to < pages - 1 && <span className="armory-pager-dots">…</span>}
|
||||
<button onClick={() => setPage(pages)} type="button">
|
||||
{pages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => setPage(p + 1)} disabled={p >= pages} type="button" aria-label="Siguiente">
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
|
||||
export type ArmoryTab = { key: string; label: string; icon?: string; count?: number; content: ReactNode }
|
||||
|
||||
/**
|
||||
* Pestañas de la ficha de armería (Equipación / Talentos / Logros / …). Cada pestaña
|
||||
* se renderiza en el servidor y se pasa como contenido; aquí solo se conmuta cuál se
|
||||
* muestra. Se mantienen todas montadas (hidden) para no reinicializar el visor 3D.
|
||||
*/
|
||||
export function ArmoryTabs({ tabs }: { tabs: ArmoryTab[] }) {
|
||||
const [active, setActive] = useState(tabs[0]?.key)
|
||||
|
||||
return (
|
||||
<div className="armory-ctabs">
|
||||
<div className="armory-ctabbar">
|
||||
{tabs.map((tb) => (
|
||||
<button
|
||||
key={tb.key}
|
||||
type="button"
|
||||
className={`armory-ctab${tb.key === active ? ' active' : ''}`}
|
||||
onClick={() => setActive(tb.key)}
|
||||
>
|
||||
{tb.icon ? <i className={tb.icon} /> : null}
|
||||
<span>{tb.label}</span>
|
||||
{typeof tb.count === 'number' ? <span className="armory-ctab-count">{tb.count}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tabs.map((tb) => (
|
||||
<div key={tb.key} className="armory-ctab-panel" hidden={tb.key !== active}>
|
||||
{tb.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { WowheadRefresh } from './WowheadRefresh'
|
||||
import { ArmoryTalents, type TalentGroupView } from './ArmoryTalents'
|
||||
|
||||
/**
|
||||
* Pestaña de talentos con conmutador Primaria/Secundaria (doble especialización).
|
||||
* Cada grupo trae su propio árbol y sus propios glifos, así que al conmutar cambian
|
||||
* ambos. Marca cuál es la spec activa del personaje.
|
||||
*/
|
||||
export function ArmoryTalentTab({
|
||||
groups,
|
||||
activeIndex,
|
||||
spellIcons,
|
||||
locale,
|
||||
labels,
|
||||
}: {
|
||||
groups: TalentGroupView[]
|
||||
activeIndex: number
|
||||
spellIcons: Record<number, string>
|
||||
locale: string
|
||||
labels: {
|
||||
distribution: string
|
||||
glyphs: string
|
||||
majorGlyphs: string
|
||||
minorGlyphs: string
|
||||
primary: string
|
||||
secondary: string
|
||||
activeSpec: string
|
||||
}
|
||||
}) {
|
||||
const [active, setActive] = useState(activeIndex)
|
||||
const groupLabel = (i: number) => (i === 0 ? labels.primary : labels.secondary)
|
||||
const g = groups[active] ?? groups[0]
|
||||
if (!g) return null
|
||||
|
||||
return (
|
||||
<div className="armory-panel armory-talents-panel">
|
||||
<WowheadRefresh dep={`talents-${active}`} />
|
||||
|
||||
{groups.length > 1 && (
|
||||
<div className="armory-spec-toggle">
|
||||
{groups.map((_, i) => (
|
||||
<button key={i} type="button" className={i === active ? 'active' : ''} onClick={() => setActive(i)}>
|
||||
{groupLabel(i)}
|
||||
{i === activeIndex ? <span className="armory-spec-toggle-dot"> • {labels.activeSpec}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ArmoryTalents
|
||||
group={g}
|
||||
spellIcons={spellIcons}
|
||||
locale={locale}
|
||||
labels={{
|
||||
distribution: labels.distribution,
|
||||
groupLabel: groupLabel(active),
|
||||
glyphs: labels.glyphs,
|
||||
majorGlyphs: labels.majorGlyphs,
|
||||
minorGlyphs: labels.minorGlyphs,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +1,43 @@
|
||||
import { wowheadUrl, wowheadData, wowheadIcon } from '@/lib/wowhead'
|
||||
import { wowheadIcon, wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
|
||||
export type TalentCell = { tier: number; col: number; rank: number; max: number; spell: number }
|
||||
export type TalentTreeData = { index: number; name: string; points: number; talents: TalentCell[] }
|
||||
|
||||
/**
|
||||
* Árbol de talentos estilo calculador: 3 árboles, cada uno una rejilla de casillas
|
||||
* posicionadas por fila (tier) y columna. Cada casilla muestra el icono del talento
|
||||
* (del hechizo de rango 1) y su rango actual/máximo; las no aprendidas van atenuadas.
|
||||
* El icono va en un <span> hijo porque wowhead sobrescribe el background del <a>.
|
||||
*/
|
||||
export function ArmoryTalents({
|
||||
trees,
|
||||
spec,
|
||||
total,
|
||||
maxPoints,
|
||||
locale,
|
||||
spellIcons,
|
||||
}: {
|
||||
trees: TalentTreeData[]
|
||||
export type GlyphInfo = { name: string; icon: string }
|
||||
export type TalentGroupView = {
|
||||
spec: string | null
|
||||
total: number
|
||||
maxPoints: number
|
||||
locale: string
|
||||
dist: number[]
|
||||
trees: TalentTreeData[]
|
||||
glyphs: { major: GlyphInfo[]; minor: GlyphInfo[] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderiza UN grupo de talentos: cabecera de distribución + los 3 árboles (rejilla de
|
||||
* iconos) + una 4ª columna con los Glifos (mayores/menores). Componente puro sin estado,
|
||||
* usado por ArmoryTalentTab (que conmuta Primaria/Secundaria).
|
||||
*/
|
||||
export function ArmoryTalents({
|
||||
group,
|
||||
spellIcons,
|
||||
locale,
|
||||
labels,
|
||||
}: {
|
||||
group: TalentGroupView
|
||||
spellIcons: Record<number, string>
|
||||
locale: string
|
||||
labels: { distribution: string; groupLabel: string; glyphs: string; majorGlyphs: string; minorGlyphs: string }
|
||||
}) {
|
||||
const { trees, glyphs, dist } = group
|
||||
return (
|
||||
<div className="armory-talents">
|
||||
<div className="armory-spec">
|
||||
<span className="armory-spec-name">{spec}</span>
|
||||
<span className="armory-spec-dist">{trees.map((tr) => tr.points).join(' / ')}</span>
|
||||
<span className="armory-spec-total">
|
||||
{total} / {maxPoints}
|
||||
</span>
|
||||
<div className="armory-tdist">
|
||||
<div className="armory-tdist-label">
|
||||
{labels.distribution} · {labels.groupLabel.toUpperCase()}
|
||||
</div>
|
||||
<div className="armory-tdist-value">{dist.join(' / ')}</div>
|
||||
</div>
|
||||
<div className="armory-talent-trees">
|
||||
|
||||
<div className="armory-talent-cols">
|
||||
{trees.map((tree) => {
|
||||
const maxTier = tree.talents.reduce((m, t) => Math.max(m, t.tier), 0)
|
||||
return (
|
||||
@@ -76,6 +80,42 @@ export function ArmoryTalents({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="armory-talent-tree armory-glyph-col">
|
||||
<div className="armory-talent-tree-head">
|
||||
<span>{labels.glyphs}</span>
|
||||
<span className="armory-talent-tree-pts">
|
||||
{glyphs.major.length} · {glyphs.minor.length}
|
||||
</span>
|
||||
</div>
|
||||
{glyphs.major.length > 0 && (
|
||||
<div className="armory-glyph-group">
|
||||
<h3 className="armory-prof-grouptitle">{labels.majorGlyphs}</h3>
|
||||
{glyphs.major.map((g, i) => (
|
||||
<div className="armory-glyph" key={i}>
|
||||
<span className="armory-glyph-icon" style={{ backgroundImage: `url(${wowheadIcon(g.icon, 'small')})` }} />
|
||||
<span className="armory-glyph-name">{g.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{glyphs.minor.length > 0 && (
|
||||
<div className="armory-glyph-group">
|
||||
<h3 className="armory-prof-grouptitle">{labels.minorGlyphs}</h3>
|
||||
{glyphs.minor.map((g, i) => (
|
||||
<div className="armory-glyph" key={i}>
|
||||
<span className="armory-glyph-icon" style={{ backgroundImage: `url(${wowheadIcon(g.icon, 'small')})` }} />
|
||||
<span className="armory-glyph-name">{g.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{glyphs.major.length === 0 && glyphs.minor.length === 0 && (
|
||||
<p className="armory-hint" style={{ padding: '8px 0' }}>
|
||||
—
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+1200
-10
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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user