diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index c032b84..589c1c8 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -172,6 +172,13 @@ const updateEventSchema = baseEventSchema.partial().refine( eventsRouter.get('/', async (c) => { const status = c.req.query('status'); const upcoming = c.req.query('upcoming'); + // Pagination is opt-in: callers that pass neither page nor pageSize (public + // pages, admin filter dropdowns) still get the full list. + const pageParam = c.req.query('page'); + const pageSizeParam = c.req.query('pageSize'); + const paginated = pageParam !== undefined || pageSizeParam !== undefined; + const page = Math.max(parseInt(pageParam || '1', 10) || 1, 1); + const pageSize = Math.min(Math.max(parseInt(pageSizeParam || '25', 10) || 25, 1), 200); // Only privileged users may see non-public events (drafts, archived, etc.). // Anonymous/regular callers are restricted to published events regardless of @@ -195,12 +202,24 @@ eventsRouter.get('/', async (c) => { conditions.push(eq((events as any).status, 'published')); } + const whereClause = conditions.length === 0 + ? undefined + : conditions.length === 1 ? conditions[0] : and(...conditions); + let query = (db as any).select().from(events); - if (conditions.length > 0) { - query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions)); + if (whereClause) query = query.where(whereClause); + query = query.orderBy(desc((events as any).startDatetime)); + + let total: number | undefined; + if (paginated) { + let countQuery = (db as any).select({ count: sql`count(*)` }).from(events); + if (whereClause) countQuery = countQuery.where(whereClause); + const totalRow = await dbGet(countQuery); + total = Number(totalRow?.count || 0); + query = query.limit(pageSize).offset((page - 1) * pageSize); } - - const result = await dbAll(query.orderBy(desc((events as any).startDatetime))); + + const result = await dbAll(query); // Single grouped query for seat counts across all events (avoids N+1: previously // this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in); @@ -227,7 +246,9 @@ eventsRouter.get('/', async (c) => { }; }); - return c.json({ events: eventsWithCounts }); + return paginated + ? c.json({ events: eventsWithCounts, total, page, pageSize }) + : c.json({ events: eventsWithCounts }); }); // Get single event (public) - resolves by id, canonical slug, or historical alias diff --git a/frontend/src/app/admin/events/page.tsx b/frontend/src/app/admin/events/page.tsx index 1bb1051..501db04 100644 --- a/frontend/src/app/admin/events/page.tsx +++ b/frontend/src/app/admin/events/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { useLanguage } from '@/context/LanguageContext'; @@ -9,18 +9,35 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { AdminPageSkeleton } from '@/components/ui/Skeleton'; import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; -import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon } from '@heroicons/react/24/outline'; +import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'; import toast from 'react-hot-toast'; import clsx from 'clsx'; import { parseDate } from '@/lib/utils'; import EventFormModal from './_components/EventFormModal'; +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +function getPageNumbers(current: number, totalPages: number): (number | '...')[] { + if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1); + const pages: (number | '...')[] = [1]; + const start = Math.max(2, current - 1); + const end = Math.min(totalPages - 1, current + 1); + if (start > 2) pages.push('...'); + for (let i = start; i <= end; i++) pages.push(i); + if (end < totalPages - 1) pages.push('...'); + pages.push(totalPages); + return pages; +} + export default function AdminEventsPage() { const router = useRouter(); const { t, locale } = useLanguage(); const searchParams = useSearchParams(); const [events, setEvents] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [editingEvent, setEditingEvent] = useState(null); @@ -28,22 +45,42 @@ export default function AdminEventsPage() { const [settingFeatured, setSettingFeatured] = useState(null); useEffect(() => { - loadEvents(); loadFeaturedEvent(); }, []); + useEffect(() => { + loadEvents(); + }, [page, pageSize]); + + // The ?edit= deep link may point at an event that is not on the current + // page, so fall back to fetching it directly instead of only scanning the page. + const handledEditId = useRef(null); useEffect(() => { const editId = searchParams.get('edit'); - if (editId && events.length > 0) { - const event = events.find(e => e.id === editId); - if (event) handleEdit(event); + if (!editId || handledEditId.current === editId) return; + const event = events.find(e => e.id === editId); + if (event) { + handledEditId.current = editId; + handleEdit(event); + return; } - }, [searchParams, events]); + if (loading) return; + handledEditId.current = editId; + eventsApi.getById(editId) + .then(({ event }) => handleEdit(event)) + .catch(() => toast.error('Event not found')); + }, [searchParams, events, loading]); const loadEvents = async () => { try { - const { events } = await eventsApi.getAll(); + const { events, total } = await eventsApi.getAll({ page, pageSize }); setEvents(events); + setTotal(total ?? events.length); + // If the current page emptied out (e.g. after deleting its last event), + // fall back to the new last page. + if (events.length === 0 && (total ?? 0) > 0 && page > 1) { + setPage(Math.max(1, Math.ceil((total ?? 0) / pageSize))); + } } catch (error) { toast.error('Failed to load events'); } finally { @@ -401,6 +438,65 @@ export default function AdminEventsPage() { )} + {/* Pagination */} + {total > 0 && ( +
+
+ + + + {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} + +
+
+ + {getPageNumbers(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) => + p === '...' ? ( + + ) : ( + + ) + )} + +
+
+ )} + {/* Mobile FAB */}