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.
This commit is contained in:
@@ -1,12 +1,26 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { getCharacter, getCharacterEquipment } from '@/lib/armory'
|
||||
import { raceName, className, classColor, factionOf, EQUIP_SLOTS } from '@/lib/wow-data'
|
||||
import { wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
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'
|
||||
import { ArmoryModel3D } from '@/components/ArmoryModel3D'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -22,34 +36,76 @@ export default async function CharacterPage({
|
||||
const id = Number(guid)
|
||||
const character = await getCharacter(id)
|
||||
if (!character) notFound()
|
||||
const equipment = await getCharacterEquipment(id, locale)
|
||||
const bySlot = new Map(equipment.map((e) => [e.slot, e]))
|
||||
|
||||
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)
|
||||
|
||||
// Modelo 3D: raza, género y equipo (entry + display; transmog si lo hay).
|
||||
const modelEquip = equipment
|
||||
.map((e) => ({
|
||||
slot: e.slot,
|
||||
entry: e.transmogEntry ?? e.entry,
|
||||
displayid: e.transmogDisplayId ?? e.displayId ?? 0,
|
||||
}))
|
||||
.filter((e) => e.displayid > 0)
|
||||
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">
|
||||
<WowheadRefresh dep={`char-${id}`} />
|
||||
<p className="forum-path">
|
||||
<Link href="/armory">← {t('backToArmory')}</Link>
|
||||
</p>
|
||||
|
||||
<div className="armory-char">
|
||||
{/* Columna izquierda: identidad + modelo 3D */}
|
||||
<div className="armory-char-left">
|
||||
<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)} ·{' '}
|
||||
@@ -65,52 +121,133 @@ export default async function CharacterPage({
|
||||
<{character.guildName}>
|
||||
</Link>
|
||||
)}
|
||||
<span className="armory-achpoints" title={t('achievementPoints')}>
|
||||
<i className="fas fa-star" /> {achievementPoints.toLocaleString(locale)}
|
||||
</span>
|
||||
</div>
|
||||
<ArmoryModel3D
|
||||
race={character.race}
|
||||
gender={character.gender}
|
||||
equip={modelEquip}
|
||||
labels={{ loading: t('modelLoading'), unavailable: t('modelUnavailable') }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Columna derecha: equipo */}
|
||||
<div className="armory-char-right">
|
||||
<h2 className="armory-section-title">{t('equipment')}</h2>
|
||||
{equipment.length === 0 ? (
|
||||
<p className="armory-hint">{t('noEquipment')}</p>
|
||||
) : (
|
||||
<div className="armory-equip">
|
||||
{EQUIP_SLOTS.map(({ slot, es, en }) => {
|
||||
const item = bySlot.get(slot)
|
||||
const slotName = locale === 'en' ? en : es
|
||||
if (!item) {
|
||||
return (
|
||||
<div key={slot} className="armory-equip-slot empty">
|
||||
<span className="armory-slot-name">{slotName}</span>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={slot} className="armory-equip-slot">
|
||||
<a
|
||||
href={wowheadUrl('item', item.entry, locale)}
|
||||
data-wowhead={wowheadData('item', item.entry, locale)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`armory-item q${item.quality}`}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
<span className="armory-slot-name">
|
||||
{slotName}
|
||||
{item.itemLevel ? ` · ${item.itemLevel}` : ''}
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="armory-hint">{t('noProfessions')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getActivityPage } from '@/lib/armory'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
/** Actividad reciente paginada + búsqueda (por nombre o ID) 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') || ''
|
||||
if (!guid) return NextResponse.json({ entries: [], total: 0, pageSize: PAGE_SIZE })
|
||||
const { entries, total } = await getActivityPage(guid, locale, page, PAGE_SIZE, q)
|
||||
return NextResponse.json({ entries, total, pageSize: PAGE_SIZE })
|
||||
}
|
||||
@@ -86,3 +86,244 @@ a.armory-name:hover { text-decoration: underline; }
|
||||
@media (max-width: 767px) {
|
||||
.armory-model-canvas { height: 320px; }
|
||||
}
|
||||
|
||||
/* ===== Ficha de personaje estilo paperdoll (rediseño nemesis, tema NightSpire) ===== */
|
||||
.armory-sheet { display: flex; flex-direction: column; gap: 20px; }
|
||||
.armory-sheet-head { }
|
||||
|
||||
/* Paperdoll: equipo izq · modelo 3D · equipo der */
|
||||
.armory-paperdoll {
|
||||
display: grid; grid-template-columns: 1fr minmax(300px, 1.15fr) 1fr; gap: 14px; align-items: start;
|
||||
}
|
||||
.armory-gear-col { display: flex; flex-direction: column; gap: 6px; }
|
||||
.armory-model-col { display: flex; flex-direction: column; gap: 10px; }
|
||||
.armory-weapons { display: flex; gap: 6px; }
|
||||
.armory-weapons .armory-cell { flex: 1 1 0; }
|
||||
|
||||
/* Celda de equipo */
|
||||
.armory-cell {
|
||||
display: flex; align-items: center; gap: 9px; padding: 6px 9px; border-radius: 3px; min-height: 46px;
|
||||
background: rgba(0, 0, 0, .22); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03);
|
||||
text-decoration: none; transition: background .12s;
|
||||
}
|
||||
a.armory-cell:hover { background: rgba(255, 255, 255, .06); }
|
||||
.armory-cell.empty { opacity: .4; }
|
||||
.armory-cell-icon {
|
||||
width: 36px; height: 36px; flex: 0 0 36px; border-radius: 3px;
|
||||
background: #0d0b08 center/cover no-repeat; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .07);
|
||||
}
|
||||
.armory-cell-icon.q1 { box-shadow: inset 0 0 0 1px #9d9d9d; }
|
||||
.armory-cell-icon.q2 { box-shadow: inset 0 0 0 1px #1eff00; }
|
||||
.armory-cell-icon.q3 { box-shadow: inset 0 0 0 1px #0070dd; }
|
||||
.armory-cell-icon.q4 { box-shadow: inset 0 0 0 1px #a335ee; }
|
||||
.armory-cell-icon.q5 { box-shadow: inset 0 0 0 1px #ff8000; }
|
||||
.armory-cell-icon.q6 { box-shadow: inset 0 0 0 1px #e6cc80; }
|
||||
.armory-cell-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||
.armory-cell-name {
|
||||
font-size: 12px; font-weight: bold; line-height: 1.25; text-decoration: none;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.armory-cell-meta { display: flex; gap: 8px; align-items: center; }
|
||||
.armory-cell-ilvl { font-size: 11px; color: #d79602; font-weight: bold; }
|
||||
.armory-cell-slot { font-size: 10px; color: #6d6a5e; text-transform: uppercase; letter-spacing: .03em; }
|
||||
|
||||
/* Paneles inferiores: stats + profesiones */
|
||||
.armory-panels { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; }
|
||||
.armory-panel {
|
||||
background: rgba(0, 0, 0, .18); border-radius: 4px; padding: 14px 16px;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03);
|
||||
}
|
||||
.armory-stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px 20px; }
|
||||
.armory-stat {
|
||||
display: flex; justify-content: space-between; gap: 10px; padding: 5px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .03);
|
||||
}
|
||||
.armory-stat-label { font-size: 12px; color: #8a8578; }
|
||||
.armory-stat-value { font-size: 13px; font-weight: bold; color: #d4cdbb; }
|
||||
.armory-stats-pending { text-align: left; padding: 8px 0; color: #8a8578; }
|
||||
|
||||
.armory-prof-list { display: flex; flex-direction: column; gap: 7px; }
|
||||
.armory-prof { display: flex; justify-content: space-between; gap: 10px; }
|
||||
.armory-prof-name { font-size: 13px; color: #d4cdbb; }
|
||||
.armory-prof-value { font-size: 13px; font-weight: bold; color: #d79602; }
|
||||
.armory-prof-max { color: #6d6a5e; font-weight: normal; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.armory-paperdoll { grid-template-columns: 1fr; }
|
||||
.armory-panels { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ===== Paperdoll de iconos (override: tiles solo-icono alrededor del modelo) ===== */
|
||||
.armory-paperdoll {
|
||||
display: grid; grid-template-columns: auto minmax(280px, 1fr) auto; gap: 18px;
|
||||
align-items: start; justify-items: center;
|
||||
}
|
||||
.armory-slots-col { display: flex; flex-direction: column; gap: 9px; }
|
||||
.armory-slot {
|
||||
width: 58px; height: 58px; border-radius: 5px; display: block;
|
||||
background: #0d0b08 center/cover no-repeat; box-shadow: inset 0 0 0 2px #3a352c;
|
||||
}
|
||||
.armory-slot.empty { box-shadow: inset 0 0 0 2px #29251e; opacity: .5; }
|
||||
a.armory-slot { transition: transform .1s; }
|
||||
a.armory-slot:hover { transform: scale(1.06); }
|
||||
.armory-slot.q1 { box-shadow: inset 0 0 0 2px #9d9d9d, 0 0 6px rgba(157, 157, 157, .4); }
|
||||
.armory-slot.q2 { box-shadow: inset 0 0 0 2px #1eff00, 0 0 6px rgba(30, 255, 0, .35); }
|
||||
.armory-slot.q3 { box-shadow: inset 0 0 0 2px #0070dd, 0 0 6px rgba(0, 112, 221, .4); }
|
||||
.armory-slot.q4 { box-shadow: inset 0 0 0 2px #a335ee, 0 0 7px rgba(163, 53, 238, .45); }
|
||||
.armory-slot.q5 { box-shadow: inset 0 0 0 2px #ff8000, 0 0 7px rgba(255, 128, 0, .45); }
|
||||
.armory-slot.q6 { box-shadow: inset 0 0 0 2px #e6cc80, 0 0 8px rgba(230, 204, 128, .5); }
|
||||
|
||||
.armory-center { display: flex; flex-direction: column; gap: 10px; width: 100%; max-width: 460px; }
|
||||
.armory-center .armory-model { margin-top: 0; }
|
||||
.armory-weapons { display: flex; gap: 10px; justify-content: center; }
|
||||
.armory-bars { display: flex; justify-content: space-between; padding: 4px 6px; font-size: 15px; }
|
||||
.armory-bar { color: #8a8578; }
|
||||
.armory-bar.health b { color: #ff5555; }
|
||||
.armory-bar.mana b { color: #4a90d9; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.armory-paperdoll { display: flex; flex-direction: column; align-items: center; }
|
||||
.armory-slots-col { flex-direction: row; flex-wrap: wrap; justify-content: center; }
|
||||
.armory-center { order: -1; }
|
||||
}
|
||||
|
||||
/* ===== Barra de herramientas del paperdoll (toggles MH/OH + vista modelo/lista) ===== */
|
||||
.armory-doll-wrap { display: flex; flex-direction: column; gap: 12px; }
|
||||
.armory-toolbar { display: flex; gap: 8px; align-items: center; justify-content: flex-end; }
|
||||
.armory-toggle {
|
||||
font-size: 11px; font-weight: bold; letter-spacing: .03em; padding: 5px 10px; border-radius: 3px;
|
||||
border: 1px solid #2a2723; background: rgba(0, 0, 0, .25); color: #6d6a5e; cursor: pointer; transition: all .12s;
|
||||
}
|
||||
.armory-toggle.on { color: #1eff00; border-color: rgba(30, 255, 0, .35); }
|
||||
.armory-toggle.off { color: #8a8578; }
|
||||
.armory-toggle:hover { background: rgba(255, 255, 255, .06); }
|
||||
.armory-view-switch { display: inline-flex; margin-left: 4px; border: 1px solid #2a2723; border-radius: 3px; overflow: hidden; }
|
||||
.armory-view-switch button {
|
||||
background: rgba(0, 0, 0, .25); color: #6d6a5e; border: 0; padding: 6px 11px; cursor: pointer; font-size: 13px; transition: all .12s;
|
||||
}
|
||||
.armory-view-switch button:hover { background: rgba(255, 255, 255, .06); color: #d4cdbb; }
|
||||
.armory-view-switch button.active { background: #d79602; color: #14110d; }
|
||||
|
||||
/* Vista lista de equipo */
|
||||
.armory-listview { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 6px; }
|
||||
.armory-listrow {
|
||||
display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 3px; text-decoration: none;
|
||||
background: rgba(0, 0, 0, .2); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03); transition: background .12s;
|
||||
}
|
||||
.armory-listrow:hover { background: rgba(255, 255, 255, .05); }
|
||||
.armory-slot.small { width: 38px; height: 38px; flex: 0 0 38px; border-radius: 3px; }
|
||||
.armory-listrow-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||
.armory-listrow .armory-cell-name {
|
||||
font-size: 12px; font-weight: bold; text-decoration: none; line-height: 1.2;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Icono del ítem como capa hija (wowhead sobrescribe el background del <a>, no el hijo) */
|
||||
.armory-slot { position: relative; overflow: hidden; }
|
||||
.armory-slot-icon {
|
||||
position: absolute; inset: 2px; border-radius: 3px;
|
||||
background-position: center; background-size: cover; background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* Badge de puntos de logros en la cabecera */
|
||||
.armory-achpoints { font-size: 13px; font-weight: bold; color: #d79602; display: inline-flex; align-items: center; gap: 5px; }
|
||||
.armory-achpoints i { font-size: 12px; }
|
||||
|
||||
/* Panel de talentos */
|
||||
.armory-talents-panel { background: rgba(0,0,0,.18); border-radius: 4px; padding: 14px 16px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.03); }
|
||||
.armory-spec { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
|
||||
.armory-spec-name { font-size: 16px; font-weight: bold; color: #d79602; }
|
||||
.armory-spec-dist { font-size: 13px; color: #8a8578; font-weight: bold; letter-spacing: .05em; }
|
||||
.armory-trees { display: flex; flex-direction: column; gap: 8px; }
|
||||
.armory-tree { display: grid; grid-template-columns: 130px 1fr 32px; align-items: center; gap: 10px; }
|
||||
.armory-tree-name { font-size: 13px; color: #d4cdbb; }
|
||||
.armory-tree-bar { position: relative; height: 8px; background: rgba(255,255,255,.05); border-radius: 4px; overflow: hidden; }
|
||||
.armory-tree-bar > span { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #a8791a, #d79602); border-radius: 4px; }
|
||||
.armory-tree-pts { font-size: 13px; font-weight: bold; color: #d4cdbb; text-align: right; }
|
||||
|
||||
/* ===== Árbol de talentos (rejilla con iconos) ===== */
|
||||
.armory-spec-total { margin-left: auto; font-size: 12px; color: #6d6a5e; }
|
||||
.armory-talent-trees { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.armory-talent-tree { background: rgba(0,0,0,.2); border-radius: 4px; padding: 10px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.03); }
|
||||
.armory-talent-tree-head { display: flex; justify-content: space-between; font-size: 13px; font-weight: bold; color: #d4cdbb; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid rgba(255,255,255,.05); }
|
||||
.armory-talent-tree-pts { color: #d79602; }
|
||||
.armory-talent-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; justify-items: center; }
|
||||
.armory-talent { position: relative; width: 40px; height: 40px; border-radius: 5px; display: block; background: #0d0b08; box-shadow: inset 0 0 0 1px #2a2723; overflow: hidden; }
|
||||
.armory-talent-icon { position: absolute; inset: 1px; background-position: center; background-size: cover; border-radius: 4px; }
|
||||
.armory-talent.off .armory-talent-icon { filter: grayscale(1) brightness(.45); }
|
||||
.armory-talent.on { box-shadow: inset 0 0 0 2px #1eff00, 0 0 5px rgba(30,255,0,.35); }
|
||||
.armory-talent.max { box-shadow: inset 0 0 0 2px #d79602, 0 0 6px rgba(215,150,2,.45); }
|
||||
.armory-talent-rank { position: absolute; right: 0; bottom: 0; z-index: 2; font-size: 10px; font-weight: bold; line-height: 1; padding: 1px 3px; border-radius: 3px 0 0 0; background: rgba(0,0,0,.82); color: #fff; }
|
||||
.armory-talent.max .armory-talent-rank { color: #ffd100; }
|
||||
@media (max-width: 700px) { .armory-talent-trees { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Compactar la rejilla: columnas de ancho fijo y centradas (como el juego) */
|
||||
.armory-talent-grid { grid-template-columns: repeat(4, 44px); gap: 10px; justify-content: center; }
|
||||
.armory-talent { width: 44px; height: 44px; }
|
||||
|
||||
/* wowhead (iconizeLinks) inyecta un background-image inline en el <a>; sin su CSS
|
||||
se repite en mosaico (visible en la vista lista). Ponemos nuestros propios iconos
|
||||
en un <span> hijo, así que anulamos el background que inyecta wowhead. */
|
||||
a.armory-slot, a.armory-listrow, a.armory-talent { background-image: none !important; }
|
||||
|
||||
/* ===== Panel de Especialización (spec primaria + doble spec) ===== */
|
||||
.armory-specs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.armory-spec-box {
|
||||
display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 4px; min-height: 64px;
|
||||
background: rgba(0, 0, 0, .22); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .04);
|
||||
}
|
||||
.armory-spec-box.active { box-shadow: inset 0 0 0 1px rgba(215, 150, 2, .55), 0 0 8px rgba(215, 150, 2, .12); }
|
||||
.armory-spec-box.na { justify-content: center; opacity: .5; }
|
||||
.armory-spec-box-icon {
|
||||
width: 46px; height: 46px; flex: 0 0 46px; border-radius: 5px;
|
||||
background: #0d0b08 center/cover no-repeat; box-shadow: inset 0 0 0 2px #d79602;
|
||||
}
|
||||
.armory-spec-box-name { font-size: 16px; font-weight: bold; color: #d79602; }
|
||||
.armory-spec-box-dist { font-size: 13px; color: #8a8578; font-weight: bold; letter-spacing: .05em; margin-top: 2px; }
|
||||
.armory-spec-box-na { font-size: 20px; font-weight: bold; color: #6d6a5e; letter-spacing: .12em; }
|
||||
@media (max-width: 600px) { .armory-specs { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Rango de profesión bajo el nombre */
|
||||
.armory-prof-nameblock { display: flex; flex-direction: column; gap: 1px; }
|
||||
.armory-prof-rank { font-size: 11px; color: #6d6a5e; }
|
||||
|
||||
/* Nivel de objeto medio junto al nombre del personaje */
|
||||
.armory-char-ilvl { font-size: 15px; font-weight: normal; color: #8a8578; }
|
||||
|
||||
/* ===== Actividad reciente (logros con fecha) ===== */
|
||||
.armory-activity { display: flex; flex-direction: column; }
|
||||
.armory-activity-row { display: flex; align-items: center; gap: 11px; padding: 9px 4px; border-bottom: 1px solid rgba(255,255,255,.04); }
|
||||
.armory-activity-row:last-child { border-bottom: 0; }
|
||||
.armory-activity-icon { width: 32px; height: 32px; flex: 0 0 32px; border-radius: 4px; background: #0d0b08 center/cover no-repeat; box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); }
|
||||
.armory-activity-text { flex: 1; font-size: 13px; color: #8a8578; }
|
||||
.armory-activity-link { font-weight: bold; text-decoration: none; }
|
||||
.armory-activity-link:hover { text-decoration: underline; }
|
||||
.armory-activity-date { font-size: 12px; color: #6d6a5e; font-style: italic; white-space: nowrap; }
|
||||
/* Evitar el mosaico de icono que inyecta wowhead en el enlace */
|
||||
a.armory-activity-link { background-image: none !important; }
|
||||
|
||||
/* Botón "Ver primera actividad" */
|
||||
.armory-activity-more {
|
||||
display: block; width: 100%; margin-top: 12px; padding: 13px; border-radius: 4px; cursor: pointer;
|
||||
background: rgba(0,0,0,.25); border: 1px solid #2a2723; color: #8a8578;
|
||||
font-weight: bold; text-transform: uppercase; letter-spacing: .08em; font-size: 12px; transition: all .12s;
|
||||
}
|
||||
.armory-activity-more:hover:not(:disabled) { color: #d79602; border-color: rgba(215,150,2,.4); }
|
||||
.armory-activity-more:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
/* Buscador de logros + paginación */
|
||||
.armory-ach-search {
|
||||
width: 100%; margin-bottom: 10px; padding: 9px 12px; border-radius: 4px;
|
||||
background: #14110d; border: 1px solid #2a2723; color: #d4cdbb; font-size: 13px;
|
||||
}
|
||||
.armory-ach-search:focus { outline: none; border-color: #d79602; }
|
||||
.armory-activity.loading { opacity: .5; pointer-events: none; }
|
||||
.armory-pager { display: flex; gap: 4px; justify-content: center; margin-top: 12px; flex-wrap: wrap; }
|
||||
.armory-pager button {
|
||||
min-width: 34px; padding: 6px 10px; border-radius: 3px; cursor: pointer; font-weight: bold; font-size: 13px;
|
||||
background: rgba(0,0,0,.25); border: 1px solid #2a2723; color: #8a8578; transition: all .12s;
|
||||
}
|
||||
.armory-pager button:hover:not(:disabled) { color: #d79602; border-color: rgba(215,150,2,.4); }
|
||||
.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; }
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
/**
|
||||
* Proxy con CACHÉ EN DISCO del contenido del visor de modelos de wowhead.
|
||||
*
|
||||
* Sustituye al antiguo rewrite (que solo hacía de proxy transparente). La primera
|
||||
* vez que el visor pide un fichero (/modelviewer/<rama>/...), se descarga de
|
||||
* wow.zamimg.com y se guarda en disco; a partir de ahí se sirve desde nuestro
|
||||
* servidor. Así la armería NO depende de que wowhead siga disponible: lo ya visto
|
||||
* queda cacheado localmente (mismo enfoque que el /wmmv-cache/ de otras armerías).
|
||||
*
|
||||
* Same-origin, por lo que además evita el bloqueo CORS del visor.
|
||||
*/
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const UPSTREAM = 'https://wow.zamimg.com/modelviewer/'
|
||||
const CACHE_DIR = process.env.MODELVIEWER_CACHE_DIR || '/root/NightSpire/wmmv-cache'
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.mo3': 'application/octet-stream',
|
||||
'.m2': 'application/octet-stream',
|
||||
'.blp': 'application/octet-stream',
|
||||
'.ktx': 'application/octet-stream',
|
||||
'.bone': 'application/octet-stream',
|
||||
'.wasm': 'application/wasm',
|
||||
}
|
||||
|
||||
function contentType(rel: string): string {
|
||||
return TYPES[path.extname(rel).toLowerCase()] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
function safeRel(parts: string[]): string | null {
|
||||
const rel = (parts || []).join('/')
|
||||
if (!rel || rel.includes('..') || rel.includes('\0') || rel.startsWith('/')) return null
|
||||
return rel
|
||||
}
|
||||
|
||||
const cacheHeaders = (ct: string, hit: boolean): HeadersInit => ({
|
||||
'Content-Type': ct,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'X-Cache': hit ? 'HIT' : 'MISS',
|
||||
})
|
||||
|
||||
/**
|
||||
* Descarga de wowhead probando ramas alternativas. El visor pide todo bajo `live/`
|
||||
* (que tiene geometría), pero muchos metadatos/texturas de ítems de WotLK solo
|
||||
* existen en `wrath`/`cata`. Si `live` da 404, se reintenta con esas ramas y se
|
||||
* cachea el resultado bajo la ruta `live/` original que pidió el visor.
|
||||
*/
|
||||
async function fetchWithFallback(rel: string): Promise<Response | null> {
|
||||
const candidates = [rel]
|
||||
if (rel.startsWith('live/')) {
|
||||
const tail = rel.slice('live/'.length)
|
||||
candidates.push('wrath/' + tail, 'cata/' + tail)
|
||||
}
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const r = await fetch(UPSTREAM + c, { headers: { 'User-Agent': 'NightSpire-Armory/1.0' } })
|
||||
if (r.ok) return r
|
||||
} catch {
|
||||
/* probar siguiente rama */
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path: parts } = await params
|
||||
const rel = safeRel(parts)
|
||||
if (!rel) return new Response('Bad path', { status: 400 })
|
||||
|
||||
const filePath = path.join(CACHE_DIR, rel)
|
||||
const ct = contentType(rel)
|
||||
|
||||
// 1) Servir desde la caché local si existe.
|
||||
try {
|
||||
const buf = await fs.readFile(filePath)
|
||||
return new Response(buf, { headers: cacheHeaders(ct, true) })
|
||||
} catch {
|
||||
/* no cacheado todavía */
|
||||
}
|
||||
|
||||
// 2) Descargar de wowhead (con reintento en ramas alternativas).
|
||||
const upstream = await fetchWithFallback(rel)
|
||||
if (!upstream) return new Response(null, { status: 404 })
|
||||
|
||||
const buf = Buffer.from(await upstream.arrayBuffer())
|
||||
|
||||
// 3) Guardar en disco de forma atómica (temp + rename) y servir.
|
||||
try {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
const tmp = `${filePath}.${process.pid}.${buf.length}.tmp`
|
||||
await fs.writeFile(tmp, buf)
|
||||
await fs.rename(tmp, filePath)
|
||||
} catch {
|
||||
/* si no se pudo guardar, servimos igualmente desde memoria */
|
||||
}
|
||||
|
||||
return new Response(buf, { headers: cacheHeaders(ct, false) })
|
||||
}
|
||||
Reference in New Issue
Block a user