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:
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user