Foro: autor con personaje aleatorio de la cuenta

- El nombre del autor muestra un personaje ALEATORIO de la cuenta (no la id).
- Nombre con <Hermandad> en dorado (oculto si el personaje no tiene guild).
- Avatar = imagen de raza del personaje (raza+género).
- Cajas nuevas: puntos de logros, nivel y clase (con su color de clase).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 07:57:41 +00:00
parent beba7301a1
commit 24bd28fcca
93 changed files with 249 additions and 28 deletions
@@ -11,6 +11,7 @@ import {
import { formatForumDate } from '@/lib/forum-format'
import { localizeWowheadLinks } from '@/lib/forum-wowhead'
import { getSession } from '@/lib/session'
import { className, classColor } from '@/lib/wow-data'
import { forumIsModerator, canEditPost } from '@/lib/forum-perm'
import { PageShell } from '@/components/PageShell'
import { ForumBreadcrumb } from '@/components/ForumBreadcrumb'
@@ -105,7 +106,10 @@ export default async function TopicPage({
<div key={post.id} className={`topic_post${isStaff ? ' isStaff' : ''}${post.deleted ? ' post_deleted' : ''}`}>
<div className="left_side">
<div className="username_container">
<span className="username">{author?.name ?? `#${post.poster}`}</span>
<span className="username">
{author?.charName ?? author?.name ?? `#${post.poster}`}
{author?.guildName ? <span className="username_guild"> &lt;{author.guildName}&gt;</span> : null}
</span>
</div>
<div className={`user_avatar${isStaff ? ' isStaff' : ''}`}>
<span
@@ -128,6 +132,30 @@ export default async function TopicPage({
</dt>
<dd>{formatForumDate(author?.joindate ?? null, locale) || '—'}</dd>
</h3>
{author?.charName ? (
<>
<h3 className="post_field">
<dt>
<i className="fa-solid fa-medal fa-fw" />
</dt>
<dd>{(author.achPoints ?? 0).toLocaleString(locale)}</dd>
</h3>
<h3 className="post_field">
<dt>
<i className="fa-solid fa-star fa-fw" />
</dt>
<dd>
{t('level')} {author.level}
</dd>
</h3>
<h3 className="post_field">
<dt>
<i className="fa-solid fa-khanda fa-fw" />
</dt>
<dd style={{ color: classColor(author.classId) }}>{className(author.classId, locale)}</dd>
</h3>
</>
) : null}
<h3 className="post_field">
<dt>
<i className="fa-solid fa-comments fa-fw" />
+3 -2
View File
@@ -54,8 +54,9 @@ export default async function LocaleLayout({
return (
<html lang={locale}>
<head>
{/* Verificación de la cuenta de Google AdSense: solo si hay ADSENSE_CLIENT_ID
y el visitante no es admin. Ver components/AdSense.tsx. */}
{/* Google AdSense (meta de verificación + adsbygoogle.js): solo si hay
ADSENSE_CLIENT_ID, el visitante no es admin ni ha pagado, y —para el
script— ha aceptado «Marketing». Ver components/AdSense.tsx. */}
<AdSense />
{/* Iconos del sitio (idénticos a la web Django) */}
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
+3
View File
@@ -295,3 +295,6 @@ a.nice_button:focus {
.topic_post .left_side { flex-basis: 100%; }
.topic_post .left_side .user_avatar { width: 56px; height: 56px; }
}
/* Nombre de hermandad junto al personaje en el foro */
.username_guild { color: #c8a24c; font-weight: 400; font-size: .8em; }
+16 -6
View File
@@ -1,7 +1,11 @@
import { shouldServeAds } from '@/lib/ads'
import { AdSenseScript } from './AdSenseScript'
/**
* Etiqueta de verificación de Google AdSense, con DOS interruptores:
* Google AdSense: etiqueta de verificación de la cuenta + script de anuncios
* («Auto ads»: se pega en todas las páginas y Google decide dónde colocarlos).
*
* TRES interruptores:
*
* 1. `ADSENSE_CLIENT_ID` en el .env. Vacío o sin poner = no se emite nada. Es el
* interruptor del administrador. No lleva prefijo NEXT_PUBLIC_ a propósito:
@@ -10,10 +14,11 @@ import { shouldServeAds } from '@/lib/ads'
* rebuildear para activarlo o desactivarlo.
* 2. El filtro común de publicidad (`shouldServeAds`, ver lib/ads.ts): fuera para
* admins y para quien sea rango 2+ o haya pagado alguna vez.
*
* Ojo: esto es solo la etiqueta de verificación de la cuenta, no carga adsbygoogle.js
* ni escribe cookies, por eso no pasa por el banner de consentimiento (el script de
* anuncios sí lo hace, ver components/Monetag.tsx).
* 3. El consentimiento «Marketing» del visitante, pero SOLO para el script, en
* <AdSenseScript/> (cliente). La <meta> se queda fuera del banner a propósito:
* es un identificador estático de la cuenta, no carga nada ni escribe cookies,
* y Google la quiere presente para poder verificar el sitio aunque el visitante
* rechace los anuncios.
*/
export async function AdSense() {
const client = process.env.ADSENSE_CLIENT_ID
@@ -21,5 +26,10 @@ export async function AdSense() {
if (!(await shouldServeAds())) return null
return <meta name="google-adsense-account" content={client} />
return (
<>
<meta name="google-adsense-account" content={client} />
<AdSenseScript client={client} />
</>
)
}
+32
View File
@@ -0,0 +1,32 @@
'use client'
import Script from 'next/script'
import { useConsent } from './CookieConsent'
/**
* Parte de cliente de AdSense: el script que de verdad pinta los anuncios
* (adsbygoogle.js, «Auto ads»). Existe separado de <AdSense/> solo para mirar el
* consentimiento, que vive en el navegador (cookie `ns_cookie_consent` + evento
* `ns:consentchange`).
*
* A diferencia de la etiqueta <meta> de verificación, esto SÍ pasa por el banner:
* adsbygoogle.js escribe cookies de personalización y perfila al visitante, así que
* cargarlo sin mirar la categoría «Marketing» convertiría el banner en mentira y, con
* visitantes en la UE, en un problema legal. Mismo criterio que <MonetagScript/>.
*
* `useConsent` reacciona en caliente: entra en cuanto se acepta y deja de renderizarse
* si se revoca, sin recargar. Arranca en `false`, así que hasta confirmar el
* consentimiento no se carga nada.
*/
export function AdSenseScript({ client }: { client: string }) {
const consent = useConsent('marketing')
if (!consent) return null
return (
<Script
src={`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${client}`}
crossOrigin="anonymous"
strategy="afterInteractive"
/>
)
}
+77 -17
View File
@@ -87,6 +87,23 @@ export interface PosterInfo {
joindate: string | null
postCount: number
avatar: string
charName: string | null
race: number
gender: number
classId: number
level: number
guildName: string | null
achPoints: number
}
// Imagen de raza (CDN wowhead) según raza+género del personaje elegido.
const FORUM_RACE_SLUG: Record<number, string> = {
1: 'human', 2: 'orc', 3: 'dwarf', 4: 'nightelf', 5: 'scourge', 6: 'tauren',
7: 'gnome', 8: 'troll', 9: 'goblin', 10: 'bloodelf', 11: 'draenei', 22: 'worgen',
}
function raceAvatar(race: number, gender: number): string {
const slug = FORUM_RACE_SLUG[race] || 'human'
return `https://wow.zamimg.com/images/wow/icons/large/race_${slug}_${gender === 1 ? 'female' : 'male'}.jpg`
}
// Avatar por clase (FusionCMS) del personaje de más nivel de la cuenta. Solo clases
@@ -383,20 +400,74 @@ export async function resolvePosters(ids: number[]): Promise<Map<number, PosterI
// Clase del personaje de más nivel de cada cuenta (para el avatar). Se tolera que
// acore_characters no exista o esté vacía: el avatar cae a guerrero por defecto.
const classByAccount = new Map<number, number>()
// Personaje ALEATORIO por cuenta (nombre, raza, género, clase, nivel).
const charByAccount = new Map<number, { guid: number; name: string; race: number; gender: number; class: number; level: number }>()
try {
const [chars] = await db(DB.characters).query<RowDataPacket[]>(
`SELECT account, class FROM characters WHERE account IN (${placeholders}) ORDER BY level DESC, guid ASC`,
`SELECT account, guid, name, race, gender, class, level FROM characters WHERE account IN (${placeholders})`,
unique,
)
const byAcc = new Map<number, RowDataPacket[]>()
for (const c of chars) {
const acc = Number(c.account)
if (!classByAccount.has(acc)) classByAccount.set(acc, Number(c.class))
if (!byAcc.has(acc)) byAcc.set(acc, [])
byAcc.get(acc)!.push(c)
}
for (const [acc, list] of byAcc) {
const c = list[Math.floor(Math.random() * list.length)]
charByAccount.set(acc, {
guid: Number(c.guid), name: String(c.name), race: Number(c.race),
gender: Number(c.gender), class: Number(c.class), level: Number(c.level),
})
}
} catch {
/* sin personajes: avatar por defecto */
}
// Hermandad y puntos de logros de los personajes elegidos.
const chosenGuids = [...charByAccount.values()].map((c) => c.guid)
const guildByGuid = new Map<number, string>()
const ptsByGuid = new Map<number, number>()
if (chosenGuids.length) {
const gp = chosenGuids.map(() => '?').join(',')
try {
const [gr] = await db(DB.characters).query<RowDataPacket[]>(
`SELECT gm.guid, g.name FROM guild_member gm JOIN guild g ON g.guildid = gm.guildid WHERE gm.guid IN (${gp})`,
chosenGuids,
)
for (const r of gr) guildByGuid.set(Number(r.guid), String(r.name))
} catch { /* */ }
try {
const [pr] = await db(DB.characters).query<RowDataPacket[]>(
`SELECT ca.guid, COALESCE(SUM(ap.points),0) AS pts
FROM character_achievement ca JOIN acore_world.achievement_points ap ON ap.id = ca.achievement
WHERE ca.guid IN (${gp}) GROUP BY ca.guid`,
chosenGuids,
)
for (const r of pr) ptsByGuid.set(Number(r.guid), Number(r.pts))
} catch { /* */ }
}
const buildPoster = (id: number, base: Partial<PosterInfo>): PosterInfo => {
const ch = charByAccount.get(id)
return {
id,
name: base.name ?? `#${id}`,
gmlevel: base.gmlevel ?? 0,
isStaff: base.isStaff ?? false,
joindate: base.joindate ?? null,
postCount: 0,
avatar: ch ? raceAvatar(ch.race, ch.gender) : avatarForClass(undefined),
charName: ch?.name ?? null,
race: ch?.race ?? 0,
gender: ch?.gender ?? 0,
classId: ch?.class ?? 0,
level: ch?.level ?? 0,
guildName: ch ? (guildByGuid.get(ch.guid) ?? null) : null,
achPoints: ch ? (ptsByGuid.get(ch.guid) ?? 0) : 0,
}
}
try {
const [accs] = await db(DB.auth).query<RowDataPacket[]>(
`SELECT id, username, joindate FROM account WHERE id IN (${placeholders})`,
@@ -411,15 +482,12 @@ export async function resolvePosters(ids: number[]): Promise<Map<number, PosterI
for (const a of accs) {
const gmlevel = gm.get(Number(a.id)) ?? 0
map.set(Number(a.id), {
id: Number(a.id),
map.set(Number(a.id), buildPoster(Number(a.id), {
name: String(a.username || '').replace(/#\d+$/, '') || `#${a.id}`,
gmlevel,
isStaff: gmlevel >= STAFF_GMLEVEL,
joindate: a.joindate ? new Date(a.joindate).toISOString() : null,
postCount: 0,
avatar: avatarForClass(classByAccount.get(Number(a.id))),
})
}))
}
} catch {
/* acore_auth no disponible: se devuelven placeholders abajo */
@@ -427,15 +495,7 @@ export async function resolvePosters(ids: number[]): Promise<Map<number, PosterI
// Cualquier id no resuelto (cuenta borrada) recibe un placeholder.
for (const id of unique) {
if (!map.has(id)) {
map.set(id, {
id,
name: `#${id}`,
gmlevel: 0,
isStaff: false,
joindate: null,
postCount: 0,
avatar: avatarForClass(classByAccount.get(id)),
})
map.set(id, buildPoster(id, {}))
}
}
return map
+2 -1
View File
@@ -514,7 +514,8 @@
"staff": "Staff",
"member": "Member",
"editPost": "Edit post",
"undelete": "Undelete"
"undelete": "Undelete",
"level": "Level"
},
"Admin": {
"title": "Admin panel",
+2 -1
View File
@@ -514,7 +514,8 @@
"staff": "Staff",
"member": "Miembro",
"editPost": "Editar mensaje",
"undelete": "Restaurar"
"undelete": "Restaurar",
"level": "Nivel"
},
"Admin": {
"title": "Panel de administración",
+1
View File
@@ -0,0 +1 @@
inv_axe_09
+1
View File
@@ -0,0 +1 @@
inv_sword_27
+1
View File
@@ -0,0 +1 @@
ability_rogue_ambush
+1
View File
@@ -0,0 +1 @@
inv_mace_01
+1
View File
@@ -0,0 +1 @@
ability_warrior_charge
+1
View File
@@ -0,0 +1 @@
ability_gouge
+1
View File
@@ -0,0 +1 @@
ability_thunderclap
+1
View File
@@ -0,0 +1 @@
ability_shockwave
+1
View File
@@ -0,0 +1 @@
inv_sword_05
+1
View File
@@ -0,0 +1 @@
spell_shadow_deathpact
+1
View File
@@ -0,0 +1 @@
ability_warrior_savageblow
+1
View File
@@ -0,0 +1 @@
spell_nature_enchantarmor
+1
View File
@@ -0,0 +1 @@
spell_holy_blessingofstamina
+1
View File
@@ -0,0 +1 @@
spell_nature_mirrorimage
+1
View File
@@ -0,0 +1 @@
inv_shield_06
+1
View File
@@ -0,0 +1 @@
spell_holy_devotion
+1
View File
@@ -0,0 +1 @@
spell_magic_magearmor
+1
View File
@@ -0,0 +1 @@
ability_racial_bloodrage
+1
View File
@@ -0,0 +1 @@
ability_warrior_sunder
+1
View File
@@ -0,0 +1 @@
ability_warrior_shieldbash
+1
View File
@@ -0,0 +1 @@
ability_warrior_shieldwall
+1
View File
@@ -0,0 +1 @@
ability_warrior_disarm
+1
View File
@@ -0,0 +1 @@
spell_shadow_unholyfrenzy
+1
View File
@@ -0,0 +1 @@
spell_nature_focusedmind
+1
View File
@@ -0,0 +1 @@
ability_ghoulfrenzy
+1
View File
@@ -0,0 +1 @@
ability_rogue_eviscerate
+1
View File
@@ -0,0 +1 @@
spell_nature_purge
+1
View File
@@ -0,0 +1 @@
spell_nature_stoneclawtotem
+1
View File
@@ -0,0 +1 @@
spell_shadow_deathscream
+1
View File
@@ -0,0 +1 @@
ability_warrior_warcry
+1
View File
@@ -0,0 +1 @@
ability_rogue_slicedice
+1
View File
@@ -0,0 +1 @@
ability_warrior_cleave
+1
View File
@@ -0,0 +1 @@
inv_axe_06
+1
View File
@@ -0,0 +1 @@
ability_warrior_revenge
+1
View File
@@ -0,0 +1 @@
ability_thunderbolt
+1
View File
@@ -0,0 +1 @@
ability_backstab
+1
View File
@@ -0,0 +1 @@
ability_warrior_decisivestrike
+1
View File
@@ -0,0 +1 @@
spell_holy_ashestoashes
+1
View File
@@ -0,0 +1 @@
ability_parry
+1
View File
@@ -0,0 +1 @@
spell_shadow_summonimp
+1
View File
@@ -0,0 +1 @@
ability_searingarrow
+1
View File
@@ -0,0 +1 @@
inv_sword_20
+1
View File
@@ -0,0 +1 @@
inv_sword_11
+1
View File
@@ -0,0 +1 @@
spell_nature_ancestralguardian
+1
View File
@@ -0,0 +1 @@
inv_sword_48
+1
View File
@@ -0,0 +1 @@
ability_warrior_weaponmastery
+1
View File
@@ -0,0 +1 @@
ability_dualwield
+1
View File
@@ -0,0 +1 @@
spell_nature_bloodlust
+1
View File
@@ -0,0 +1 @@
inv_helmet_21
+1
View File
@@ -0,0 +1 @@
ability_marksmanship
+1
View File
@@ -0,0 +1 @@
ability_warrior_defensivestance
+1
View File
@@ -0,0 +1 @@
ability_warrior_shieldmastery
+1
View File
@@ -0,0 +1 @@
ability_warrior_endlessrage
+1
View File
@@ -0,0 +1 @@
ability_whirlwind
+1
View File
@@ -0,0 +1 @@
ability_warrior_improveddisciplines
+1
View File
@@ -0,0 +1 @@
ability_racial_avatar
+1
View File
@@ -0,0 +1 @@
ability_warrior_focusedrage
+1
View File
@@ -0,0 +1 @@
ability_warrior_rampage
+1
View File
@@ -0,0 +1 @@
ability_hunter_harass
+1
View File
@@ -0,0 +1 @@
ability_warrior_bloodfrenzy
+1
View File
@@ -0,0 +1 @@
ability_rogue_sprint
+1
View File
@@ -0,0 +1 @@
ability_warrior_savageblow
+1
View File
@@ -0,0 +1 @@
ability_warrior_bloodnova
+1
View File
@@ -0,0 +1 @@
ability_warrior_unrelentingassault
+1
View File
@@ -0,0 +1 @@
ability_warrior_offensivestance
+1
View File
@@ -0,0 +1 @@
ability_warrior_trauma
+1
View File
@@ -0,0 +1 @@
ability_warrior_endlessrage
+1
View File
@@ -0,0 +1 @@
ability_warrior_furiousresolve
+1
View File
@@ -0,0 +1 @@
ability_warrior_bloodsurge
+1
View File
@@ -0,0 +1 @@
ability_warrior_titansgrip
+1
View File
@@ -0,0 +1 @@
ability_warrior_bladestorm
+1
View File
@@ -0,0 +1 @@
ability_warrior_safeguard
+1
View File
@@ -0,0 +1 @@
ability_warrior_swordandboard
+1
View File
@@ -0,0 +1 @@
ability_warrior_shockwave
+1
View File
@@ -0,0 +1 @@
ability_warrior_criticalblock
+1
View File
@@ -0,0 +1 @@
ability_warrior_incite
+1
View File
@@ -0,0 +1 @@
ability_warrior_vigilance
+1
View File
@@ -0,0 +1 @@
ability_rogue_hungerforblood
+1
View File
@@ -0,0 +1 @@
ability_warrior_intensifyrage
+1
View File
@@ -0,0 +1 @@
ability_warrior_warbringer
+1
View File
@@ -0,0 +1 @@
inv_shield_31
+1
View File
@@ -0,0 +1 @@
ability_warrior_shieldreflection
+1
View File
@@ -0,0 +1 @@
ability_heroicleap
+1
View File
@@ -0,0 +1 @@
inv_shoulder_22
+1
View File
@@ -0,0 +1 @@
ability_warrior_bullrush