Foro: selector para fijar el personaje del autor

- Modal "Cambiar personaje" (diseño de la web) con filtro, avatar de raza,
  nombre en color de clase y subtítulo nivel/raza/clase.
- Barra "Publicas como: X · Cambiar personaje" sobre el formulario de respuesta
  (solo con sesión iniciada).
- Preferencia persistente en acore_web.forum_character (por cuenta), validada.
- resolvePosters usa el personaje fijado si existe; si no, el de más nivel.
- API /api/forum/character (GET lista + actual, POST fijar), gated por sesión.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 08:14:54 +00:00
parent bf9931e042
commit 0f6e0d514f
7 changed files with 311 additions and 4 deletions
@@ -7,6 +7,8 @@ import {
countPosts,
resolvePosters,
getUserPostCount,
getAccountCharactersForForum,
getForumCharacterPick,
} from '@/lib/forum'
import { formatForumDate } from '@/lib/forum-format'
import { localizeWowheadLinks } from '@/lib/forum-wowhead'
@@ -17,6 +19,7 @@ import { PageShell } from '@/components/PageShell'
import { ForumBreadcrumb } from '@/components/ForumBreadcrumb'
import { Pagination } from '@/components/Pagination'
import { ReplyForm } from '@/components/ReplyForm'
import { ForumCharacterPicker } from '@/components/ForumCharacterPicker'
import { EditPostForm } from '@/components/EditPostForm'
import { ForumModActions } from '@/components/ForumModActions'
import { WowheadRefresh } from '@/components/WowheadRefresh'
@@ -58,6 +61,10 @@ export default async function TopicPage({
await Promise.all(uniquePosters.map(async (pid) => [pid, await getUserPostCount(pid)] as const)),
)
const [forumChars, forumPick] = session.accountId
? await Promise.all([getAccountCharactersForForum(session.accountId), getForumCharacterPick(session.accountId)])
: [[], null]
const path = await getTopicPath(id, locale)
const canReply = Boolean(session.username) && (!topic.locked || isMod)
@@ -199,7 +206,22 @@ export default async function TopicPage({
{canReply ? (
page === totalPages ? (
<ReplyForm topicId={id} />
<>
<ForumCharacterPicker
locale={locale}
initialChars={forumChars}
initialCurrent={forumPick}
labels={{
postingAs: t('postingAs'),
change: t('changeCharacter'),
title: t('changeCharacter'),
filter: t('filter'),
empty: t('noCharacters'),
auto: t('autoCharacter'),
}}
/>
<ReplyForm topicId={id} />
</>
) : (
<p className="forum-empty">
<a href={`/forum/topic/${id}?page=${totalPages}`}>{t('replyOnLastPage')}</a>
+34
View File
@@ -0,0 +1,34 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/session'
import { getAccountCharactersForForum, getForumCharacterPick, setForumCharacterPick } from '@/lib/forum'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/** Lista de personajes de la cuenta + el fijado actualmente. */
export async function GET() {
const session = await getSession()
const accountId = session.accountId ?? 0
if (!accountId) return NextResponse.json({ characters: [], current: null }, { status: 401 })
const [characters, current] = await Promise.all([
getAccountCharactersForForum(accountId),
getForumCharacterPick(accountId),
])
return NextResponse.json({ characters, current })
}
/** Fija el personaje del foro para la cuenta en sesión. */
export async function POST(req: NextRequest) {
const session = await getSession()
const accountId = session.accountId ?? 0
if (!accountId) return NextResponse.json({ ok: false }, { status: 401 })
let guid = 0
try {
const body = await req.json()
guid = Number(body?.guid) || 0
} catch {
/* body inválido */
}
const ok = await setForumCharacterPick(accountId, guid)
return NextResponse.json({ ok }, { status: ok ? 200 : 400 })
}
+40
View File
@@ -298,3 +298,43 @@ a.nice_button:focus {
/* Nombre de hermandad junto al personaje en el foro */
.username_guild { color: #c8a24c; font-weight: 400; font-size: .8em; }
/* ===== Selector de personaje del foro ===== */
.forum-charpick { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 12px;
background: #17140f; border: 1px solid #2b2620; border-radius: 8px; padding: 10px 14px; }
.forum-charpick-as { color: #9a948a; font-size: 13px; }
.forum-charpick-as b { font-weight: 700; }
.forum-charpick-auto { color: #b8b0a2 !important; font-weight: 600; }
.forum-charpick-btn { display: inline-flex; align-items: center; gap: 7px; background: #241f19;
border: 1px solid #3a342b; color: #e6ddc9; border-radius: 6px; padding: 7px 13px; cursor: pointer;
font-family: inherit; font-size: 13px; transition: border-color .15s, color .15s; }
.forum-charpick-btn:hover { border-color: #c8a24c; color: #fff; }
.forum-charpick-btn > i { color: #c8a24c; }
.forum-charpick-overlay { position: fixed; inset: 0; z-index: 1000; background: rgba(0,0,0,.65);
display: flex; align-items: center; justify-content: center; padding: 20px; }
.forum-charpick-modal { width: 100%; max-width: 460px; max-height: 80vh; display: flex; flex-direction: column;
background: #14110c; border: 1px solid #c8a24c; border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,.6); overflow: hidden; }
.forum-charpick-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px;
border-bottom: 1px solid #2b2620; }
.forum-charpick-head h3 { margin: 0; font-size: 18px; font-weight: 800; color: #e6b53c; }
.forum-charpick-close { background: #241f19; border: 1px solid #3a342b; color: #d9c9a0; width: 32px; height: 32px;
border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.forum-charpick-close:hover { border-color: #c8a24c; color: #fff; }
.forum-charpick-search { display: flex; align-items: center; gap: 8px; padding: 12px 16px; border-bottom: 1px solid #201c16; }
.forum-charpick-search > i { color: #7d7568; }
.forum-charpick-search input { flex: 1; background: #17140f; border: 1px solid #3a342b; color: #e6ddc9;
border-radius: 6px; padding: 8px 10px; font-family: inherit; font-size: 14px; }
.forum-charpick-search input:focus { outline: none; border-color: #c8a24c; }
.forum-charpick-list { overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
.forum-charpick-empty { color: #8a847a; text-align: center; padding: 20px; margin: 0; }
.forum-charpick-item { display: flex; align-items: center; gap: 12px; background: #17140f; border: 1px solid #2b2620;
border-radius: 8px; padding: 8px 10px; cursor: pointer; font-family: inherit; text-align: left; width: 100%;
transition: border-color .15s, background .15s; }
.forum-charpick-item:hover { border-color: #c8a24c; background: #1e1a13; }
.forum-charpick-item.active { border-color: #c8a24c; background: #241f19; }
.forum-charpick-ava { width: 48px; height: 48px; border-radius: 8px; border: 1px solid #3a342b; flex: 0 0 auto; }
.forum-charpick-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.forum-charpick-name { font-weight: 700; font-size: 15px; }
.forum-charpick-sub { color: #9a948a; font-size: 12px; }
.forum-charpick-chk { color: #4ade5f; margin-left: auto; }
@@ -0,0 +1,123 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { className, classColor, raceName } from '@/lib/wow-data'
type Char = { guid: number; name: string; race: number; gender: number; class: number; level: number }
const 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',
}
const raceIcon = (r: number, g: number) =>
`https://wow.zamimg.com/images/wow/icons/large/race_${RACE_SLUG[r] || 'human'}_${g === 1 ? 'female' : 'male'}.jpg`
export function ForumCharacterPicker({
locale,
initialChars,
initialCurrent,
labels,
}: {
locale: string
initialChars: Char[]
initialCurrent: number | null
labels: { postingAs: string; change: string; title: string; filter: string; empty: string; auto: string }
}) {
const router = useRouter()
const [open, setOpen] = useState(false)
const [chars] = useState<Char[]>(initialChars)
const [current, setCurrent] = useState<number | null>(initialCurrent)
const [q, setQ] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false)
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open])
if (chars.length === 0) return null
const currentChar = chars.find((c) => c.guid === current)
const shown = q.trim() ? chars.filter((c) => c.name.toLowerCase().includes(q.trim().toLowerCase())) : chars
const pick = async (guid: number) => {
if (saving) return
setSaving(true)
try {
const res = await fetch('/api/forum/character', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guid }),
})
if (res.ok) {
setCurrent(guid)
setOpen(false)
router.refresh()
}
} finally {
setSaving(false)
}
}
return (
<div className="forum-charpick">
<span className="forum-charpick-as">
{labels.postingAs}{' '}
{currentChar ? (
<b style={{ color: classColor(currentChar.class) }}>{currentChar.name}</b>
) : (
<b className="forum-charpick-auto">{labels.auto}</b>
)}
</span>
<button type="button" className="forum-charpick-btn" onClick={() => setOpen(true)}>
<i className="fa-solid fa-user-pen" /> {labels.change}
</button>
{open && (
<div className="forum-charpick-overlay" onClick={() => setOpen(false)}>
<div className="forum-charpick-modal" onClick={(e) => e.stopPropagation()}>
<div className="forum-charpick-head">
<h3>{labels.title}</h3>
<button type="button" className="forum-charpick-close" onClick={() => setOpen(false)} aria-label="X">
<i className="fa-solid fa-xmark" />
</button>
</div>
<div className="forum-charpick-search">
<i className="fa-solid fa-magnifying-glass" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={labels.filter} autoFocus />
</div>
<div className="forum-charpick-list">
{shown.length === 0 ? (
<p className="forum-charpick-empty">{labels.empty}</p>
) : (
shown.map((c) => (
<button
key={c.guid}
type="button"
className={`forum-charpick-item${c.guid === current ? ' active' : ''}`}
onClick={() => pick(c.guid)}
disabled={saving}
>
<img className="forum-charpick-ava" src={raceIcon(c.race, c.gender)} alt="" loading="lazy" />
<span className="forum-charpick-info">
<span className="forum-charpick-name" style={{ color: classColor(c.class) }}>
{c.name}
</span>
<span className="forum-charpick-sub">
{c.level} {raceName(c.race, locale)} {className(c.class, locale)}
</span>
</span>
{c.guid === current && <i className="fa-solid fa-check forum-charpick-chk" />}
</button>
))
)}
</div>
</div>
</div>
)}
</div>
)
}
+79 -1
View File
@@ -413,8 +413,21 @@ export async function resolvePosters(ids: number[]): Promise<Map<number, PosterI
if (!byAcc.has(acc)) byAcc.set(acc, [])
byAcc.get(acc)!.push(c)
}
// Personaje fijado por el usuario (si lo hay y sigue siendo suyo); si no, el de más nivel.
const pickByAcc = new Map<number, number>()
try {
const [picks] = await db(DB.web).query<RowDataPacket[]>(
`SELECT account, guid FROM forum_character WHERE account IN (${placeholders})`,
unique,
)
for (const p of picks) pickByAcc.set(Number(p.account), Number(p.guid))
} catch {
/* sin tabla de preferencias */
}
for (const [acc, list] of byAcc) {
const c = list.reduce((best, cur) => (Number(cur.level) > Number(best.level) ? cur : best), list[0])
const pg = pickByAcc.get(acc)
const picked = pg ? list.find((x) => Number(x.guid) === pg) : undefined
const c = picked || list.reduce((best, cur) => (Number(cur.level) > Number(best.level) ? cur : best), list[0])
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),
@@ -500,3 +513,68 @@ export async function resolvePosters(ids: number[]): Promise<Map<number, PosterI
}
return map
}
export interface ForumCharOption {
guid: number
name: string
race: number
gender: number
class: number
level: number
}
/** Personajes de la cuenta para el selector del foro. */
export async function getAccountCharactersForForum(accountId: number): Promise<ForumCharOption[]> {
if (!accountId) return []
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT guid, name, race, gender, class, level FROM characters WHERE account = ? ORDER BY level DESC, name ASC',
[accountId],
)
return rows.map((r) => ({
guid: Number(r.guid), name: String(r.name), race: Number(r.race),
gender: Number(r.gender), class: Number(r.class), level: Number(r.level),
}))
} catch {
return []
}
}
/** GUID del personaje fijado por la cuenta para el foro (validado), o null. */
export async function getForumCharacterPick(accountId: number): Promise<number | null> {
if (!accountId) return null
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT guid FROM forum_character WHERE account = ?',
[accountId],
)
const guid = rows[0] ? Number(rows[0].guid) : 0
if (!guid) return null
const [ok] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT 1 FROM characters WHERE guid = ? AND account = ? LIMIT 1',
[guid, accountId],
)
return ok.length ? guid : null
} catch {
return null
}
}
/** Fija el personaje del foro para la cuenta (valida que le pertenece). */
export async function setForumCharacterPick(accountId: number, guid: number): Promise<boolean> {
if (!accountId || !guid) return false
try {
const [ok] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT 1 FROM characters WHERE guid = ? AND account = ? LIMIT 1',
[guid, accountId],
)
if (!ok.length) return false
await db(DB.web).query(
'INSERT INTO forum_character (account, guid) VALUES (?, ?) ON DUPLICATE KEY UPDATE guid = VALUES(guid)',
[accountId, guid],
)
return true
} catch {
return false
}
}
+6 -1
View File
@@ -515,7 +515,12 @@
"member": "Member",
"editPost": "Edit post",
"undelete": "Undelete",
"level": "Level"
"level": "Level",
"postingAs": "Posting as:",
"changeCharacter": "Change character",
"filter": "Filter",
"noCharacters": "No characters.",
"autoCharacter": "Automatic (highest level)"
},
"Admin": {
"title": "Admin panel",
+6 -1
View File
@@ -515,7 +515,12 @@
"member": "Miembro",
"editPost": "Editar mensaje",
"undelete": "Restaurar",
"level": "Nivel"
"level": "Nivel",
"postingAs": "Publicas como:",
"changeCharacter": "Cambiar personaje",
"filter": "Filtrar",
"noCharacters": "No hay personajes.",
"autoCharacter": "Automático (mayor nivel)"
},
"Admin": {
"title": "Panel de administración",