Files
NightSpire/web-next/lib/forum.ts
T
Inna 36a4354ff7 Foro: paginación de temas (20/pág) y de posts (15/pág)
- lib/forum.ts: countTopics/countPosts + offset/limit en getTopics/getPosts
  (mismos límites que Django: FORUM_TOPICS_PER_PAGE=20, FORUM_POSTS_PER_PAGE=15).
- components/Pagination.tsx: paginación reutilizable (ventana ±2, primera/última,
  prev/next) con enlaces ?page=N; no renderiza si solo hay una página.
- Foro y tema aceptan ?page=N (clamp al rango válido).
- El formulario de respuesta solo aparece en la última página; enlace a ella si no.
- i18n: namespace Common (prev/next/page) + replyOnLastPage.

Build OK. Sin datos de foro en acore_web (estado vacío correcto).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:32:16 +00:00

239 lines
7.3 KiB
TypeScript

import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
export interface ForumInfo {
id: number
category_id: number
name: string
description: string
icon: string | null
topic_count: number
post_count: number
last_topic_id: number | null
last_topic_name: string | null
last_topic_poster: string | null
}
export interface Category {
id: number
name: string
forums: ForumInfo[]
}
export interface Topic {
id: number
name: string
poster: string
created: string | null
sticky: boolean
locked: boolean
views: number
}
export interface Post {
id: number
poster: string
poster_id: number | null
text: string
time: string | null
edited: boolean
}
export interface TopicFull {
id: number
name: string
forum_id: number
poster_id: number | null
locked: boolean
sticky: boolean
}
export interface SearchResult {
id: number
name: string
poster: string
forum_id: number
forum_name: string
updated_at: string | null
}
/** Índice del foro: categorías con sus foros visibles (+ contadores y último tema). */
export async function getForumIndex(): Promise<Category[]> {
try {
const [cats] = await db(DB.web).query<RowDataPacket[]>(
'SELECT id, name FROM forum_categories ORDER BY `order`, id',
)
const [forums] = await db(DB.web).query<RowDataPacket[]>(
`SELECT f.id, f.category_id, f.name, f.description, f.icon,
(SELECT COUNT(*) FROM forum_topics t WHERE t.forum_id = f.id AND t.deleted = 0) AS topic_count,
(SELECT COUNT(*) FROM forum_posts p JOIN forum_topics t ON p.topic_id = t.id
WHERE t.forum_id = f.id AND p.deleted = 0 AND t.deleted = 0) AS post_count,
(SELECT t2.id FROM forum_topics t2 WHERE t2.forum_id = f.id AND t2.deleted = 0
ORDER BY t2.updated_at DESC, t2.id DESC LIMIT 1) AS last_topic_id,
(SELECT t3.name FROM forum_topics t3 WHERE t3.forum_id = f.id AND t3.deleted = 0
ORDER BY t3.updated_at DESC, t3.id DESC LIMIT 1) AS last_topic_name,
(SELECT t4.poster FROM forum_topics t4 WHERE t4.forum_id = f.id AND t4.deleted = 0
ORDER BY t4.updated_at DESC, t4.id DESC LIMIT 1) AS last_topic_poster
FROM forums f
WHERE f.visibility = 1 AND f.deleted = 0
ORDER BY f.\`order\`, f.id`,
)
const byCat = new Map<number, ForumInfo[]>()
for (const f of forums) {
const info = f as unknown as ForumInfo
if (!byCat.has(info.category_id)) byCat.set(info.category_id, [])
byCat.get(info.category_id)!.push(info)
}
return cats
.map((c) => ({ id: c.id, name: c.name, forums: byCat.get(c.id) ?? [] }))
.filter((c) => c.forums.length > 0)
} catch {
return []
}
}
export async function getForum(id: number): Promise<{ name: string; description: string } | null> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT name, description FROM forums WHERE id = ? AND visibility = 1 AND deleted = 0',
[id],
)
return rows[0] ? { name: rows[0].name, description: rows[0].description } : null
} catch {
return null
}
}
export async function countTopics(forumId: number): Promise<number> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT COUNT(*) AS n FROM forum_topics WHERE forum_id = ? AND deleted = 0',
[forumId],
)
return Number(rows[0]?.n ?? 0)
} catch {
return 0
}
}
export async function getTopics(forumId: number, offset = 0, limit = 20): Promise<Topic[]> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT id, name, poster, created, sticky, locked, views FROM forum_topics WHERE forum_id = ? AND deleted = 0 ORDER BY sticky DESC, updated_at DESC, id DESC LIMIT ? OFFSET ?',
[forumId, limit, offset],
)
return rows.map((r) => ({
id: r.id,
name: r.name,
poster: r.poster,
created: r.created ? new Date(r.created).toISOString() : null,
sticky: r.sticky === 1,
locked: r.locked === 1,
views: r.views ?? 0,
}))
} catch {
return []
}
}
export async function getTopic(id: number): Promise<TopicFull | null> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT id, name, forum_id, poster_id, locked, sticky FROM forum_topics WHERE id = ? AND deleted = 0',
[id],
)
const r = rows[0]
return r
? {
id: r.id,
name: r.name,
forum_id: r.forum_id,
poster_id: r.poster_id ?? null,
locked: r.locked === 1,
sticky: r.sticky === 1,
}
: null
} catch {
return null
}
}
export async function countPosts(topicId: number): Promise<number> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT COUNT(*) AS n FROM forum_posts WHERE topic_id = ? AND deleted = 0',
[topicId],
)
return Number(rows[0]?.n ?? 0)
} catch {
return 0
}
}
export async function getPosts(topicId: number, offset = 0, limit = 15): Promise<Post[]> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT id, poster, poster_id, text, time, created_at, updated_at FROM forum_posts WHERE topic_id = ? AND deleted = 0 ORDER BY time ASC, id ASC LIMIT ? OFFSET ?',
[topicId, limit, offset],
)
return rows.map((r) => ({
id: r.id,
poster: r.poster,
poster_id: r.poster_id ?? null,
text: r.text,
time: r.time ? new Date(r.time).toISOString() : null,
// editado si updated_at es posterior (más de 2s) al alta del post
edited: Boolean(
r.updated_at && r.created_at && new Date(r.updated_at).getTime() - new Date(r.created_at).getTime() > 2000,
),
}))
} catch {
return []
}
}
/** Nº de temas que coinciden con la búsqueda (foros visibles). */
export async function countSearchTopics(query: string): Promise<number> {
const like = `%${query}%`
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT COUNT(DISTINCT t.id) AS n
FROM forum_topics t
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
WHERE t.deleted = 0 AND (t.name LIKE ? OR p.text LIKE ?)`,
[like, like],
)
return Number(rows[0]?.n ?? 0)
} catch {
return 0
}
}
/** Busca temas por título o contenido de sus posts (solo foros visibles). */
export async function searchTopics(query: string, offset = 0, limit = 20): Promise<SearchResult[]> {
const like = `%${query}%`
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT DISTINCT t.id, t.name, t.poster, t.forum_id, t.updated_at, f.name AS forum_name
FROM forum_topics t
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
WHERE t.deleted = 0 AND (t.name LIKE ? OR p.text LIKE ?)
ORDER BY t.updated_at DESC
LIMIT ? OFFSET ?`,
[like, like, limit, offset],
)
return rows.map((r) => ({
id: r.id,
name: r.name,
poster: r.poster,
forum_id: r.forum_id,
forum_name: r.forum_name,
updated_at: r.updated_at ? new Date(r.updated_at).toISOString() : null,
}))
} catch {
return []
}
}