Foro: barra de acciones por publicación (me gusta, enlace, denuncia, marcador)

- Me gusta con contador y toggle (al volver a pulsar se retira).
- Copiar enlace (permalink al post con ancla #post-ID).
- Denunciar: modal con motivos (troleo, amenaza, spam, etc.) + detalle.
- Guardar en marcadores + popover de recordatorio (2h/mañana/3 días/más)
  y modal "Editar marcador" con nota y recordatorio (nota + remind_at en BD).
- Tablas acore_web: forum_post_like, forum_post_bookmark (+note,+remind_at), forum_post_report.
- APIs /api/forum/{like,bookmark,report,bookmark-reminder}, gated por sesión.
- Estilo oscuro+dorado de la web.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 08:30:57 +00:00
parent aaf106ef55
commit a73fe4d8d5
10 changed files with 776 additions and 3 deletions
@@ -10,6 +10,7 @@ import {
getAccountCharactersForForum,
getForumCharacterPick,
} from '@/lib/forum'
import { getPostsSocial, REPORT_REASONS } from '@/lib/forum-social'
import { formatForumDate } from '@/lib/forum-format'
import { localizeWowheadLinks } from '@/lib/forum-wowhead'
import { getSession } from '@/lib/session'
@@ -20,6 +21,7 @@ import { ForumBreadcrumb } from '@/components/ForumBreadcrumb'
import { Pagination } from '@/components/Pagination'
import { ReplyForm } from '@/components/ReplyForm'
import { ForumCharacterPicker } from '@/components/ForumCharacterPicker'
import { PostActions } from '@/components/PostActions'
import { EditPostForm } from '@/components/EditPostForm'
import { ForumModActions } from '@/components/ForumModActions'
import { WowheadRefresh } from '@/components/WowheadRefresh'
@@ -61,6 +63,32 @@ export default async function TopicPage({
await Promise.all(uniquePosters.map(async (pid) => [pid, await getUserPostCount(pid)] as const)),
)
const social = await getPostsSocial(posts.map((p) => p.id), session.accountId ?? 0)
const REASON_DESC: Record<string, string> = {
troll: t('rTrollDesc'), threat: t('rThreatDesc'), username: t('rUsernameDesc'),
offtopic: t('rOfftopicDesc'), inappropriate: t('rInappropriateDesc'), spam: t('rSpamDesc'),
violence: t('rViolenceDesc'), child: t('rChildDesc'), illegal: t('rIllegalDesc'),
}
const REASON_LABEL: Record<string, string> = {
troll: t('rTroll'), threat: t('rThreat'), username: t('rUsername'), offtopic: t('rOfftopic'),
inappropriate: t('rInappropriate'), spam: t('rSpam'), violence: t('rViolence'),
child: t('rChild'), illegal: t('rIllegal'),
}
const reportReasons = REPORT_REASONS.map((k) => ({ key: k, label: REASON_LABEL[k] || k, desc: REASON_DESC[k] || '' }))
const paLabels = {
like: t('like'), copyLink: t('copyLink'), copied: t('copied'), report: t('report'),
bookmark: t('bookmark'), bookmarked: t('bookmarked'), loginRequired: t('loginToPost'),
reportTitle: t('reportTitle'), reportIntro: t('reportIntro'), reportDetail: t('reportDetail'),
reportSubmit: t('reportSubmit'), reportSent: t('reportSent'),
reminderTitle: t('reminderTitle'), savedBookmark: t('savedBookmark'),
remind2h: t('remind2h'), remindTomorrow: t('remindTomorrow'), remind3days: t('remind3days'), remindMore: t('remindMore'),
editTitle: t('editBookmark'), notePlaceholder: t('notePlaceholder'), save: t('save'), cancel: t('cancel'),
remove: t('removeBookmark'), reminderLabel: t('reminderLabel'), rNone: t('rNone'),
rTomorrow: t('rmTomorrow'), rNextWeek: t('rmNextWeek'), rNextMonth: t('rmNextMonth'), rCustom: t('rmCustom'),
}
const paCanAct = Boolean(session.accountId)
const paLoginHref = `/${locale}/my-account`
const [forumChars, forumPick] = session.accountId
? await Promise.all([getAccountCharactersForForum(session.accountId), getForumCharacterPick(session.accountId)])
: [[], null]
@@ -110,7 +138,7 @@ export default async function TopicPage({
const author = posters.get(post.poster)
const isStaff = author?.isStaff ?? false
return (
<div key={post.id} className={`topic_post${isStaff ? ' isStaff' : ''}${post.deleted ? ' post_deleted' : ''}`}>
<div key={post.id} id={`post-${post.id}`} className={`topic_post${isStaff ? ' isStaff' : ''}${post.deleted ? ' post_deleted' : ''}`}>
<div className="left_side">
<div className="username_container">
<span className="username">
@@ -176,6 +204,22 @@ export default async function TopicPage({
<div className="right_side">
<div className="post_container" dangerouslySetInnerHTML={{ __html: post.text }} />
<ul className="post_controls">
<li className="pa-li">
<PostActions
postId={post.id}
permalink={`/${locale}/forum/topic/${id}#post-${post.id}`}
canAct={paCanAct}
loginHref={paLoginHref}
initialLikes={social.get(post.id)?.likes ?? 0}
initialLiked={social.get(post.id)?.liked ?? false}
initialBookmarked={social.get(post.id)?.bookmarked ?? false}
initialNote={social.get(post.id)?.note ?? ''}
initialRemindAt={social.get(post.id)?.remindAt ?? null}
locale={locale}
reasons={reportReasons}
labels={paLabels}
/>
</li>
<li className="post_date">{formatForumDate(post.time, locale)}</li>
{canEditPost(session, isMod, post.poster) && (
<li>
@@ -0,0 +1,23 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/session'
import { updateBookmark } from '@/lib/forum-social'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/** Guarda nota + recordatorio del marcador (crea el marcador si no existía). */
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 postId = 0, note = '', remindAt: number | null = null
try {
const b = await req.json()
postId = Number(b?.postId) || 0
note = String(b?.note || '')
remindAt = b?.remindAt ? Number(b.remindAt) : null
} catch { /* */ }
if (!postId) return NextResponse.json({ ok: false }, { status: 400 })
const ok = await updateBookmark(accountId, postId, note, remindAt)
return NextResponse.json({ ok, bookmarked: true, note, remindAt }, { status: ok ? 200 : 400 })
}
+17
View File
@@ -0,0 +1,17 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/session'
import { togglePostBookmark } from '@/lib/forum-social'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
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 postId = 0
try { postId = Number((await req.json())?.postId) || 0 } catch { /* */ }
if (!postId) return NextResponse.json({ ok: false }, { status: 400 })
const r = await togglePostBookmark(accountId, postId)
return NextResponse.json({ ok: true, ...r })
}
+17
View File
@@ -0,0 +1,17 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/session'
import { togglePostLike } from '@/lib/forum-social'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
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 postId = 0
try { postId = Number((await req.json())?.postId) || 0 } catch { /* */ }
if (!postId) return NextResponse.json({ ok: false }, { status: 400 })
const r = await togglePostLike(accountId, postId)
return NextResponse.json({ ok: true, ...r })
}
+23
View File
@@ -0,0 +1,23 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/session'
import { reportPost } from '@/lib/forum-social'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
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 postId = 0, reason = '', detail = ''
try {
const b = await req.json()
postId = Number(b?.postId) || 0
reason = String(b?.reason || '')
detail = String(b?.detail || '')
} catch { /* */ }
if (!postId || !reason) return NextResponse.json({ ok: false }, { status: 400 })
const now = Math.floor(Date.now() / 1000)
const ok = await reportPost(accountId, postId, reason, detail, now)
return NextResponse.json({ ok }, { status: ok ? 200 : 400 })
}
+72
View File
@@ -338,3 +338,75 @@ a.nice_button:focus {
.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; }
/* ===== Barra de acciones de publicación ===== */
.post_actions { display: inline-flex; align-items: center; gap: 4px; position: relative; }
.pa-btn { position: relative; display: inline-flex; align-items: center; gap: 5px; background: transparent;
border: 1px solid transparent; color: #8a847a; border-radius: 6px; padding: 5px 8px; cursor: pointer;
font-family: inherit; font-size: 14px; transition: color .12s, background .12s, border-color .12s; }
.pa-btn:hover { color: #e6ddc9; background: #201c16; }
.pa-btn.liked { color: #e0555f; }
.pa-btn.liked:hover { color: #ff6b74; }
.pa-btn.marked { color: #c8a24c; }
.pa-count { font-size: 13px; font-weight: 700; }
.pa-remind-dot { font-size: 9px; margin-left: 2px; color: #c8a24c; }
.pa-toast { position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%);
background: #0d0b08; border: 1px solid #c8a24c; color: #e6b53c; font-size: 11px; padding: 3px 8px;
border-radius: 5px; white-space: nowrap; z-index: 20; }
/* Popover de recordatorio */
.pa-bookmark-wrap { position: relative; }
.pa-reminder-pop { position: absolute; top: calc(100% + 6px); right: 0; z-index: 40; width: 220px;
background: #14110c; border: 1px solid #c8a24c; border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,.6);
padding: 6px; display: flex; flex-direction: column; }
.pa-reminder-head { display: flex; align-items: center; gap: 7px; color: #4ade5f; font-weight: 700; font-size: 13px; padding: 8px 8px 6px; }
.pa-reminder-q { color: #e6ddc9; font-weight: 700; font-size: 13px; padding: 4px 8px 8px; border-bottom: 1px solid #2b2620; }
.pa-reminder-pop button { text-align: left; background: transparent; border: 0; color: #d9c9a0; font-family: inherit;
font-size: 13px; padding: 9px 8px; border-radius: 6px; cursor: pointer; }
.pa-reminder-pop button:hover { background: #241f19; color: #fff; }
.pa-reminder-more { color: #c8a24c !important; }
/* Modales (denuncia / editar marcador) */
.pa-overlay { position: fixed; inset: 0; z-index: 1000; background: rgba(0,0,0,.65);
display: flex; align-items: center; justify-content: center; padding: 20px; }
.pa-modal { width: 100%; max-width: 480px; max-height: 85vh; overflow-y: auto; background: #14110c;
border: 1px solid #c8a24c; border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,.6); padding: 18px 20px; }
.pa-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.pa-modal-head h3 { margin: 0; font-size: 19px; font-weight: 800; color: #e6b53c; line-height: 1.25; }
.pa-close { background: #241f19; border: 1px solid #3a342b; color: #d9c9a0; width: 32px; height: 32px; flex: 0 0 auto;
border-radius: 6px; cursor: pointer; }
.pa-close:hover { border-color: #c8a24c; color: #fff; }
.pa-modal-intro { color: #9a948a; font-size: 13px; margin: 0 0 12px; }
.pa-reasons { display: flex; flex-direction: column; gap: 4px; }
.pa-reason { display: flex; gap: 10px; align-items: flex-start; padding: 9px 10px; border-radius: 8px; cursor: pointer;
border: 1px solid transparent; }
.pa-reason:hover { background: #1b1811; }
.pa-reason.active { background: #241f19; border-color: #c8a24c; }
.pa-reason input { margin-top: 4px; accent-color: #c8a24c; }
.pa-reason b { color: #e6ddc9; font-size: 14px; display: block; }
.pa-reason em { color: #8a847a; font-size: 12px; font-style: normal; display: block; margin-top: 1px; }
.pa-report-detail, .pa-note { width: 100%; background: #17140f; border: 1px solid #3a342b; color: #e6ddc9;
border-radius: 8px; padding: 9px 11px; font-family: inherit; font-size: 14px; margin-top: 12px; resize: vertical; }
.pa-report-detail:focus, .pa-note:focus { outline: none; border-color: #c8a24c; }
.pa-modal-foot { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
.pa-primary { display: inline-flex; align-items: center; gap: 7px; background: #c8a24c; color: #1a1710; border: 0;
border-radius: 7px; padding: 9px 16px; font-weight: 700; font-family: inherit; cursor: pointer; }
.pa-primary:hover { background: #d9b45e; }
.pa-primary:disabled { opacity: .5; cursor: default; }
.pa-ghost { background: transparent; border: 0; color: #9a948a; font-family: inherit; cursor: pointer; padding: 9px 6px; }
.pa-ghost:hover { color: #e6ddc9; }
.pa-danger { margin-left: auto; background: #241f19; border: 1px solid #6b2f2f; color: #e0777a; width: 38px; height: 38px;
border-radius: 7px; cursor: pointer; }
.pa-danger:hover { border-color: #d9534f; color: #fff; }
.pa-sent { color: #4ade5f; font-size: 15px; padding: 16px 4px; display: flex; align-items: center; gap: 8px; }
.pa-reminder-label { color: #c8a24c; font-weight: 700; font-size: 13px; margin: 14px 0 8px; }
.pa-reminder-rows { display: flex; flex-direction: column; gap: 4px; }
.pa-reminder-rows button { display: flex; justify-content: space-between; align-items: center; gap: 10px;
background: #17140f; border: 1px solid #2b2620; color: #d9c9a0; border-radius: 7px; padding: 9px 12px;
font-family: inherit; font-size: 13px; cursor: pointer; }
.pa-reminder-rows button:hover { border-color: #c8a24c; color: #fff; }
.pa-reminder-rows button.active { border-color: #c8a24c; background: #241f19; }
.pa-reminder-rows button span { color: #8a847a; font-size: 12px; }
.pa-custom { display: flex; flex-direction: column; gap: 5px; color: #9a948a; font-size: 12px; margin-top: 10px; }
.pa-custom input { background: #17140f; border: 1px solid #3a342b; color: #e6ddc9; border-radius: 7px; padding: 8px 10px; font-family: inherit; }
.pa-remind-current { margin-top: 10px; color: #e6b53c; font-size: 13px; display: flex; align-items: center; gap: 7px; }