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:
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
type Reason = { key: string; label: string; desc: string }
|
||||
|
||||
export type PostActionLabels = {
|
||||
like: string
|
||||
copyLink: string
|
||||
copied: string
|
||||
report: string
|
||||
bookmark: string
|
||||
bookmarked: string
|
||||
loginRequired: string
|
||||
reportTitle: string
|
||||
reportIntro: string
|
||||
reportDetail: string
|
||||
reportSubmit: string
|
||||
reportSent: string
|
||||
reminderTitle: string
|
||||
savedBookmark: string
|
||||
remind2h: string
|
||||
remindTomorrow: string
|
||||
remind3days: string
|
||||
remindMore: string
|
||||
editTitle: string
|
||||
notePlaceholder: string
|
||||
save: string
|
||||
cancel: string
|
||||
remove: string
|
||||
reminderLabel: string
|
||||
rNone: string
|
||||
rTomorrow: string
|
||||
rNextWeek: string
|
||||
rNextMonth: string
|
||||
rCustom: string
|
||||
}
|
||||
|
||||
const at8am = (daysAhead: number) => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + daysAhead)
|
||||
d.setHours(8, 0, 0, 0)
|
||||
return Math.floor(d.getTime() / 1000)
|
||||
}
|
||||
|
||||
export function PostActions({
|
||||
postId,
|
||||
permalink,
|
||||
canAct,
|
||||
loginHref,
|
||||
initialLikes,
|
||||
initialLiked,
|
||||
initialBookmarked,
|
||||
initialNote,
|
||||
initialRemindAt,
|
||||
locale,
|
||||
reasons,
|
||||
labels,
|
||||
}: {
|
||||
postId: number
|
||||
permalink: string
|
||||
canAct: boolean
|
||||
loginHref: string
|
||||
initialLikes: number
|
||||
initialLiked: boolean
|
||||
initialBookmarked: boolean
|
||||
initialNote: string
|
||||
initialRemindAt: number | null
|
||||
locale: string
|
||||
reasons: Reason[]
|
||||
labels: PostActionLabels
|
||||
}) {
|
||||
const [likes, setLikes] = useState(initialLikes)
|
||||
const [liked, setLiked] = useState(initialLiked)
|
||||
const [bookmarked, setBookmarked] = useState(initialBookmarked)
|
||||
const [note, setNote] = useState(initialNote)
|
||||
const [remindAt, setRemindAt] = useState<number | null>(initialRemindAt)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const [reportOpen, setReportOpen] = useState(false)
|
||||
const [reportReason, setReportReason] = useState('')
|
||||
const [reportDetail, setReportDetail] = useState('')
|
||||
const [reportSent, setReportSent] = useState(false)
|
||||
|
||||
const [remindPop, setRemindPop] = useState(false)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
|
||||
const requireLogin = () => {
|
||||
window.location.href = loginHref
|
||||
}
|
||||
|
||||
const toggleLike = async () => {
|
||||
if (!canAct) return requireLogin()
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch('/api/forum/like', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ postId }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (d.ok) {
|
||||
setLiked(d.liked)
|
||||
setLikes(d.likes)
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyLink = async () => {
|
||||
const url = `${window.location.origin}${permalink}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
} catch {
|
||||
/* sin clipboard API */
|
||||
}
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
const toggleBookmark = async () => {
|
||||
if (!canAct) return requireLogin()
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch('/api/forum/bookmark', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ postId }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (d.ok) {
|
||||
setBookmarked(d.bookmarked)
|
||||
if (d.bookmarked) setRemindPop(true)
|
||||
else {
|
||||
setNote('')
|
||||
setRemindAt(null)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveReminder = async (when: number | null, closeAll = true) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch('/api/forum/bookmark-reminder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ postId, note, remindAt: when }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (d.ok) {
|
||||
setBookmarked(true)
|
||||
setRemindAt(when)
|
||||
if (closeAll) {
|
||||
setRemindPop(false)
|
||||
setEditOpen(false)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitReport = async () => {
|
||||
if (!reportReason || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await fetch('/api/forum/report', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ postId, reason: reportReason, detail: reportDetail }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (d.ok) {
|
||||
setReportSent(true)
|
||||
setTimeout(() => {
|
||||
setReportOpen(false)
|
||||
setReportSent(false)
|
||||
setReportReason('')
|
||||
setReportDetail('')
|
||||
}, 1200)
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fmtRemind = (ts: number) => new Date(ts * 1000).toLocaleString(locale, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
return (
|
||||
<div className="post_actions">
|
||||
<button type="button" className={`pa-btn${liked ? ' liked' : ''}`} onClick={toggleLike} title={labels.like}>
|
||||
<i className={liked ? 'fa-solid fa-heart' : 'fa-regular fa-heart'} />
|
||||
{likes > 0 && <span className="pa-count">{likes}</span>}
|
||||
</button>
|
||||
|
||||
<button type="button" className="pa-btn" onClick={copyLink} title={labels.copyLink}>
|
||||
<i className="fa-solid fa-link" />
|
||||
{copied && <span className="pa-toast">{labels.copied}</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="pa-btn"
|
||||
onClick={() => (canAct ? setReportOpen(true) : requireLogin())}
|
||||
title={labels.report}
|
||||
>
|
||||
<i className="fa-regular fa-flag" />
|
||||
</button>
|
||||
|
||||
<div className="pa-bookmark-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className={`pa-btn${bookmarked ? ' marked' : ''}`}
|
||||
onClick={() => (bookmarked ? setEditOpen(true) : toggleBookmark())}
|
||||
title={bookmarked ? labels.bookmarked : labels.bookmark}
|
||||
>
|
||||
<i className={bookmarked ? 'fa-solid fa-bookmark' : 'fa-regular fa-bookmark'} />
|
||||
{remindAt && <i className="fa-solid fa-clock pa-remind-dot" />}
|
||||
</button>
|
||||
|
||||
{remindPop && (
|
||||
<div className="pa-reminder-pop">
|
||||
<div className="pa-reminder-head">
|
||||
<i className="fa-solid fa-circle-check" /> {labels.savedBookmark}
|
||||
</div>
|
||||
<div className="pa-reminder-q">{labels.reminderTitle}</div>
|
||||
<button type="button" onClick={() => saveReminder(Math.floor(Date.now() / 1000) + 7200)}>{labels.remind2h}</button>
|
||||
<button type="button" onClick={() => saveReminder(at8am(1))}>{labels.remindTomorrow}</button>
|
||||
<button type="button" onClick={() => saveReminder(at8am(3))}>{labels.remind3days}</button>
|
||||
<button type="button" className="pa-reminder-more" onClick={() => { setRemindPop(false); setEditOpen(true) }}>
|
||||
{labels.remindMore}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal denuncia */}
|
||||
{reportOpen && (
|
||||
<div className="pa-overlay" onClick={() => setReportOpen(false)}>
|
||||
<div className="pa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="pa-modal-head">
|
||||
<h3>{labels.reportTitle}</h3>
|
||||
<button type="button" className="pa-close" onClick={() => setReportOpen(false)}><i className="fa-solid fa-xmark" /></button>
|
||||
</div>
|
||||
{reportSent ? (
|
||||
<p className="pa-sent"><i className="fa-solid fa-circle-check" /> {labels.reportSent}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="pa-modal-intro">{labels.reportIntro}</p>
|
||||
<div className="pa-reasons">
|
||||
{reasons.map((r) => (
|
||||
<label key={r.key} className={`pa-reason${reportReason === r.key ? ' active' : ''}`}>
|
||||
<input type="radio" name={`report-${postId}`} value={r.key} checked={reportReason === r.key} onChange={() => setReportReason(r.key)} />
|
||||
<span>
|
||||
<b>{r.label}</b>
|
||||
{r.desc && <em>{r.desc}</em>}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<textarea className="pa-report-detail" value={reportDetail} onChange={(e) => setReportDetail(e.target.value)} placeholder={labels.reportDetail} rows={2} />
|
||||
<div className="pa-modal-foot">
|
||||
<button type="button" className="pa-primary" disabled={!reportReason || busy} onClick={submitReport}>
|
||||
<i className="fa-solid fa-flag" /> {labels.reportSubmit}
|
||||
</button>
|
||||
<button type="button" className="pa-ghost" onClick={() => setReportOpen(false)}>{labels.cancel}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal editar marcador */}
|
||||
{editOpen && (
|
||||
<div className="pa-overlay" onClick={() => setEditOpen(false)}>
|
||||
<div className="pa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="pa-modal-head">
|
||||
<h3>{labels.editTitle}</h3>
|
||||
<button type="button" className="pa-close" onClick={() => setEditOpen(false)}><i className="fa-solid fa-xmark" /></button>
|
||||
</div>
|
||||
<input className="pa-note" value={note} onChange={(e) => setNote(e.target.value)} placeholder={labels.notePlaceholder} />
|
||||
<div className="pa-reminder-label">{labels.reminderLabel}</div>
|
||||
<div className="pa-reminder-rows">
|
||||
<button type="button" className={!remindAt ? 'active' : ''} onClick={() => setRemindAt(null)}>{labels.rNone}</button>
|
||||
<button type="button" onClick={() => setRemindAt(at8am(1))}>{labels.rTomorrow}<span>{fmtRemind(at8am(1))}</span></button>
|
||||
<button type="button" onClick={() => setRemindAt(at8am(7))}>{labels.rNextWeek}<span>{fmtRemind(at8am(7))}</span></button>
|
||||
<button type="button" onClick={() => setRemindAt(at8am(30))}>{labels.rNextMonth}<span>{fmtRemind(at8am(30))}</span></button>
|
||||
</div>
|
||||
<label className="pa-custom">
|
||||
{labels.rCustom}
|
||||
<input
|
||||
type="datetime-local"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value ? Math.floor(new Date(e.target.value).getTime() / 1000) : null
|
||||
setRemindAt(v)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{remindAt && <div className="pa-remind-current"><i className="fa-solid fa-clock" /> {fmtRemind(remindAt)}</div>}
|
||||
<div className="pa-modal-foot">
|
||||
<button type="button" className="pa-primary" disabled={busy} onClick={() => saveReminder(remindAt)}>{labels.save}</button>
|
||||
<button type="button" className="pa-ghost" onClick={() => setEditOpen(false)}>{labels.cancel}</button>
|
||||
<button type="button" className="pa-danger" disabled={busy} onClick={toggleBookmark} title={labels.remove}>
|
||||
<i className="fa-solid fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
|
||||
export interface PostSocial {
|
||||
likes: number
|
||||
liked: boolean
|
||||
bookmarked: boolean
|
||||
note: string
|
||||
remindAt: number | null
|
||||
}
|
||||
|
||||
/** Motivos de denuncia permitidos (clave estable; el texto se traduce en la UI). */
|
||||
export const REPORT_REASONS = [
|
||||
'troll',
|
||||
'threat',
|
||||
'username',
|
||||
'offtopic',
|
||||
'inappropriate',
|
||||
'spam',
|
||||
'violence',
|
||||
'child',
|
||||
'illegal',
|
||||
] as const
|
||||
export type ReportReason = (typeof REPORT_REASONS)[number]
|
||||
|
||||
/** Likes/bookmarks de un conjunto de posts para el usuario actual. */
|
||||
export async function getPostsSocial(postIds: number[], accountId: number): Promise<Map<number, PostSocial>> {
|
||||
const map = new Map<number, PostSocial>()
|
||||
const ids = [...new Set(postIds.filter((n) => Number.isFinite(n) && n > 0))]
|
||||
for (const id of ids) map.set(id, { likes: 0, liked: false, bookmarked: false, note: '', remindAt: null })
|
||||
if (ids.length === 0) return map
|
||||
const ph = ids.map(() => '?').join(',')
|
||||
try {
|
||||
const [likes] = await db(DB.web).query<RowDataPacket[]>(
|
||||
`SELECT post_id, COUNT(*) AS c FROM forum_post_like WHERE post_id IN (${ph}) GROUP BY post_id`,
|
||||
ids,
|
||||
)
|
||||
for (const r of likes) {
|
||||
const e = map.get(Number(r.post_id))
|
||||
if (e) e.likes = Number(r.c)
|
||||
}
|
||||
if (accountId) {
|
||||
const [liked] = await db(DB.web).query<RowDataPacket[]>(
|
||||
`SELECT post_id FROM forum_post_like WHERE account = ? AND post_id IN (${ph})`,
|
||||
[accountId, ...ids],
|
||||
)
|
||||
for (const r of liked) {
|
||||
const e = map.get(Number(r.post_id))
|
||||
if (e) e.liked = true
|
||||
}
|
||||
const [bm] = await db(DB.web).query<RowDataPacket[]>(
|
||||
`SELECT post_id, note, remind_at FROM forum_post_bookmark WHERE account = ? AND post_id IN (${ph})`,
|
||||
[accountId, ...ids],
|
||||
)
|
||||
for (const r of bm) {
|
||||
const e = map.get(Number(r.post_id))
|
||||
if (e) {
|
||||
e.bookmarked = true
|
||||
e.note = String(r.note || '')
|
||||
e.remindAt = r.remind_at != null ? Number(r.remind_at) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* tablas ausentes */
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Alterna el me gusta (al volver a pulsar se retira). Devuelve estado + total. */
|
||||
export async function togglePostLike(accountId: number, postId: number): Promise<{ liked: boolean; likes: number }> {
|
||||
if (!accountId || !postId) return { liked: false, likes: 0 }
|
||||
try {
|
||||
const [ex] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT 1 FROM forum_post_like WHERE post_id = ? AND account = ? LIMIT 1',
|
||||
[postId, accountId],
|
||||
)
|
||||
if (ex.length) {
|
||||
await db(DB.web).query('DELETE FROM forum_post_like WHERE post_id = ? AND account = ?', [postId, accountId])
|
||||
} else {
|
||||
await db(DB.web).query('INSERT IGNORE INTO forum_post_like (post_id, account) VALUES (?, ?)', [postId, accountId])
|
||||
}
|
||||
const [[c]] = (await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT COUNT(*) AS c FROM forum_post_like WHERE post_id = ?',
|
||||
[postId],
|
||||
)) as unknown as [RowDataPacket[]]
|
||||
return { liked: !ex.length, likes: Number(c?.c ?? 0) }
|
||||
} catch {
|
||||
return { liked: false, likes: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/** Alterna guardar en marcadores. */
|
||||
export async function togglePostBookmark(accountId: number, postId: number): Promise<{ bookmarked: boolean }> {
|
||||
if (!accountId || !postId) return { bookmarked: false }
|
||||
try {
|
||||
const [ex] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT 1 FROM forum_post_bookmark WHERE post_id = ? AND account = ? LIMIT 1',
|
||||
[postId, accountId],
|
||||
)
|
||||
if (ex.length) {
|
||||
await db(DB.web).query('DELETE FROM forum_post_bookmark WHERE post_id = ? AND account = ?', [postId, accountId])
|
||||
return { bookmarked: false }
|
||||
}
|
||||
await db(DB.web).query('INSERT IGNORE INTO forum_post_bookmark (post_id, account) VALUES (?, ?)', [postId, accountId])
|
||||
return { bookmarked: true }
|
||||
} catch {
|
||||
return { bookmarked: false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Detalle del marcador (nota + recordatorio) de un post para el usuario. */
|
||||
export async function getBookmarkDetail(
|
||||
accountId: number,
|
||||
postId: number,
|
||||
): Promise<{ note: string; remindAt: number | null } | null> {
|
||||
if (!accountId || !postId) return null
|
||||
try {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT note, remind_at FROM forum_post_bookmark WHERE post_id = ? AND account = ?',
|
||||
[postId, accountId],
|
||||
)
|
||||
if (!rows[0]) return null
|
||||
return { note: String(rows[0].note || ''), remindAt: rows[0].remind_at != null ? Number(rows[0].remind_at) : null }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Crea/actualiza el marcador con nota y recordatorio (upsert). */
|
||||
export async function updateBookmark(
|
||||
accountId: number,
|
||||
postId: number,
|
||||
note: string,
|
||||
remindAt: number | null,
|
||||
): Promise<boolean> {
|
||||
if (!accountId || !postId) return false
|
||||
try {
|
||||
await db(DB.web).query(
|
||||
`INSERT INTO forum_post_bookmark (post_id, account, note, remind_at) VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE note = VALUES(note), remind_at = VALUES(remind_at)`,
|
||||
[postId, accountId, (note || '').slice(0, 255), remindAt && remindAt > 0 ? remindAt : null],
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Registra una denuncia de un post. */
|
||||
export async function reportPost(
|
||||
accountId: number,
|
||||
postId: number,
|
||||
reason: string,
|
||||
detail: string,
|
||||
nowUnix: number,
|
||||
): Promise<boolean> {
|
||||
if (!accountId || !postId) return false
|
||||
if (!(REPORT_REASONS as readonly string[]).includes(reason)) return false
|
||||
try {
|
||||
await db(DB.web).query(
|
||||
'INSERT INTO forum_post_report (post_id, account, reason, detail, created) VALUES (?, ?, ?, ?, ?)',
|
||||
[postId, accountId, reason, (detail || '').slice(0, 500), nowUnix],
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -520,7 +520,51 @@
|
||||
"changeCharacter": "Change character",
|
||||
"filter": "Filter",
|
||||
"noCharacters": "No characters.",
|
||||
"autoCharacter": "Automatic (highest level)"
|
||||
"autoCharacter": "Automatic (highest level)",
|
||||
"like": "Like",
|
||||
"copyLink": "Copy link",
|
||||
"copied": "Copied!",
|
||||
"report": "Report",
|
||||
"bookmark": "Bookmark",
|
||||
"bookmarked": "Bookmarked",
|
||||
"removeBookmark": "Remove bookmark",
|
||||
"reportTitle": "Thanks for helping to keep our community civil!",
|
||||
"reportIntro": "Moderators receive all reports and review them promptly.",
|
||||
"reportDetail": "Add more information (optional)…",
|
||||
"reportSubmit": "Report post",
|
||||
"reportSent": "Report sent! Thank you.",
|
||||
"reminderTitle": "Set a reminder too?",
|
||||
"savedBookmark": "Bookmarked!",
|
||||
"remind2h": "In two hours",
|
||||
"remindTomorrow": "Tomorrow",
|
||||
"remind3days": "In three days",
|
||||
"remindMore": "More options…",
|
||||
"editBookmark": "Edit bookmark",
|
||||
"notePlaceholder": "What is this bookmark for?",
|
||||
"reminderLabel": "Reminder",
|
||||
"rNone": "No reminder",
|
||||
"rmTomorrow": "Tomorrow",
|
||||
"rmNextWeek": "Next week",
|
||||
"rmNextMonth": "Next month",
|
||||
"rmCustom": "Custom date and time",
|
||||
"rTroll": "It's a troll",
|
||||
"rTrollDesc": "This post is provocative, off-topic or annoying.",
|
||||
"rThreat": "It's a real-life threat",
|
||||
"rThreatDesc": "This post is a threat to a user.",
|
||||
"rUsername": "The author has an inappropriate username",
|
||||
"rUsernameDesc": "The author of this post has an inappropriate username.",
|
||||
"rOfftopic": "It's off-topic",
|
||||
"rOfftopicDesc": "This post is not relevant to the current discussion.",
|
||||
"rInappropriate": "It's inappropriate",
|
||||
"rInappropriateDesc": "Contains content that anyone might find offensive or abusive.",
|
||||
"rSpam": "It's spam",
|
||||
"rSpamDesc": "Contains advertising. Not relevant or useful for this topic.",
|
||||
"rViolence": "Violent content/terrorism",
|
||||
"rViolenceDesc": "Violent content or incitement to terrorism.",
|
||||
"rChild": "Child abuse",
|
||||
"rChildDesc": "Child abuse or exploitation content.",
|
||||
"rIllegal": "Illegal content",
|
||||
"rIllegalDesc": "Content that breaks the law."
|
||||
},
|
||||
"Admin": {
|
||||
"title": "Admin panel",
|
||||
|
||||
@@ -520,7 +520,51 @@
|
||||
"changeCharacter": "Cambiar personaje",
|
||||
"filter": "Filtrar",
|
||||
"noCharacters": "No hay personajes.",
|
||||
"autoCharacter": "Automático (mayor nivel)"
|
||||
"autoCharacter": "Automático (mayor nivel)",
|
||||
"like": "Me gusta",
|
||||
"copyLink": "Copiar enlace",
|
||||
"copied": "¡Copiado!",
|
||||
"report": "Denunciar",
|
||||
"bookmark": "Guardar en marcadores",
|
||||
"bookmarked": "Guardado",
|
||||
"removeBookmark": "Quitar marcador",
|
||||
"reportTitle": "¡Gracias por ayudarnos a mantener el civismo en nuestra comunidad!",
|
||||
"reportIntro": "Los moderadores reciben todas las denuncias y las revisan cuanto antes.",
|
||||
"reportDetail": "Añade más información (opcional)…",
|
||||
"reportSubmit": "Denunciar publicación",
|
||||
"reportSent": "¡Denuncia enviada! Gracias.",
|
||||
"reminderTitle": "¿Establecer un recordatorio también?",
|
||||
"savedBookmark": "¡Guardado en marcadores!",
|
||||
"remind2h": "En dos horas",
|
||||
"remindTomorrow": "Mañana",
|
||||
"remind3days": "En tres días",
|
||||
"remindMore": "Más opciones…",
|
||||
"editBookmark": "Editar marcador",
|
||||
"notePlaceholder": "¿Para qué es este marcador?",
|
||||
"reminderLabel": "Recordatorio",
|
||||
"rNone": "Sin recordatorio",
|
||||
"rmTomorrow": "Mañana",
|
||||
"rmNextWeek": "La semana que viene",
|
||||
"rmNextMonth": "El mes que viene",
|
||||
"rmCustom": "Fecha y hora personalizadas",
|
||||
"rTroll": "Se trata de un «troleo»",
|
||||
"rTrollDesc": "Esta publicación es provocadora, no pertinente o molesta.",
|
||||
"rThreat": "Se trata de una amenaza en la vida real",
|
||||
"rThreatDesc": "Esta publicación es una amenaza para un usuario.",
|
||||
"rUsername": "El autor tiene un nombre de usuario inapropiado",
|
||||
"rUsernameDesc": "El autor de esta publicación tiene un nombre de usuario inapropiado.",
|
||||
"rOfftopic": "No tiene relación con el tema",
|
||||
"rOfftopicDesc": "Esta publicación no es relevante para el debate actual.",
|
||||
"rInappropriate": "Es inapropiado",
|
||||
"rInappropriateDesc": "Incluye contenido que cualquiera podría considerar ofensivo o abusivo.",
|
||||
"rSpam": "Es spam",
|
||||
"rSpamDesc": "Contiene publicidad. No resulta relevante ni útil para este tema.",
|
||||
"rViolence": "Contenido violento/terrorismo",
|
||||
"rViolenceDesc": "Contenido violento o que incita al terrorismo.",
|
||||
"rChild": "Abuso infantil",
|
||||
"rChildDesc": "Contenido de abuso o explotación infantil.",
|
||||
"rIllegal": "Contenido ilegal",
|
||||
"rIllegalDesc": "Contenido que infringe la ley."
|
||||
},
|
||||
"Admin": {
|
||||
"title": "Panel de administración",
|
||||
|
||||
Reference in New Issue
Block a user