diff --git a/web-next/app/[locale]/armory/character/[guid]/page.tsx b/web-next/app/[locale]/armory/character/[guid]/page.tsx new file mode 100644 index 0000000..936faab --- /dev/null +++ b/web-next/app/[locale]/armory/character/[guid]/page.tsx @@ -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 ( + +
+ +

+ ← {t('backToArmory')} +

+ +
+ {/* Columna izquierda: identidad + modelo 3D */} +
+

+ {character.name} +

+
+ {t('level')} {character.level} · {raceName(character.race, locale)} ·{' '} + {className(character.class, locale)} +
+
+ {factionLabel && {factionLabel}} + + {character.online ? t('online') : t('offline')} + + {character.guildName && character.guildId && ( + + <{character.guildName}> + + )} +
+ +
+ + {/* Columna derecha: equipo */} +
+

{t('equipment')}

+ {equipment.length === 0 ? ( +

{t('noEquipment')}

+ ) : ( +
+ {EQUIP_SLOTS.map(({ slot, es, en }) => { + const item = bySlot.get(slot) + const slotName = locale === 'en' ? en : es + if (!item) { + return ( +
+ {slotName} +
+ ) + } + return ( +
+ + {item.name} + + + {slotName} + {item.itemLevel ? ` · ${item.itemLevel}` : ''} + +
+ ) + })} +
+ )} +
+
+
+
+ ) +} diff --git a/web-next/app/[locale]/armory/guild/[guid]/page.tsx b/web-next/app/[locale]/armory/guild/[guid]/page.tsx new file mode 100644 index 0000000..8e50c3f --- /dev/null +++ b/web-next/app/[locale]/armory/guild/[guid]/page.tsx @@ -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 ( + +
+

+ ← {t('backToArmory')} +

+ +
+ {guild.members.length} {t('members')} + {guild.leaderName && ( + <> + {' · '} + {t('leader')}: {guild.leaderName} + + )} +
+ +
+ {guild.members.map((m) => ( + + + {m.name} + + + {t('level')} {m.level} · {raceName(m.race, locale)} · {className(m.class, locale)} + + + ))} +
+
+
+ ) +} diff --git a/web-next/app/[locale]/armory/layout.tsx b/web-next/app/[locale]/armory/layout.tsx new file mode 100644 index 0000000..9f03957 --- /dev/null +++ b/web-next/app/[locale]/armory/layout.tsx @@ -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 +} diff --git a/web-next/app/[locale]/armory/page.tsx b/web-next/app/[locale]/armory/page.tsx new file mode 100644 index 0000000..f0e94cf --- /dev/null +++ b/web-next/app/[locale]/armory/page.tsx @@ -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 = { + characters: t('tabCharacters'), + items: t('tabItems'), + guilds: t('tabGuilds'), + } + + return ( + +
+ + + +
+ {TABS.map((tb) => ( + + {tabLabel[tb]} + + ))} +
+ + {!hasQuery ? ( +

{t('searchHint')}

+ ) : tab === 'characters' ? ( + characters.length === 0 ? ( +

{t('noResults')}

+ ) : ( +
+ {characters.map((c) => ( + + + {c.name} + + + {t('level')} {c.level} · {raceName(c.race, locale)} · {className(c.class, locale)} + + + ))} +
+ ) + ) : tab === 'items' ? ( + items.length === 0 ? ( +

{t('noResults')}

+ ) : ( +
+ {items.map((it) => ( +
+ + {it.name} + + + {t('itemLevel')} {it.itemLevel} + +
+ ))} +
+ ) + ) : guilds.length === 0 ? ( +

{t('noResults')}

+ ) : ( +
+ {guilds.map((g) => ( + + {g.name} + + {g.members} {t('members')} + + + ))} +
+ )} +
+
+ ) +} diff --git a/web-next/app/armory.css b/web-next/app/armory.css new file mode 100644 index 0000000..89331b8 --- /dev/null +++ b/web-next/app/armory.css @@ -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; } +} diff --git a/web-next/components/ArmoryModel3D.tsx b/web-next/components/ArmoryModel3D.tsx new file mode 100644 index 0000000..1b08e62 --- /dev/null +++ b/web-next/components/ArmoryModel3D.tsx @@ -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 { + 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(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 ( +
+
+ {state === 'loading' &&

{labels.loading}

} + {state === 'fail' &&

{labels.unavailable}

} +
+ ) +} diff --git a/web-next/components/ArmorySearchBox.tsx b/web-next/components/ArmorySearchBox.tsx new file mode 100644 index 0000000..2ccfbde --- /dev/null +++ b/web-next/components/ArmorySearchBox.tsx @@ -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 ( +
+ setQ(e.target.value)} + placeholder={t('searchPlaceholder')} + maxLength={100} + /> + +
+ ) +} diff --git a/web-next/components/SiteHeader.tsx b/web-next/components/SiteHeader.tsx index f3a7eb2..e4fcf98 100644 --- a/web-next/components/SiteHeader.tsx +++ b/web-next/components/SiteHeader.tsx @@ -85,6 +85,9 @@ export function SiteHeader({ {t('header.players')}

)} +

+ {t('header.armory')} +

diff --git a/web-next/lib/armory.ts b/web-next/lib/armory.ts new file mode 100644 index 0000000..b4566ef --- /dev/null +++ b/web-next/lib/armory.ts @@ -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 { + 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], + ) + 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 { + try { + const [rows] = await db(DB.characters).query( + `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 { + const col = locale === 'en' ? 'name_en' : 'name_es' + 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], + ) + 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( + '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( + `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 { + const col = locale === 'en' ? 'name_en' : 'name_es' + try { + const [inv] = await db(DB.characters).query( + `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() + if (entries.length) { + const [items] = await db(DB.default).query( + `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( + 'SELECT guildid, name, leaderguid FROM guild WHERE guildid = ?', + [guildid], + ) + if (!g[0]) return null + const [members] = await db(DB.characters).query( + `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 + } +} diff --git a/web-next/lib/wow-data.ts b/web-next/lib/wow-data.ts new file mode 100644 index 0000000..fa64886 --- /dev/null +++ b/web-next/lib/wow-data.ts @@ -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 = { + 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 = { + 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 +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 5cbcb58..a975dd7 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -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" } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 578ad2b..1aad777 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -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" } } diff --git a/web-next/package-lock.json b/web-next/package-lock.json index 01819cb..8e2b8d3 100644 --- a/web-next/package-lock.json +++ b/web-next/package-lock.json @@ -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", diff --git a/web-next/package.json b/web-next/package.json index 43a66ce..018c763 100644 --- a/web-next/package.json +++ b/web-next/package.json @@ -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",