beba7301a1
- 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>
279 lines
11 KiB
Plaintext
279 lines
11 KiB
Plaintext
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>
|
|
)
|
|
}
|