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 { notFound } from 'next/navigation'
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
import { Link } from '@/i18n/navigation'
|
import { Link } from '@/i18n/navigation'
|
||||||
import { getForum, getTopics } from '@/lib/forum'
|
import { getForum, getTopics, countTopics } from '@/lib/forum'
|
||||||
import { getSession } from '@/lib/session'
|
import { getSession } from '@/lib/session'
|
||||||
import { NewTopicForm } from '@/components/NewTopicForm'
|
import { NewTopicForm } from '@/components/NewTopicForm'
|
||||||
|
import { Pagination } from '@/components/Pagination'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const PER_PAGE = 20
|
||||||
|
|
||||||
export default async function ForumPage({
|
export default async function ForumPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string; forumId: string }>
|
params: Promise<{ locale: string; forumId: string }>
|
||||||
|
searchParams: Promise<{ page?: string }>
|
||||||
}) {
|
}) {
|
||||||
const { locale, forumId } = await params
|
const { locale, forumId } = await params
|
||||||
setRequestLocale(locale)
|
setRequestLocale(locale)
|
||||||
@@ -19,7 +24,10 @@ export default async function ForumPage({
|
|||||||
const id = Number(forumId)
|
const id = Number(forumId)
|
||||||
const forum = await getForum(id)
|
const forum = await getForum(id)
|
||||||
if (!forum) notFound()
|
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 session = await getSession()
|
||||||
const canPost = Boolean(session.username)
|
const canPost = Boolean(session.username)
|
||||||
|
|
||||||
@@ -57,6 +65,8 @@ export default async function ForumPage({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => `/forum/${id}?page=${p}`} />
|
||||||
|
|
||||||
{canPost ? (
|
{canPost ? (
|
||||||
<NewTopicForm forumId={id} />
|
<NewTopicForm forumId={id} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
import { Link } from '@/i18n/navigation'
|
import { Link } from '@/i18n/navigation'
|
||||||
import { getTopic, getPosts } from '@/lib/forum'
|
import { getTopic, getPosts, countPosts } from '@/lib/forum'
|
||||||
import { getSession } from '@/lib/session'
|
import { getSession } from '@/lib/session'
|
||||||
import { forumIsModerator, canEditPost } from '@/lib/forum-perm'
|
import { forumIsModerator, canEditPost } from '@/lib/forum-perm'
|
||||||
import { ReplyForm } from '@/components/ReplyForm'
|
import { ReplyForm } from '@/components/ReplyForm'
|
||||||
import { PostActions } from '@/components/PostActions'
|
import { PostActions } from '@/components/PostActions'
|
||||||
import { TopicModBar } from '@/components/TopicModBar'
|
import { TopicModBar } from '@/components/TopicModBar'
|
||||||
|
import { Pagination } from '@/components/Pagination'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const PER_PAGE = 15
|
||||||
|
|
||||||
export default async function TopicPage({
|
export default async function TopicPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string; topicId: string }>
|
params: Promise<{ locale: string; topicId: string }>
|
||||||
|
searchParams: Promise<{ page?: string }>
|
||||||
}) {
|
}) {
|
||||||
const { locale, topicId } = await params
|
const { locale, topicId } = await params
|
||||||
setRequestLocale(locale)
|
setRequestLocale(locale)
|
||||||
@@ -22,7 +27,10 @@ export default async function TopicPage({
|
|||||||
const id = Number(topicId)
|
const id = Number(topicId)
|
||||||
const topic = await getTopic(id)
|
const topic = await getTopic(id)
|
||||||
if (!topic) notFound()
|
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 session = await getSession()
|
||||||
const isMod = await forumIsModerator(session)
|
const isMod = await forumIsModerator(session)
|
||||||
const canReply = Boolean(session.username) && (!topic!.locked || isMod)
|
const canReply = Boolean(session.username) && (!topic!.locked || isMod)
|
||||||
@@ -65,8 +73,18 @@ export default async function TopicPage({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => `/forum/topic/${id}?page=${p}`} />
|
||||||
|
|
||||||
{canReply ? (
|
{canReply ? (
|
||||||
|
page === totalPages ? (
|
||||||
<ReplyForm topicId={id} />
|
<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>
|
!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 {
|
try {
|
||||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
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],
|
[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) => ({
|
return rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
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 {
|
try {
|
||||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
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],
|
[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) => ({
|
return rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
poster: r.poster,
|
poster: r.poster,
|
||||||
|
|||||||
@@ -290,7 +290,8 @@
|
|||||||
"searchHint": "Type at least 2 characters to search.",
|
"searchHint": "Type at least 2 characters to search.",
|
||||||
"noResults": "No results for “{query}”.",
|
"noResults": "No results for “{query}”.",
|
||||||
"resultsCount": "{count} result(s) for “{query}”.",
|
"resultsCount": "{count} result(s) for “{query}”.",
|
||||||
"in": "in"
|
"in": "in",
|
||||||
|
"replyOnLastPage": "Go to the last page to reply"
|
||||||
},
|
},
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"title": "Admin panel",
|
"title": "Admin panel",
|
||||||
@@ -339,5 +340,10 @@
|
|||||||
"genericError": "Something went wrong. Please try again later.",
|
"genericError": "Something went wrong. Please try again later.",
|
||||||
"noSites": "No vote sites configured yet.",
|
"noSites": "No vote sites configured yet.",
|
||||||
"loginToVote": "Sign in to vote."
|
"loginToVote": "Sign in to vote."
|
||||||
|
},
|
||||||
|
"Common": {
|
||||||
|
"prev": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"page": "Page"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,7 +290,8 @@
|
|||||||
"searchHint": "Escribe al menos 2 caracteres para buscar.",
|
"searchHint": "Escribe al menos 2 caracteres para buscar.",
|
||||||
"noResults": "No hay resultados para «{query}».",
|
"noResults": "No hay resultados para «{query}».",
|
||||||
"resultsCount": "{count} resultado(s) para «{query}».",
|
"resultsCount": "{count} resultado(s) para «{query}».",
|
||||||
"in": "en"
|
"in": "en",
|
||||||
|
"replyOnLastPage": "Ir a la última página para responder"
|
||||||
},
|
},
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"title": "Panel de administración",
|
"title": "Panel de administración",
|
||||||
@@ -339,5 +340,10 @@
|
|||||||
"genericError": "Algo ha salido mal. Inténtalo más tarde.",
|
"genericError": "Algo ha salido mal. Inténtalo más tarde.",
|
||||||
"noSites": "No hay sitios de votación configurados todavía.",
|
"noSites": "No hay sitios de votación configurados todavía.",
|
||||||
"loginToVote": "Inicia sesión para votar."
|
"loginToVote": "Inicia sesión para votar."
|
||||||
|
},
|
||||||
|
"Common": {
|
||||||
|
"prev": "Anterior",
|
||||||
|
"next": "Siguiente",
|
||||||
|
"page": "Página"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user