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
+169
View File
@@ -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
}
}