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:
@@ -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"> <{author.guildName}></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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -514,7 +514,8 @@
|
||||
"staff": "Staff",
|
||||
"member": "Member",
|
||||
"editPost": "Edit post",
|
||||
"undelete": "Undelete"
|
||||
"undelete": "Undelete",
|
||||
"level": "Level"
|
||||
},
|
||||
"Admin": {
|
||||
"title": "Admin panel",
|
||||
|
||||
@@ -514,7 +514,8 @@
|
||||
"staff": "Staff",
|
||||
"member": "Miembro",
|
||||
"editPost": "Editar mensaje",
|
||||
"undelete": "Restaurar"
|
||||
"undelete": "Restaurar",
|
||||
"level": "Nivel"
|
||||
},
|
||||
"Admin": {
|
||||
"title": "Panel de administración",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
inv_axe_09
|
||||
@@ -0,0 +1 @@
|
||||
inv_sword_27
|
||||
@@ -0,0 +1 @@
|
||||
ability_rogue_ambush
|
||||
@@ -0,0 +1 @@
|
||||
inv_mace_01
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_charge
|
||||
@@ -0,0 +1 @@
|
||||
ability_gouge
|
||||
@@ -0,0 +1 @@
|
||||
ability_thunderclap
|
||||
@@ -0,0 +1 @@
|
||||
ability_shockwave
|
||||
@@ -0,0 +1 @@
|
||||
inv_sword_05
|
||||
@@ -0,0 +1 @@
|
||||
spell_shadow_deathpact
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_savageblow
|
||||
@@ -0,0 +1 @@
|
||||
spell_nature_enchantarmor
|
||||
@@ -0,0 +1 @@
|
||||
spell_holy_blessingofstamina
|
||||
@@ -0,0 +1 @@
|
||||
spell_nature_mirrorimage
|
||||
@@ -0,0 +1 @@
|
||||
inv_shield_06
|
||||
@@ -0,0 +1 @@
|
||||
spell_holy_devotion
|
||||
@@ -0,0 +1 @@
|
||||
spell_magic_magearmor
|
||||
@@ -0,0 +1 @@
|
||||
ability_racial_bloodrage
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_sunder
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_shieldbash
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_shieldwall
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_disarm
|
||||
@@ -0,0 +1 @@
|
||||
spell_shadow_unholyfrenzy
|
||||
@@ -0,0 +1 @@
|
||||
spell_nature_focusedmind
|
||||
@@ -0,0 +1 @@
|
||||
ability_ghoulfrenzy
|
||||
@@ -0,0 +1 @@
|
||||
ability_rogue_eviscerate
|
||||
@@ -0,0 +1 @@
|
||||
spell_nature_purge
|
||||
@@ -0,0 +1 @@
|
||||
spell_nature_stoneclawtotem
|
||||
@@ -0,0 +1 @@
|
||||
spell_shadow_deathscream
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_warcry
|
||||
@@ -0,0 +1 @@
|
||||
ability_rogue_slicedice
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_cleave
|
||||
@@ -0,0 +1 @@
|
||||
inv_axe_06
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_revenge
|
||||
@@ -0,0 +1 @@
|
||||
ability_thunderbolt
|
||||
@@ -0,0 +1 @@
|
||||
ability_backstab
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_decisivestrike
|
||||
@@ -0,0 +1 @@
|
||||
spell_holy_ashestoashes
|
||||
@@ -0,0 +1 @@
|
||||
ability_parry
|
||||
@@ -0,0 +1 @@
|
||||
spell_shadow_summonimp
|
||||
@@ -0,0 +1 @@
|
||||
ability_searingarrow
|
||||
@@ -0,0 +1 @@
|
||||
inv_sword_20
|
||||
@@ -0,0 +1 @@
|
||||
inv_sword_11
|
||||
@@ -0,0 +1 @@
|
||||
spell_nature_ancestralguardian
|
||||
@@ -0,0 +1 @@
|
||||
inv_sword_48
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_weaponmastery
|
||||
@@ -0,0 +1 @@
|
||||
ability_dualwield
|
||||
@@ -0,0 +1 @@
|
||||
spell_nature_bloodlust
|
||||
@@ -0,0 +1 @@
|
||||
inv_helmet_21
|
||||
@@ -0,0 +1 @@
|
||||
ability_marksmanship
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_defensivestance
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_shieldmastery
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_endlessrage
|
||||
@@ -0,0 +1 @@
|
||||
ability_whirlwind
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_improveddisciplines
|
||||
@@ -0,0 +1 @@
|
||||
ability_racial_avatar
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_focusedrage
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_rampage
|
||||
@@ -0,0 +1 @@
|
||||
ability_hunter_harass
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_bloodfrenzy
|
||||
@@ -0,0 +1 @@
|
||||
ability_rogue_sprint
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_savageblow
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_bloodnova
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_unrelentingassault
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_offensivestance
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_trauma
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_endlessrage
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_furiousresolve
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_bloodsurge
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_titansgrip
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_bladestorm
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_safeguard
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_swordandboard
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_shockwave
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_criticalblock
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_incite
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_vigilance
|
||||
@@ -0,0 +1 @@
|
||||
ability_rogue_hungerforblood
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_intensifyrage
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_warbringer
|
||||
@@ -0,0 +1 @@
|
||||
inv_shield_31
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_shieldreflection
|
||||
@@ -0,0 +1 @@
|
||||
ability_heroicleap
|
||||
@@ -0,0 +1 @@
|
||||
inv_shoulder_22
|
||||
@@ -0,0 +1 @@
|
||||
ability_warrior_bullrush
|
||||
Reference in New Issue
Block a user