- )}
+ {/* Image/Video container.
+ For photos this hosts a 3-slide carousel (prev/current/next) so
+ swipe gestures animate the track and the neighbour images preload
+ while the user views the current one. Videos still render as a
+ single player — sliding video elements during a drag is awkward
+ and the carousel adds nothing for that case. */}
+ {(() => {
+ const isVideoCurrent = currentPhoto.media_type === 'video';
- {currentPhoto.media_type === 'video' ? (
-
- ) : (
- {
+ // Reserve the slot even when there's no neighbour (single-photo
+ // gallery) so the flex layout keeps slides aligned.
+ if (!photo) {
+ return ;
+ }
+
+ // Neighbouring slides are plain thumbnails — they're only on
+ // screen during the swipe animation, so we save the work of a
+ // protected canvas pipeline for them. The current slide keeps
+ // the full protection chain.
+ if (!isCurrent) {
+ return (
+
)}
- {expiringEvents.length > 5 && (
+ {expiringTotal > 5 && (
)}
diff --git a/frontend/src/pages/admin/EventsListPage.tsx b/frontend/src/pages/admin/EventsListPage.tsx
index f09cf903..a1378cd2 100644
--- a/frontend/src/pages/admin/EventsListPage.tsx
+++ b/frontend/src/pages/admin/EventsListPage.tsx
@@ -1,8 +1,8 @@
-import React, { useState, useMemo, useEffect } from 'react';
+import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
-import {
- Plus,
- Search,
+import {
+ Plus,
+ Search,
Archive,
AlertTriangle,
MoreVertical,
@@ -14,7 +14,9 @@ import {
Image,
Activity,
Copy,
- CheckCircle
+ CheckCircle,
+ ChevronLeft,
+ ChevronRight
} from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
@@ -23,12 +25,15 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
import { BulkArchiveModal } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { eventsService } from '../../services/events.service';
+import { eventsService, type EventStatusFilter } from '../../services/events.service';
+import { adminService } from '../../services/admin.service';
import { isGalleryPublic } from '../../utils/accessControl';
import { buildShareLinkUrl } from '../../utils/url';
import type { Event } from '../../types';
import { useTranslation } from 'react-i18next';
+const PAGE_SIZE = 20;
+
export const EventsListPage: React.FC = () => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
@@ -72,10 +77,31 @@ export const EventsListPage: React.FC = () => {
}
};
- // Get filter from URL
- const statusFilter = searchParams.get('filter') as 'active' | 'archived' | 'draft' | null;
- const isExpiringFilter = searchParams.get('filter') === 'expiring';
- const isDraftFilter = searchParams.get('filter') === 'draft';
+ // Get filter from URL — backend supports all of these as `status` values
+ const filterParam = searchParams.get('filter');
+ const statusFilter: EventStatusFilter | undefined =
+ filterParam === 'active' || filterParam === 'archived' ||
+ filterParam === 'draft' || filterParam === 'expiring' ||
+ filterParam === 'inactive'
+ ? filterParam
+ : undefined;
+ const isExpiringFilter = filterParam === 'expiring';
+ const isDraftFilter = filterParam === 'draft';
+
+ // Server-side pagination + debounced search
+ const [page, setPage] = useState(1);
+ const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
+
+ useEffect(() => {
+ const t = setTimeout(() => setDebouncedSearchTerm(searchTerm.trim()), 300);
+ return () => clearTimeout(t);
+ }, [searchTerm]);
+
+ // Reset to page 1 whenever the filter or search changes so users don't
+ // get stuck on a page index that no longer exists in the new result set.
+ useEffect(() => {
+ setPage(1);
+ }, [statusFilter, debouncedSearchTerm]);
// Close dropdown when clicking outside
useEffect(() => {
@@ -111,10 +137,20 @@ export const EventsListPage: React.FC = () => {
};
}, [activeDropdown]);
- // Fetch events
+ // Fetch events — fully server-side: pagination, status filter, and search
+ // (#346 — counters and search were previously bounded to the first 100 rows).
const { data, isLoading, error } = useQuery({
- queryKey: ['admin-events', statusFilter],
- queryFn: () => eventsService.getEvents(1, 100, (statusFilter === 'archived' || statusFilter === 'active') ? statusFilter : undefined),
+ queryKey: ['admin-events', statusFilter ?? 'all', debouncedSearchTerm, page],
+ queryFn: () => eventsService.getEvents(page, PAGE_SIZE, statusFilter, debouncedSearchTerm || undefined),
+ placeholderData: (prev) => prev,
+ });
+
+ // Aggregate counters come from the dashboard stats endpoint so the cards
+ // and the "All (N)" filter button always reflect global totals, not the
+ // currently visible page.
+ const { data: dashboardStats } = useQuery({
+ queryKey: ['admin-dashboard-stats'],
+ queryFn: () => adminService.getDashboardStats(),
});
// Archive mutation
@@ -122,6 +158,7 @@ export const EventsListPage: React.FC = () => {
mutationFn: eventsService.archiveEvent,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
+ queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
toast.success(t('toast.eventArchived'));
},
onError: () => {
@@ -134,6 +171,7 @@ export const EventsListPage: React.FC = () => {
mutationFn: eventsService.deleteEvent,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
+ queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
toast.success(t('toast.deleteSuccess'));
},
onError: () => {
@@ -146,6 +184,7 @@ export const EventsListPage: React.FC = () => {
mutationFn: eventsService.bulkArchiveEvents,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
+ queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
setSelectedEvents([]);
setShowBulkArchiveModal(false);
@@ -160,52 +199,19 @@ export const EventsListPage: React.FC = () => {
},
});
- // Filter and search events
- const filteredEvents = useMemo(() => {
- if (!data?.events) return [];
-
- let events = [...data.events];
-
- // Apply status filter
- if (isDraftFilter) {
- events = events.filter(e => e.is_draft);
- } else if (statusFilter === 'active') {
- events = events.filter(e => e.is_active && !e.is_archived && !e.is_draft);
- } else if (isExpiringFilter) {
- events = events.filter(e => {
- if (!e.is_active || e.is_archived) return false;
- const days = e.expires_at ? differenceInDays(parseISO(e.expires_at), new Date()) : 0;
- return days <= 7 && days > 0;
- });
- } else if (statusFilter === 'archived') {
- events = events.filter(e => e.is_archived);
- }
-
- // Apply search
- if (searchTerm) {
- const term = searchTerm.toLowerCase();
- events = events.filter(e =>
- e.event_name.toLowerCase().includes(term) ||
- e.event_type.toLowerCase().includes(term) ||
- (e.customer_email || '').toLowerCase().includes(term)
- );
- }
-
- // Sort by creation date (newest first)
- events.sort((a, b) => {
- const dateA = a.created_at ? new Date(a.created_at).getTime() : 0;
- const dateB = b.created_at ? new Date(b.created_at).getTime() : 0;
- return dateB - dateA;
- });
-
- return events;
- }, [data?.events, statusFilter, searchTerm]);
+ // Filtering and searching now happen server-side. Use the response directly,
+ // ordered as the backend returned them (created_at desc by default).
+ const events: Event[] = data?.events ?? [];
+ const pagination = data?.pagination;
+ const totalPages = pagination?.totalPages ?? 1;
+ const filteredCount = pagination?.total ?? 0;
+ const isFilteringOrSearching = !!statusFilter || !!debouncedSearchTerm;
const handleSelectAll = () => {
- if (selectedEvents.length === filteredEvents.length) {
+ if (selectedEvents.length === events.length) {
setSelectedEvents([]);
} else {
- setSelectedEvents(filteredEvents.map(e => e.id));
+ setSelectedEvents(events.map(e => e.id));
}
};
@@ -274,52 +280,49 @@ export const EventsListPage: React.FC = () => {
- {/* Statistics Cards */}
+ {/* Statistics Cards — fed from /admin/dashboard/stats so the totals
+ stay accurate regardless of the visible page (#346). */}