From 0f6e0d514ff083658f7cc9b7ac050cca0a3ff07f Mon Sep 17 00:00:00 2001 From: adevopg Date: Fri, 17 Jul 2026 08:14:54 +0000 Subject: [PATCH] Foro: selector para fijar el personaje del autor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../[locale]/forum/topic/[topicId]/page.tsx | 24 +++- web-next/app/api/forum/character/route.ts | 34 +++++ web-next/app/forum.css | 40 ++++++ web-next/components/ForumCharacterPicker.tsx | 123 ++++++++++++++++++ web-next/lib/forum.ts | 80 +++++++++++- web-next/messages/en.json | 7 +- web-next/messages/es.json | 7 +- 7 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 web-next/app/api/forum/character/route.ts create mode 100644 web-next/components/ForumCharacterPicker.tsx diff --git a/web-next/app/[locale]/forum/topic/[topicId]/page.tsx b/web-next/app/[locale]/forum/topic/[topicId]/page.tsx index 523f76f..004f948 100644 --- a/web-next/app/[locale]/forum/topic/[topicId]/page.tsx +++ b/web-next/app/[locale]/forum/topic/[topicId]/page.tsx @@ -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 ? ( - + <> + + + ) : (

{t('replyOnLastPage')} diff --git a/web-next/app/api/forum/character/route.ts b/web-next/app/api/forum/character/route.ts new file mode 100644 index 0000000..3c0984a --- /dev/null +++ b/web-next/app/api/forum/character/route.ts @@ -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 }) +} diff --git a/web-next/app/forum.css b/web-next/app/forum.css index 5120585..eec4724 100644 --- a/web-next/app/forum.css +++ b/web-next/app/forum.css @@ -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; } diff --git a/web-next/components/ForumCharacterPicker.tsx b/web-next/components/ForumCharacterPicker.tsx new file mode 100644 index 0000000..cb2e730 --- /dev/null +++ b/web-next/components/ForumCharacterPicker.tsx @@ -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 = { + 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(initialChars) + const [current, setCurrent] = useState(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 ( +

+ + {labels.postingAs}{' '} + {currentChar ? ( + {currentChar.name} + ) : ( + {labels.auto} + )} + + + + {open && ( +
setOpen(false)}> +
e.stopPropagation()}> +
+

{labels.title}

+ +
+
+ + setQ(e.target.value)} placeholder={labels.filter} autoFocus /> +
+
+ {shown.length === 0 ? ( +

{labels.empty}

+ ) : ( + shown.map((c) => ( + + )) + )} +
+
+
+ )} +
+ ) +} diff --git a/web-next/lib/forum.ts b/web-next/lib/forum.ts index 04ce414..d345932 100644 --- a/web-next/lib/forum.ts +++ b/web-next/lib/forum.ts @@ -413,8 +413,21 @@ export async function resolvePosters(ids: number[]): Promise() + try { + const [picks] = await db(DB.web).query( + `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 { + if (!accountId) return [] + try { + const [rows] = await db(DB.characters).query( + '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 { + if (!accountId) return null + try { + const [rows] = await db(DB.web).query( + '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( + '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 { + if (!accountId || !guid) return false + try { + const [ok] = await db(DB.characters).query( + '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 + } +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index ef66166..1b827fe 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -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", diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 77d2de9..7285442 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -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",