Merge pull request #352 from the-luap/fix/issue-321-346-348-and-discussions
fix: events search/counters (#346), lazy gallery skeleton (#321), smooth lightbox swipe (#348)
This commit is contained in:
@@ -62,6 +62,13 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
|||||||
.count('id as count')
|
.count('id as count')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
|
// Get total events count (all events regardless of status) — used by the
|
||||||
|
// events list page to render accurate "All (N)" / Total Events counters
|
||||||
|
// when the table is server-paginated (#346).
|
||||||
|
const totalEvents = await db('events')
|
||||||
|
.count('id as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
// Calculate trends (compare with previous 30 days)
|
// Calculate trends (compare with previous 30 days)
|
||||||
const sixtyDaysAgo = new Date();
|
const sixtyDaysAgo = new Date();
|
||||||
sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60);
|
sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60);
|
||||||
@@ -98,7 +105,8 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
|||||||
totalDownloads: totalDownloads.count || 0,
|
totalDownloads: totalDownloads.count || 0,
|
||||||
viewsTrend: Math.round(viewsTrend * 10) / 10,
|
viewsTrend: Math.round(viewsTrend * 10) / 10,
|
||||||
downloadsTrend: Math.round(downloadsTrend * 10) / 10,
|
downloadsTrend: Math.round(downloadsTrend * 10) / 10,
|
||||||
archivedEvents: archivedEvents.count || 0
|
archivedEvents: archivedEvents.count || 0,
|
||||||
|
totalEvents: totalEvents.count || 0
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Dashboard stats error:', error);
|
console.error('Dashboard stats error:', error);
|
||||||
|
|||||||
@@ -739,6 +739,7 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
|
|||||||
query = query.where((builder) => {
|
query = query.where((builder) => {
|
||||||
builder.where('event_name', 'like', `%${escapedSearch}%`)
|
builder.where('event_name', 'like', `%${escapedSearch}%`)
|
||||||
.orWhere('admin_email', 'like', `%${escapedSearch}%`)
|
.orWhere('admin_email', 'like', `%${escapedSearch}%`)
|
||||||
|
.orWhere('customer_email', 'like', `%${escapedSearch}%`)
|
||||||
.orWhere('slug', 'like', `%${escapedSearch}%`);
|
.orWhere('slug', 'like', `%${escapedSearch}%`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Skeleton, SkeletonGalleryGrid } from '../common';
|
import { Skeleton, SkeletonGalleryGrid } from '../common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loading placeholder shown while a gallery is resolving (slug → info →
|
* Loading placeholder shown while a gallery is resolving (slug → info →
|
||||||
* auto-login → photos). Used by GalleryPage during the pre-photos phases and
|
* auto-login → photos). The tile grid is delayed 300ms so fast loads
|
||||||
* by GalleryView while the photos query runs, so the visitor sees one
|
* never flash an empty grid before the real photos render (#321 follow-up).
|
||||||
* continuous skeleton instead of multiple full-page interstitials (#321).
|
|
||||||
*/
|
*/
|
||||||
export const GallerySkeleton: React.FC = () => (
|
export const GallerySkeleton: React.FC = () => {
|
||||||
|
const [showGrid, setShowGrid] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setShowGrid(true), 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||||
<header className="bg-surface border-b border-surface sticky top-0 z-40">
|
<header className="bg-surface border-b border-surface sticky top-0 z-40">
|
||||||
<div className="container py-4">
|
<div className="container py-4">
|
||||||
@@ -23,9 +30,12 @@ export const GallerySkeleton: React.FC = () => (
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{showGrid && (
|
||||||
<div className="container mt-6">
|
<div className="container mt-6">
|
||||||
<Skeleton height={80} className="mb-6" />
|
<Skeleton height={80} className="mb-6" />
|
||||||
<SkeletonGalleryGrid count={12} />
|
<SkeletonGalleryGrid count={12} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react';
|
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
|
||||||
import type { Photo } from '../../types';
|
import type { Photo } from '../../types';
|
||||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||||
import { AuthenticatedImage } from '../common';
|
import { AuthenticatedImage } from '../common';
|
||||||
@@ -50,6 +50,21 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
// Ref (not state) so handleTouchEnd reads the value set by handleTouchStart
|
// Ref (not state) so handleTouchEnd reads the value set by handleTouchStart
|
||||||
// even when both fire in the same render batch.
|
// even when both fire in the same render batch.
|
||||||
const swipeStartRef = useRef<{ x: number; y: number; t: number } | null>(null);
|
const swipeStartRef = useRef<{ x: number; y: number; t: number } | null>(null);
|
||||||
|
|
||||||
|
// Carousel swipe state. A 3-slide track (prev/current/next) is shifted
|
||||||
|
// so the current slide is centered; the user's finger drags the track,
|
||||||
|
// and the track snaps to the neighbour or springs back when released.
|
||||||
|
// Percentage-based transforms avoid the need to measure the container
|
||||||
|
// before the first paint.
|
||||||
|
// - 'idle': showing the current slide, no transition
|
||||||
|
// - 'dragging': finger is down, track follows the finger (no transition)
|
||||||
|
// - 'committing': finger lifted past the threshold, animating to the
|
||||||
|
// neighbouring slot. On transitionend we advance currentIndex and reset.
|
||||||
|
// - 'springing': finger lifted below threshold, animating back to center.
|
||||||
|
const trackContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dragX, setDragX] = useState(0);
|
||||||
|
const [phase, setPhase] = useState<'idle' | 'dragging' | 'committing' | 'springing'>('idle');
|
||||||
|
const [commitDirection, setCommitDirection] = useState<-1 | 1>(1);
|
||||||
const [showFeedback, setShowFeedback] = useState(initialShowFeedback);
|
const [showFeedback, setShowFeedback] = useState(initialShowFeedback);
|
||||||
const [isSmallScreen, setIsSmallScreen] = useState<boolean>(typeof window !== 'undefined' ? window.innerWidth < 640 : false);
|
const [isSmallScreen, setIsSmallScreen] = useState<boolean>(typeof window !== 'undefined' ? window.innerWidth < 640 : false);
|
||||||
const [feedbackSettings, setFeedbackSettings] = useState<{
|
const [feedbackSettings, setFeedbackSettings] = useState<{
|
||||||
@@ -66,7 +81,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
|
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
|
||||||
const guestIdentity = useGuestIdentityOptional();
|
const guestIdentity = useGuestIdentityOptional();
|
||||||
const isGuestMode = guestIdentity?.identityMode === 'guest';
|
const isGuestMode = guestIdentity?.identityMode === 'guest';
|
||||||
|
|
||||||
@@ -76,10 +90,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
return () => window.removeEventListener('resize', onResize);
|
return () => window.removeEventListener('resize', onResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Reset image loaded state when changing photos
|
|
||||||
useEffect(() => {
|
|
||||||
setImageLoaded(false);
|
|
||||||
}, [currentIndex]);
|
|
||||||
|
|
||||||
const downloadPhotoMutation = useDownloadPhoto();
|
const downloadPhotoMutation = useDownloadPhoto();
|
||||||
const currentPhoto = photos[currentIndex];
|
const currentPhoto = photos[currentIndex];
|
||||||
@@ -365,8 +375,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Touch event handlers: pinch-to-zoom (2 fingers) + single-finger swipe nav.
|
// Touch event handlers: pinch-to-zoom (2 fingers) + single-finger
|
||||||
// Swipe is suppressed while zoomed in so the user can pan instead.
|
// carousel-style swipe nav. Swipe is suppressed while zoomed in so the
|
||||||
|
// user can pan instead. The carousel is also disabled mid-animation.
|
||||||
const handleTouchStart = (e: React.TouchEvent) => {
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
if (e.touches.length === 2) {
|
if (e.touches.length === 2) {
|
||||||
const touch1 = e.touches[0];
|
const touch1 = e.touches[0];
|
||||||
@@ -377,9 +388,21 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
);
|
);
|
||||||
setTouchDistance(distance);
|
setTouchDistance(distance);
|
||||||
swipeStartRef.current = null;
|
swipeStartRef.current = null;
|
||||||
} else if (e.touches.length === 1 && zoom <= 1) {
|
// Cancel any in-progress carousel motion when a pinch starts —
|
||||||
|
// spring the track back so the image doesn't jerk under the user.
|
||||||
|
if (phase === 'dragging') {
|
||||||
|
if (dragX === 0) {
|
||||||
|
setPhase('idle');
|
||||||
|
} else {
|
||||||
|
setPhase('springing');
|
||||||
|
setDragX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.touches.length === 1 && zoom <= 1 && (phase === 'idle' || phase === 'dragging')) {
|
||||||
const t = e.touches[0];
|
const t = e.touches[0];
|
||||||
swipeStartRef.current = { x: t.clientX, y: t.clientY, t: Date.now() };
|
swipeStartRef.current = { x: t.clientX, y: t.clientY, t: Date.now() };
|
||||||
|
setPhase('dragging');
|
||||||
|
setDragX(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -396,26 +419,124 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
const newZoom = Math.max(1, Math.min(3, zoom * scale));
|
const newZoom = Math.max(1, Math.min(3, zoom * scale));
|
||||||
setZoom(newZoom);
|
setZoom(newZoom);
|
||||||
setTouchDistance(newDistance);
|
setTouchDistance(newDistance);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'dragging' && e.touches.length === 1 && swipeStartRef.current) {
|
||||||
|
const t = e.touches[0];
|
||||||
|
const dx = t.clientX - swipeStartRef.current.x;
|
||||||
|
const dy = t.clientY - swipeStartRef.current.y;
|
||||||
|
// Cancel the carousel drag if the gesture turns out to be vertical
|
||||||
|
// (e.g. an accidental scroll attempt while not zoomed). If we
|
||||||
|
// haven't moved horizontally yet, snap straight to idle — there's
|
||||||
|
// no transition to wait on — otherwise let the spring carry it back.
|
||||||
|
if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 24) {
|
||||||
|
swipeStartRef.current = null;
|
||||||
|
if (dragX === 0) {
|
||||||
|
setPhase('idle');
|
||||||
|
} else {
|
||||||
|
setPhase('springing');
|
||||||
|
setDragX(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDragX(dx);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||||
setTouchDistance(null);
|
setTouchDistance(null);
|
||||||
const start = swipeStartRef.current;
|
const start = swipeStartRef.current;
|
||||||
if (start && e.changedTouches.length > 0) {
|
if (phase === 'dragging' && start && e.changedTouches.length > 0) {
|
||||||
const t = e.changedTouches[0];
|
const t = e.changedTouches[0];
|
||||||
const dx = t.clientX - start.x;
|
const dx = t.clientX - start.x;
|
||||||
const dy = t.clientY - start.y;
|
const dy = t.clientY - start.y;
|
||||||
const dt = Date.now() - start.t;
|
const dt = Math.max(1, Date.now() - start.t);
|
||||||
// Horizontal swipe: > 50px and dominant over vertical, completed in < 600ms.
|
const velocity = Math.abs(dx) / dt; // px / ms
|
||||||
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.2 && dt < 600) {
|
const containerWidth = trackContainerRef.current?.offsetWidth ?? 0;
|
||||||
if (dx > 0) goToPrevious();
|
const threshold = Math.max(60, containerWidth * 0.2);
|
||||||
else goToNext();
|
const isHorizontal = Math.abs(dx) > Math.abs(dy) * 1.2;
|
||||||
|
const shouldCommit = isHorizontal && (Math.abs(dx) > threshold || (velocity > 0.5 && Math.abs(dx) > 40));
|
||||||
|
|
||||||
|
if (shouldCommit) {
|
||||||
|
setCommitDirection(dx < 0 ? 1 : -1);
|
||||||
|
setDragX(dx);
|
||||||
|
setPhase('committing');
|
||||||
|
} else if (dragX === 0) {
|
||||||
|
// Tap with no movement — no transition would fire, so skip the
|
||||||
|
// springing phase to avoid getting stuck waiting for transitionend.
|
||||||
|
setPhase('idle');
|
||||||
|
} else {
|
||||||
|
setPhase('springing');
|
||||||
|
setDragX(0);
|
||||||
}
|
}
|
||||||
|
} else if (phase === 'dragging') {
|
||||||
|
// Touch ended without changedTouches data (rare) — reset cleanly.
|
||||||
|
setPhase('idle');
|
||||||
|
setDragX(0);
|
||||||
}
|
}
|
||||||
swipeStartRef.current = null;
|
swipeStartRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTouchCancel = () => {
|
||||||
|
// System took over the gesture (incoming call, edge swipe, etc.).
|
||||||
|
// Spring back if the carousel was being dragged.
|
||||||
|
if (phase === 'dragging') {
|
||||||
|
if (dragX === 0) {
|
||||||
|
setPhase('idle');
|
||||||
|
} else {
|
||||||
|
setPhase('springing');
|
||||||
|
setDragX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
swipeStartRef.current = null;
|
||||||
|
setTouchDistance(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track transform. Percentages on translateX are self-referential (a
|
||||||
|
// 300%-wide track translated -33.333% moves left by exactly one container
|
||||||
|
// width), so we never need to know the container width to position the
|
||||||
|
// slides. The drag delta is added in pixels.
|
||||||
|
// - idle / springing target: -33.333% (current centered)
|
||||||
|
// - dragging: -33.333% + dragX px (finger follows)
|
||||||
|
// - committing next: -66.666% (next centered)
|
||||||
|
// - committing prev: 0% (previous centered)
|
||||||
|
const trackTransform = (() => {
|
||||||
|
if (phase === 'dragging') return `translate3d(calc(-33.3333% + ${dragX}px), 0, 0)`;
|
||||||
|
if (phase === 'committing') {
|
||||||
|
return commitDirection === 1
|
||||||
|
? 'translate3d(-66.6666%, 0, 0)'
|
||||||
|
: 'translate3d(0%, 0, 0)';
|
||||||
|
}
|
||||||
|
return 'translate3d(-33.3333%, 0, 0)'; // idle | springing
|
||||||
|
})();
|
||||||
|
|
||||||
|
const trackTransition = phase === 'committing' || phase === 'springing'
|
||||||
|
? 'transform 280ms cubic-bezier(0.22, 0.61, 0.36, 1)'
|
||||||
|
: 'none';
|
||||||
|
|
||||||
|
const handleTrackTransitionEnd = (e: React.TransitionEvent) => {
|
||||||
|
if (e.propertyName !== 'transform') return;
|
||||||
|
if (phase === 'committing') {
|
||||||
|
if (commitDirection === 1) {
|
||||||
|
setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0));
|
||||||
|
} else {
|
||||||
|
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
||||||
|
}
|
||||||
|
setDragX(0);
|
||||||
|
setPhase('idle');
|
||||||
|
} else if (phase === 'springing') {
|
||||||
|
setPhase('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevPhoto = photos.length > 1
|
||||||
|
? photos[(currentIndex - 1 + photos.length) % photos.length]
|
||||||
|
: null;
|
||||||
|
const nextPhoto = photos.length > 1
|
||||||
|
? photos[(currentIndex + 1) % photos.length]
|
||||||
|
: null;
|
||||||
|
|
||||||
// Apply protection class to the lightbox container
|
// Apply protection class to the lightbox container
|
||||||
const lightboxClass = useEnhancedProtection ?
|
const lightboxClass = useEnhancedProtection ?
|
||||||
`fixed inset-0 bg-black z-50 flex items-center justify-center protected-image protection-${protectionLevel}` :
|
`fixed inset-0 bg-black z-50 flex items-center justify-center protected-image protection-${protectionLevel}` :
|
||||||
@@ -566,57 +687,77 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image/Video container */}
|
{/* Image/Video container.
|
||||||
<div
|
For photos this hosts a 3-slide carousel (prev/current/next) so
|
||||||
className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0"
|
swipe gestures animate the track and the neighbour images preload
|
||||||
onClick={currentPhoto.media_type === 'video' ? undefined : handleImageClick}
|
while the user views the current one. Videos still render as a
|
||||||
onMouseDown={currentPhoto.media_type === 'video' ? undefined : handleMouseDown}
|
single player — sliding video elements during a drag is awkward
|
||||||
onMouseMove={currentPhoto.media_type === 'video' ? undefined : handleMouseMove}
|
and the carousel adds nothing for that case. */}
|
||||||
onMouseUp={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
|
{(() => {
|
||||||
onMouseLeave={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
|
const isVideoCurrent = currentPhoto.media_type === 'video';
|
||||||
onTouchStart={currentPhoto.media_type === 'video' ? undefined : handleTouchStart}
|
|
||||||
onTouchMove={currentPhoto.media_type === 'video' ? undefined : handleTouchMove}
|
|
||||||
onTouchEnd={currentPhoto.media_type === 'video' ? undefined : handleTouchEnd}
|
|
||||||
style={{
|
|
||||||
cursor: currentPhoto.media_type === 'video' ? 'default' : (zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'),
|
|
||||||
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Loading spinner */}
|
|
||||||
{!imageLoaded && currentPhoto.media_type !== 'video' && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
|
||||||
<Loader2 className="w-12 h-12 text-white animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentPhoto.media_type === 'video' ? (
|
const renderSlide = (photo: Photo | null, isCurrent: boolean) => {
|
||||||
<VideoPlayer
|
// Reserve the slot even when there's no neighbour (single-photo
|
||||||
src={currentPhoto.url}
|
// gallery) so the flex layout keeps slides aligned.
|
||||||
poster={currentPhoto.thumbnail_url}
|
if (!photo) {
|
||||||
className="max-w-full max-h-full"
|
return <div className="h-full" style={{ flex: '0 0 33.3333%' }} aria-hidden="true" />;
|
||||||
controls={true}
|
}
|
||||||
autoPlay={false}
|
|
||||||
|
// 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 (
|
||||||
|
<div className="h-full flex items-center justify-center px-2" style={{ flex: '0 0 33.3333%' }}>
|
||||||
|
{photo.media_type === 'video' && photo.thumbnail_url ? (
|
||||||
|
<img
|
||||||
|
src={photo.thumbnail_url}
|
||||||
|
alt={photo.filename}
|
||||||
|
className="max-w-full max-h-full object-contain select-none pointer-events-none"
|
||||||
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AuthenticatedImage
|
<AuthenticatedImage
|
||||||
src={currentPhoto.url}
|
src={photo.url}
|
||||||
alt={currentPhoto.filename}
|
alt={photo.filename}
|
||||||
fallbackSrc={currentPhoto.thumbnail_url || undefined}
|
fallbackSrc={photo.thumbnail_url || undefined}
|
||||||
|
className="max-w-full max-h-full object-contain select-none pointer-events-none"
|
||||||
|
draggable={false}
|
||||||
|
isGallery={true}
|
||||||
|
slug={slug}
|
||||||
|
photoId={photo.id}
|
||||||
|
requiresToken={photo.requires_token}
|
||||||
|
secureUrlTemplate={photo.secure_url_template}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-full flex items-center justify-center"
|
||||||
|
style={{ flex: '0 0 33.3333%' }}
|
||||||
|
onClick={handleImageClick}
|
||||||
|
>
|
||||||
|
<AuthenticatedImage
|
||||||
|
src={photo.url}
|
||||||
|
alt={photo.filename}
|
||||||
|
fallbackSrc={photo.thumbnail_url || undefined}
|
||||||
className="max-w-full max-h-full object-contain select-none"
|
className="max-w-full max-h-full object-contain select-none"
|
||||||
style={{
|
style={{
|
||||||
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
|
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
|
||||||
transition: isDragging ? 'none' : 'transform 0.2s',
|
transition: isDragging ? 'none' : 'transform 0.2s',
|
||||||
opacity: imageLoaded ? 1 : 0,
|
|
||||||
}}
|
}}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onLoad={() => setImageLoaded(true)}
|
|
||||||
useWatermark={useEnhancedProtection}
|
useWatermark={useEnhancedProtection}
|
||||||
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
|
watermarkText={useEnhancedProtection ? `${photo.filename} - Protected` : undefined}
|
||||||
isGallery={true}
|
isGallery={true}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
photoId={currentPhoto.id}
|
photoId={photo.id}
|
||||||
requiresToken={currentPhoto.requires_token}
|
requiresToken={photo.requires_token}
|
||||||
secureUrlTemplate={currentPhoto.secure_url_template}
|
secureUrlTemplate={photo.secure_url_template}
|
||||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||||
protectionLevel={protectionLevel}
|
protectionLevel={protectionLevel}
|
||||||
useEnhancedProtection={useEnhancedProtection}
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
@@ -626,33 +767,78 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
detectPrintScreen={useEnhancedProtection}
|
detectPrintScreen={useEnhancedProtection}
|
||||||
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
||||||
onProtectionViolation={(violationType) => {
|
onProtectionViolation={(violationType) => {
|
||||||
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
|
console.warn(`Protection violation in lightbox for photo ${photo.id}: ${violationType}`);
|
||||||
|
|
||||||
// Track analytics
|
|
||||||
if (typeof window !== 'undefined' && (window as any).umami) {
|
if (typeof window !== 'undefined' && (window as any).umami) {
|
||||||
(window as any).umami.track('lightbox_protection_violation', {
|
(window as any).umami.track('lightbox_protection_violation', {
|
||||||
photoId: currentPhoto.id,
|
photoId: photo.id,
|
||||||
violationType,
|
violationType,
|
||||||
protectionLevel,
|
protectionLevel,
|
||||||
zoom
|
zoom
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// For maximum protection, close lightbox on violation
|
|
||||||
if (protectionLevel === 'maximum' &&
|
if (protectionLevel === 'maximum' &&
|
||||||
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
|
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={trackContainerRef}
|
||||||
|
className="absolute top-0 left-0 bottom-0 overflow-hidden z-0"
|
||||||
|
onClick={isVideoCurrent ? undefined : handleImageClick}
|
||||||
|
onMouseDown={isVideoCurrent ? undefined : handleMouseDown}
|
||||||
|
onMouseMove={isVideoCurrent ? undefined : handleMouseMove}
|
||||||
|
onMouseUp={isVideoCurrent ? undefined : handleMouseUp}
|
||||||
|
onMouseLeave={isVideoCurrent ? undefined : handleMouseUp}
|
||||||
|
onTouchStart={isVideoCurrent ? undefined : handleTouchStart}
|
||||||
|
onTouchMove={isVideoCurrent ? undefined : handleTouchMove}
|
||||||
|
onTouchEnd={isVideoCurrent ? undefined : handleTouchEnd}
|
||||||
|
onTouchCancel={isVideoCurrent ? undefined : handleTouchCancel}
|
||||||
|
style={{
|
||||||
|
cursor: isVideoCurrent ? 'default' : (zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'),
|
||||||
|
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
|
||||||
|
// Tell the browser we handle horizontal gestures ourselves so
|
||||||
|
// it doesn't fight us with edge-swipe back navigation, native
|
||||||
|
// pinch-zoom, etc. Videos keep default touch behaviour.
|
||||||
|
touchAction: isVideoCurrent ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isVideoCurrent ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<VideoPlayer
|
||||||
|
src={currentPhoto.url}
|
||||||
|
poster={currentPhoto.thumbnail_url}
|
||||||
|
className="max-w-full max-h-full"
|
||||||
|
controls={true}
|
||||||
|
autoPlay={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-stretch"
|
||||||
|
style={{
|
||||||
|
width: '300%',
|
||||||
|
transform: trackTransform,
|
||||||
|
transition: trackTransition,
|
||||||
|
willChange: 'transform',
|
||||||
|
}}
|
||||||
|
onTransitionEnd={handleTrackTransitionEnd}
|
||||||
|
>
|
||||||
|
{renderSlide(prevPhoto, false)}
|
||||||
|
{renderSlide(currentPhoto, true)}
|
||||||
|
{renderSlide(nextPhoto, false)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
{/* Touch/swipe hint for mobile. Sits above the bottom toolbar
|
})()}
|
||||||
(which can wrap to two rows when feedback controls are enabled). */}
|
|
||||||
<div className="absolute bottom-40 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
|
|
||||||
Swipe to navigate
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feedback Panel */}
|
{/* Feedback Panel */}
|
||||||
{showFeedback && (
|
{showFeedback && (
|
||||||
|
|||||||
@@ -54,10 +54,12 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
refetchInterval: 30000, // Refresh every 30 seconds
|
refetchInterval: 30000, // Refresh every 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch events data for expiring events
|
// Fetch the next 5 expiring events directly from the server. Previously
|
||||||
const { data: eventsData, isLoading: eventsLoading } = useQuery({
|
// this fetched the first 100 events and filtered client-side (#346 follow-up),
|
||||||
queryKey: ['admin-events-summary'],
|
// which silently missed any expiring event outside the first 100 rows.
|
||||||
queryFn: () => eventsService.getEvents(1, 100),
|
const { data: expiringEventsData, isLoading: eventsLoading } = useQuery({
|
||||||
|
queryKey: ['admin-events-summary', 'expiring'],
|
||||||
|
queryFn: () => eventsService.getEvents(1, 5, 'expiring'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLoading = statsLoading || eventsLoading;
|
const isLoading = statsLoading || eventsLoading;
|
||||||
@@ -70,13 +72,8 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate expiring events
|
const expiringEvents = expiringEventsData?.events ?? [];
|
||||||
const activeEvents = eventsData?.events.filter(e => e.is_active && !e.is_archived) || [];
|
const expiringTotal = expiringEventsData?.pagination?.total ?? expiringEvents.length;
|
||||||
const expiringEvents = activeEvents.filter(e => {
|
|
||||||
if (!e.expires_at) return false;
|
|
||||||
const days = differenceInDays(parseISO(e.expires_at), new Date());
|
|
||||||
return days <= 7 && days > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Format numbers for display
|
// Format numbers for display
|
||||||
const formatNumber = (num: number): string => {
|
const formatNumber = (num: number): string => {
|
||||||
@@ -194,7 +191,7 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
<p className="text-neutral-600 dark:text-neutral-400 py-8 text-center">{t('admin.noEventsExpiring')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 py-8 text-center">{t('admin.noEventsExpiring')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{expiringEvents.slice(0, 5).map((event) => {
|
{expiringEvents.map((event) => {
|
||||||
const daysLeft = differenceInDays(parseISO(event.expires_at!), new Date());
|
const daysLeft = differenceInDays(parseISO(event.expires_at!), new Date());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -225,12 +222,12 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{expiringEvents.length > 5 && (
|
{expiringTotal > 5 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin/events?filter=expiring')}
|
onClick={() => navigate('/admin/events?filter=expiring')}
|
||||||
className="w-full mt-4 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium"
|
className="w-full mt-4 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium"
|
||||||
>
|
>
|
||||||
{t('admin.viewAllExpiringEvents', { count: expiringEvents.length })} →
|
{t('admin.viewAllExpiringEvents', { count: expiringTotal })} →
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Activity,
|
Activity,
|
||||||
Copy,
|
Copy,
|
||||||
CheckCircle
|
CheckCircle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { parseISO, differenceInDays } from 'date-fns';
|
import { parseISO, differenceInDays } from 'date-fns';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -23,12 +25,15 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
|||||||
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
|
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
|
||||||
import { BulkArchiveModal } from '../../components/admin';
|
import { BulkArchiveModal } from '../../components/admin';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { isGalleryPublic } from '../../utils/accessControl';
|
||||||
import { buildShareLinkUrl } from '../../utils/url';
|
import { buildShareLinkUrl } from '../../utils/url';
|
||||||
import type { Event } from '../../types';
|
import type { Event } from '../../types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
export const EventsListPage: React.FC = () => {
|
export const EventsListPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { format } = useLocalizedDate();
|
const { format } = useLocalizedDate();
|
||||||
@@ -72,10 +77,31 @@ export const EventsListPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get filter from URL
|
// Get filter from URL — backend supports all of these as `status` values
|
||||||
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | 'draft' | null;
|
const filterParam = searchParams.get('filter');
|
||||||
const isExpiringFilter = searchParams.get('filter') === 'expiring';
|
const statusFilter: EventStatusFilter | undefined =
|
||||||
const isDraftFilter = searchParams.get('filter') === 'draft';
|
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
|
// Close dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -111,10 +137,20 @@ export const EventsListPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [activeDropdown]);
|
}, [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({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ['admin-events', statusFilter],
|
queryKey: ['admin-events', statusFilter ?? 'all', debouncedSearchTerm, page],
|
||||||
queryFn: () => eventsService.getEvents(1, 100, (statusFilter === 'archived' || statusFilter === 'active') ? statusFilter : undefined),
|
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
|
// Archive mutation
|
||||||
@@ -122,6 +158,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
mutationFn: eventsService.archiveEvent,
|
mutationFn: eventsService.archiveEvent,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
|
||||||
toast.success(t('toast.eventArchived'));
|
toast.success(t('toast.eventArchived'));
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -134,6 +171,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
mutationFn: eventsService.deleteEvent,
|
mutationFn: eventsService.deleteEvent,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
|
||||||
toast.success(t('toast.deleteSuccess'));
|
toast.success(t('toast.deleteSuccess'));
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -146,6 +184,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
mutationFn: eventsService.bulkArchiveEvents,
|
mutationFn: eventsService.bulkArchiveEvents,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
|
||||||
setSelectedEvents([]);
|
setSelectedEvents([]);
|
||||||
setShowBulkArchiveModal(false);
|
setShowBulkArchiveModal(false);
|
||||||
|
|
||||||
@@ -160,52 +199,19 @@ export const EventsListPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter and search events
|
// Filtering and searching now happen server-side. Use the response directly,
|
||||||
const filteredEvents = useMemo(() => {
|
// ordered as the backend returned them (created_at desc by default).
|
||||||
if (!data?.events) return [];
|
const events: Event[] = data?.events ?? [];
|
||||||
|
const pagination = data?.pagination;
|
||||||
let events = [...data.events];
|
const totalPages = pagination?.totalPages ?? 1;
|
||||||
|
const filteredCount = pagination?.total ?? 0;
|
||||||
// Apply status filter
|
const isFilteringOrSearching = !!statusFilter || !!debouncedSearchTerm;
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
if (selectedEvents.length === filteredEvents.length) {
|
if (selectedEvents.length === events.length) {
|
||||||
setSelectedEvents([]);
|
setSelectedEvents([]);
|
||||||
} else {
|
} else {
|
||||||
setSelectedEvents(filteredEvents.map(e => e.id));
|
setSelectedEvents(events.map(e => e.id));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,13 +280,14 @@ export const EventsListPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards — fed from /admin/dashboard/stats so the totals
|
||||||
|
stay accurate regardless of the visible page (#346). */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
<Card padding="sm">
|
<Card padding="sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.totalEvents')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.totalEvents')}</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{data?.events.length || 0}</p>
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{dashboardStats?.totalEvents ?? 0}</p>
|
||||||
</div>
|
</div>
|
||||||
<Calendar className="w-8 h-8 text-primary-600" />
|
<Calendar className="w-8 h-8 text-primary-600" />
|
||||||
</div>
|
</div>
|
||||||
@@ -291,7 +298,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.activeEvents')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.activeEvents')}</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{data?.events.filter(e => e.is_active && !e.is_archived).length || 0}
|
{dashboardStats?.activeEvents ?? 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Activity className="w-8 h-8 text-green-600" />
|
<Activity className="w-8 h-8 text-green-600" />
|
||||||
@@ -303,7 +310,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.totalPhotos')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.totalPhotos')}</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{data?.events.reduce((sum, e) => sum + (e.photo_count || 0), 0) || 0}
|
{dashboardStats?.totalPhotos ?? 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Image className="w-8 h-8 text-blue-600" />
|
<Image className="w-8 h-8 text-blue-600" />
|
||||||
@@ -315,11 +322,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.expiringEvents')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.expiringEvents')}</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{data?.events.filter(e => {
|
{dashboardStats?.expiringEvents ?? 0}
|
||||||
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;
|
|
||||||
}).length || 0}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AlertTriangle className="w-8 h-8 text-orange-600" />
|
<AlertTriangle className="w-8 h-8 text-orange-600" />
|
||||||
@@ -351,7 +354,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('events.all')} ({data?.events.length || 0})
|
{t('events.all')} ({dashboardStats?.totalEvents ?? 0})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={statusFilter === 'active' ? 'primary' : 'outline'}
|
variant={statusFilter === 'active' ? 'primary' : 'outline'}
|
||||||
@@ -417,7 +420,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
<th className="px-6 py-3 text-left">
|
<th className="px-6 py-3 text-left">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedEvents.length === filteredEvents.length && filteredEvents.length > 0}
|
checked={selectedEvents.length === events.length && events.length > 0}
|
||||||
onChange={handleSelectAll}
|
onChange={handleSelectAll}
|
||||||
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500 dark:bg-neutral-700"
|
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500 dark:bg-neutral-700"
|
||||||
/>
|
/>
|
||||||
@@ -443,14 +446,14 @@ export const EventsListPage: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||||
{filteredEvents.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-6 py-12 text-center text-neutral-500 dark:text-neutral-400">
|
<td colSpan={7} className="px-6 py-12 text-center text-neutral-500 dark:text-neutral-400">
|
||||||
{t('events.noEventsFound')}
|
{t('events.noEventsFound')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredEvents.map((event) => {
|
events.map((event) => {
|
||||||
const status = getEventStatus(event);
|
const status = getEventStatus(event);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -658,12 +661,56 @@ export const EventsListPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Pagination — only when the current filter has more than one page */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
<div>
|
||||||
|
{t('events.paginationLabel', {
|
||||||
|
from: events.length === 0 ? 0 : (page - 1) * PAGE_SIZE + 1,
|
||||||
|
to: (page - 1) * PAGE_SIZE + events.length,
|
||||||
|
total: filteredCount,
|
||||||
|
defaultValue: '{{from}}–{{to}} of {{total}}',
|
||||||
|
})}
|
||||||
|
{isFilteringOrSearching && (
|
||||||
|
<span className="ml-2 text-neutral-400">({t('events.filtered', 'filtered')})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<ChevronLeft className="w-4 h-4" />}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
{t('common.previous', 'Previous')}
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
{t('events.pageOf', {
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
defaultValue: 'Page {{page}} of {{totalPages}}',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
rightIcon={<ChevronRight className="w-4 h-4" />}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
{t('common.next', 'Next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bulk Archive Modal */}
|
{/* Bulk Archive Modal */}
|
||||||
<BulkArchiveModal
|
<BulkArchiveModal
|
||||||
isOpen={showBulkArchiveModal}
|
isOpen={showBulkArchiveModal}
|
||||||
onClose={() => setShowBulkArchiveModal(false)}
|
onClose={() => setShowBulkArchiveModal(false)}
|
||||||
onConfirm={() => bulkArchiveMutation.mutate(selectedEvents)}
|
onConfirm={() => bulkArchiveMutation.mutate(selectedEvents)}
|
||||||
selectedEvents={filteredEvents.filter(e => selectedEvents.includes(e.id))}
|
selectedEvents={events.filter(e => selectedEvents.includes(e.id))}
|
||||||
isLoading={bulkArchiveMutation.isPending}
|
isLoading={bulkArchiveMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface DashboardStats {
|
|||||||
viewsTrend: number;
|
viewsTrend: number;
|
||||||
downloadsTrend: number;
|
downloadsTrend: number;
|
||||||
archivedEvents: number;
|
archivedEvents: number;
|
||||||
|
totalEvents: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemHealth {
|
export interface SystemHealth {
|
||||||
|
|||||||
@@ -64,11 +64,16 @@ interface UpdateEventData {
|
|||||||
default_photo_sort?: string;
|
default_photo_sort?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EventStatusFilter = 'active' | 'inactive' | 'archived' | 'draft' | 'expiring';
|
||||||
|
|
||||||
interface EventsListResponse {
|
interface EventsListResponse {
|
||||||
events: Event[];
|
events: Event[];
|
||||||
total: number;
|
pagination: {
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const eventsService = {
|
export const eventsService = {
|
||||||
@@ -76,7 +81,8 @@ export const eventsService = {
|
|||||||
async getEvents(
|
async getEvents(
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
status?: 'active' | 'inactive' | 'archived' | 'draft'
|
status?: EventStatusFilter,
|
||||||
|
search?: string
|
||||||
): Promise<EventsListResponse> {
|
): Promise<EventsListResponse> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
@@ -86,6 +92,9 @@ export const eventsService = {
|
|||||||
if (status) {
|
if (status) {
|
||||||
params.append('status', status);
|
params.append('status', status);
|
||||||
}
|
}
|
||||||
|
if (search) {
|
||||||
|
params.append('search', search);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
|
const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
|
||||||
const data: any = response.data;
|
const data: any = response.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user