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