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,452 @@
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft,
ExternalLink,
Calendar,
Users,
Eye,
Download,
Archive,
Edit2,
Save,
X,
AlertTriangle,
Copy,
CheckCircle
} from 'lucide-react';
import { format, parseISO, differenceInDays, addDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { galleryService } from '../../services/gallery.service';
export const EventDetailsPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [editForm, setEditForm] = useState({
welcome_message: '',
color_theme: '',
expires_at: '',
});
const [copiedLink, setCopiedLink] = useState(false);
// Fetch event details
const { data: event, isLoading: eventLoading } = useQuery({
queryKey: ['admin-event', id],
queryFn: () => eventsService.getEvent(parseInt(id!)),
enabled: !!id,
});
// Fetch event statistics
const { data: stats } = useQuery({
queryKey: ['admin-event-stats', event?.slug],
queryFn: () => galleryService.getGalleryStats(event!.slug),
enabled: !!event?.slug,
});
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: any) => eventsService.updateEvent(parseInt(id!), data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
toast.success('Event updated successfully');
setIsEditing(false);
},
onError: () => {
toast.error('Failed to update event');
},
});
// Archive mutation
const archiveMutation = useMutation({
mutationFn: () => eventsService.archiveEvent(parseInt(id!)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
toast.success('Event archived successfully');
},
onError: () => {
toast.error('Failed to archive event');
},
});
// Extend expiration mutation
const extendMutation = useMutation({
mutationFn: (days: number) => {
const newDate = addDays(parseISO(event!.expires_at), days);
return eventsService.extendExpiration(parseInt(id!), newDate.toISOString());
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
toast.success('Expiration extended successfully');
},
onError: () => {
toast.error('Failed to extend expiration');
},
});
if (eventLoading || !event) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text="Loading event details..." />
</div>
);
}
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
const isExpired = daysUntilExpiration <= 0;
const isExpiring = daysUntilExpiration > 0 && daysUntilExpiration <= 7;
const handleStartEdit = () => {
setEditForm({
welcome_message: event.welcome_message || '',
color_theme: event.color_theme || '',
expires_at: format(parseISO(event.expires_at), 'yyyy-MM-dd'),
});
setIsEditing(true);
};
const handleSaveEdit = () => {
updateMutation.mutate({
welcome_message: editForm.welcome_message || undefined,
color_theme: editForm.color_theme || undefined,
expires_at: editForm.expires_at,
});
};
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(event.share_link);
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
toast.success('Link copied to clipboard');
} catch (err) {
toast.error('Failed to copy link');
}
};
return (
<div>
{/* Page Header */}
<div className="mb-6">
<Button
variant="outline"
size="sm"
leftIcon={<ArrowLeft className="w-4 h-4" />}
onClick={() => navigate('/admin/events')}
className="mb-4"
>
Back to Events
</Button>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-neutral-900">{event.event_name}</h1>
<div className="flex items-center gap-4 mt-2 text-sm text-neutral-600">
<span className="flex items-center">
<Calendar className="w-4 h-4 mr-1" />
{format(parseISO(event.event_date), 'MMMM d, yyyy')}
</span>
<span className="capitalize">{event.event_type}</span>
{event.is_archived && (
<span className="text-neutral-500 flex items-center">
<Archive className="w-4 h-4 mr-1" />
Archived
</span>
)}
</div>
</div>
<div className="flex gap-2">
{!event.is_archived && (
<>
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
leftIcon={<X className="w-4 h-4" />}
onClick={() => setIsEditing(false)}
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
leftIcon={<Save className="w-4 h-4" />}
onClick={handleSaveEdit}
isLoading={updateMutation.isPending}
>
Save Changes
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
leftIcon={<Edit2 className="w-4 h-4" />}
onClick={handleStartEdit}
>
Edit
</Button>
)}
</>
)}
<a
href={event.share_link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 border border-primary-600 rounded-lg hover:bg-primary-50 transition-colors"
>
<ExternalLink className="w-4 h-4" />
View Gallery
</a>
</div>
</div>
</div>
{/* Expiration Warning */}
{!event.is_archived && (isExpired || isExpiring) && (
<Card className={`p-4 mb-6 border-2 ${isExpired ? 'border-red-500 bg-red-50' : 'border-orange-500 bg-orange-50'}`}>
<div className="flex items-start gap-3">
<AlertTriangle className={`w-5 h-5 flex-shrink-0 ${isExpired ? 'text-red-600' : 'text-orange-600'}`} />
<div className="flex-1">
<p className={`font-medium ${isExpired ? 'text-red-900' : 'text-orange-900'}`}>
{isExpired
? 'This event has expired'
: `This event expires in ${daysUntilExpiration} ${daysUntilExpiration === 1 ? 'day' : 'days'}`
}
</p>
<p className={`text-sm mt-1 ${isExpired ? 'text-red-700' : 'text-orange-700'}`}>
{isExpired
? 'Guests can no longer access the gallery. Consider archiving this event.'
: 'Warning emails have been sent to the host.'}
</p>
</div>
{!isExpired && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (confirm('Extend expiration by 7 days?')) {
extendMutation.mutate(7);
}
}}
>
Extend 7 Days
</Button>
)}
</div>
</Card>
)}
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Details */}
<div className="lg:col-span-2 space-y-6">
{/* Event Information */}
<Card className="p-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Event Information</h2>
{isEditing ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
Welcome Message
</label>
<textarea
value={editForm.welcome_message}
onChange={(e) => setEditForm(prev => ({ ...prev, welcome_message: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
rows={3}
placeholder="Add a welcome message for guests..."
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
Expiration Date
</label>
<Input
type="date"
value={editForm.expires_at}
onChange={(e) => setEditForm(prev => ({ ...prev, expires_at: e.target.value }))}
min={format(new Date(), 'yyyy-MM-dd')}
/>
</div>
</div>
) : (
<dl className="space-y-4">
<div>
<dt className="text-sm font-medium text-neutral-500">Welcome Message</dt>
<dd className="mt-1 text-sm text-neutral-900">
{event.welcome_message || <span className="text-neutral-400">No welcome message set</span>}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-neutral-500">Host Email</dt>
<dd className="mt-1 text-sm text-neutral-900">{event.host_email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">Admin Email</dt>
<dd className="mt-1 text-sm text-neutral-900">{event.admin_email}</dd>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-neutral-500">Created</dt>
<dd className="mt-1 text-sm text-neutral-900">
{format(parseISO(event.created_at), 'MMM d, yyyy')}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">Expires</dt>
<dd className="mt-1 text-sm text-neutral-900">
{format(parseISO(event.expires_at), 'MMM d, yyyy')}
{!event.is_archived && daysUntilExpiration > 0 && (
<span className="text-neutral-500 ml-1">
({daysUntilExpiration} {daysUntilExpiration === 1 ? 'day' : 'days'} left)
</span>
)}
</dd>
</div>
</div>
</dl>
)}
</Card>
{/* Share Link */}
<Card className="p-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Share Link</h2>
<div className="flex items-center gap-2">
<input
type="text"
value={event.share_link}
readOnly
className="flex-1 px-3 py-2 bg-neutral-50 border border-neutral-300 rounded-lg text-sm"
/>
<Button
variant="outline"
size="md"
leftIcon={copiedLink ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
onClick={handleCopyLink}
>
{copiedLink ? 'Copied!' : 'Copy'}
</Button>
</div>
<p className="text-sm text-neutral-600 mt-2">
Share this link with guests. They'll need the password to access the gallery.
</p>
</Card>
{/* Actions */}
{!event.is_archived && (
<Card className="p-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Actions</h2>
<div className="space-y-3">
<Button
variant="outline"
leftIcon={<Archive className="w-4 h-4" />}
onClick={() => {
if (confirm('Are you sure you want to archive this event? This action cannot be undone.')) {
archiveMutation.mutate();
}
}}
isLoading={archiveMutation.isPending}
className="w-full justify-center"
>
Archive Event
</Button>
<p className="text-xs text-neutral-500 text-center">
Archiving will create a ZIP file of all photos and remove the gallery from public access.
</p>
</div>
</Card>
)}
</div>
{/* Right Column - Statistics */}
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Statistics</h2>
{stats ? (
<div className="space-y-4">
<div className="text-center p-4 bg-neutral-50 rounded-lg">
<p className="text-3xl font-bold text-neutral-900">{stats.total_photos}</p>
<p className="text-sm text-neutral-500">Total Photos</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-3 bg-blue-50 rounded-lg">
<Eye className="w-5 h-5 text-blue-600 mx-auto mb-1" />
<p className="text-xl font-semibold text-neutral-900">{stats.total_views}</p>
<p className="text-xs text-neutral-500">Views</p>
</div>
<div className="text-center p-3 bg-purple-50 rounded-lg">
<Download className="w-5 h-5 text-purple-600 mx-auto mb-1" />
<p className="text-xl font-semibold text-neutral-900">{stats.total_downloads}</p>
<p className="text-xs text-neutral-500">Downloads</p>
</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<Users className="w-5 h-5 text-green-600 mx-auto mb-1" />
<p className="text-xl font-semibold text-neutral-900">{stats.unique_visitors}</p>
<p className="text-xs text-neutral-500">Unique Visitors</p>
</div>
</div>
) : (
<div className="text-center py-8 text-neutral-500">
<p>No statistics available yet</p>
</div>
)}
</Card>
{/* Archive Status */}
{event.is_archived && (
<Card className="p-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Archive Status</h2>
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-neutral-500">Archived On</p>
<p className="text-sm text-neutral-900">
{event.archived_at && format(parseISO(event.archived_at), 'MMM d, yyyy h:mm a')}
</p>
</div>
{event.archive_path && (
<Button
variant="outline"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={() => toast.info('Archive download coming soon')}
className="w-full justify-center"
>
Download Archive
</Button>
)}
</div>
</Card>
)}
</div>
</div>
</div>
);
};