a73fe4d8d5
- 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>
321 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|