Files
Inna a73fe4d8d5 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>
2026-07-17 08:30:57 +00:00

321 lines
11 KiB
TypeScript

'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>
)
}