From 65f677696903825ef225acae73184a72f0d21889 Mon Sep 17 00:00:00 2001 From: adevopg Date: Thu, 16 Jul 2026 14:41:06 +0000 Subject: [PATCH] =?UTF-8?q?Armer=C3=ADa:=20b=C3=BAsqueda=20insensible=20a?= =?UTF-8?q?=20may=C3=BAsculas,=20autocompletar=20y=20proxy=20del=20visor?= =?UTF-8?q?=203D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web-next/app/api/armory/suggest/route.ts | 23 ++++ web-next/app/armory.css | 23 +++- web-next/components/ArmoryModel3D.tsx | 24 ++-- web-next/components/ArmorySearchBox.tsx | 148 ++++++++++++++++++++--- web-next/lib/armory.ts | 25 +++- web-next/next.config.ts | 8 ++ 6 files changed, 221 insertions(+), 30 deletions(-) create mode 100644 web-next/app/api/armory/suggest/route.ts diff --git a/web-next/app/api/armory/suggest/route.ts b/web-next/app/api/armory/suggest/route.ts new file mode 100644 index 0000000..0031f5f --- /dev/null +++ b/web-next/app/api/armory/suggest/route.ts @@ -0,0 +1,23 @@ +import { suggest } from '@/lib/armory' +import { raceName, className } from '@/lib/wow-data' + +// Sugerencias para el autocompletar de la armería (personajes, ítems, hermandades). +export async function GET(request: Request) { + const url = new URL(request.url) + const q = (url.searchParams.get('q') ?? '').trim() + const locale = url.searchParams.get('locale') === 'en' ? 'en' : 'es' + if (q.length < 1) return Response.json({ characters: [], items: [], guilds: [] }) + + const { characters, items, guilds } = await suggest(q, locale) + return Response.json({ + characters: characters.map((c) => ({ + guid: c.guid, + name: c.name, + level: c.level, + class: c.class, + sub: `${raceName(c.race, locale)} · ${className(c.class, locale)}`, + })), + items: items.map((i) => ({ entry: i.entry, name: i.name, quality: i.quality, itemLevel: i.itemLevel })), + guilds: guilds.map((g) => ({ guildid: g.guildid, name: g.name, members: g.members })), + }) +} diff --git a/web-next/app/armory.css b/web-next/app/armory.css index 89331b8..0f534df 100644 --- a/web-next/app/armory.css +++ b/web-next/app/armory.css @@ -1,9 +1,30 @@ /* 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 { display: flex; gap: 10px; align-items: center; margin: 10px 0 18px; flex-wrap: wrap; } +.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; diff --git a/web-next/components/ArmoryModel3D.tsx b/web-next/components/ArmoryModel3D.tsx index 1b08e62..b9b8780 100644 --- a/web-next/components/ArmoryModel3D.tsx +++ b/web-next/components/ArmoryModel3D.tsx @@ -15,9 +15,11 @@ import { useEffect, useRef, useState } from 'react' * slot del visor. displayid = transmog si lo hay, si no el del ítem equipado. */ -const ZAM_VIEWER = 'https://wow.zamimg.com/modelviewer/live/viewer/viewer.min.js' +// 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 = 'https://wow.zamimg.com/modelviewer/live/' +const CONTENT_PATH = '/modelviewer/live/' type Win = typeof window & { jQuery?: unknown; $?: unknown; ZamModelViewer?: unknown; CONTENT_PATH?: string } @@ -61,10 +63,17 @@ export function ArmoryModel3D({ const mv = await import('wow-model-viewer') if (cancelled || !ref.current) return - const items = await mv.findItemsInEquipments( - equip.map((e) => ({ slot: e.slot, entry: e.entry, displayid: e.displayid })), - 'live', - ) + // 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}` @@ -73,7 +82,8 @@ export function ArmoryModel3D({ clearTimeout(failTimer) setState('ok') } - } catch { + } catch (err) { + console.error('[armory-3d] no se pudo cargar el visor 3D:', err) if (!cancelled) { clearTimeout(failTimer) setState('fail') diff --git a/web-next/components/ArmorySearchBox.tsx b/web-next/components/ArmorySearchBox.tsx index 2ccfbde..deffa9c 100644 --- a/web-next/components/ArmorySearchBox.tsx +++ b/web-next/components/ArmorySearchBox.tsx @@ -1,34 +1,150 @@ 'use client' -import { useState } from 'react' -import { useTranslations } from 'next-intl' +import { useEffect, useRef, useState } from 'react' +import { useTranslations, useLocale } from 'next-intl' import { useRouter } from '@/i18n/navigation' +import { classColor } from '@/lib/wow-data' +import { wowheadUrl } from '@/lib/wowhead' -/** Caja de búsqueda de la armería: navega a /armory?tab=&q=. */ +interface Suggestions { + characters: { guid: number; name: string; level: number; class: number; sub: string }[] + items: { entry: number; name: string; quality: number; itemLevel: number }[] + guilds: { guildid: number; name: string; members: number }[] +} + +const EMPTY: Suggestions = { characters: [], items: [], guilds: [] } + +/** + * Buscador de la armería con autocompletar tipo wowhead: al teclear (≥1 carácter) + * muestra un desplegable con personajes, ítems y hermandades que coinciden. Enter o + * el botón hacen la búsqueda completa; clic en una sugerencia va directo. + */ export function ArmorySearchBox({ tab, initialQuery }: { tab: string; initialQuery: string }) { const t = useTranslations('Armory') + const locale = useLocale() const router = useRouter() const [q, setQ] = useState(initialQuery) + const [sug, setSug] = useState(EMPTY) + const [open, setOpen] = useState(false) + const boxRef = useRef(null) - function submit(e: React.FormEvent) { + // Buscar sugerencias con debounce. + useEffect(() => { + const query = q.trim() + if (query.length < 1) { + setSug(EMPTY) + return + } + const ctrl = new AbortController() + const timer = setTimeout(async () => { + try { + const res = await fetch(`/api/armory/suggest?q=${encodeURIComponent(query)}&locale=${locale}`, { + signal: ctrl.signal, + }) + const data: Suggestions = await res.json() + setSug(data) + setOpen(true) + } catch { + /* abortado o error */ + } + }, 200) + return () => { + clearTimeout(timer) + ctrl.abort() + } + }, [q, locale]) + + // Cerrar al hacer clic fuera. + useEffect(() => { + function onClick(e: MouseEvent) { + if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', onClick) + return () => document.removeEventListener('mousedown', onClick) + }, []) + + function fullSearch(e: React.FormEvent) { e.preventDefault() const query = q.trim() if (query.length < 3) return + setOpen(false) router.push(`/armory?tab=${tab}&q=${encodeURIComponent(query)}`) } + function go(href: string) { + setOpen(false) + router.push(href) + } + + const hasResults = sug.characters.length + sug.items.length + sug.guilds.length > 0 + return ( -
- setQ(e.target.value)} - placeholder={t('searchPlaceholder')} - maxLength={100} - /> - -
+
+
+ setQ(e.target.value)} + onFocus={() => q.trim() && setOpen(true)} + placeholder={t('searchPlaceholder')} + maxLength={100} + autoComplete="off" + /> + +
+ + {open && hasResults && ( +
+ {sug.characters.length > 0 && ( +
+
{t('tabCharacters')}
+ {sug.characters.map((c) => ( + + ))} +
+ )} + {sug.items.length > 0 && ( +
+
{t('tabItems')}
+ {sug.items.map((it) => ( + setOpen(false)} + > + {it.name} + + {t('itemLevel')} {it.itemLevel} + + + ))} +
+ )} + {sug.guilds.length > 0 && ( +
+
{t('tabGuilds')}
+ {sug.guilds.map((g) => ( + + ))} +
+ )} +
+ )} +
) } diff --git a/web-next/lib/armory.ts b/web-next/lib/armory.ts index b4566ef..354a223 100644 --- a/web-next/lib/armory.ts +++ b/web-next/lib/armory.ts @@ -46,8 +46,8 @@ const LIMIT = 25 export async function searchCharacters(q: string, limit = LIMIT): Promise { try { const [rows] = await db(DB.characters).query( - 'SELECT guid, name, race, gender, class, level FROM characters WHERE name LIKE ? ORDER BY level DESC, name LIMIT ?', - [`%${q}%`, limit], + '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, @@ -66,8 +66,8 @@ export async function searchGuilds(q: string, limit = LIMIT): Promise( `SELECT g.guildid, g.name, (SELECT COUNT(*) FROM guild_member gm WHERE gm.guildid = g.guildid) AS members - FROM guild g WHERE g.name LIKE ? ORDER BY members DESC LIMIT ?`, - [`%${q}%`, limit], + 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 { @@ -80,8 +80,8 @@ export async function searchItems(q: string, locale: string, limit = LIMIT): Pro try { const [rows] = await db(DB.default).query( `SELECT entry, COALESCE(${col}, name_en) AS name, quality, item_level, inventory_type - FROM item_data WHERE ${col} LIKE ? OR name_en LIKE ? ORDER BY item_level DESC LIMIT ?`, - [`%${q}%`, `%${q}%`, limit], + 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, @@ -95,6 +95,19 @@ export async function searchItems(q: string, locale: string, limit = LIMIT): Pro } } +/** 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 > { diff --git a/web-next/next.config.ts b/web-next/next.config.ts index 7f87dfe..d8b7f6e 100644 --- a/web-next/next.config.ts +++ b/web-next/next.config.ts @@ -8,6 +8,14 @@ const nextConfig: NextConfig = { // 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)