Files
NightSpire/web-next/components/ArmoryModel3D.tsx
T
Inna 65f6776969 Armería: búsqueda insensible a mayúsculas, autocompletar y proxy del visor 3D
Tres arreglos:

1) Búsqueda insensible a mayúsculas/minúsculas. Las consultas usaban LIKE directo
   (sensible según la colación de la tabla), así que 'test' no encontraba 'Test'.
   Ahora comparan con LOWER(col) LIKE LOWER(?).

2) Autocompletar tipo wowhead. Nuevo /api/armory/suggest + ArmorySearchBox
   reescrito: al teclear (≥1 carácter, con debounce) muestra un desplegable con
   personajes, ítems y hermandades que coinciden; clic va directo a la ficha/wowhead.
   Enter o el botón siguen haciendo la búsqueda completa.

3) Visor 3D «no disponible». Causa: el contenido de wow.zamimg.com/modelviewer no
   envía cabeceras CORS (el propio README de wow-model-viewer pide un proxy). Se
   añade un rewrite en next.config (/modelviewer/* -> zamimg) para servirlo
   same-origin, y el visor usa esas rutas. Además, si falla la resolución de ítems
   se renderiza el personaje sin equipo, y los errores se registran en consola para
   diagnóstico.

Verificado: 'test' encuentra 'Test'; el autocompletar responde; el proxy
/modelviewer/live/viewer/viewer.min.js sirve 200 same-origin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 14:41:06 +00:00

110 lines
3.8 KiB
TypeScript

'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 }
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')
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>
)
}