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