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:
co-authored by
Claude Opus 5
parent
e296e80e48
commit
5970d707af
@@ -172,6 +172,13 @@ const updateEventSchema = baseEventSchema.partial().refine(
|
|||||||
eventsRouter.get('/', async (c) => {
|
eventsRouter.get('/', async (c) => {
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const upcoming = c.req.query('upcoming');
|
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.).
|
// Only privileged users may see non-public events (drafts, archived, etc.).
|
||||||
// Anonymous/regular callers are restricted to published events regardless of
|
// 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'));
|
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);
|
let query = (db as any).select().from(events);
|
||||||
if (conditions.length > 0) {
|
if (whereClause) query = query.where(whereClause);
|
||||||
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
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
|
// 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);
|
// 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
|
// Get single event (public) - resolves by id, canonical slug, or historical alias
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
@@ -9,18 +9,35 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
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 { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
import EventFormModal from './_components/EventFormModal';
|
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() {
|
export default function AdminEventsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||||
@@ -28,22 +45,42 @@ export default function AdminEventsPage() {
|
|||||||
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
|
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEvents();
|
|
||||||
loadFeaturedEvent();
|
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(() => {
|
useEffect(() => {
|
||||||
const editId = searchParams.get('edit');
|
const editId = searchParams.get('edit');
|
||||||
if (editId && events.length > 0) {
|
if (!editId || handledEditId.current === editId) return;
|
||||||
const event = events.find(e => e.id === editId);
|
const event = events.find(e => e.id === editId);
|
||||||
if (event) handleEdit(event);
|
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 () => {
|
const loadEvents = async () => {
|
||||||
try {
|
try {
|
||||||
const { events } = await eventsApi.getAll();
|
const { events, total } = await eventsApi.getAll({ page, pageSize });
|
||||||
setEvents(events);
|
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) {
|
} catch (error) {
|
||||||
toast.error('Failed to load events');
|
toast.error('Failed to load events');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -401,6 +438,65 @@ export default function AdminEventsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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}–{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">…</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 */}
|
{/* Mobile FAB */}
|
||||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||||
<button onClick={() => { setEditingEvent(null); setShowForm(true); }}
|
<button onClick={() => { setEditingEvent(null); setShowForm(true); }}
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import { fetchApi } from './client';
|
|||||||
import type { Event } from './types';
|
import type { Event } from './types';
|
||||||
|
|
||||||
export const eventsApi = {
|
export const eventsApi = {
|
||||||
getAll: (params?: { status?: string; upcoming?: boolean }) => {
|
getAll: (params?: { status?: string; upcoming?: boolean; page?: number; pageSize?: number }) => {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.status) query.set('status', params.status);
|
if (params?.status) query.set('status', params.status);
|
||||||
if (params?.upcoming) query.set('upcoming', 'true');
|
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}`),
|
getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user