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>
170 lines
5.7 KiB
TypeScript
170 lines
5.7 KiB
TypeScript
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
|
|
}
|
|
}
|