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