Armería/Armory: buscador, ficha de personaje con equipo y visor 3D
Nueva sección Armería (menú Jugadores del header, pública). Migra la idea del módulo armory de FusionCMS a Next, ajustada al diseño del sitio y en ES/EN. DATOS (verificado antes de construir): item_template no existe en 3.4.3, pero los datos de ítem ya están en MySQL (django_wow.item_data, 44.873 filas con nombre es/en, calidad, item_level, inventory_type y display_id). El equipo del personaje sale de acore_characters (character_inventory + item_instance, con transmog) y se resuelve contra item_data en dos pasos (sin joins entre esquemas). lib/armory.ts + lib/wow-data.ts (razas/clases/slots ES/EN y colores de clase). RUTAS: - /armory: buscador con pestañas personajes/ítems/hermandades (SSR por searchParams), ítems con tooltip/color/icono de wowhead. - /armory/character/[guid]: identidad, equipo (tooltips wowhead, color por calidad) y visor 3D. - /armory/guild/[guid]: miembros con enlace a su ficha. VISOR 3D (components/ArmoryModel3D): usa la librería wow-model-viewer (generateModels) con jQuery + ZamModelViewer de wowhead cargados bajo demanda, alimentado con raza+género+display_ids del equipo (transmog si lo hay). Es integración cliente que depende del CDN de zamimg y NO se puede verificar sin navegador: va todo en try/catch con temporizador de reserva y aviso si no carga, para no romper la ficha. Verificado en producción (salvo el render 3D): buscador, ficha con equipo real («Camisa de acólito»/«Acolyte's Shirt»), tooltips de wowhead y página de hermandad, en ES y EN. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
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 { 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 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).
|
||||
const modelEquip = equipment
|
||||
.map((e) => ({
|
||||
slot: e.slot,
|
||||
entry: e.transmogEntry ?? e.entry,
|
||||
displayid: e.transmogDisplayId ?? e.displayId ?? 0,
|
||||
}))
|
||||
.filter((e) => e.displayid > 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">
|
||||
<{character.guildName}>
|
||||
</Link>
|
||||
)}
|
||||
</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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { getGuild } from '@/lib/armory'
|
||||
import { raceName, className, classColor } from '@/lib/wow-data'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function GuildPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; guid: string }>
|
||||
}) {
|
||||
const { locale, guid } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations('Armory')
|
||||
|
||||
const guild = await getGuild(Number(guid))
|
||||
if (!guild) notFound()
|
||||
|
||||
return (
|
||||
<PageShell title={guild.name}>
|
||||
<div className="main-wide">
|
||||
<p className="forum-path">
|
||||
<Link href="/armory">← {t('backToArmory')}</Link>
|
||||
</p>
|
||||
|
||||
<div className="armory-char-sub">
|
||||
{guild.members.length} {t('members')}
|
||||
{guild.leaderName && (
|
||||
<>
|
||||
{' · '}
|
||||
{t('leader')}: {guild.leaderName}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="armory-list" style={{ marginTop: 16 }}>
|
||||
{guild.members.map((m) => (
|
||||
<Link key={m.guid} href={`/armory/character/${m.guid}`} className="armory-row">
|
||||
<span className="armory-name" style={{ color: classColor(m.class) }}>
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="armory-meta">
|
||||
{t('level')} {m.level} · {raceName(m.race, locale)} · {className(m.class, locale)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import '@/app/forum.css'
|
||||
import '@/app/armory.css'
|
||||
|
||||
// La armería reutiliza clases del foro (main-wide, nice_button, forum-input, colores
|
||||
// de calidad qN) y añade las suyas en armory.css. Todas las rutas /armory heredan esto.
|
||||
export default function ArmoryLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { searchCharacters, searchItems, searchGuilds } from '@/lib/armory'
|
||||
import { raceName, className, classColor } from '@/lib/wow-data'
|
||||
import { wowheadUrl, wowheadData } from '@/lib/wowhead'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ArmorySearchBox } from '@/components/ArmorySearchBox'
|
||||
import { WowheadRefresh } from '@/components/WowheadRefresh'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const TABS = ['characters', 'items', 'guilds'] as const
|
||||
type Tab = (typeof TABS)[number]
|
||||
|
||||
export default async function ArmoryPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
searchParams: Promise<{ tab?: string; q?: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations('Armory')
|
||||
const sp = await searchParams
|
||||
const tab: Tab = TABS.includes(sp.tab as Tab) ? (sp.tab as Tab) : 'characters'
|
||||
const q = (sp.q ?? '').trim()
|
||||
const hasQuery = q.length >= 3
|
||||
|
||||
const characters = hasQuery && tab === 'characters' ? await searchCharacters(q) : []
|
||||
const items = hasQuery && tab === 'items' ? await searchItems(q, locale) : []
|
||||
const guilds = hasQuery && tab === 'guilds' ? await searchGuilds(q) : []
|
||||
|
||||
const tabLabel: Record<Tab, string> = {
|
||||
characters: t('tabCharacters'),
|
||||
items: t('tabItems'),
|
||||
guilds: t('tabGuilds'),
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell title={t('title')}>
|
||||
<div className="main-wide">
|
||||
<WowheadRefresh dep={`${tab}-${q}`} />
|
||||
<ArmorySearchBox tab={tab} initialQuery={q} />
|
||||
|
||||
<div className="armory-tabs">
|
||||
{TABS.map((tb) => (
|
||||
<Link
|
||||
key={tb}
|
||||
href={`/armory?tab=${tb}${q ? `&q=${encodeURIComponent(q)}` : ''}`}
|
||||
className={`armory-tab${tb === tab ? ' active' : ''}`}
|
||||
>
|
||||
{tabLabel[tb]}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!hasQuery ? (
|
||||
<p className="armory-hint">{t('searchHint')}</p>
|
||||
) : tab === 'characters' ? (
|
||||
characters.length === 0 ? (
|
||||
<p className="armory-hint">{t('noResults')}</p>
|
||||
) : (
|
||||
<div className="armory-list">
|
||||
{characters.map((c) => (
|
||||
<Link key={c.guid} href={`/armory/character/${c.guid}`} className="armory-row">
|
||||
<span className="armory-name" style={{ color: classColor(c.class) }}>
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="armory-meta">
|
||||
{t('level')} {c.level} · {raceName(c.race, locale)} · {className(c.class, locale)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : tab === 'items' ? (
|
||||
items.length === 0 ? (
|
||||
<p className="armory-hint">{t('noResults')}</p>
|
||||
) : (
|
||||
<div className="armory-list">
|
||||
{items.map((it) => (
|
||||
<div key={it.entry} className="armory-row">
|
||||
<a
|
||||
href={wowheadUrl('item', it.entry, locale)}
|
||||
data-wowhead={wowheadData('item', it.entry, locale)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="armory-name"
|
||||
>
|
||||
{it.name}
|
||||
</a>
|
||||
<span className="armory-meta">
|
||||
{t('itemLevel')} {it.itemLevel}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : guilds.length === 0 ? (
|
||||
<p className="armory-hint">{t('noResults')}</p>
|
||||
) : (
|
||||
<div className="armory-list">
|
||||
{guilds.map((g) => (
|
||||
<Link key={g.guildid} href={`/armory/guild/${g.guildid}`} className="armory-row">
|
||||
<span className="armory-name">{g.name}</span>
|
||||
<span className="armory-meta">
|
||||
{g.members} {t('members')}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/* 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 .forum-input { flex: 1 1 260px; margin: 0; }
|
||||
|
||||
.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; }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'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.
|
||||
*/
|
||||
|
||||
const ZAM_VIEWER = 'https://wow.zamimg.com/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/'
|
||||
|
||||
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
|
||||
|
||||
const items = await mv.findItemsInEquipments(
|
||||
equip.map((e) => ({ slot: e.slot, entry: e.entry, displayid: e.displayid })),
|
||||
'live',
|
||||
)
|
||||
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 {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
|
||||
/** Caja de búsqueda de la armería: navega a /armory?tab=&q=. */
|
||||
export function ArmorySearchBox({ tab, initialQuery }: { tab: string; initialQuery: string }) {
|
||||
const t = useTranslations('Armory')
|
||||
const router = useRouter()
|
||||
const [q, setQ] = useState(initialQuery)
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const query = q.trim()
|
||||
if (query.length < 3) return
|
||||
router.push(`/armory?tab=${tab}&q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="armory-search">
|
||||
<input
|
||||
className="forum-input"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
maxLength={100}
|
||||
/>
|
||||
<button type="submit" className="nice_button">
|
||||
{t('search')}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -85,6 +85,9 @@ export function SiteHeader({
|
||||
<Link href={playersHref}>{t('header.players')}</Link>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<Link href="/armory">{t('header.armory')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
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 name LIKE ? ORDER BY level DESC, name LIMIT ?',
|
||||
[`%${q}%`, 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 g.name LIKE ? ORDER BY members DESC LIMIT ?`,
|
||||
[`%${q}%`, 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 ${col} LIKE ? OR name_en LIKE ? ORDER BY item_level DESC LIMIT ?`,
|
||||
[`%${q}%`, `%${q}%`, limit],
|
||||
)
|
||||
return rows.map((r) => ({
|
||||
entry: r.entry,
|
||||
name: r.name,
|
||||
quality: r.quality,
|
||||
itemLevel: r.item_level,
|
||||
inventoryType: r.inventory_type,
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -2000,5 +2000,38 @@
|
||||
"errConfirm": "The target character name does not match the confirmation.",
|
||||
"successMsg": "Gift sent to {character}! They will receive it in their in-game mail."
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"armory": "Armory"
|
||||
},
|
||||
"Armory": {
|
||||
"title": "Armory",
|
||||
"searchPlaceholder": "Search character, item or guild…",
|
||||
"search": "Search",
|
||||
"tabCharacters": "Characters",
|
||||
"tabItems": "Items",
|
||||
"tabGuilds": "Guilds",
|
||||
"noResults": "No results.",
|
||||
"searchHint": "Type at least 3 characters.",
|
||||
"level": "Level",
|
||||
"itemLevel": "Item level",
|
||||
"members": "Members",
|
||||
"equipment": "Equipment",
|
||||
"guild": "Guild",
|
||||
"race": "Race",
|
||||
"class": "Class",
|
||||
"faction": "Faction",
|
||||
"alliance": "Alliance",
|
||||
"horde": "Horde",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"viewProfile": "View profile",
|
||||
"model3d": "3D model",
|
||||
"modelLoading": "Loading model…",
|
||||
"modelUnavailable": "The 3D model is not available.",
|
||||
"rank": "Rank",
|
||||
"leader": "Leader",
|
||||
"noEquipment": "No visible equipment.",
|
||||
"backToArmory": "Back to armory"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2000,5 +2000,38 @@
|
||||
"errConfirm": "El nombre del personaje destino no coincide con la confirmación.",
|
||||
"successMsg": "¡Regalo enviado a {character}! Lo recibirá en su correo del juego."
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"armory": "Armería"
|
||||
},
|
||||
"Armory": {
|
||||
"title": "Armería",
|
||||
"searchPlaceholder": "Buscar personaje, ítem o hermandad…",
|
||||
"search": "Buscar",
|
||||
"tabCharacters": "Personajes",
|
||||
"tabItems": "Ítems",
|
||||
"tabGuilds": "Hermandades",
|
||||
"noResults": "Sin resultados.",
|
||||
"searchHint": "Escribe al menos 3 caracteres.",
|
||||
"level": "Nivel",
|
||||
"itemLevel": "Nivel de objeto",
|
||||
"members": "Miembros",
|
||||
"equipment": "Equipo",
|
||||
"guild": "Hermandad",
|
||||
"race": "Raza",
|
||||
"class": "Clase",
|
||||
"faction": "Facción",
|
||||
"alliance": "Alianza",
|
||||
"horde": "Horda",
|
||||
"online": "Conectado",
|
||||
"offline": "Desconectado",
|
||||
"viewProfile": "Ver ficha",
|
||||
"model3d": "Modelo 3D",
|
||||
"modelLoading": "Cargando modelo…",
|
||||
"modelUnavailable": "El modelo 3D no está disponible.",
|
||||
"rank": "Rango",
|
||||
"leader": "Líder",
|
||||
"noEquipment": "Sin equipo visible.",
|
||||
"backToArmory": "Volver a la armería"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+11
-1
@@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "web-next",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@next/third-parties": "^16.2.10",
|
||||
"@tinymce/tinymce-react": "^6.3.0",
|
||||
@@ -21,7 +22,8 @@
|
||||
"react-dom": "19.2.4",
|
||||
"sanitize-html": "^2.17.6",
|
||||
"stripe": "^22.3.1",
|
||||
"tinymce": "^8.8.0"
|
||||
"tinymce": "^8.8.0",
|
||||
"wow-model-viewer": "^1.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
@@ -7998,6 +8000,14 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wow-model-viewer": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/wow-model-viewer/-/wow-model-viewer-1.5.3.tgz",
|
||||
"integrity": "sha512-henv8+Cnu3MwuAWjBGe3Zg1mfMcbD2kIx4E4fm0nMEodvBO/9+Gfe8RrLS/5SKjtYLTG7Rm2OtDlkQBvM00JRw==",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"react-dom": "19.2.4",
|
||||
"sanitize-html": "^2.17.6",
|
||||
"stripe": "^22.3.1",
|
||||
"tinymce": "^8.8.0"
|
||||
"tinymce": "^8.8.0",
|
||||
"wow-model-viewer": "^1.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
|
||||
Reference in New Issue
Block a user