Armería: versionar .bak-armory (copias previas al rediseño)

Copias de los ficheros de la armería tal y como estaban antes del rediseño
(paperdoll, talentos y actividad), por si hace falta consultarlas o volver atrás.
This commit is contained in:
2026-07-16 21:10:48 +00:00
parent 4dfe86ef5f
commit c0f53deede
11 changed files with 4878 additions and 3 deletions
-3
View File
@@ -10,6 +10,3 @@ home/migrations/__pycache__
# Frontend (Vite / React islands)
frontend/node_modules/
static/dist/
# Copias de seguridad locales
web-next/.bak-armory/
+142
View File
@@ -0,0 +1,142 @@
'use client'
import { useEffect, useRef, useState } from 'react'
/**
* Visor 3D del personaje. Usa la librería `wow-model-viewer` (generateModels), que a
* su vez necesita jQuery y el ZamModelViewer de wowhead (wow.zamimg.com) cargados en
* global. Se cargan bajo demanda solo en esta ficha.
*
* OJO: es integración cliente que depende de un CDN externo (zamimg) y no se puede
* verificar sin navegador; por eso todo va envuelto en try/catch y con un temporizador
* de reserva: si el modelo no aparece, se muestra un aviso en lugar de romper la ficha.
*
* El equipo se pasa como {slot, entry, displayid}; la librería mapea cada ítem a su
* slot del visor. displayid = transmog si lo hay, si no el del ítem equipado.
*/
// Same-origin (proxy /modelviewer/* -> zamimg en next.config): evita el bloqueo CORS
// del visor, que es lo que dejaba el modelo «no disponible».
const ZAM_VIEWER = '/modelviewer/live/viewer/viewer.min.js'
const JQUERY = 'https://code.jquery.com/jquery-3.7.1.min.js'
const CONTENT_PATH = '/modelviewer/live/'
type Win = typeof window & { jQuery?: unknown; $?: unknown; ZamModelViewer?: unknown; CONTENT_PATH?: string }
/**
* Garantiza que window.WH está completo. El viewer.min.js de zamimg crea un WH
* propio SIN `debug`, y entonces el setup.js de la librería (que hace `if (!WH)`)
* se lo salta y nunca define WH.debug → el visor casca con «WH.debug is not a
* function». Aquí se rellena cada propiedad que falte, con fallback, tras cargar
* los scripts y antes de generar el modelo.
*/
function ensureWH() {
const w = window as unknown as { WH?: Record<string, unknown> }
const WH = (w.WH = w.WH || {})
if (typeof WH.debug !== 'function') WH.debug = () => {}
if (!WH.defaultAnimation) WH.defaultAnimation = 'Stand'
if (!WH.WebP) WH.WebP = { getImageExtension: () => '.webp' }
if (!WH.Wow) {
WH.Wow = {
Item: {
INVENTORY_TYPE_HEAD: 1, INVENTORY_TYPE_NECK: 2, INVENTORY_TYPE_SHOULDERS: 3,
INVENTORY_TYPE_SHIRT: 4, INVENTORY_TYPE_CHEST: 5, INVENTORY_TYPE_WAIST: 6,
INVENTORY_TYPE_LEGS: 7, INVENTORY_TYPE_FEET: 8, INVENTORY_TYPE_WRISTS: 9,
INVENTORY_TYPE_HANDS: 10, INVENTORY_TYPE_FINGER: 11, INVENTORY_TYPE_TRINKET: 12,
INVENTORY_TYPE_ONE_HAND: 13, INVENTORY_TYPE_SHIELD: 14, INVENTORY_TYPE_RANGED: 15,
INVENTORY_TYPE_BACK: 16, INVENTORY_TYPE_TWO_HAND: 17, INVENTORY_TYPE_BAG: 18,
INVENTORY_TYPE_TABARD: 19, INVENTORY_TYPE_ROBE: 20, INVENTORY_TYPE_MAIN_HAND: 21,
INVENTORY_TYPE_OFF_HAND: 22, INVENTORY_TYPE_HELD_IN_OFF_HAND: 23,
INVENTORY_TYPE_PROJECTILE: 24, INVENTORY_TYPE_THROWN: 25, INVENTORY_TYPE_RANGED_RIGHT: 26,
INVENTORY_TYPE_QUIVER: 27, INVENTORY_TYPE_RELIC: 28, INVENTORY_TYPE_PROFESSION_TOOL: 29,
INVENTORY_TYPE_PROFESSION_ACCESSORY: 30,
},
}
}
}
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) return resolve()
const s = document.createElement('script')
s.src = src
s.async = true
s.onload = () => resolve()
s.onerror = () => reject(new Error(`no se pudo cargar ${src}`))
document.head.appendChild(s)
})
}
export function ArmoryModel3D({
race,
gender,
equip,
labels,
}: {
race: number
gender: number
equip: { slot: number; entry: number; displayid: number }[]
labels: { loading: string; unavailable: string }
}) {
const ref = useRef<HTMLDivElement>(null)
const [state, setState] = useState<'loading' | 'ok' | 'fail'>('loading')
useEffect(() => {
let cancelled = false
const failTimer = setTimeout(() => !cancelled && setState((s) => (s === 'loading' ? 'fail' : s)), 12000)
;(async () => {
try {
const w = window as Win
w.CONTENT_PATH = CONTENT_PATH
await loadScript(JQUERY)
await loadScript(ZAM_VIEWER)
// La librería es ESM y usa jQuery/ZamModelViewer globales.
const mv = await import('wow-model-viewer')
ensureWH() // completar WH.debug/WebP/Wow tras cargar viewer.min.js + setup.js
if (cancelled || !ref.current) return
// La resolución de ítems hace consultas al CDN y puede fallar; si falla, se
// renderiza al menos el personaje sin equipo en vez de dejar la ficha vacía.
let items: unknown[] = []
try {
items = await mv.findItemsInEquipments(
equip.map((e) => ({ slot: e.slot, entry: e.entry, displayid: e.displayid })),
'live',
)
} catch (err) {
console.warn('[armory-3d] no se pudieron resolver los ítems del modelo:', err)
}
if (cancelled || !ref.current) return
ref.current.id = ref.current.id || `armory-model-${race}-${gender}`
await mv.generateModels(1, `#${ref.current.id}`, { race, gender, items }, 'live')
if (!cancelled) {
clearTimeout(failTimer)
setState('ok')
}
} catch (err) {
console.error('[armory-3d] no se pudo cargar el visor 3D:', err)
if (!cancelled) {
clearTimeout(failTimer)
setState('fail')
}
}
})()
return () => {
cancelled = true
clearTimeout(failTimer)
}
// solo al montar / cambiar de personaje
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [race, gender])
return (
<div className="armory-model">
<div ref={ref} className="armory-model-canvas" />
{state === 'loading' && <p className="armory-hint">{labels.loading}</p>}
{state === 'fail' && <p className="armory-hint">{labels.unavailable}</p>}
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
/* Estilos de la armería, en la línea del tema (oscuro + dorado #d79602). Reutiliza
main-wide, nice_button, forum-input y los colores de calidad qN de forum.css. */
.armory-search { position: relative; margin: 10px 0 18px; }
.armory-search-form { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.armory-search .forum-input { flex: 1 1 260px; margin: 0; }
/* Desplegable de autocompletar (tipo wowhead) */
.armory-suggest {
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 50; max-height: 420px; overflow-y: auto;
background: #14110d; border: 1px solid #2a2723; border-radius: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, .6);
}
.armory-suggest-group { border-bottom: 1px solid #201d18; }
.armory-suggest-group:last-child { border-bottom: 0; }
.armory-suggest-head {
font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: #6d6a5e; font-weight: bold;
padding: 6px 12px; background: rgba(255, 255, 255, .02);
}
.armory-suggest-item {
display: flex; justify-content: space-between; align-items: center; gap: 10px; width: 100%;
padding: 8px 12px; background: none; border: 0; cursor: pointer; text-align: left; text-decoration: none;
font-size: 13px; font-weight: bold; color: #d4cdbb; transition: background .1s;
}
.armory-suggest-item:hover { background: rgba(255, 255, 255, .05); }
.armory-suggest-sub { font-size: 11px; color: #6d6a5e; font-weight: normal; white-space: nowrap; }
.armory-tabs { display: flex; gap: 6px; border-bottom: 1px solid #2a2723; margin-bottom: 16px; flex-wrap: wrap; }
.armory-tab {
padding: 8px 16px; color: #8a8578; text-decoration: none; font-weight: bold; text-transform: uppercase;
font-size: 13px; border-bottom: 2px solid transparent; transition: all .15s;
}
.armory-tab:hover { color: #d79602; }
.armory-tab.active { color: #d79602; border-bottom-color: #d79602; }
.armory-hint { color: #6d6a5e; text-align: center; padding: 24px 0; }
.armory-list { display: flex; flex-direction: column; gap: 6px; }
.armory-row {
display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 10px 14px; border-radius: 3px; text-decoration: none;
background: rgba(0, 0, 0, .18); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03);
transition: background .15s;
}
a.armory-row:hover { background: rgba(255, 255, 255, .05); }
.armory-name { font-weight: bold; font-size: 14px; color: #d4cdbb; text-decoration: none; }
a.armory-name:hover { text-decoration: underline; }
.armory-meta { font-size: 12px; color: #6d6a5e; }
/* Ficha de personaje: dos columnas (modelo 3D | equipo). */
.armory-char { display: flex; gap: 20px; flex-wrap: wrap; align-items: flex-start; }
.armory-char-left { flex: 1 1 320px; }
.armory-char-right { flex: 1 1 320px; }
.armory-char-name { font-size: 28px; font-weight: bold; margin: 0; }
.armory-char-sub { color: #8a8578; font-size: 14px; margin: 6px 0 10px; }
.armory-char-tags { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
.armory-faction { font-size: 11px; text-transform: uppercase; font-weight: bold; padding: 2px 8px; border-radius: 3px; }
.armory-faction.alliance { color: #4a90d9; background: rgba(74, 144, 217, .12); }
.armory-faction.horde { color: #cc3038; background: rgba(204, 48, 56, .12); }
.armory-status { font-size: 11px; text-transform: uppercase; font-weight: bold; }
.armory-status.online { color: #1eff00; }
.armory-status.offline { color: #6d6a5e; }
.armory-guild-tag { color: #8a8578; font-size: 13px; text-decoration: none; }
.armory-guild-tag:hover { color: #d79602; }
.armory-section-title { font-size: 15px; text-transform: uppercase; color: #8a8578; margin: 0 0 12px; }
.armory-equip { display: flex; flex-direction: column; gap: 4px; }
.armory-equip-slot {
display: flex; justify-content: space-between; align-items: center; gap: 10px;
padding: 7px 12px; border-radius: 3px; background: rgba(0, 0, 0, .18);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03);
}
.armory-equip-slot.empty { opacity: .45; }
.armory-item { font-weight: bold; font-size: 13px; text-decoration: none; }
.armory-item:hover { text-decoration: none; }
.armory-slot-name { font-size: 11px; color: #6d6a5e; text-align: right; white-space: nowrap; }
/* Visor 3D */
.armory-model {
margin-top: 8px; border-radius: 3px; overflow: hidden; background: rgba(0, 0, 0, .3);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .03);
}
.armory-model-canvas { width: 100%; height: 420px; }
.armory-model .armory-hint { padding: 12px; }
@media (max-width: 767px) {
.armory-model-canvas { height: 320px; }
}
+254
View File
@@ -0,0 +1,254 @@
import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
// Armería: búsqueda de personajes/ítems/hermandades y ficha de personaje con equipo.
// Datos de personaje/equipo en acore_characters; datos de ítem (nombre/calidad/
// display_id) en django_wow.item_data (importado de los db2; item_template no existe
// en 3.4.3). Se resuelve en dos pasos para no depender de joins entre esquemas.
export interface CharacterResult {
guid: number
name: string
race: number
gender: number
class: number
level: number
}
export interface ItemResult {
entry: number
name: string
quality: number
itemLevel: number
inventoryType: number
}
export interface GuildResult {
guildid: number
name: string
members: number
}
export interface EquipItem {
slot: number
entry: number
name: string
quality: number
itemLevel: number
inventoryType: number
displayId: number | null
transmogEntry: number | null
transmogDisplayId: number | null
}
const LIMIT = 25
export async function searchCharacters(q: string, limit = LIMIT): Promise<CharacterResult[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT guid, name, race, gender, class, level FROM characters WHERE LOWER(name) LIKE ? ORDER BY level DESC, name LIMIT ?',
[`%${q.toLowerCase()}%`, limit],
)
return rows.map((r) => ({
guid: r.guid,
name: r.name,
race: r.race,
gender: r.gender,
class: r.class,
level: r.level,
}))
} catch {
return []
}
}
export async function searchGuilds(q: string, limit = LIMIT): Promise<GuildResult[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
`SELECT g.guildid, g.name, (SELECT COUNT(*) FROM guild_member gm WHERE gm.guildid = g.guildid) AS members
FROM guild g WHERE LOWER(g.name) LIKE ? ORDER BY members DESC LIMIT ?`,
[`%${q.toLowerCase()}%`, limit],
)
return rows.map((r) => ({ guildid: r.guildid, name: r.name, members: Number(r.members ?? 0) }))
} catch {
return []
}
}
export async function searchItems(q: string, locale: string, limit = LIMIT): Promise<ItemResult[]> {
const col = locale === 'en' ? 'name_en' : 'name_es'
try {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
`SELECT entry, COALESCE(${col}, name_en) AS name, quality, item_level, inventory_type
FROM item_data WHERE LOWER(${col}) LIKE ? OR LOWER(name_en) LIKE ? ORDER BY item_level DESC LIMIT ?`,
[`%${q.toLowerCase()}%`, `%${q.toLowerCase()}%`, limit],
)
return rows.map((r) => ({
entry: r.entry,
name: r.name,
quality: r.quality,
itemLevel: r.item_level,
inventoryType: r.inventory_type,
}))
} catch {
return []
}
}
/** Sugerencias para el autocompletar: pocos resultados de cada tipo. */
export async function suggest(
q: string,
locale: string,
): Promise<{ characters: CharacterResult[]; items: ItemResult[]; guilds: GuildResult[] }> {
const [characters, items, guilds] = await Promise.all([
searchCharacters(q, 6),
searchItems(q, locale, 6),
searchGuilds(q, 5),
])
return { characters, items, guilds }
}
export async function getCharacter(guid: number): Promise<
(CharacterResult & { guildName: string | null; guildId: number | null; online: boolean }) | null
> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT guid, name, race, gender, class, level, online FROM characters WHERE guid = ?',
[guid],
)
const c = rows[0]
if (!c) return null
let guildName: string | null = null
let guildId: number | null = null
try {
const [g] = await db(DB.characters).query<RowDataPacket[]>(
`SELECT gu.guildid, gu.name FROM guild_member gm JOIN guild gu ON gu.guildid = gm.guildid WHERE gm.guid = ?`,
[guid],
)
if (g[0]) {
guildId = g[0].guildid
guildName = g[0].name
}
} catch {
/* sin hermandad */
}
return {
guid: c.guid,
name: c.name,
race: c.race,
gender: c.gender,
class: c.class,
level: c.level,
online: c.online === 1,
guildName,
guildId,
}
} catch {
return null
}
}
/** Ítems equipados (slots 0-18) del personaje, resueltos con item_data. */
export async function getCharacterEquipment(guid: number, locale: string): Promise<EquipItem[]> {
const col = locale === 'en' ? 'name_en' : 'name_es'
try {
const [inv] = await db(DB.characters).query<RowDataPacket[]>(
`SELECT ci.slot, ii.itemEntry, ii.transmogrification
FROM character_inventory ci JOIN item_instance ii ON ii.guid = ci.item
WHERE ci.guid = ? AND ci.bag = 0 AND ci.slot <= 18 ORDER BY ci.slot`,
[guid],
)
if (inv.length === 0) return []
// Resolver todos los entries (equipados + transmog) en una consulta a item_data.
const entries = [
...new Set(inv.flatMap((r) => [Number(r.itemEntry), Number(r.transmogrification)]).filter((n) => n > 0)),
]
const meta = new Map<number, RowDataPacket>()
if (entries.length) {
const [items] = await db(DB.default).query<RowDataPacket[]>(
`SELECT entry, COALESCE(${col}, name_en) AS name, quality, item_level, inventory_type, display_id
FROM item_data WHERE entry IN (${entries.map(() => '?').join(',')})`,
entries,
)
for (const it of items) meta.set(Number(it.entry), it)
}
return inv.map((r) => {
const entry = Number(r.itemEntry)
const tmog = Number(r.transmogrification) || null
const m = meta.get(entry)
const tm = tmog ? meta.get(tmog) : undefined
return {
slot: r.slot,
entry,
name: m?.name ?? `#${entry}`,
quality: m ? Number(m.quality) : 0,
itemLevel: m ? Number(m.item_level) : 0,
inventoryType: m ? Number(m.inventory_type) : 0,
displayId: m && m.display_id != null ? Number(m.display_id) : null,
transmogEntry: tmog,
transmogDisplayId: tm && tm.display_id != null ? Number(tm.display_id) : null,
}
})
} catch {
return []
}
}
export interface GuildMember {
guid: number
name: string
race: number
class: number
level: number
rank: number
}
export async function getGuild(
guildid: number,
): Promise<{ guildid: number; name: string; members: GuildMember[]; leaderName: string | null } | null> {
try {
const [g] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT guildid, name, leaderguid FROM guild WHERE guildid = ?',
[guildid],
)
if (!g[0]) return null
const [members] = await db(DB.characters).query<RowDataPacket[]>(
`SELECT c.guid, c.name, c.race, c.class, c.level, gm.rank
FROM guild_member gm JOIN characters c ON c.guid = gm.guid
WHERE gm.guildid = ? ORDER BY gm.rank, c.level DESC`,
[guildid],
)
const list = members.map((m) => ({
guid: m.guid,
name: m.name,
race: m.race,
class: m.class,
level: m.level,
rank: m.rank,
}))
const leader = list.find((m) => m.guid === g[0].leaderguid)
return { guildid: g[0].guildid, name: g[0].name, members: list, leaderName: leader?.name ?? null }
} catch {
return null
}
}
export interface CharacterCustomization {
optionId: number
choiceId: number
}
/** Apariencia del personaje (piel, cara, pelo, marcas...) del sistema de choices de 3.4.3. */
export async function getCharacterCustomizations(guid: number): Promise<CharacterCustomization[]> {
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT chrCustomizationOptionID AS optionId, chrCustomizationChoiceID AS choiceId FROM character_customizations WHERE guid = ? ORDER BY chrCustomizationOptionID',
[guid],
)
return rows.map((r) => ({ optionId: Number(r.optionId), choiceId: Number(r.choiceId) }))
} catch {
return []
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
import createMiddleware from 'next-intl/middleware'
import { NextResponse, type NextRequest } from 'next/server'
import { routing } from './i18n/routing'
const intlMiddleware = createMiddleware(routing)
// Raíces de idioma: «/es», «/en». Estas SÍ llevan barra final; el resto NO.
const LOCALE_ROOTS = new Set(routing.locales.map((l) => `/${l}`))
export default function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 1) Raíz de idioma sin barra → con barra: /es → /es/
if (LOCALE_ROOTS.has(pathname)) {
const url = new URL(request.url)
url.pathname = `${pathname}/`
return NextResponse.redirect(url)
}
// 2) Sub-ruta con barra final → sin barra (la raíz /es/ se conserva): /es/x/ → /es/x
if (pathname.length > 1 && pathname.endsWith('/') && !LOCALE_ROOTS.has(pathname.slice(0, -1))) {
const url = new URL(request.url)
url.pathname = pathname.replace(/\/+$/, '')
return NextResponse.redirect(url)
}
return intlMiddleware(request)
}
export const config = {
// Aplica a todo salvo /api, estáticos de Next e ipas con extensión.
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
}
+21
View File
@@ -0,0 +1,21 @@
import type { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin'
const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
const nextConfig: NextConfig = {
// Barra final SOLO en la raíz de idioma (/es/, /en/); el resto de rutas SIN
// barra (/es/content-creators). Desactivamos el auto-redirect de Next: la
// normalización la hace el middleware con lógica estricta de pathname.
skipTrailingSlashRedirect: true,
// Proxy same-origin del visor de modelos de wowhead. El contenido de
// wow.zamimg.com/modelviewer no envía cabeceras CORS, así que el navegador
// bloquea el visor 3D si se pide cross-origin. Sirviéndolo bajo nuestro dominio
// (/modelviewer/*) es same-origin y no hay bloqueo. Ver components/ArmoryModel3D.
async rewrites() {
return [{ source: '/modelviewer/:path*', destination: 'https://wow.zamimg.com/modelviewer/:path*' }]
},
}
export default withNextIntl(nextConfig)
+133
View File
@@ -0,0 +1,133 @@
import { notFound } from 'next/navigation'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import { getCharacter, getCharacterEquipment, getCharacterCustomizations } from '@/lib/armory'
import { raceName, className, classColor, factionOf, EQUIP_SLOTS } from '@/lib/wow-data'
import { wowheadUrl, wowheadData } from '@/lib/wowhead'
import { PageShell } from '@/components/PageShell'
import { WowheadRefresh } from '@/components/WowheadRefresh'
import { ArmoryModel3D } from '@/components/ArmoryModel3D'
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 = await getCharacterEquipment(id, locale)
const customizations = await getCharacterCustomizations(id)
const bySlot = new Map(equipment.map((e) => [e.slot, e]))
const faction = factionOf(character.race)
const factionLabel = faction === 'alliance' ? t('alliance') : faction === 'horde' ? t('horde') : ''
// Modelo 3D: raza, género y equipo (entry + display; transmog si lo hay).
// Slots de equipo que NO se dibujan en el modelo (cuello, anillos, abalorios):
// se omiten para no pedir metadatos inexistentes (evita 404 en consola).
const HIDDEN_SLOTS = new Set([1, 10, 11, 12, 13])
// Slot del visor: armadura usa inventory_type; armas por slot de equipo (mano
// principal=21, secundaria=22/23). A distancia solo se ven arco/arma de fuego
// (invType 15/26); las arrojadizas (25) y reliquias (28) no se dibujan → se omiten.
const viewerSlot = (equipSlot: number, invType: number): number => {
if (equipSlot === 15) return 21
if (equipSlot === 16) return invType === 23 ? 23 : 22
if (equipSlot === 17) return invType === 15 || invType === 26 ? invType : 0
return invType
}
const modelEquip = equipment
.filter((e) => !HIDDEN_SLOTS.has(e.slot))
.map((e) => ({
slot: viewerSlot(e.slot, e.inventoryType),
display: e.transmogDisplayId ?? e.displayId ?? 0,
}))
.filter((e) => e.slot > 0 && e.display > 0)
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">
<h1 className="armory-char-name" style={{ color: classColor(character.class) }}>
{character.name}
</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>
)}
</div>
<ArmoryModel3D
race={character.race}
gender={character.gender}
equip={modelEquip}
customizations={customizations}
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>
</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>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</PageShell>
)
}
+67
View File
@@ -0,0 +1,67 @@
// Mapas de datos estáticos de WoW (WotLK 3.4.3): razas, clases, slots de equipo.
// Nombres en ES/EN; el color de clase para la interfaz de la armería.
export const RACES: Record<number, { es: string; en: string; faction: 'alliance' | 'horde' }> = {
1: { es: 'Humano', en: 'Human', faction: 'alliance' },
2: { es: 'Orco', en: 'Orc', faction: 'horde' },
3: { es: 'Enano', en: 'Dwarf', faction: 'alliance' },
4: { es: 'Elfo de la noche', en: 'Night Elf', faction: 'alliance' },
5: { es: 'No-muerto', en: 'Undead', faction: 'horde' },
6: { es: 'Tauren', en: 'Tauren', faction: 'horde' },
7: { es: 'Gnomo', en: 'Gnome', faction: 'alliance' },
8: { es: 'Trol', en: 'Troll', faction: 'horde' },
10: { es: 'Elfo de sangre', en: 'Blood Elf', faction: 'horde' },
11: { es: 'Draenei', en: 'Draenei', faction: 'alliance' },
}
export const CLASSES: Record<number, { es: string; en: string; color: string }> = {
1: { es: 'Guerrero', en: 'Warrior', color: '#C79C6E' },
2: { es: 'Paladín', en: 'Paladin', color: '#F58CBA' },
3: { es: 'Cazador', en: 'Hunter', color: '#ABD473' },
4: { es: 'Pícaro', en: 'Rogue', color: '#FFF569' },
5: { es: 'Sacerdote', en: 'Priest', color: '#FFFFFF' },
6: { es: 'Caballero de la Muerte', en: 'Death Knight', color: '#C41F3B' },
7: { es: 'Chamán', en: 'Shaman', color: '#0070DE' },
8: { es: 'Mago', en: 'Mage', color: '#69CCF0' },
9: { es: 'Brujo', en: 'Warlock', color: '#9482C9' },
11: { es: 'Druida', en: 'Druid', color: '#FF7D0A' },
}
// Slots de equipo 0-18 (EQUIPMENT_SLOT_*). Los que no se muestran (camisa 3, tabardo 18)
// se dejan igual por completitud del modelo 3D.
export const EQUIP_SLOTS: { slot: number; es: string; en: string }[] = [
{ slot: 0, es: 'Cabeza', en: 'Head' },
{ slot: 1, es: 'Cuello', en: 'Neck' },
{ slot: 2, es: 'Hombros', en: 'Shoulders' },
{ slot: 14, es: 'Espalda', en: 'Back' },
{ slot: 4, es: 'Pecho', en: 'Chest' },
{ slot: 3, es: 'Camisa', en: 'Shirt' },
{ slot: 18, es: 'Tabardo', en: 'Tabard' },
{ slot: 8, es: 'Muñecas', en: 'Wrists' },
{ slot: 9, es: 'Manos', en: 'Hands' },
{ slot: 5, es: 'Cintura', en: 'Waist' },
{ slot: 6, es: 'Piernas', en: 'Legs' },
{ slot: 7, es: 'Pies', en: 'Feet' },
{ slot: 10, es: 'Anillo', en: 'Ring' },
{ slot: 11, es: 'Anillo', en: 'Ring' },
{ slot: 12, es: 'Abalorio', en: 'Trinket' },
{ slot: 13, es: 'Abalorio', en: 'Trinket' },
{ slot: 15, es: 'Mano derecha', en: 'Main Hand' },
{ slot: 16, es: 'Mano izquierda', en: 'Off Hand' },
{ slot: 17, es: 'A distancia', en: 'Ranged' },
]
export function raceName(id: number, locale: string): string {
const r = RACES[id]
return r ? (locale === 'en' ? r.en : r.es) : `#${id}`
}
export function className(id: number, locale: string): string {
const c = CLASSES[id]
return c ? (locale === 'en' ? c.en : c.es) : `#${id}`
}
export function classColor(id: number): string {
return CLASSES[id]?.color ?? '#ffffff'
}
export function factionOf(race: number): 'alliance' | 'horde' | null {
return RACES[race]?.faction ?? null
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Enlaces, iconos y tooltips de wowhead (rama WotLK Classic) con el locale de la
* web. Sustituye a la antigua base de datos de ítems (AoWoW en wotlk.ultimowow /
* wotlk.novawow) y a los iconos de otros mirrors: todo pasa por wowhead / zamimg.
*
* El tooltip lo pinta el script global (ver el layout raíz); el idioma del
* tooltip lo determina el SUBDOMINIO del enlace, por eso construimos las URLs
* con el subdominio del locale actual.
*
* Es un módulo puro (sin acceso a BD), así que lo pueden importar tanto los
* Server Components como los Client Components.
*/
export type WowheadType = 'item' | 'spell' | 'quest' | 'npc' | 'achievement' | 'object' | 'faction'
export type IconSize = 'tiny' | 'small' | 'medium' | 'large'
// Subdominio de wowhead por locale de la web (www = inglés, por defecto).
// Añadir aquí si se soportan más idiomas (de, fr, ru, pt, it, ko...).
const LOCALE_SUBDOMAIN: Record<string, string> = { es: 'es', en: 'www' }
function subdomain(locale: string): string {
return LOCALE_SUBDOMAIN[locale] ?? 'www'
}
/** URL de wowhead (rama WotLK) para una entidad, en el locale de la web. */
export function wowheadUrl(type: WowheadType, id: number | string, locale: string): string {
return `https://${subdomain(locale)}.wowhead.com/wotlk/${type}=${id}`
}
/** Enlace a un ítem en wowhead (WotLK) en el locale de la web. */
export function wowheadItemUrl(id: number | string, locale: string): string {
return wowheadUrl('item', id, locale)
}
/** Home de la base de datos WotLK en wowhead (enlace "WOTLK DB"). */
export function wowheadWotlkHome(locale: string): string {
return `https://${subdomain(locale)}.wowhead.com/wotlk`
}
/** Icono (de ítem/hechizo/misión) servido por el CDN de wowhead (zamimg). */
export function wowheadIcon(name: string, size: IconSize = 'large'): string {
const ext = size === 'tiny' ? 'gif' : 'jpg'
return `https://wow.zamimg.com/images/wow/icons/${size}/${name}.${ext}`
}
// Código de idioma de wowhead para el parámetro `domain` del tooltip. El inglés
// es la base (sin prefijo). El script mapea el 1.er segmento a locale
// ({es:6,fr:2,de:3,...}) y el resto a la versión del juego (wotlk = WRATH).
const LOCALE_WH_CODE: Record<string, string> = { es: 'es' }
function wowheadDomain(locale: string): string {
const code = LOCALE_WH_CODE[locale]
return code ? `${code}.wotlk` : 'wotlk'
}
/**
* Valor del atributo `data-wowhead`, para que el tooltip salga en la rama WotLK
* y EN EL IDIOMA de la web. El `domain` lleva el idioma (p.ej. `es.wotlk`) porque
* el script prioriza este atributo sobre el subdominio del href; sin el idioma
* aquí, el tooltip saldría en inglés aunque el enlace sea es.wowhead.com.
*/
export function wowheadData(type: WowheadType, id: number | string, locale: string): string {
return `${type}=${id}&domain=${wowheadDomain(locale)}`
}