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:
2025-07-06 22:04:45 +02:00
parent 6c82958c79
commit 28632e8970
53 changed files with 13843 additions and 181 deletions
@@ -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>
);
};
+153 -13
View File
@@ -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
+45 -3
View File
@@ -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
+2 -1
View File
@@ -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';