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>
This commit is contained in:
2026-07-13 00:32:16 +00:00
parent 1de1002938
commit 36a4354ff7
6 changed files with 143 additions and 11 deletions
+12 -2
View File
@@ -1,16 +1,21 @@
import { notFound } from 'next/navigation'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import { getForum, getTopics } from '@/lib/forum'
import { getForum, getTopics, countTopics } from '@/lib/forum'
import { getSession } from '@/lib/session'
import { NewTopicForm } from '@/components/NewTopicForm'
import { Pagination } from '@/components/Pagination'
export const dynamic = 'force-dynamic'
const PER_PAGE = 20
export default async function ForumPage({
params,
searchParams,
}: {
params: Promise<{ locale: string; forumId: string }>
searchParams: Promise<{ page?: string }>
}) {
const { locale, forumId } = await params
setRequestLocale(locale)
@@ -19,7 +24,10 @@ export default async function ForumPage({
const id = Number(forumId)
const forum = await getForum(id)
if (!forum) notFound()
const topics = await getTopics(id)
const total = await countTopics(id)
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE))
const page = Math.min(Math.max(1, Number((await searchParams).page) || 1), totalPages)
const topics = await getTopics(id, (page - 1) * PER_PAGE, PER_PAGE)
const session = await getSession()
const canPost = Boolean(session.username)
@@ -57,6 +65,8 @@ export default async function ForumPage({
</ul>
)}
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => `/forum/${id}?page=${p}`} />
{canPost ? (
<NewTopicForm forumId={id} />
) : (
@@ -1,19 +1,24 @@
import { notFound } from 'next/navigation'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import { getTopic, getPosts } from '@/lib/forum'
import { getTopic, getPosts, countPosts } from '@/lib/forum'
import { getSession } from '@/lib/session'
import { forumIsModerator, canEditPost } from '@/lib/forum-perm'
import { ReplyForm } from '@/components/ReplyForm'
import { PostActions } from '@/components/PostActions'
import { TopicModBar } from '@/components/TopicModBar'
import { Pagination } from '@/components/Pagination'
export const dynamic = 'force-dynamic'
const PER_PAGE = 15
export default async function TopicPage({
params,
searchParams,
}: {
params: Promise<{ locale: string; topicId: string }>
searchParams: Promise<{ page?: string }>
}) {
const { locale, topicId } = await params
setRequestLocale(locale)
@@ -22,7 +27,10 @@ export default async function TopicPage({
const id = Number(topicId)
const topic = await getTopic(id)
if (!topic) notFound()
const posts = await getPosts(id)
const total = await countPosts(id)
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE))
const page = Math.min(Math.max(1, Number((await searchParams).page) || 1), totalPages)
const posts = await getPosts(id, (page - 1) * PER_PAGE, PER_PAGE)
const session = await getSession()
const isMod = await forumIsModerator(session)
const canReply = Boolean(session.username) && (!topic!.locked || isMod)
@@ -65,8 +73,18 @@ export default async function TopicPage({
))}
</div>
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => `/forum/topic/${id}?page=${p}`} />
{canReply ? (
<ReplyForm topicId={id} />
page === totalPages ? (
<ReplyForm topicId={id} />
) : (
<p className="mt-6 text-sm">
<Link href={`/forum/topic/${id}?page=${totalPages}`} className="text-sky-400 hover:underline">
{t('replyOnLastPage')}
</Link>
</p>
)
) : (
!session.username && <p className="mt-6 text-sm text-amber-200/60">{t('loginToPost')}</p>
)}
+68
View File
@@ -0,0 +1,68 @@
import { getTranslations } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
/**
* Paginación por número de página. `hrefFor(p)` construye la ruta de cada página
* (p.ej. `/forum/5?page=2`). No renderiza nada si solo hay una página.
*/
export async function Pagination({
page,
totalPages,
hrefFor,
}: {
page: number
totalPages: number
hrefFor: (page: number) => string
}) {
if (totalPages <= 1) return null
const t = await getTranslations('Common')
// Ventana de páginas alrededor de la actual.
const from = Math.max(1, page - 2)
const to = Math.min(totalPages, page + 2)
const pages: number[] = []
for (let p = from; p <= to; p++) pages.push(p)
const cls = (active: boolean) =>
`inline-flex min-w-9 items-center justify-center rounded border px-2.5 py-1 text-sm ${
active
? 'border-nw-gold bg-nw-gold/15 font-semibold text-nw-gold-light'
: 'border-nw-border/60 text-nw-muted hover:border-nw-gold/60 hover:text-nw-gold-light'
}`
return (
<nav className="mt-6 flex flex-wrap items-center justify-center gap-1" aria-label="pagination">
{page > 1 && (
<Link href={hrefFor(page - 1)} className={cls(false)}>
{t('prev')}
</Link>
)}
{from > 1 && (
<>
<Link href={hrefFor(1)} className={cls(false)}>
1
</Link>
{from > 2 && <span className="px-1 text-nw-muted"></span>}
</>
)}
{pages.map((p) => (
<Link key={p} href={hrefFor(p)} className={cls(p === page)} aria-current={p === page ? 'page' : undefined}>
{p}
</Link>
))}
{to < totalPages && (
<>
{to < totalPages - 1 && <span className="px-1 text-nw-muted"></span>}
<Link href={hrefFor(totalPages)} className={cls(false)}>
{totalPages}
</Link>
</>
)}
{page < totalPages && (
<Link href={hrefFor(page + 1)} className={cls(false)}>
{t('next')}
</Link>
)}
</nav>
)
}
+28 -4
View File
@@ -104,12 +104,24 @@ export async function getForum(id: number): Promise<{ name: string; description:
}
}
export async function getTopics(forumId: number): Promise<Topic[]> {
export async function countTopics(forumId: number): Promise<number> {
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',
'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,
@@ -146,12 +158,24 @@ export async function getTopic(id: number): Promise<TopicFull | null> {
}
}
export async function getPosts(topicId: number): Promise<Post[]> {
export async function countPosts(topicId: number): Promise<number> {
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',
'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,
+7 -1
View File
@@ -290,7 +290,8 @@
"searchHint": "Type at least 2 characters to search.",
"noResults": "No results for “{query}”.",
"resultsCount": "{count} result(s) for “{query}”.",
"in": "in"
"in": "in",
"replyOnLastPage": "Go to the last page to reply"
},
"Admin": {
"title": "Admin panel",
@@ -339,5 +340,10 @@
"genericError": "Something went wrong. Please try again later.",
"noSites": "No vote sites configured yet.",
"loginToVote": "Sign in to vote."
},
"Common": {
"prev": "Previous",
"next": "Next",
"page": "Page"
}
}
+7 -1
View File
@@ -290,7 +290,8 @@
"searchHint": "Escribe al menos 2 caracteres para buscar.",
"noResults": "No hay resultados para «{query}».",
"resultsCount": "{count} resultado(s) para «{query}».",
"in": "en"
"in": "en",
"replyOnLastPage": "Ir a la última página para responder"
},
"Admin": {
"title": "Panel de administración",
@@ -339,5 +340,10 @@
"genericError": "Algo ha salido mal. Inténtalo más tarde.",
"noSites": "No hay sitios de votación configurados todavía.",
"loginToVote": "Inicia sesión para votar."
},
"Common": {
"prev": "Anterior",
"next": "Siguiente",
"page": "Página"
}
}