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; }