Implement complete frontend with admin panel and theme system
- Add admin authentication and dashboard - Create event management pages (list, create, edit, archive) - Implement gallery enhancements (search, sorting, bulk download) - Add email configuration and archive management pages - Integrate Umami analytics with tracking throughout the app - Add comprehensive error boundaries and loading states - Implement accessibility features (WCAG 2.1 AA compliance) - Create theme system with preset themes and customization - Add branding settings and company information management - Fix backend database initialization and health check - Configure proper API URLs and environment variables 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Clock, AlertCircle } from 'lucide-react';
|
||||
import { differenceInSeconds } from 'date-fns';
|
||||
|
||||
interface CountdownTimerProps {
|
||||
expiresAt: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CountdownTimer: React.FC<CountdownTimerProps> = ({ expiresAt, className = '' }) => {
|
||||
const [timeLeft, setTimeLeft] = useState<{
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
isExpired: boolean;
|
||||
}>({ hours: 0, minutes: 0, seconds: 0, isExpired: false });
|
||||
|
||||
useEffect(() => {
|
||||
const calculateTimeLeft = () => {
|
||||
const expirationDate = new Date(expiresAt);
|
||||
const now = new Date();
|
||||
|
||||
if (expirationDate <= now) {
|
||||
setTimeLeft({ hours: 0, minutes: 0, seconds: 0, isExpired: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSeconds = differenceInSeconds(expirationDate, now);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
setTimeLeft({ hours, minutes, seconds, isExpired: false });
|
||||
};
|
||||
|
||||
calculateTimeLeft();
|
||||
const interval = setInterval(calculateTimeLeft, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [expiresAt]);
|
||||
|
||||
if (timeLeft.isExpired) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-red-600 ${className}`}>
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span className="font-semibold">Gallery Expired</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only show countdown if less than 24 hours remain
|
||||
if (timeLeft.hours >= 24) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
<Clock className="w-5 h-5 text-orange-600 animate-pulse" />
|
||||
<div className="flex items-center gap-1 font-mono text-lg">
|
||||
<div className="bg-orange-100 text-orange-900 px-2 py-1 rounded">
|
||||
{String(timeLeft.hours).padStart(2, '0')}
|
||||
</div>
|
||||
<span className="text-orange-600">:</span>
|
||||
<div className="bg-orange-100 text-orange-900 px-2 py-1 rounded">
|
||||
{String(timeLeft.minutes).padStart(2, '0')}
|
||||
</div>
|
||||
<span className="text-orange-600">:</span>
|
||||
<div className="bg-orange-100 text-orange-900 px-2 py-1 rounded">
|
||||
{String(timeLeft.seconds).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-orange-600 font-medium">remaining</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Download, X } from 'lucide-react';
|
||||
|
||||
interface DownloadProgressProps {
|
||||
isDownloading: boolean;
|
||||
progress?: number;
|
||||
fileName?: string;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export const DownloadProgress: React.FC<DownloadProgressProps> = ({
|
||||
isDownloading,
|
||||
progress = 0,
|
||||
fileName,
|
||||
onCancel,
|
||||
}) => {
|
||||
if (!isDownloading) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 bg-white rounded-lg shadow-lg border border-neutral-200 p-4 min-w-[300px] z-50">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="w-5 h-5 text-primary-600 animate-bounce" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">Downloading...</p>
|
||||
{fileName && (
|
||||
<p className="text-xs text-neutral-500 truncate max-w-[200px]">{fileName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1 hover:bg-neutral-100 rounded transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-neutral-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{progress > 0 && (
|
||||
<p className="text-xs text-neutral-500 mt-1">{Math.round(progress)}% complete</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Download, Grid, Square, LogOut, Calendar, Clock } from 'lucide-react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { Download, Grid, Square, LogOut, Calendar, Clock, Search, SortAsc } from 'lucide-react';
|
||||
import { format, differenceInDays, parseISO } from 'date-fns';
|
||||
|
||||
import { Button, Loading } from '../common';
|
||||
import { Button, Input, SkeletonGalleryGrid, Skeleton } from '../common';
|
||||
import { useGalleryAuth } from '../../contexts';
|
||||
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
|
||||
import { PhotoGrid } from './PhotoGrid';
|
||||
import { ExpirationBanner } from './ExpirationBanner';
|
||||
import { CountdownTimer } from './CountdownTimer';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
|
||||
interface GalleryViewProps {
|
||||
slug: string;
|
||||
@@ -24,6 +26,9 @@ interface GalleryViewProps {
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { logout } = useGalleryAuth();
|
||||
const [viewMode, setViewMode] = useState<'all' | 'collages' | 'individual'>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
|
||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||
|
||||
// Fetch photos
|
||||
const { data, isLoading, error } = useGalleryPhotos(slug);
|
||||
@@ -33,22 +38,96 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
|
||||
const showUrgentWarning = daysUntilExpiration <= 7;
|
||||
|
||||
// Filter photos based on view mode
|
||||
const filteredPhotos = data?.photos.filter(photo => {
|
||||
if (viewMode === 'all') return true;
|
||||
if (viewMode === 'collages') return photo.type === 'collage';
|
||||
if (viewMode === 'individual') return photo.type === 'individual';
|
||||
return true;
|
||||
}) || [];
|
||||
// Filter and sort photos
|
||||
const filteredPhotos = useMemo(() => {
|
||||
if (!data?.photos) return [];
|
||||
|
||||
let photos = [...data.photos];
|
||||
|
||||
// Apply view mode filter
|
||||
if (viewMode === 'collages') {
|
||||
photos = photos.filter(photo => photo.type === 'collage');
|
||||
} else if (viewMode === 'individual') {
|
||||
photos = photos.filter(photo => photo.type === 'individual');
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
photos = photos.filter(photo =>
|
||||
photo.filename.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
photos.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.filename.localeCompare(b.filename);
|
||||
case 'size':
|
||||
return b.size - a.size;
|
||||
case 'date':
|
||||
default:
|
||||
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
return photos;
|
||||
}, [data?.photos, viewMode, searchTerm, sortBy]);
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
downloadAllMutation.mutate(slug);
|
||||
|
||||
// Track download all action
|
||||
analyticsService.trackGalleryEvent('bulk_download', {
|
||||
gallery: slug,
|
||||
photo_count: data?.photos.length || 0,
|
||||
is_download_all: true
|
||||
});
|
||||
};
|
||||
|
||||
// Track search usage with debouncing
|
||||
useEffect(() => {
|
||||
if (searchTerm.length > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
analyticsService.trackSearch(searchTerm, filteredPhotos.length, 'gallery');
|
||||
}, 1000); // Debounce for 1 second
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [searchTerm, filteredPhotos.length]);
|
||||
|
||||
// Track expiration warning views
|
||||
useEffect(() => {
|
||||
if (showUrgentWarning && daysUntilExpiration > 0) {
|
||||
analyticsService.trackExpirationWarning(slug, daysUntilExpiration);
|
||||
}
|
||||
}, [showUrgentWarning, daysUntilExpiration, slug]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||
<Loading size="lg" text="Loading photos..." />
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
{/* Header Skeleton */}
|
||||
<header className="bg-white border-b border-neutral-200 sticky top-0 z-40">
|
||||
<div className="container py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton height={32} width={200} className="mb-2" />
|
||||
<Skeleton height={20} width={300} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton height={40} width={120} />
|
||||
<Skeleton height={40} width={100} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content Skeleton */}
|
||||
<div className="container mt-6">
|
||||
<Skeleton height={80} className="mb-6" />
|
||||
<SkeletonGalleryGrid count={12} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,6 +171,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{daysUntilExpiration <= 1 && daysUntilExpiration > 0 && (
|
||||
<CountdownTimer expiresAt={event.expires_at} className="mr-4" />
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
@@ -124,8 +206,66 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
{/* Search and Filters */}
|
||||
<div className="container mt-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 mb-6">
|
||||
{/* Search Bar */}
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search photos by filename..."
|
||||
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={<SortAsc className="w-4 h-4" />}
|
||||
onClick={() => setShowSortMenu(!showSortMenu)}
|
||||
>
|
||||
Sort by {sortBy === 'date' ? 'Date' : sortBy === 'name' ? 'Name' : 'Size'}
|
||||
</Button>
|
||||
|
||||
{showSortMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-10">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSortBy('date');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${sortBy === 'date' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'}`}
|
||||
>
|
||||
Sort by Date
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSortBy('name');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${sortBy === 'name' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'}`}
|
||||
>
|
||||
Sort by Name
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSortBy('size');
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${sortBy === 'size' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'}`}
|
||||
>
|
||||
Sort by Size
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Download, Maximize2, Check } from 'lucide-react';
|
||||
import { Download, Maximize2, Check, Package } from 'lucide-react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import type { Photo } from '../../types';
|
||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||
import { PhotoLightbox } from './PhotoLightbox';
|
||||
import { Button } from '../common';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
|
||||
interface PhotoGridProps {
|
||||
photos: Photo[];
|
||||
@@ -34,6 +37,10 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
|
||||
|
||||
const handleDownload = (photo: Photo, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Track individual photo download
|
||||
analyticsService.trackDownload(photo.id, slug, false);
|
||||
|
||||
downloadPhotoMutation.mutate({
|
||||
slug,
|
||||
photoId: photo.id,
|
||||
@@ -54,6 +61,40 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
|
||||
setSelectedPhotos(new Set());
|
||||
};
|
||||
|
||||
const handleDownloadSelected = async () => {
|
||||
if (selectedPhotos.size === 0) return;
|
||||
|
||||
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id));
|
||||
|
||||
toast.info(`Downloading ${selectedPhotos.size} photos...`);
|
||||
|
||||
// Download each selected photo
|
||||
const downloadPromises = selectedPhotosList.map(photo =>
|
||||
galleryService.downloadPhoto(slug, photo.id, photo.filename)
|
||||
.catch(err => {
|
||||
console.error(`Failed to download ${photo.filename}:`, err);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.all(downloadPromises);
|
||||
toast.success(`Downloaded ${selectedPhotos.size} photos!`);
|
||||
|
||||
// Track bulk download
|
||||
analyticsService.trackGalleryEvent('bulk_download', {
|
||||
gallery: slug,
|
||||
photo_count: selectedPhotos.size
|
||||
});
|
||||
|
||||
// Clear selection after download
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
} catch (error) {
|
||||
toast.error('Some photos failed to download');
|
||||
}
|
||||
};
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
@@ -90,9 +131,10 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
leftIcon={<Package className="w-4 h-4" />}
|
||||
onClick={handleDownloadSelected}
|
||||
>
|
||||
Download Selected
|
||||
Download {selectedPhotos.size} Selected
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -21,15 +21,36 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
||||
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
const currentPhoto = photos[currentIndex];
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowLeft') goToPrevious();
|
||||
if (e.key === 'ArrowRight') goToNext();
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
goToPrevious();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
goToNext();
|
||||
break;
|
||||
case '+':
|
||||
case '=':
|
||||
handleZoomIn();
|
||||
break;
|
||||
case '-':
|
||||
case '_':
|
||||
handleZoomOut();
|
||||
break;
|
||||
case 'd':
|
||||
case 'D':
|
||||
handleDownload();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
@@ -102,6 +123,39 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Touch event handlers for pinch-to-zoom
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
const distance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
);
|
||||
setTouchDistance(distance);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2 && touchDistance !== null) {
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
const newDistance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
);
|
||||
|
||||
const scale = newDistance / touchDistance;
|
||||
const newZoom = Math.max(1, Math.min(3, zoom * scale));
|
||||
setZoom(newZoom);
|
||||
setTouchDistance(newDistance);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setTouchDistance(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black z-50 flex items-center justify-center">
|
||||
{/* Close button */}
|
||||
@@ -182,6 +236,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { GalleryView } from './GalleryView';
|
||||
export { PhotoGrid } from './PhotoGrid';
|
||||
export { PhotoLightbox } from './PhotoLightbox';
|
||||
export { ExpirationBanner } from './ExpirationBanner';
|
||||
export { ExpirationBanner } from './ExpirationBanner';
|
||||
export { CountdownTimer } from './CountdownTimer';
|
||||
Reference in New Issue
Block a user