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>
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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]}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
inv_boots_01
|
||||
@@ -0,0 +1 @@
|
||||
inv_pants_02
|
||||
@@ -0,0 +1 @@
|
||||
inv_shirt_01
|
||||
@@ -0,0 +1 @@
|
||||
inv_mace_107
|
||||
@@ -0,0 +1 @@
|
||||
inv_shirt_guildtabard_01
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"Model": 0,
|
||||
"Textures": null,
|
||||
"Textures2": null,
|
||||
"TextureFiles": {
|
||||
"44168": [
|
||||
{
|
||||
"FileDataId": 165326,
|
||||
"Gender": 1,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
},
|
||||
{
|
||||
"FileDataId": 165327,
|
||||
"Gender": 0,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
}
|
||||
],
|
||||
"45052": [
|
||||
{
|
||||
"FileDataId": 162632,
|
||||
"Gender": 1,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
},
|
||||
{
|
||||
"FileDataId": 162633,
|
||||
"Gender": 0,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"ModelFiles": null,
|
||||
"Item": {
|
||||
"Flags": 1,
|
||||
"InventoryType": 19,
|
||||
"ItemClass": 0,
|
||||
"ItemSubClass": 0,
|
||||
"HideGeosetMale": null,
|
||||
"HideGeosetFemale": null,
|
||||
"GeosetGroup": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"AttachGeosetGroup": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"GeosetGroupOverride": 0,
|
||||
"ItemVisual": 0,
|
||||
"ParticleColor": null
|
||||
},
|
||||
"Creature": null,
|
||||
"Character": null,
|
||||
"ItemEffects": null,
|
||||
"Equipment": null,
|
||||
"ComponentTextures": {
|
||||
"3": 44168,
|
||||
"4": 45052
|
||||
},
|
||||
"ComponentModels": null,
|
||||
"StateKits": null,
|
||||
"Decor": null,
|
||||
"Scale": 1
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"Model": 0,
|
||||
"Textures": null,
|
||||
"Textures2": null,
|
||||
"TextureFiles": {
|
||||
"27853": [
|
||||
{
|
||||
"FileDataId": 151149,
|
||||
"Gender": 1,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
},
|
||||
{
|
||||
"FileDataId": 151150,
|
||||
"Gender": 0,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
},
|
||||
{
|
||||
"FileDataId": 151151,
|
||||
"Gender": 3,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
}
|
||||
],
|
||||
"43842": [
|
||||
{
|
||||
"FileDataId": 162708,
|
||||
"Gender": 1,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
},
|
||||
{
|
||||
"FileDataId": 162709,
|
||||
"Gender": 0,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
}
|
||||
],
|
||||
"32971": [
|
||||
{
|
||||
"FileDataId": 160057,
|
||||
"Gender": 1,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
},
|
||||
{
|
||||
"FileDataId": 160058,
|
||||
"Gender": 0,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"ModelFiles": null,
|
||||
"Item": {
|
||||
"Flags": 0,
|
||||
"InventoryType": 4,
|
||||
"ItemClass": 4,
|
||||
"ItemSubClass": 0,
|
||||
"HideGeosetMale": null,
|
||||
"HideGeosetFemale": null,
|
||||
"GeosetGroup": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"AttachGeosetGroup": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"GeosetGroupOverride": 0,
|
||||
"ItemVisual": 0,
|
||||
"ParticleColor": null
|
||||
},
|
||||
"Creature": null,
|
||||
"Character": null,
|
||||
"ItemEffects": null,
|
||||
"Equipment": null,
|
||||
"ComponentTextures": {
|
||||
"0": 27853,
|
||||
"3": 43842,
|
||||
"4": 32971
|
||||
},
|
||||
"ComponentModels": null,
|
||||
"StateKits": null,
|
||||
"Decor": null,
|
||||
"Scale": 1
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"Model": 0,
|
||||
"Textures": null,
|
||||
"Textures2": null,
|
||||
"TextureFiles": {
|
||||
"34391": [
|
||||
{
|
||||
"FileDataId": 157363,
|
||||
"Gender": 1,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
},
|
||||
{
|
||||
"FileDataId": 157364,
|
||||
"Gender": 0,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
},
|
||||
{
|
||||
"FileDataId": 157365,
|
||||
"Gender": 3,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
}
|
||||
],
|
||||
"31483": [
|
||||
{
|
||||
"FileDataId": 154745,
|
||||
"Gender": 1,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
},
|
||||
{
|
||||
"FileDataId": 154746,
|
||||
"Gender": 0,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
},
|
||||
{
|
||||
"FileDataId": 154747,
|
||||
"Gender": 3,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"ModelFiles": null,
|
||||
"Item": {
|
||||
"Flags": 0,
|
||||
"InventoryType": 7,
|
||||
"ItemClass": 4,
|
||||
"ItemSubClass": 4,
|
||||
"HideGeosetMale": null,
|
||||
"HideGeosetFemale": null,
|
||||
"GeosetGroup": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"AttachGeosetGroup": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"GeosetGroupOverride": 0,
|
||||
"ItemVisual": 0,
|
||||
"ParticleColor": null
|
||||
},
|
||||
"Creature": null,
|
||||
"Character": null,
|
||||
"ItemEffects": null,
|
||||
"Equipment": null,
|
||||
"ComponentTextures": {
|
||||
"5": 34391,
|
||||
"6": 31483
|
||||
},
|
||||
"ComponentModels": null,
|
||||
"StateKits": null,
|
||||
"Decor": null,
|
||||
"Scale": 1
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"Model": 0,
|
||||
"Textures": null,
|
||||
"Textures2": null,
|
||||
"TextureFiles": {
|
||||
"31715": [
|
||||
{
|
||||
"FileDataId": 154786,
|
||||
"Gender": 3,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
}
|
||||
],
|
||||
"25002": [
|
||||
{
|
||||
"FileDataId": 152659,
|
||||
"Gender": 3,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"ModelFiles": null,
|
||||
"Item": {
|
||||
"Flags": 0,
|
||||
"InventoryType": 8,
|
||||
"ItemClass": 4,
|
||||
"ItemSubClass": 4,
|
||||
"HideGeosetMale": null,
|
||||
"HideGeosetFemale": null,
|
||||
"GeosetGroup": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"AttachGeosetGroup": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"GeosetGroupOverride": 0,
|
||||
"ItemVisual": 0,
|
||||
"ParticleColor": null
|
||||
},
|
||||
"Creature": null,
|
||||
"Character": null,
|
||||
"ItemEffects": null,
|
||||
"Equipment": null,
|
||||
"ComponentTextures": {
|
||||
"6": 31715,
|
||||
"7": 25002
|
||||
},
|
||||
"ComponentModels": null,
|
||||
"StateKits": null,
|
||||
"Decor": null,
|
||||
"Scale": 1
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"Model": 0,
|
||||
"Textures": {
|
||||
"2": 305187
|
||||
},
|
||||
"Textures2": null,
|
||||
"TextureFiles": null,
|
||||
"ModelFiles": {
|
||||
"2989": [
|
||||
{
|
||||
"FileDataId": 305165,
|
||||
"Gender": 2,
|
||||
"Class": 0,
|
||||
"Race": 0,
|
||||
"ExtraData": -1
|
||||
}
|
||||
]
|
||||
},
|
||||
"Item": {
|
||||
"Flags": 0,
|
||||
"InventoryType": 21,
|
||||
"ItemClass": 2,
|
||||
"ItemSubClass": 4,
|
||||
"HideGeosetMale": null,
|
||||
"HideGeosetFemale": null,
|
||||
"GeosetGroup": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"AttachGeosetGroup": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"GeosetGroupOverride": 0,
|
||||
"ItemVisual": 0,
|
||||
"ParticleColor": null
|
||||
},
|
||||
"Creature": null,
|
||||
"Character": null,
|
||||
"ItemEffects": null,
|
||||
"Equipment": null,
|
||||
"ComponentTextures": null,
|
||||
"ComponentModels": {
|
||||
"0": 2989
|
||||
},
|
||||
"StateKits": null,
|
||||
"Decor": null,
|
||||
"Scale": 1
|
||||
}
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 238 B |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 486 B |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1 @@
|
||||
A través de El Portal Oscuro
|
||||
@@ -0,0 +1 @@
|
||||
Llegada a Terrallende
|
||||
@@ -0,0 +1 @@
|
||||
Habla con Gryan Mantorrecio
|
||||
@@ -0,0 +1 @@
|
||||
Recompensas por los gnolls Zarparrío
|
||||
@@ -0,0 +1 @@
|
||||
El infierno se ha congelado...
|
||||
@@ -0,0 +1 @@
|
||||
Si Valgarde cae...
|
||||
@@ -0,0 +1 @@
|
||||
Rescatar a los rescatadores
|
||||
@@ -0,0 +1 @@
|
||||
La liga de los humanos
|
||||
@@ -0,0 +1 @@
|
||||
Honrar la llama
|
||||
@@ -0,0 +1 @@
|
||||
¡Profana este fuego!
|
||||
@@ -0,0 +1 @@
|
||||
¡Profana este fuego!
|
||||
@@ -0,0 +1 @@
|
||||
¡Profana este fuego!
|
||||
@@ -0,0 +1 @@
|
||||
Honrar la llama
|
||||
@@ -0,0 +1 @@
|
||||
Honrar la llama
|
||||
@@ -0,0 +1 @@
|
||||
Honrar la llama
|
||||
@@ -0,0 +1 @@
|
||||
Honrar la llama
|
||||
@@ -0,0 +1 @@
|
||||
Honrar la llama
|
||||
@@ -0,0 +1 @@
|
||||
Jugando con fuego
|
||||
@@ -0,0 +1 @@
|
||||
Incienso para los agostizos de verano
|
||||
@@ -0,0 +1 @@
|
||||
El maestro en tradición de verano
|
||||
@@ -0,0 +1 @@
|
||||
Cubo de caramelos
|
||||
@@ -0,0 +1 @@
|
||||
Aprender a ir y a volver: la manera mágica
|
||||
@@ -0,0 +1 @@
|
||||
Pequeño huérfano Lito de los Oráculos
|
||||
@@ -0,0 +1 @@
|
||||
Investigar la Mina del Eco
|
||||
@@ -0,0 +1 @@
|
||||
Los enanos modistos
|
||||
@@ -0,0 +1 @@
|
||||
Hermandad de ladrones
|
||||
@@ -0,0 +1 @@
|
||||
Escaramuza en la Mina del Eco
|
||||
@@ -0,0 +1 @@
|
||||
El Cuartel de Arroyoeste necesita ayuda
|
||||
@@ -0,0 +1 @@
|
||||
Una carta consagrada
|
||||
@@ -0,0 +1 @@
|
||||
Los lobos cruzan la frontera
|
||||
@@ -0,0 +1 @@
|
||||
Publicidad de la bodega
|
||||
@@ -0,0 +1 @@
|
||||
SE BUSCA: ¡Viscoso!
|
||||
@@ -0,0 +1 @@
|
||||
Guardianes del altar
|
||||
@@ -0,0 +1 @@
|
||||
Eagan Desollapelambres
|
||||
@@ -0,0 +1 @@
|
||||
Informa en Villadorada
|
||||