From 36a4354ff7177683978f3928aa2691d1f5db54af Mon Sep 17 00:00:00 2001 From: adevopg Date: Mon, 13 Jul 2026 00:32:16 +0000 Subject: [PATCH] =?UTF-8?q?Foro:=20paginaci=C3=B3n=20de=20temas=20(20/p?= =?UTF-8?q?=C3=A1g)=20y=20de=20posts=20(15/p=C3=A1g)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../app/[locale]/forum/[forumId]/page.tsx | 14 +++- .../[locale]/forum/topic/[topicId]/page.tsx | 24 ++++++- web-next/components/Pagination.tsx | 68 +++++++++++++++++++ web-next/lib/forum.ts | 32 +++++++-- web-next/messages/en.json | 8 ++- web-next/messages/es.json | 8 ++- 6 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 web-next/components/Pagination.tsx diff --git a/web-next/app/[locale]/forum/[forumId]/page.tsx b/web-next/app/[locale]/forum/[forumId]/page.tsx index 9f89299..48de0c3 100644 --- a/web-next/app/[locale]/forum/[forumId]/page.tsx +++ b/web-next/app/[locale]/forum/[forumId]/page.tsx @@ -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({ )} + `/forum/${id}?page=${p}`} /> + {canPost ? ( ) : ( diff --git a/web-next/app/[locale]/forum/topic/[topicId]/page.tsx b/web-next/app/[locale]/forum/topic/[topicId]/page.tsx index 5db70ea..3f9271b 100644 --- a/web-next/app/[locale]/forum/topic/[topicId]/page.tsx +++ b/web-next/app/[locale]/forum/topic/[topicId]/page.tsx @@ -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({ ))} + `/forum/topic/${id}?page=${p}`} /> + {canReply ? ( - + page === totalPages ? ( + + ) : ( +

+ + {t('replyOnLastPage')} + +

+ ) ) : ( !session.username &&

{t('loginToPost')}

)} diff --git a/web-next/components/Pagination.tsx b/web-next/components/Pagination.tsx new file mode 100644 index 0000000..1b547d0 --- /dev/null +++ b/web-next/components/Pagination.tsx @@ -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 ( + + ) +} diff --git a/web-next/lib/forum.ts b/web-next/lib/forum.ts index 6d831b5..fe3764d 100644 --- a/web-next/lib/forum.ts +++ b/web-next/lib/forum.ts @@ -104,12 +104,24 @@ export async function getForum(id: number): Promise<{ name: string; description: } } -export async function getTopics(forumId: number): Promise { +export async function countTopics(forumId: number): Promise { try { const [rows] = await db(DB.web).query( - '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 { + try { + const [rows] = await db(DB.web).query( + '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 { } } -export async function getPosts(topicId: number): Promise { +export async function countPosts(topicId: number): Promise { try { const [rows] = await db(DB.web).query( - '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 { + try { + const [rows] = await db(DB.web).query( + '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, diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 476819c..8c7c305 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -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" } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 1b38832..f421d6f 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -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" } }