Paginate the admin events list.

The admin events page fetched every event on each load and rendered
them all, which grows without bound as the archive fills up. GET
/api/events now takes optional page and pageSize parameters and returns
total alongside the rows; pagination is opt-in, so the public pages and
the admin filter dropdowns that pass neither still get the full list
and the untouched response shape.

Page size is selectable (10/25/50/100) and deleting the last event on a
page falls back to the new last page instead of showing an empty table.
The ?edit=<id> deep link no longer depends on the target being in the
current page: when it is missing from the loaded rows the event is
fetched directly, guarded by a ref so the modal opens once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-08-23 05:16:28 +00:00
co-authored by Claude Opus 5
parent e296e80e48
commit 5970d707af
3 changed files with 136 additions and 15 deletions
+26 -5
View File
@@ -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<any>(countQuery);
total = Number(totalRow?.count || 0);
query = query.limit(pageSize).offset((page - 1) * pageSize);
}
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
const result = await dbAll<any>(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
+104 -8
View File
@@ -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<Event[]>([]);
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<Event | null>(null);
@@ -28,22 +45,42 @@ export default function AdminEventsPage() {
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
useEffect(() => {
loadEvents();
loadFeaturedEvent();
}, []);
useEffect(() => {
loadEvents();
}, [page, pageSize]);
// The ?edit=<id> 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<string | null>(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() {
)}
</div>
{/* Pagination */}
{total > 0 && (
<div className="mt-4 mb-20 md:mb-0 flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-gray-600">
<label htmlFor="events-page-size" className="whitespace-nowrap">Per page</label>
<select
id="events-page-size"
value={pageSize}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
className="px-2 py-1.5 rounded-btn border border-secondary-light-gray text-sm"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span className="text-xs text-gray-500 whitespace-nowrap">
{(page - 1) * pageSize + 1}&ndash;{Math.min(page * pageSize, total)} of {total}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(page - 1)}
disabled={page <= 1}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Previous page"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
{getPageNumbers(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) =>
p === '...' ? (
<span key={`ellipsis-${i}`} className="px-1.5 text-sm text-gray-400">&hellip;</span>
) : (
<button
key={p}
onClick={() => setPage(p)}
className={clsx(
'min-h-[36px] min-w-[36px] px-2 rounded-btn text-sm',
p === page
? 'bg-primary-yellow text-primary-dark font-semibold'
: 'border border-secondary-light-gray text-gray-600 hover:bg-gray-50'
)}
aria-current={p === page ? 'page' : undefined}
>
{p}
</button>
)
)}
<button
onClick={() => setPage(page + 1)}
disabled={page >= Math.ceil(total / pageSize)}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Next page"
>
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button onClick={() => { setEditingEvent(null); setShowForm(true); }}
+6 -2
View File
@@ -2,11 +2,15 @@ import { fetchApi } from './client';
import type { Event } from './types';
export const eventsApi = {
getAll: (params?: { status?: string; upcoming?: boolean }) => {
getAll: (params?: { status?: string; upcoming?: boolean; page?: number; pageSize?: number }) => {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.upcoming) query.set('upcoming', 'true');
return fetchApi<{ events: Event[] }>(`/api/events?${query}`);
// Passing page/pageSize switches the endpoint into paginated mode, which also
// returns `total`; without them the full list comes back as before.
if (params?.page) query.set('page', String(params.page));
if (params?.pageSize) query.set('pageSize', String(params.pageSize));
return fetchApi<{ events: Event[]; total?: number }>(`/api/events?${query}`);
},
getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`),