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>
512 lines
23 KiB
TypeScript
512 lines
23 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import Link from 'next/link';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import { useLanguage } from '@/context/LanguageContext';
|
|
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
|
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, 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);
|
|
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
|
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
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 || handledEditId.current === editId) return;
|
|
const event = events.find(e => e.id === editId);
|
|
if (event) {
|
|
handledEditId.current = editId;
|
|
handleEdit(event);
|
|
return;
|
|
}
|
|
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, 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 {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadFeaturedEvent = async () => {
|
|
try {
|
|
const { settings } = await siteSettingsApi.get();
|
|
setFeaturedEventId(settings.featuredEventId || null);
|
|
} catch (error) {
|
|
// Ignore - settings may not exist yet
|
|
}
|
|
};
|
|
|
|
const handleSetFeatured = async (eventId: string | null) => {
|
|
setSettingFeatured(eventId || 'clearing');
|
|
try {
|
|
await siteSettingsApi.setFeaturedEvent(eventId);
|
|
setFeaturedEventId(eventId);
|
|
toast.success(eventId ? 'Event set as featured' : 'Featured event removed');
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Failed to update featured event');
|
|
} finally {
|
|
setSettingFeatured(null);
|
|
}
|
|
};
|
|
|
|
const handleEdit = (event: Event) => {
|
|
setEditingEvent(event);
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleCloseForm = () => {
|
|
setShowForm(false);
|
|
setEditingEvent(null);
|
|
};
|
|
|
|
const handleFormSaved = () => {
|
|
handleCloseForm();
|
|
loadEvents();
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm('Are you sure you want to delete this event?')) return;
|
|
try {
|
|
await eventsApi.delete(id);
|
|
toast.success('Event deleted');
|
|
loadEvents();
|
|
} catch (error) {
|
|
toast.error('Failed to delete event');
|
|
}
|
|
};
|
|
|
|
const handleStatusChange = async (event: Event, status: Event['status']) => {
|
|
try {
|
|
await eventsApi.update(event.id, { status });
|
|
toast.success('Status updated');
|
|
loadEvents();
|
|
} catch (error) {
|
|
toast.error('Failed to update status');
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
|
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Asuncion',
|
|
});
|
|
};
|
|
|
|
const isEventOver = (event: Event) => {
|
|
const refDate = event.endDatetime || event.startDatetime;
|
|
return new Date(refDate) < new Date();
|
|
};
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
const styles: Record<string, string> = {
|
|
draft: 'badge-gray', published: 'badge-success', unlisted: 'badge-warning',
|
|
cancelled: 'badge-danger', completed: 'badge-info', archived: 'badge-gray',
|
|
};
|
|
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
|
|
};
|
|
|
|
const handleDuplicate = async (event: Event) => {
|
|
try {
|
|
await eventsApi.duplicate(event.id);
|
|
toast.success('Event duplicated successfully');
|
|
loadEvents();
|
|
} catch (error) {
|
|
toast.error('Failed to duplicate event');
|
|
}
|
|
};
|
|
|
|
const handleArchive = async (event: Event) => {
|
|
try {
|
|
await eventsApi.update(event.id, { status: 'archived' });
|
|
toast.success('Event archived');
|
|
loadEvents();
|
|
} catch (error) {
|
|
toast.error('Failed to archive event');
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return <AdminPageSkeleton cols={5} />;
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
|
|
<Button onClick={() => { setEditingEvent(null); setShowForm(true); }} className="hidden md:flex">
|
|
<PlusIcon className="w-5 h-5 mr-2" />
|
|
{t('admin.events.create')}
|
|
</Button>
|
|
</div>
|
|
|
|
<EventFormModal
|
|
open={showForm}
|
|
event={editingEvent}
|
|
featuredEventId={featuredEventId}
|
|
onFeaturedChange={setFeaturedEventId}
|
|
onClose={handleCloseForm}
|
|
onSaved={handleFormSaved}
|
|
/>
|
|
|
|
{/* Desktop: Table */}
|
|
<Card className="overflow-hidden hidden md:block">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-secondary-gray">
|
|
<tr>
|
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Capacity</th>
|
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-secondary-light-gray">
|
|
{events.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
|
No events found. Create your first event!
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
events.map((event) => (
|
|
<tr
|
|
key={event.id}
|
|
onClick={() => router.push(`/admin/events/${event.id}`)}
|
|
className={clsx("hover:bg-gray-50 cursor-pointer", featuredEventId === event.id && "bg-amber-50")}
|
|
>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
{event.bannerUrl ? (
|
|
<img src={event.bannerUrl} alt={event.title}
|
|
className="w-10 h-10 rounded-lg object-cover flex-shrink-0" />
|
|
) : (
|
|
<div className="w-10 h-10 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
|
|
<PhotoIcon className="w-5 h-5 text-gray-400" />
|
|
</div>
|
|
)}
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<p className="font-medium text-sm">{event.title}</p>
|
|
{featuredEventId === event.id && (
|
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-amber-100 text-amber-800">
|
|
<StarIconSolid className="w-2.5 h-2.5" /> Featured
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-gray-500 truncate max-w-xs">{event.location}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td>
|
|
<td className="px-4 py-3 text-sm">
|
|
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity}
|
|
{(event.claimedCount || 0) > 0 && (
|
|
<span className="block text-[11px] text-yellow-600">
|
|
{event.claimedCount} pending approval
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-1.5">
|
|
{getStatusBadge(event.status)}
|
|
{isEventOver(event) && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'archived' && (
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-800 text-white">Over</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex items-center justify-end gap-1">
|
|
{event.status === 'draft' && (
|
|
<Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
|
|
Publish
|
|
</Button>
|
|
)}
|
|
{event.status === 'published' && (
|
|
<button onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
|
|
disabled={settingFeatured !== null}
|
|
className={clsx("p-2 rounded-btn disabled:opacity-50",
|
|
featuredEventId === event.id ? "bg-amber-100 text-amber-600 hover:bg-amber-200" : "hover:bg-amber-100 text-gray-400 hover:text-amber-600")}
|
|
title={featuredEventId === event.id ? "Remove from featured" : "Set as featured"}>
|
|
{featuredEventId === event.id ? <StarIconSolid className="w-4 h-4" /> : <StarIcon className="w-4 h-4" />}
|
|
</button>
|
|
)}
|
|
<Link href={`/admin/events/${event.id}`}
|
|
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn" title="Manage">
|
|
<EyeIcon className="w-4 h-4" />
|
|
</Link>
|
|
<button onClick={() => handleEdit(event)} className="p-2 hover:bg-gray-100 rounded-btn" title="Edit">
|
|
<PencilIcon className="w-4 h-4" />
|
|
</button>
|
|
<MoreMenu>
|
|
{(event.status === 'draft' || event.status === 'published') && (
|
|
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
|
|
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
|
|
</DropdownItem>
|
|
)}
|
|
{event.status === 'unlisted' && (
|
|
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
|
|
Make Public
|
|
</DropdownItem>
|
|
)}
|
|
{(event.status === 'published' || event.status === 'unlisted') && (
|
|
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
|
|
Unpublish
|
|
</DropdownItem>
|
|
)}
|
|
<DropdownItem onClick={() => handleDuplicate(event)}>
|
|
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
|
|
</DropdownItem>
|
|
{event.status !== 'archived' && (
|
|
<DropdownItem onClick={() => handleArchive(event)}>
|
|
<ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive
|
|
</DropdownItem>
|
|
)}
|
|
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
|
|
<TrashIcon className="w-4 h-4 mr-2" /> Delete
|
|
</DropdownItem>
|
|
</MoreMenu>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Mobile: Card List */}
|
|
<div className="md:hidden space-y-2">
|
|
{events.length === 0 ? (
|
|
<div className="text-center py-10 text-gray-500 text-sm">
|
|
No events found. Create your first event!
|
|
</div>
|
|
) : (
|
|
events.map((event) => (
|
|
<Card
|
|
key={event.id}
|
|
className={clsx("p-3 cursor-pointer", featuredEventId === event.id && "ring-2 ring-amber-300")}
|
|
onClick={() => router.push(`/admin/events/${event.id}`)}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
{event.bannerUrl ? (
|
|
<img src={event.bannerUrl} alt={event.title}
|
|
className="w-14 h-14 rounded-lg object-cover flex-shrink-0" />
|
|
) : (
|
|
<div className="w-14 h-14 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
|
|
<PhotoIcon className="w-6 h-6 text-gray-400" />
|
|
</div>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<p className="font-medium text-sm truncate">{event.title}</p>
|
|
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
|
<p className="text-xs text-gray-400 truncate">{event.location}</p>
|
|
</div>
|
|
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap justify-end">
|
|
{getStatusBadge(event.status)}
|
|
{isEventOver(event) && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'archived' && (
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-800 text-white">Over</span>
|
|
)}
|
|
{featuredEventId === event.id && (
|
|
<StarIconSolid className="w-4 h-4 text-amber-500" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
|
<p className="text-xs text-gray-500">
|
|
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity} spots
|
|
{(event.claimedCount || 0) > 0 && (
|
|
<span className="text-yellow-600"> · {event.claimedCount} pending</span>
|
|
)}
|
|
</p>
|
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
|
<Link href={`/admin/events/${event.id}`}
|
|
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
|
|
<EyeIcon className="w-4 h-4" />
|
|
</Link>
|
|
<MoreMenu>
|
|
<DropdownItem onClick={() => handleEdit(event)}>
|
|
<PencilIcon className="w-4 h-4 mr-2" /> Edit
|
|
</DropdownItem>
|
|
{event.status === 'draft' && (
|
|
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
|
|
Publish
|
|
</DropdownItem>
|
|
)}
|
|
{(event.status === 'draft' || event.status === 'published') && (
|
|
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
|
|
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
|
|
</DropdownItem>
|
|
)}
|
|
{event.status === 'unlisted' && (
|
|
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
|
|
Make Public
|
|
</DropdownItem>
|
|
)}
|
|
{(event.status === 'published' || event.status === 'unlisted') && (
|
|
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
|
|
Unpublish
|
|
</DropdownItem>
|
|
)}
|
|
{event.status === 'published' && (
|
|
<DropdownItem onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}>
|
|
<StarIcon className="w-4 h-4 mr-2" />
|
|
{featuredEventId === event.id ? 'Unfeature' : 'Set Featured'}
|
|
</DropdownItem>
|
|
)}
|
|
<DropdownItem onClick={() => handleDuplicate(event)}>
|
|
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
|
|
</DropdownItem>
|
|
{event.status !== 'archived' && (
|
|
<DropdownItem onClick={() => handleArchive(event)}>
|
|
<ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive
|
|
</DropdownItem>
|
|
)}
|
|
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
|
|
<TrashIcon className="w-4 h-4 mr-2" /> Delete
|
|
</DropdownItem>
|
|
</MoreMenu>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))
|
|
)}
|
|
</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 */}
|
|
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
|
<button onClick={() => { setEditingEvent(null); setShowForm(true); }}
|
|
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
|
|
<PlusIcon className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<AdminMobileStyles />
|
|
</div>
|
|
);
|
|
}
|