Files
NightSpire/web-next/app/[locale]/armory/character/[guid]/page.tsx
T
Inna 3af7f5e804 Armería: muñeco de papel, talentos, actividad y caché del visor 3D
La ficha de personaje pasa a mostrar el equipo sobre el paperdoll (con
encantamientos y gemas en los tooltips de wowhead), el árbol de talentos y
la actividad reciente del personaje.

El proxy de /modelviewer/* deja de ser un rewrite de Next y pasa a un route
handler que cachea en disco los assets de zamimg, para no repetir la descarga
en cada visita.
2026-07-16 21:07:39 +00:00

257 lines
10 KiB
TypeScript

import { notFound } from 'next/navigation'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import {
getCharacter,
getCharacterEquipment,
getCharacterCustomizations,
getCharacterStats,
getCharacterProfessions,
getItemIcons,
getAchievementPoints,
getCharacterTalents,
getCharacterSpecs,
getSpellIcons,
getActivityPage,
} from '@/lib/armory'
import { raceName, className, classColor, factionOf, classPower } from '@/lib/wow-data'
import { PageShell } from '@/components/PageShell'
import { ArmoryPaperdoll, type SheetItem } from '@/components/ArmoryPaperdoll'
import { wowheadIcon } from '@/lib/wowhead'
import { ArmoryTalents } from '@/components/ArmoryTalents'
import { ArmoryActivity } from '@/components/ArmoryActivity'
import { WowheadRefresh } from '@/components/WowheadRefresh'
export const dynamic = 'force-dynamic'
export default async function CharacterPage({
params,
}: {
params: Promise<{ locale: string; guid: string }>
}) {
const { locale, guid } = await params
setRequestLocale(locale)
const t = await getTranslations('Armory')
const id = Number(guid)
const character = await getCharacter(id)
if (!character) notFound()
const [equipment, customizations, stats, professions, achievementPoints, talents, specs, activityData] = await Promise.all([
getCharacterEquipment(id, locale),
getCharacterCustomizations(id),
getCharacterStats(id),
getCharacterProfessions(id, locale),
getAchievementPoints(id),
getCharacterTalents(id, character.class, locale),
getCharacterSpecs(id, character.class, locale),
getActivityPage(id, locale, 1, 10, ''),
])
const icons = await getItemIcons(equipment.map((e) => e.entry))
const spellIcons = talents ? await getSpellIcons(talents.trees.flatMap((tr) => tr.talents.map((c) => c.spell))) : {}
const items: SheetItem[] = equipment.map((e) => ({
slot: e.slot,
entry: e.entry,
name: e.name,
quality: e.quality,
itemLevel: e.itemLevel,
inventoryType: e.inventoryType,
displayId: e.displayId,
transmogDisplayId: e.transmogDisplayId,
ench: e.ench,
gems: e.gems,
icon: icons[e.entry] ?? null,
}))
const gear = equipment.filter((e) => e.slot !== 3 && e.slot !== 18 && e.itemLevel > 0)
const avgItemLevel = gear.length ? Math.round(gear.reduce((s, e) => s + e.itemLevel, 0) / gear.length) : 0
const faction = factionOf(character.race)
const factionLabel = faction === 'alliance' ? t('alliance') : faction === 'horde' ? t('horde') : ''
const power = classPower(character.class, locale)
const statRows = stats
? [
{ label: t('strength'), value: `${stats.strength}` },
{ label: t('agility'), value: `${stats.agility}` },
{ label: t('stamina'), value: `${stats.stamina}` },
{ label: t('intellect'), value: `${stats.intellect}` },
{ label: t('armor'), value: `${stats.armor}` },
{ label: t('attackPower'), value: `${stats.attackPower}` },
{ label: t('rangedAttackPower'), value: `${stats.rangedAttackPower}` },
{ label: t('spellPower'), value: `${stats.spellPower}` },
{ label: t('critChance'), value: `${stats.critPct.toFixed(2)}%` },
{ label: t('spellCrit'), value: `${stats.spellCritPct.toFixed(2)}%` },
{ label: t('dodge'), value: `${stats.dodgePct.toFixed(2)}%` },
{ label: t('parry'), value: `${stats.parryPct.toFixed(2)}%` },
{ label: t('block'), value: `${stats.blockPct.toFixed(2)}%` },
{ label: t('resilience'), value: `${stats.resilience}` },
]
: []
return (
<PageShell title={character.name}>
<div className="main-wide">
<p className="forum-path">
<Link href="/armory"> {t('backToArmory')}</Link>
</p>
<div className="armory-sheet">
<div className="armory-sheet-head">
<h1 className="armory-char-name" style={{ color: classColor(character.class) }}>
{character.name}
{avgItemLevel > 0 ? (
<span className="armory-char-ilvl">
{' '}
({t('itemLevel')} {avgItemLevel})
</span>
) : null}
</h1>
<div className="armory-char-sub">
{t('level')} {character.level} · {raceName(character.race, locale)} ·{' '}
<span style={{ color: classColor(character.class) }}>{className(character.class, locale)}</span>
</div>
<div className="armory-char-tags">
{factionLabel && <span className={`armory-faction ${faction}`}>{factionLabel}</span>}
<span className={`armory-status ${character.online ? 'online' : 'offline'}`}>
{character.online ? t('online') : t('offline')}
</span>
{character.guildName && character.guildId && (
<Link href={`/armory/guild/${character.guildId}`} className="armory-guild-tag">
&lt;{character.guildName}&gt;
</Link>
)}
<span className="armory-achpoints" title={t('achievementPoints')}>
<i className="fas fa-star" /> {achievementPoints.toLocaleString(locale)}
</span>
</div>
</div>
<ArmoryPaperdoll
race={character.race}
gender={character.gender}
customizations={customizations}
items={items}
health={character.health}
power={character.power}
powerLabel={power.label}
powerColor={power.color}
locale={locale}
labels={{
loading: t('modelLoading'),
unavailable: t('modelUnavailable'),
health: t('health'),
itemLevel: t('itemLevel'),
model: t('model3d'),
list: t('equipment'),
}}
/>
{specs.primary && (
<div className="armory-panel armory-spec-panel">
<h2 className="armory-section-title">{t('specialization')}</h2>
<div className="armory-specs">
<div className="armory-spec-box active">
<span
className="armory-spec-box-icon"
style={{ backgroundImage: `url(${wowheadIcon(specs.primary.icon, 'medium')})` }}
/>
<div className="armory-spec-box-info">
<div className="armory-spec-box-name">{specs.primary.name}</div>
<div className="armory-spec-box-dist">{specs.primary.dist.join(' / ')}</div>
</div>
</div>
<div className={`armory-spec-box ${specs.secondary ? '' : 'na'}`}>
{specs.secondary ? (
<>
<span
className="armory-spec-box-icon"
style={{ backgroundImage: `url(${wowheadIcon(specs.secondary.icon, 'medium')})` }}
/>
<div className="armory-spec-box-info">
<div className="armory-spec-box-name">{specs.secondary.name}</div>
<div className="armory-spec-box-dist">{specs.secondary.dist.join(' / ')}</div>
</div>
</>
) : (
<span className="armory-spec-box-na">N/A</span>
)}
</div>
</div>
</div>
)}
{talents && talents.total > 0 && (
<div className="armory-panel armory-talents-panel">
<h2 className="armory-section-title">{t('talents')}</h2>
<WowheadRefresh dep={`talents-${id}`} />
<ArmoryTalents
trees={talents.trees}
spec={talents.spec}
total={talents.total}
maxPoints={Math.max(0, character.level - 9)}
locale={locale}
spellIcons={spellIcons}
/>
</div>
)}
{activityData.entries.length > 0 && (
<div className="armory-panel armory-activity-panel">
<h2 className="armory-section-title">{t('recentActivity')}</h2>
<ArmoryActivity
guid={id}
locale={locale}
initial={activityData.entries}
total={activityData.total}
pageSize={10}
labels={{ earned: t('earned'), search: t('achSearch'), empty: t('noAchievements') }}
/>
</div>
)}
<div className="armory-panels">
<div className="armory-panel">
<h2 className="armory-section-title">{t('stats')}</h2>
{stats ? (
<div className="armory-stats-grid">
{statRows.map((r) => (
<div className="armory-stat" key={r.label}>
<span className="armory-stat-label">{r.label}</span>
<span className="armory-stat-value">{r.value}</span>
</div>
))}
</div>
) : (
<p className="armory-hint armory-stats-pending">{t('statsPending')}</p>
)}
</div>
<div className="armory-panel">
<h2 className="armory-section-title">{t('professions')}</h2>
{professions.length ? (
<div className="armory-prof-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>
</div>
</div>
</div>
</PageShell>
)
}