Fix brand theme application and add comprehensive translations

- Fixed theme not being reflected on gallery and admin login pages
- Created GlobalThemeProvider to apply themes globally
- Updated gallery and admin login pages to use dynamic CSS variables
- Added complete translations for all admin sections in English and German:
  - Notifications management
  - Event view and creation
  - Photo upload functionality
  - Category management
  - Archive page view
  - Analytics dashboard
  - Branding and theme settings
  - System settings
  - CMS page management
  - Email configuration
- Fixed admin photo management display issues
- Fixed photo upload category assignment
- Added password reset functionality for galleries
- Improved error handling and user feedback

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

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
2025-07-08 17:07:40 +02:00
co-authored by Claude
parent 2012b0bab9
commit d594d00227
79 changed files with 4570 additions and 329 deletions
+32 -20
View File
@@ -5,7 +5,7 @@ import { format, differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, Input, Button, Loading } from '../components/common';
import { Card, CardContent, Input, Button, Loading, ReCaptcha } from '../components/common';
import { useGalleryAuth } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery';
@@ -19,6 +19,7 @@ export const GalleryPage: React.FC = () => {
const [password, setPassword] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
// Fetch gallery info (public data)
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
@@ -55,7 +56,7 @@ export const GalleryPage: React.FC = () => {
try {
setIsLoggingIn(true);
setLoginError(null);
await login(slug!, password);
await login(slug!, password, recaptchaToken);
// Track successful password entry
analyticsService.trackGalleryEvent('password_entry', {
@@ -78,7 +79,7 @@ export const GalleryPage: React.FC = () => {
// Show loading state
if (isLoadingInfo) {
return (
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center">
<Loading size="lg" text={t('gallery.loading')} />
</div>
@@ -88,14 +89,18 @@ export const GalleryPage: React.FC = () => {
// Show error state
if (infoError) {
// Check if it's an archived gallery error
const errorMessage = (infoError as any)?.response?.data?.error;
const isArchived = errorMessage?.includes('archived');
return (
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{/* Logo at top */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={settingsData.branding_logo_url}
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
@@ -106,9 +111,11 @@ export const GalleryPage: React.FC = () => {
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">{t('errors.galleryNotFound')}</h2>
<h2 className="text-xl font-semibold mb-2">
{t(isArchived ? 'errors.galleryArchived' : 'errors.galleryNotFound')}
</h2>
<p className="text-neutral-600">
{t('errors.galleryNotFoundMessage')}
{t(isArchived ? 'errors.galleryArchivedMessage' : 'errors.galleryNotFoundMessage')}
</p>
</CardContent>
</Card>
@@ -140,13 +147,13 @@ export const GalleryPage: React.FC = () => {
// Show expired state
if (galleryInfo?.is_expired) {
return (
<div className="min-h-screen bg-neutral-50">
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{/* Logo at top */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={settingsData.branding_logo_url}
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
@@ -198,26 +205,26 @@ export const GalleryPage: React.FC = () => {
// Show login form
return (
<div className="min-h-screen bg-gradient-to-br from-neutral-50 to-sand-100">
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
{settingsData?.branding_logo_url ? (
<img
src={settingsData.branding_logo_url}
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-20 w-auto object-contain mx-auto mb-4"
/>
) : (
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary-600 rounded-2xl mb-4">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
<Camera className="w-10 h-10 text-white" />
</div>
)}
<h1 className="text-3xl font-bold text-neutral-900 mb-2">
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--color-text, #171717)' }}>
{galleryInfo?.event_name}
</h1>
<div className="flex items-center justify-center text-neutral-600 text-sm">
<div className="flex items-center justify-center text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
<Calendar className="w-4 h-4 mr-1" />
{format(parseISO(galleryInfo!.event_date), 'MMMM d, yyyy')}
</div>
@@ -256,6 +263,11 @@ export const GalleryPage: React.FC = () => {
autoFocus
/>
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
<Button
type="submit"
variant="primary"
@@ -277,19 +289,19 @@ export const GalleryPage: React.FC = () => {
{/* Legal Links */}
<div className="text-center mt-6">
<div className="flex items-center justify-center gap-4">
<a
href="/impressum"
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</a>
</Link>
<span className="text-xs text-neutral-400">|</span>
<a
href="/datenschutz"
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</a>
</Link>
</div>
</div>
</div>
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { MaintenanceMode } from '../components/MaintenanceMode';
export const MaintenancePage: React.FC = () => {
return <MaintenanceMode />;
};
+55 -14
View File
@@ -1,15 +1,17 @@
import React, { useState } from 'react';
import { Navigate } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Navigate, useSearchParams } from 'react-router-dom';
import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useQuery } from '@tanstack/react-query';
import { Button, Input, Card } from '../../components/common';
import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useAdminAuth } from '../../contexts';
import { authService } from '../../services/auth.service';
import { getAuthToken } from '../../config/api';
import { getAuthToken, api } from '../../config/api';
export const AdminLoginPage: React.FC = () => {
const { isAuthenticated, login } = useAdminAuth();
const [searchParams] = useSearchParams();
const [formData, setFormData] = useState({
email: '',
@@ -19,6 +21,24 @@ export const AdminLoginPage: React.FC = () => {
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [loginSuccess, setLoginSuccess] = useState(false);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
// Fetch branding settings
const { data: settingsData } = useQuery({
queryKey: ['admin-login-settings'],
queryFn: async () => {
const response = await api.get('/api/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Check for session expired message
useEffect(() => {
if (searchParams.get('session') === 'expired') {
toast.info('Your session has expired. Please log in again.');
}
}, [searchParams]);
// Redirect if already authenticated or login successful
if (isAuthenticated || loginSuccess) {
@@ -55,7 +75,10 @@ export const AdminLoginPage: React.FC = () => {
setErrors({});
try {
const response = await authService.adminLogin(formData);
const response = await authService.adminLogin({
...formData,
recaptchaToken
});
login(response.token, response.user);
toast.success('Login successful!');
setLoginSuccess(true);
@@ -93,15 +116,23 @@ export const AdminLoginPage: React.FC = () => {
};
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-neutral-100 flex items-center justify-center p-4">
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary-600 rounded-full mb-4">
<Lock className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-neutral-900">Admin Login</h1>
<p className="text-neutral-600 mt-2">Sign in to manage your photo galleries</p>
{settingsData?.branding_logo_url ? (
<img
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto mb-4"
/>
) : (
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full mb-4" style={{ backgroundColor: 'var(--color-primary, #5C8762)' }}>
<Lock className="w-8 h-8 text-white" />
</div>
)}
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>Admin Login</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>Sign in to manage your photo galleries</p>
</div>
{/* Login Form */}
@@ -178,6 +209,12 @@ export const AdminLoginPage: React.FC = () => {
</a>
</div>
{/* reCAPTCHA */}
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
{/* Submit Button */}
<Button
type="submit"
@@ -192,10 +229,14 @@ export const AdminLoginPage: React.FC = () => {
</Card>
{/* Footer */}
<p className="text-center text-sm text-neutral-600 mt-8">
<p className="text-center text-sm mt-8" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
Need help? Contact{' '}
<a href="mailto:[email protected]" className="text-primary-600 hover:text-primary-700">
support@example.com
<a
href={`mailto:${settingsData?.branding_support_email || '[email protected]'}`}
className="hover:underline"
style={{ color: 'var(--color-primary, #5C8762)' }}
>
{settingsData?.branding_support_email || '[email protected]'}
</a>
</p>
+26 -11
View File
@@ -9,26 +9,36 @@ import {
AlertCircle,
RotateCcw,
Trash2,
Eye,
ChevronLeft,
ChevronRight
} from 'lucide-react';
import { format, parseISO } from 'date-fns';
import { format, parseISO, isValid } 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 { archiveService } from '../../services/archive.service';
import { useNavigate } from 'react-router-dom';
// import { useNavigate } from 'react-router-dom';
export const ArchivesPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState<string>('all');
const [sortBy, setSortBy] = useState<'date' | 'size' | 'name'>('date');
const [currentPage, setCurrentPage] = useState(1);
const navigate = useNavigate();
// const navigate = useNavigate();
const queryClient = useQueryClient();
// Helper function to safely format dates
const formatDate = (dateString: string | null | undefined, formatStr: string): string => {
if (!dateString) return '';
try {
const date = parseISO(dateString);
return isValid(date) ? format(date, formatStr) : '';
} catch {
return '';
}
};
// Fetch archives from API
const { data: archivesData, isLoading } = useQuery({
queryKey: ['admin-archives', currentPage],
@@ -54,7 +64,9 @@ export const ArchivesPage: React.FC = () => {
return b.archiveSize - a.archiveSize;
case 'date':
default:
return new Date(b.archivedAt).getTime() - new Date(a.archivedAt).getTime();
const dateA = a.archivedAt ? new Date(a.archivedAt).getTime() : 0;
const dateB = b.archivedAt ? new Date(b.archivedAt).getTime() : 0;
return dateB - dateA;
}
});
@@ -107,9 +119,10 @@ export const ArchivesPage: React.FC = () => {
}
};
const handleViewDetails = (archive: typeof archives[0]) => {
navigate(`/admin/archives/${archive.id}`);
};
// Details view not implemented yet
// const handleViewDetails = (archive: typeof archives[0]) => {
// navigate(`/admin/archives/${archive.id}`);
// };
if (isLoading) {
return (
@@ -257,7 +270,7 @@ export const ArchivesPage: React.FC = () => {
<div>
<p className="text-sm font-medium text-neutral-900">{archive.eventName}</p>
<p className="text-xs text-neutral-500">
Event date: {format(parseISO(archive.eventDate), 'MMM d, yyyy')}
Event date: {formatDate(archive.eventDate, 'MMM d, yyyy') || 'N/A'}
</p>
</div>
</td>
@@ -266,9 +279,9 @@ export const ArchivesPage: React.FC = () => {
</td>
<td className="px-6 py-4 text-sm text-neutral-700">
<div>
<p>{format(parseISO(archive.archivedAt), 'MMM d, yyyy')}</p>
<p>{formatDate(archive.archivedAt, 'MMM d, yyyy') || 'Processing...'}</p>
<p className="text-xs text-neutral-500">
{format(parseISO(archive.archivedAt), 'h:mm a')}
{formatDate(archive.archivedAt, 'h:mm a')}
</p>
</div>
</td>
@@ -280,6 +293,7 @@ export const ArchivesPage: React.FC = () => {
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{/* Details view not implemented yet
<Button
variant="ghost"
size="sm"
@@ -288,6 +302,7 @@ export const ArchivesPage: React.FC = () => {
>
Details
</Button>
*/}
<Button
variant="ghost"
size="sm"
+64 -9
View File
@@ -14,6 +14,7 @@ import {
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { emailService, type EmailConfig, type EmailTemplate } from '../../services/email.service';
@@ -87,6 +88,12 @@ export const EmailConfigPage: React.FC = () => {
const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({});
const [showPassword, setShowPassword] = useState(false);
const [testEmail, setTestEmail] = useState('');
const [showPreview, setShowPreview] = useState(false);
const [previewData, setPreviewData] = useState<{ subject: string; htmlContent: string; textContent?: string }>({
subject: '',
htmlContent: '',
textContent: ''
});
const queryClient = useQueryClient();
// SMTP Configuration state
@@ -205,6 +212,35 @@ export const EmailConfigPage: React.FC = () => {
}
};
const handlePreviewTemplate = async () => {
if (!selectedTemplateKey || !editedTemplate) return;
// Generate sample data based on the template
const sampleData: Record<string, string> = {
event_name: 'John & Jane Wedding',
event_date: 'December 25, 2024',
password: 'wedding2024',
gallery_link: 'https://photos.example.com/gallery/john-jane-wedding',
expiration_date: 'January 25, 2025',
welcome_message: 'Thank you for celebrating our special day with us!',
days_remaining: '30',
admin_email: '[email protected]',
host_email: '[email protected]'
};
try {
const preview = await emailService.previewTemplate(selectedTemplateKey, sampleData);
setPreviewData({
subject: preview.subject,
htmlContent: preview.body_html,
textContent: preview.body_text
});
setShowPreview(true);
} catch (error) {
toast.error('Failed to preview template');
}
};
const renderVariableHelp = () => {
const variables = editedTemplate.variables || [];
return (
@@ -479,15 +515,25 @@ export const EmailConfigPage: React.FC = () => {
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900">Edit Template</h3>
<Button
variant="primary"
size="sm"
onClick={handleSaveTemplate}
isLoading={saveTemplateMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
Save Changes
</Button>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePreviewTemplate}
leftIcon={<Eye className="w-4 h-4" />}
>
Preview
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSaveTemplate}
isLoading={saveTemplateMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
Save Changes
</Button>
</div>
</div>
<div className="space-y-4">
@@ -533,6 +579,15 @@ export const EmailConfigPage: React.FC = () => {
</div>
</div>
)}
{/* Email Preview Modal */}
<EmailPreviewModal
isOpen={showPreview}
onClose={() => setShowPreview(false)}
subject={previewData.subject}
htmlContent={previewData.htmlContent}
textContent={previewData.textContent}
/>
</div>
);
};
+244 -39
View File
@@ -14,16 +14,20 @@ import {
AlertTriangle,
Copy,
CheckCircle,
Upload
Upload,
Image,
Key
} from 'lucide-react';
import { format, parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { PhotoUpload, EventCategoryManager } from '../../components/admin';
import { PhotoUpload, EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { galleryService } from '../../services/gallery.service';
import { archiveService } from '../../services/archive.service';
import { photosService, AdminPhoto } from '../../services/photos.service';
export const EventDetailsPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -45,6 +49,17 @@ export const EventDetailsPage: React.FC = () => {
});
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);
// 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({
@@ -61,6 +76,23 @@ export const EventDetailsPage: React.FC = () => {
retry: false,
});
// Fetch photos when on photos tab
const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({
queryKey: ['admin-event-photos', id, photoFilters],
queryFn: () => photosService.getEventPhotos(parseInt(id!), photoFilters),
enabled: !!id && activeTab === 'photos',
});
// 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),
@@ -257,8 +289,51 @@ export const EventDetailsPage: React.FC = () => {
</Card>
)}
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 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'
}`}
>
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" />
Photos
{event.photo_count && 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'
}`}
>
Categories
</button>
</nav>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<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 */}
@@ -361,34 +436,25 @@ export const EventDetailsPage: React.FC = () => {
<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>
{/* Photo Management */}
<Card padding="md">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">Photo Management</h2>
<Button
variant="primary"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowPhotoUpload(!showPhotoUpload)}
>
Upload Photos
</Button>
</div>
{showPhotoUpload && (
<div className="mb-4">
<PhotoUpload
eventId={parseInt(id!)}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
toast.success('Photos uploaded successfully');
setShowPhotoUpload(false);
}}
/>
{!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"
>
Reset Gallery Password
</Button>
</div>
)}
</Card>
{/* Photo Statistics */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">Photo Statistics</h2>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
@@ -403,18 +469,22 @@ export const EventDetailsPage: React.FC = () => {
</span>
</div>
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Storage Location:</strong> /storage/events/active/{event.slug}/
</p>
<p className="text-xs text-blue-600 mt-1">
Photos are organized by categories you define.
</p>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-600">Categories</span>
<span className="text-sm font-medium">{categories.length}</span>
</div>
</div>
<div className="mt-6 pt-4 border-t border-neutral-200">
<EventCategoryManager eventId={parseInt(id!)} />
<div className="mt-4">
<Button
variant="outline"
size="sm"
leftIcon={<Image className="w-4 h-4" />}
onClick={() => setActiveTab('photos')}
className="w-full justify-center"
>
Manage Photos
</Button>
</div>
</Card>
@@ -503,7 +573,15 @@ export const EventDetailsPage: React.FC = () => {
variant="outline"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={() => toast.info('Archive download coming soon')}
onClick={async () => {
try {
toast.info(`Downloading ${event.event_name} archive...`);
await archiveService.downloadArchive(Number(id), `${event.slug}-archive.zip`);
toast.success('Download started');
} catch (error) {
toast.error('Failed to download archive');
}
}}
className="w-full justify-center"
>
Download Archive
@@ -514,6 +592,133 @@ export const EventDetailsPage: React.FC = () => {
)}
</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">Upload Photos</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('Photos uploaded successfully');
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)}
>
Upload Photos
</Button>
</div>
)}
{/* Photo Grid */}
{photosLoading ? (
<div className="flex items-center justify-center py-12">
<Loading size="lg" text="Loading photos..." />
</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">Photo Categories</h2>
<p className="text-sm text-neutral-600">
Organize your photos into categories. Categories help guests navigate and find specific types of photos.
</p>
</div>
<EventCategoryManager
eventId={parseInt(id!)}
/>
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Tip:</strong> Categories are specific to each event. You can create custom categories like "Ceremony", "Reception", "Portraits", etc.
</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)}
/>
)}
</div>
);
};
+31 -4
View File
@@ -15,6 +15,7 @@ import { format, parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
import { BulkArchiveModal } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import type { Event } from '../../types';
@@ -28,6 +29,7 @@ export const EventsListPage: React.FC = () => {
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
// const [showFilters, setShowFilters] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
// Get filter from URL
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
@@ -63,6 +65,25 @@ export const EventsListPage: React.FC = () => {
},
});
// Bulk archive mutation
const bulkArchiveMutation = useMutation({
mutationFn: eventsService.bulkArchiveEvents,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
setSelectedEvents([]);
setShowBulkArchiveModal(false);
if (data.results.failed.length === 0) {
toast.success(`Successfully archived ${data.results.successful.length} events`);
} else {
toast.warning(`Archived ${data.results.successful.length} events, ${data.results.failed.length} failed`);
}
},
onError: () => {
toast.error('Failed to archive events');
},
});
// Filter and search events
const filteredEvents = useMemo(() => {
if (!data?.events) return [];
@@ -237,10 +258,7 @@ export const EventsListPage: React.FC = () => {
<Button
variant="outline"
size="sm"
onClick={() => {
// Handle bulk archive
toast.info('Bulk archive coming soon');
}}
onClick={() => setShowBulkArchiveModal(true)}
>
Archive Selected
</Button>
@@ -407,6 +425,15 @@ export const EventsListPage: React.FC = () => {
</table>
</div>
</Card>
{/* Bulk Archive Modal */}
<BulkArchiveModal
isOpen={showBulkArchiveModal}
onClose={() => setShowBulkArchiveModal(false)}
onConfirm={() => bulkArchiveMutation.mutate(selectedEvents)}
selectedEvents={filteredEvents.filter(e => selectedEvents.includes(e.id))}
isLoading={bulkArchiveMutation.isPending}
/>
</div>
</ErrorBoundary>
);
+6 -1
View File
@@ -18,7 +18,7 @@ import { useTranslation } from 'react-i18next';
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'storage' | 'security' | 'categories'>('general');
const queryClient = useQueryClient();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
// Fetch settings
const { data: settings, isLoading } = useQuery({
@@ -60,6 +60,11 @@ export const SettingsPage: React.FC = () => {
React.useEffect(() => {
if (settings) {
// Set the language if it's different from current
if (settings.general_default_language && settings.general_default_language !== i18n.language) {
i18n.changeLanguage(settings.general_default_language);
}
// Extract general settings
setGeneralSettings({
site_url: settings.general_site_url || '',