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:
@@ -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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import { MaintenanceMode } from '../components/MaintenanceMode';
|
||||
|
||||
export const MaintenancePage: React.FC = () => {
|
||||
return <MaintenanceMode />;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
Reference in New Issue
Block a user