Files
picpeak/frontend/src/pages/admin/EventDetailsPage.tsx
T
paul 5328b4f73a Enhance email templates with clickable links, branding, and improved styling
- Add clickable gallery links in all email templates
- Include application logo in email header and footer (custom or PicPeak default)
- Redesign emails with professional styling matching gallery login page
  - Gray background with white content box
  - PicPeak green header with centered logo
  - Clean typography and proper spacing
  - Responsive design for mobile devices
  - Styled call-to-action buttons
  - Footer with branding and copyright
- Update email processor to fetch branding settings dynamically
- Use proper API URLs for logo images in emails

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-10 22:19:15 +02:00

1028 lines
40 KiB
TypeScript

import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
ExternalLink,
Calendar,
Download,
Archive,
Edit2,
Save,
X,
AlertTriangle,
Copy,
CheckCircle,
Upload,
Image,
Key,
Palette,
Settings
} from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading } from '../../components/common';
import { PhotoUpload, EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeDisplay, ThemeCustomizerEnhanced, ThemeEditorModal, HeroPhotoSelector } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { archiveService } from '../../services/archive.service';
import { photosService, AdminPhoto } from '../../services/photos.service';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
export const EventDetailsPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { format } = useLocalizedDate();
// Validate ID parameter
React.useEffect(() => {
if (!id || isNaN(parseInt(id))) {
navigate('/admin/events');
}
}, [id, navigate]);
const [isEditing, setIsEditing] = useState(false);
const [editForm, setEditForm] = useState({
welcome_message: '',
color_theme: '',
expires_at: '',
allow_user_uploads: false,
upload_category_id: null as number | null,
hero_photo_id: null as number | null,
host_name: '',
});
const [copiedLink, setCopiedLink] = useState(false);
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
const [showPasswordReset, setShowPasswordReset] = useState(false);
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
const [showThemeEditorModal, setShowThemeEditorModal] = useState(false);
// Photo filters state
const [photoFilters, setPhotoFilters] = useState({
category_id: undefined as number | null | undefined,
search: '',
sort: 'date' as 'date' | 'name' | 'size',
order: 'desc' as 'asc' | 'desc'
});
// Fetch event details
const { data: event, isLoading: eventLoading } = useQuery({
queryKey: ['admin-event', id],
queryFn: () => eventsService.getEvent(parseInt(id!)),
enabled: !!id,
});
// Statistics are now fetched with the event details from the admin API
// Fetch photos (needed for both photos tab and hero photo selector)
const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({
queryKey: ['admin-event-photos', id, photoFilters],
queryFn: () => photosService.getEventPhotos(parseInt(id!), photoFilters),
enabled: !!id && (activeTab === 'photos' || isEditing),
});
// Fetch categories for the event
const { data: categories = [] } = useQuery({
queryKey: ['admin-event-categories', id],
queryFn: async () => {
const response = await eventsService.getEventCategories(parseInt(id!));
return response || [];
},
enabled: !!id,
});
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: any) => eventsService.updateEvent(parseInt(id!), data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
toast.success(t('toast.eventUpdated'));
setIsEditing(false);
},
onError: (error: any) => {
console.error('Update event error:', error.response?.data || error);
if (error.response?.data?.errors) {
console.error('Validation errors:', error.response.data.errors);
const errorMessage = error.response.data.errors[0].msg + ' (field: ' + error.response.data.errors[0].path + ')';
toast.error(errorMessage);
} else {
toast.error(error.response?.data?.error || t('toast.saveError'));
}
},
});
// Archive mutation
const archiveMutation = useMutation({
mutationFn: () => eventsService.archiveEvent(parseInt(id!)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
toast.success(t('toast.eventArchived'));
},
onError: () => {
toast.error(t('errors.somethingWentWrong'));
},
});
// Extend expiration mutation
const extendMutation = useMutation({
mutationFn: (days: number) => {
return eventsService.extendExpiration(parseInt(id!), days);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
toast.success(t('toast.saveSuccess'));
},
onError: () => {
toast.error(t('toast.saveError'));
},
});
if (eventLoading || !event) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('events.loadingEventDetails')} />
</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'),
allow_user_uploads: event.allow_user_uploads || false,
upload_category_id: event.upload_category_id || null,
hero_photo_id: event.hero_photo_id || null,
host_name: event.host_name || '',
});
// Parse theme configuration
if (event.color_theme) {
try {
if (event.color_theme.startsWith('{')) {
const parsedTheme = JSON.parse(event.color_theme);
setCurrentTheme(parsedTheme);
// Try to find matching preset
const matchingPreset = Object.entries(GALLERY_THEME_PRESETS).find(
([_, preset]) => JSON.stringify(preset.config) === JSON.stringify(parsedTheme)
);
setCurrentPresetName(matchingPreset ? matchingPreset[0] : 'custom');
} else {
// Legacy theme name
const preset = GALLERY_THEME_PRESETS[event.color_theme];
if (preset) {
setCurrentTheme(preset.config);
setCurrentPresetName(event.color_theme);
}
}
} catch (e) {
console.error('Failed to parse theme:', e);
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
setCurrentPresetName('default');
}
} else {
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
setCurrentPresetName('default');
}
setIsEditing(true);
};
const handleSaveEdit = () => {
// Prepare color_theme - if we have a custom theme, serialize it
let themeToSave = editForm.color_theme;
if (currentTheme && (currentPresetName === 'custom' || showThemeCustomizer)) {
themeToSave = JSON.stringify(currentTheme);
} else if (currentPresetName && currentPresetName !== 'custom') {
// Use preset name for non-custom themes
themeToSave = currentPresetName;
}
// Clean up the data - remove undefined values
const updateData: any = {
expires_at: editForm.expires_at,
allow_user_uploads: editForm.allow_user_uploads,
};
// Only include fields that have defined values
if (editForm.welcome_message !== undefined && editForm.welcome_message !== null) {
updateData.welcome_message = editForm.welcome_message;
}
if (themeToSave) {
updateData.color_theme = themeToSave;
}
if (editForm.upload_category_id !== undefined) {
updateData.upload_category_id = editForm.upload_category_id;
}
if (editForm.hero_photo_id !== undefined) {
updateData.hero_photo_id = editForm.hero_photo_id;
}
if (editForm.host_name !== undefined && editForm.host_name !== null) {
updateData.host_name = editForm.host_name;
}
// Remove any keys with undefined values
Object.keys(updateData).forEach(key => {
if (updateData[key] === undefined) {
delete updateData[key];
}
});
console.log('Updating event with data:', updateData);
console.log('Theme length:', updateData.color_theme ? updateData.color_theme.length : 0);
updateMutation.mutate(updateData);
};
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(event.share_link);
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
toast.success(t('toast.linkCopied'));
} catch (err) {
toast.error(t('errors.somethingWentWrong'));
}
};
const handleThemeModalSave = async (theme: ThemeConfig, presetName: string) => {
// Prepare theme for saving
let themeToSave: string;
if (presetName !== 'custom' && GALLERY_THEME_PRESETS[presetName]) {
// Save preset name for standard presets
themeToSave = presetName;
} else {
// Save full theme config for custom themes
themeToSave = JSON.stringify(theme);
}
try {
// Update the event with new theme
await eventsService.updateEvent(parseInt(id!), {
color_theme: themeToSave
});
// Invalidate queries to refresh the data
await queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
// Close modal and show success
setShowThemeEditorModal(false);
toast.success(t('toast.themeUpdated'));
} catch (error) {
console.error('Failed to save theme:', error);
toast.error(t('toast.saveError'));
}
};
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"
>
{t('events.backToEvents')}
</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), 'PPP')}
</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" />
{t('events.archived')}
</span>
) : null}
</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)}
>
{t('common.cancel')}
</Button>
<Button
variant="primary"
size="sm"
leftIcon={<Save className="w-4 h-4" />}
onClick={handleSaveEdit}
isLoading={updateMutation.isPending}
>
{t('events.saveChanges')}
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
leftIcon={<Edit2 className="w-4 h-4" />}
onClick={handleStartEdit}
>
{t('common.edit')}
</Button>
)}
</>
)}
{event.share_link && (
<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" />
{t('events.viewGallery')}
</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
? t('events.eventExpiredMessage')
: t('events.eventExpiresIn', { days: daysUntilExpiration })
}
</p>
<p className={`text-sm mt-1 ${isExpired ? 'text-red-700' : 'text-orange-700'}`}>
{isExpired
? t('events.guestsCannotAccessGallery')
: t('events.warningEmailsHaveBeenSent')}
</p>
</div>
{!isExpired && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (confirm(t('events.extendExpiration', { days: 7 }) + '?')) {
extendMutation.mutate(7);
}
}}
>
{t('events.extendSevenDays')}
</Button>
)}
</div>
</Card>
)}
{/* Tabs */}
<div className="mb-6 border-b border-neutral-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('overview')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'overview'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
{t('events.overview')}
</button>
<button
onClick={() => setActiveTab('photos')}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === 'photos'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
<Image className="w-4 h-4" />
<span>{t('events.photos')}</span>
{event.photo_count !== undefined && event.photo_count > 0 && (
<span className="ml-1 px-2 py-0.5 text-xs font-medium bg-neutral-100 text-neutral-700 rounded-full">
{event.photo_count}
</span>
)}
</button>
<button
onClick={() => setActiveTab('categories')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'categories'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
{t('events.categories')}
</button>
</nav>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Left Column - Main Details */}
<div className="space-y-6">
{/* Event Information */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.eventInformation')}</h2>
{isEditing ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.welcomeMessageLabel')}
</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={t('events.welcomeMessage')}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.hostName')}
</label>
<Input
type="text"
value={editForm.host_name}
onChange={(e) => setEditForm(prev => ({ ...prev, host_name: e.target.value }))}
placeholder={t('events.hostNamePlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.expirationDate')}
</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>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.galleryTheme')}
</label>
{!showThemeCustomizer ? (
<div className="space-y-2">
<select
value={currentPresetName}
onChange={(e) => {
const presetName = e.target.value;
setCurrentPresetName(presetName);
if (presetName !== 'custom') {
const preset = GALLERY_THEME_PRESETS[presetName];
if (preset) {
setCurrentTheme(preset.config);
setEditForm(prev => ({ ...prev, color_theme: presetName }));
}
}
}}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{Object.entries(GALLERY_THEME_PRESETS).map(([key, preset]) => (
<option key={key} value={key}>
{preset.name}
</option>
))}
<option value="custom">{t('branding.customTheme')}</option>
</select>
<Button
variant="outline"
size="sm"
leftIcon={<Settings className="w-4 h-4" />}
onClick={() => setShowThemeCustomizer(true)}
className="w-full"
>
{t('branding.customizeTheme')}
</Button>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600">{t('branding.customizingTheme')}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setShowThemeCustomizer(false)}
>
{t('common.hide')}
</Button>
</div>
<ThemeCustomizerEnhanced
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
onChange={setCurrentTheme}
presetName={currentPresetName}
onPresetChange={(presetName) => {
setCurrentPresetName(presetName);
if (presetName !== 'custom') {
setEditForm(prev => ({ ...prev, color_theme: presetName }));
}
}}
isPreviewMode={false}
showGalleryLayouts={true}
/>
</div>
)}
</div>
{/* Hero Photo Selection */}
<HeroPhotoSelector
photos={photos || []}
currentHeroPhotoId={editForm.hero_photo_id}
onSelect={(photoId) => setEditForm(prev => ({ ...prev, hero_photo_id: photoId }))}
isEditing={isEditing}
/>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.allow_user_uploads}
onChange={(e) => setEditForm(prev => ({ ...prev, allow_user_uploads: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('events.allowUserUploads')}</span>
</label>
<p className="text-xs text-neutral-500 mt-1 ml-6">
{t('events.allowUserUploadsHelp')}
</p>
</div>
{editForm.allow_user_uploads && (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.uploadCategory')}
</label>
<select
value={editForm.upload_category_id || ''}
onChange={(e) => setEditForm(prev => ({
...prev,
upload_category_id: e.target.value ? parseInt(e.target.value) : null
}))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">{t('events.selectCategory')}</option>
{categories?.map(category => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
<p className="text-xs text-neutral-500 mt-1">
{t('events.uploadCategoryHelp')}
</p>
</div>
)}
</div>
) : (
<dl className="space-y-4">
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.welcomeMessage')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{event.welcome_message || <span className="text-neutral-400">{t('events.noWelcomeMessageSet')}</span>}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.hostName')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{event.host_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.hostEmail')}</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">{t('events.adminEmail')}</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">{t('events.created')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{format(parseISO(event.created_at), 'PP')}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.expires')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{format(parseISO(event.expires_at), 'PP')}
{!event.is_archived && daysUntilExpiration > 0 && (
<span className="text-neutral-500 ml-1">
{t('events.daysLeft', { count: daysUntilExpiration })}
</span>
)}
</dd>
</div>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.heroPhoto')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{event.hero_photo_id ? (
<span className="text-primary-600">{t('events.heroPhotoSelected')}</span>
) : (
<span className="text-neutral-400">{t('events.noHeroPhotoSelected')}</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500">{t('events.userUploads')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{event.allow_user_uploads ? (
<div className="space-y-1">
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded">
{t('common.yes')}
</span>
{event.upload_category_id && (
<p className="text-xs text-neutral-600">
{t('events.uploadCategory')}: {categories.find(c => c.id === event.upload_category_id)?.name || 'N/A'}
</p>
)}
</div>
) : (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-neutral-700 bg-neutral-100 rounded">
{t('common.no')}
</span>
)}
</dd>
</div>
</dl>
)}
</Card>
{/* Share Link */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.shareLink')}</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 ? t('events.copied') : t('events.copy')}
</Button>
</div>
<p className="text-sm text-neutral-600 mt-2">
{t('events.shareWithGuests')}
</p>
{!event.is_archived && (
<div className="mt-4 pt-4 border-t border-neutral-200">
<Button
variant="outline"
size="sm"
leftIcon={<Key className="w-4 h-4" />}
onClick={() => setShowPasswordReset(true)}
className="w-full justify-center"
>
{t('events.resetGalleryPassword')}
</Button>
</div>
)}
</Card>
{/* Actions */}
{!event.is_archived && (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.actions')}</h2>
<div className="space-y-3">
<Button
variant="outline"
leftIcon={<Archive className="w-4 h-4" />}
onClick={() => {
if (confirm(t('events.archiveConfirm'))) {
archiveMutation.mutate();
}
}}
isLoading={archiveMutation.isPending}
className="w-full justify-center"
>
{t('events.archiveEvent')}
</Button>
<p className="text-xs text-neutral-500 text-center">
{t('events.archivingInfo')}
</p>
</div>
</Card>
)}
</div>
{/* Right Column - Statistics, Theme, and Actions */}
<div className="space-y-6">
{/* Photo Statistics */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.photoStatistics')}</h2>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalPhotos')}</span>
<span className="text-sm font-medium">{event.photo_count || 0}</span>
</div>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalSize')}</span>
<span className="text-sm font-medium">
{event.total_size ? `${(event.total_size / (1024 * 1024)).toFixed(1)} MB` : '0 MB'}
</span>
</div>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.categories')}</span>
<span className="text-sm font-medium">{categories.length}</span>
</div>
{event.total_views !== undefined && (
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalViews')}</span>
<span className="text-sm font-medium">{event.total_views || 0}</span>
</div>
)}
{event.total_downloads !== undefined && (
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.totalDownloads')}</span>
<span className="text-sm font-medium">{event.total_downloads || 0}</span>
</div>
)}
{event.unique_visitors !== undefined && (
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">{t('events.uniqueVisitors')}</span>
<span className="text-sm font-medium">{event.unique_visitors || 0}</span>
</div>
)}
</div>
<div className="mt-4">
<Button
variant="outline"
size="sm"
leftIcon={<Image className="w-4 h-4" />}
onClick={() => setActiveTab('photos')}
className="w-full justify-center"
>
{t('events.managePhotos')}
</Button>
</div>
</Card>
{/* Gallery Theme */}
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">{t('events.galleryTheme')}</h2>
{!event.is_archived && (
<Button
variant="outline"
size="sm"
leftIcon={<Palette className="w-4 h-4" />}
onClick={() => setShowThemeEditorModal(true)}
>
{t('events.customizeTheme')}
</Button>
)}
</div>
<ThemeDisplay
theme={event.color_theme || GALLERY_THEME_PRESETS.default.config}
presetName={event.color_theme && !event.color_theme.startsWith('{') ? event.color_theme : undefined}
showDetails={true}
/>
</Card>
{/* Archive Status */}
{event.is_archived ? (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.archiveStatusTitle')}</h2>
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-neutral-500">{t('events.archivedOn')}</p>
<p className="text-sm text-neutral-900">
{event.archived_at && format(parseISO(event.archived_at), 'PPp')}
</p>
</div>
{event.archive_path && (
<Button
variant="outline"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={async () => {
try {
toast.info(t('events.downloadingArchive', { name: event.event_name }));
await archiveService.downloadArchive(Number(id), `${event.slug}-archive.zip`);
toast.success(t('events.downloadStarted'));
} catch (error) {
toast.error(t('events.failedToDownloadArchive'));
}
}}
className="w-full justify-center"
>
{t('events.downloadArchive')}
</Button>
)}
</div>
</Card>
) : null}
</div>
</div>
)}
{/* Photos Tab */}
{activeTab === 'photos' && (
<div>
{/* Photo Upload */}
{showPhotoUpload && (
<Card padding="md" className="mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">{t('events.uploadPhotos')}</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setShowPhotoUpload(false)}
>
<X className="w-4 h-4" />
</Button>
</div>
<PhotoUpload
eventId={parseInt(id!)}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
queryClient.invalidateQueries({ queryKey: ['admin-event-photos', id] });
toast.success(t('toast.uploadSuccess'));
setShowPhotoUpload(false);
refetchPhotos();
}}
/>
</Card>
)}
{/* Photo Filters */}
<PhotoFilters
categories={categories}
selectedCategory={photoFilters.category_id}
searchTerm={photoFilters.search}
sortBy={photoFilters.sort}
sortOrder={photoFilters.order}
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
/>
{/* Actions Bar */}
{!showPhotoUpload && (
<div className="mb-4 flex justify-between items-center">
<Button
variant="primary"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowPhotoUpload(true)}
>
{t('events.uploadPhotos')}
</Button>
</div>
)}
{/* Photo Grid */}
{photosLoading ? (
<div className="flex items-center justify-center py-12">
<Loading size="lg" text={t('events.loadingPhotos')} />
</div>
) : (
<AdminPhotoGrid
photos={photos}
eventId={parseInt(id!)}
onPhotoClick={(photo, index) => setSelectedPhoto({ photo, index })}
onPhotosDeleted={() => {
refetchPhotos();
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
}}
/>
)}
{/* Photo Viewer */}
{selectedPhoto && (
<AdminPhotoViewer
photos={photos}
initialIndex={selectedPhoto.index}
eventId={parseInt(id!)}
onClose={() => setSelectedPhoto(null)}
onPhotoDeleted={() => {
refetchPhotos();
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
setSelectedPhoto(null);
}}
categories={categories}
/>
)}
</div>
)}
{/* Categories Tab */}
{activeTab === 'categories' && (
<div>
<Card padding="md">
<div className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-2">{t('events.photoCategories')}</h2>
<p className="text-sm text-neutral-600">
{t('events.organizeCategoriesInfo')}
</p>
</div>
<EventCategoryManager
eventId={parseInt(id!)}
/>
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
{t('events.categoriesTip')}
</p>
</div>
</Card>
</div>
)}
{/* Password Reset Modal */}
{showPasswordReset && (
<PasswordResetModal
eventName={event.event_name}
onConfirm={async (sendEmail) => {
const result = await eventsService.resetPassword(event.id, sendEmail);
return result;
}}
onClose={() => setShowPasswordReset(false)}
/>
)}
{/* Theme Editor Modal */}
{showThemeEditorModal && (
<ThemeEditorModal
isOpen={showThemeEditorModal}
onClose={() => setShowThemeEditorModal(false)}
onSave={handleThemeModalSave}
currentTheme={event.color_theme || 'default'}
eventName={event.event_name}
/>
)}
</div>
);
};
EventDetailsPage.displayName = 'EventDetailsPage';